0
Fork 0
mirror of https://github.com/stonith404/pingvin-share.git synced 2025-02-19 01:55:48 -05:00

feat: add setup wizard

This commit is contained in:
Elias Schneider 2022-12-01 23:07:49 +01:00
parent 493705e4ef
commit b579b8f330
32 changed files with 689 additions and 179 deletions

View file

@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Config" (
"updatedAt" DATETIME NOT NULL,
"key" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT NOT NULL,
"secret" BOOLEAN NOT NULL DEFAULT true,
"locked" BOOLEAN NOT NULL DEFAULT false
);
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"isAdmin" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt") SELECT "createdAt", "email", "id", "password", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -12,11 +12,10 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
password String
isAdministrator Boolean @default(false)
firstName String?
lastName String?
username String @unique
email String @unique
password String
isAdmin Boolean @default(false)
shares Share[]
refreshTokens RefreshToken[]
@ -81,10 +80,10 @@ model ShareSecurity {
model Config {
updatedAt DateTime @updatedAt
key String @id
type String
value String?
default String
secret Boolean @default(true)
locked Boolean @default(false)
key String @id
type String
value String
description String
secret Boolean @default(true)
locked Boolean @default(false)
}

View file

@ -1,79 +1,8 @@
import { PrismaClient } from "@prisma/client";
import configVariables from "../../src/configVariables";
const prisma = new PrismaClient();
const configVariables = [
{
key: "setupFinished",
type: "boolean",
default: "false",
secret: false,
locked: true
},
{
key: "appUrl",
type: "string",
default: "http://localhost:3000",
secret: false,
},
{
key: "showHomePage",
type: "boolean",
default: "true",
secret: false,
},
{
key: "allowRegistration",
type: "boolean",
default: "true",
secret: false,
},
{
key: "allowUnauthenticatedShares",
type: "boolean",
default: "false",
secret: false,
},
{
key: "maxFileSize",
type: "number",
default: "1000000000",
secret: false,
},
{
key: "jwtSecret",
type: "string",
default: "long-random-string",
locked: true
},
{
key: "emailRecipientsEnabled",
type: "boolean",
default: "false",
secret: false,
},
{
key: "smtpHost",
type: "string",
default: "",
},
{
key: "smtpPort",
type: "number",
default: "",
},
{
key: "smtpEmail",
type: "string",
default: "",
},
{
key: "smtpPassword",
type: "string",
default: "",
},
];
async function main() {
for (const variable of configVariables) {
const existingConfigVariable = await prisma.config.findUnique({
@ -85,14 +14,6 @@ async function main() {
await prisma.config.create({
data: variable,
});
} else {
// Update the config variable if the default value has changed
if (existingConfigVariable.default != variable.default) {
await prisma.config.update({
where: { key: variable.key },
data: { default: variable.default },
});
}
}
}

View file

@ -1,11 +1,13 @@
import { Module } from "@nestjs/common";
import { HttpException, HttpStatus, Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./jobs/jobs.service";
import { APP_GUARD } from "@nestjs/core";
import { MulterModule } from "@nestjs/platform-express";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { Request } from "express";
import { ConfigModule } from "./config/config.module";
import { ConfigService } from "./config/config.service";
import { EmailModule } from "./email/email.module";
@ -25,6 +27,24 @@ import { UserController } from "./user/user.controller";
EmailModule,
PrismaModule,
ConfigModule,
MulterModule.registerAsync({
useFactory: (config: ConfigService) => ({
fileFilter: (req: Request, file, cb) => {
const maxFileSize = config.get("maxFileSize");
const requestFileSize = parseInt(req.headers["content-length"]);
const isValidFileSize = requestFileSize <= maxFileSize;
cb(
!isValidFileSize &&
new HttpException(
`File must be smaller than ${maxFileSize} bytes`,
HttpStatus.PAYLOAD_TOO_LARGE
),
isValidFileSize
);
},
}),
inject: [ConfigService],
}),
ThrottlerModule.forRoot({
ttl: 60,
limit: 100,

View file

@ -27,7 +27,9 @@ export class AuthService {
const user = await this.prisma.user.create({
data: {
email: dto.email,
username: dto.username,
password: hash,
isAdmin: !this.config.get("setupFinished"),
},
});
@ -38,16 +40,22 @@ export class AuthService {
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
throw new BadRequestException("Credentials taken");
const duplicatedField: string = e.meta.target[0];
throw new BadRequestException(
`A user with this ${duplicatedField} already exists`
);
}
}
}
}
async signIn(dto: AuthSignInDTO) {
const user = await this.prisma.user.findUnique({
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
email: dto.email,
OR: [{ email: dto.email }, { username: dto.username }],
},
});

View file

@ -1,3 +1,17 @@
import { PickType } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail, Length, Matches } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthRegisterDTO extends UserDTO {}
export class AuthRegisterDTO extends PickType(UserDTO, ["password"] as const) {
@Expose()
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
message: "Username can only contain letters, numbers, dots and underscores",
})
@Length(3, 32)
username: string;
@Expose()
@IsEmail()
email: string;
}

View file

@ -2,6 +2,7 @@ import { PickType } from "@nestjs/swagger";
import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInDTO extends PickType(UserDTO, [
"username",
"email",
"password",
] as const) {}

View file

@ -3,9 +3,11 @@ import { User } from "@prisma/client";
@Injectable()
export class AdministratorGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
canActivate(context: ExecutionContext) {
const { user }: { user: User } = context.switchToHttp().getRequest();
if (!user) return false;
return user.isAdministrator;
return user.isAdmin;
}
}

View file

@ -8,7 +8,6 @@ import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
console.log(config.get("jwtSecret"));
config.get("jwtSecret");
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@ -17,11 +16,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: { sub: string }) {
console.log("vali");
const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
console.log({ user });
return user;
}
}

View file

@ -1,5 +1,14 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "./config.service";
import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto";
@ -15,7 +24,7 @@ export class ConfigController {
}
@Get("admin")
@UseGuards(AdministratorGuard)
@UseGuards(JwtGuard, AdministratorGuard)
async listForAdmin() {
return new AdminConfigDTO().fromList(
await this.configService.listForAdmin()
@ -23,10 +32,16 @@ export class ConfigController {
}
@Patch("admin/:key")
@UseGuards(AdministratorGuard)
@UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
return new AdminConfigDTO().from(
await this.configService.update(key, data.value)
);
}
@Post("admin/finishSetup")
@UseGuards(JwtGuard, AdministratorGuard)
async finishSetup() {
return await this.configService.finishSetup();
}
}

View file

@ -21,27 +21,21 @@ export class ConfigService {
if (!configVariable) throw new Error(`Config variable ${key} not found`);
const value = configVariable.value ?? configVariable.default;
if (configVariable.type == "number") return parseInt(value);
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string") return value;
if (configVariable.type == "number") return parseInt(configVariable.value);
if (configVariable.type == "boolean") return configVariable.value == "true";
if (configVariable.type == "string") return configVariable.value;
}
async listForAdmin() {
return await this.prisma.config.findMany();
return await this.prisma.config.findMany({
where: { locked: { equals: false } },
});
}
async list() {
const configVariables = await this.prisma.config.findMany({
return await this.prisma.config.findMany({
where: { secret: { equals: false } },
});
return configVariables.map((configVariable) => {
if (!configVariable.value) configVariable.value = configVariable.default;
return configVariable;
});
}
async update(key: string, value: string | number | boolean) {
@ -57,9 +51,20 @@ export class ConfigService {
`Config variable must be of type ${configVariable.type}`
);
return await this.prisma.config.update({
const updatedVariable = await this.prisma.config.update({
where: { key },
data: { value: value.toString() },
});
this.configVariables = await this.prisma.config.findMany();
return updatedVariable;
}
async finishSetup() {
return await this.prisma.config.update({
where: { key: "setupFinished" },
data: { value: "true" },
});
}
}

View file

@ -2,17 +2,19 @@ import { Expose, plainToClass } from "class-transformer";
import { ConfigDTO } from "./config.dto";
export class AdminConfigDTO extends ConfigDTO {
@Expose()
default: string;
@Expose()
secret: boolean;
@Expose()
updatedAt: Date;
@Expose()
description: string;
from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true });
return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true,
});
}
fromList(partial: Partial<AdminConfigDTO>[]) {

View file

@ -0,0 +1,88 @@
import * as crypto from "crypto";
const configVariables = [
{
key: "setupFinished",
description: "Whether the setup has been finished",
type: "boolean",
value: "false",
secret: false,
locked: true,
},
{
key: "appUrl",
description: "On which URL Pingvin Share is available",
type: "string",
value: "http://localhost:3000",
secret: false,
},
{
key: "showHomePage",
description: "Whether to show the home page",
type: "boolean",
value: "true",
secret: false,
},
{
key: "allowRegistration",
description: "Whether registration is allowed",
type: "boolean",
value: "true",
secret: false,
},
{
key: "allowUnauthenticatedShares",
description: "Whether unauthorized users can create shares",
type: "boolean",
value: "false",
secret: false,
},
{
key: "maxFileSize",
description: "Maximum file size in bytes",
type: "number",
value: "1000000000",
secret: false,
},
{
key: "jwtSecret",
description: "Long random string used to sign JWT tokens",
type: "string",
value: crypto.randomBytes(256).toString("base64"),
locked: true,
},
{
key: "emailRecipientsEnabled",
description:
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email and password of your SMTP server.",
type: "boolean",
value: "false",
secret: false,
},
{
key: "smtpHost",
description: "Host of the SMTP server",
type: "string",
value: "",
},
{
key: "smtpPort",
description: "Port of the SMTP server",
type: "number",
value: "",
},
{
key: "smtpEmail",
description: "Email address of the SMTP server",
type: "string",
value: "",
},
{
key: "smtpPassword",
description: "Password of the SMTP server",
type: "string",
value: "",
},
];
export default configVariables;

View file

@ -23,17 +23,13 @@ export class EmailService {
throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
const creatorIdentifier = creator
? creator.firstName && creator.lastName
? `${creator.firstName} ${creator.lastName}`
: creator.email
: "A Pingvin Share user";
await transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Files shared with you",
text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
});
}
}

View file

@ -18,7 +18,6 @@ import { ShareDTO } from "src/share/dto/share.dto";
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service";
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
@Controller("shares/:shareId/files")
export class FileController {
@ -32,7 +31,7 @@ export class FileController {
})
)
async create(
@UploadedFile(FileValidationPipe)
@UploadedFile()
file: Express.Multer.File,
@Param("shareId") shareId: string
) {

View file

@ -1,13 +1,17 @@
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from "@nestjs/common";
import { ConfigService } from "src/config/config.service";
@Injectable()
export class FileValidationPipe implements PipeTransform {
constructor(private config: ConfigService) {}
async transform(value: any, metadata: ArgumentMetadata) {
// "value" is an object containing the file's attributes and metadata
console.log(this.config.get("maxFileSize"));
const oneKb = 1000;
return value.size < oneKb;
if (value.size > this.config.get("maxFileSize"))
throw new BadRequestException("File is ");
return value;
}
}

View file

@ -11,7 +11,7 @@ import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO {
@IsString()
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
message: "ID only can contain letters, numbers, underscores and hyphens",
message: "ID can only contain letters, numbers, underscores and hyphens",
})
@Length(3, 50)
id: string;

View file

@ -1,18 +1,17 @@
import { Expose, plainToClass } from "class-transformer";
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class UserDTO {
@Expose()
id: string;
@Expose()
firstName: string;
@IsOptional()
@IsString()
username: string;
@Expose()
lastName: string;
@Expose()
@IsNotEmpty()
@IsOptional()
@IsEmail()
email: string;
@ -20,6 +19,9 @@ export class UserDTO {
@IsString()
password: string;
@Expose()
isAdmin: boolean;
from(partial: Partial<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
}

View file

@ -0,0 +1,95 @@
import { ActionIcon, Code, Group, Skeleton, Table, Text } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react";
import { TbEdit, TbLock } from "react-icons/tb";
import configService from "../../services/config.service";
import { AdminConfig as AdminConfigType } from "../../types/config.type";
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
const AdminConfigTable = () => {
const modals = useModals();
const [isLoading, setIsLoading] = useState(false);
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
const getConfigVariables = () => {
setIsLoading(true);
configService.listForAdmin().then((configVariables) => {
setConfigVariables(configVariables);
setIsLoading(false);
});
};
useEffect(() => {
getConfigVariables();
}, []);
const skeletonRows = [...Array(9)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={18} width={80} mb="sm" />
<Skeleton height={30} />
</td>
<td>
<Skeleton height={18} />
</td>
<td>
<Group position="right">
<Skeleton height={25} width={25} />
</Group>
</td>
</tr>
));
return (
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
{isLoading
? skeletonRows
: configVariables.map((element) => (
<tr key={element.key}>
<td style={{ maxWidth: "200px" }}>
<Code>{element.key}</Code> {element.secret && <TbLock />}{" "}
<br />
<Text size="xs" color="dimmed">
{" "}
{element.description}
</Text>
</td>
<td>{element.value}</td>
<td>
<Group position="right">
<ActionIcon
color="primary"
variant="light"
size={25}
onClick={() =>
showUpdateConfigVariableModal(
modals,
element,
getConfigVariables
)
}
>
<TbEdit />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
);
};
export default AdminConfigTable;

View file

@ -0,0 +1,96 @@
import {
Button,
Code,
NumberInput,
Select,
Space,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import configService from "../../services/config.service";
import { AdminConfig } from "../../types/config.type";
import toast from "../../utils/toast.util";
const showUpdateConfigVariableModal = (
modals: ModalsContextProps,
configVariable: AdminConfig,
getConfigVariables: () => void
) => {
return modals.openModal({
title: <Title order={5}>Update configuration variable</Title>,
children: (
<Body
configVariable={configVariable}
getConfigVariables={getConfigVariables}
/>
),
});
};
const Body = ({
configVariable,
getConfigVariables,
}: {
configVariable: AdminConfig;
getConfigVariables: () => void;
}) => {
const modals = useModals();
const form = useForm({
initialValues: {
stringValue: configVariable.value,
numberValue: parseInt(configVariable.value),
booleanValue: configVariable.value,
},
});
return (
<Stack align="stretch">
<Text>
Set <Code>{configVariable.key}</Code> to
</Text>
{configVariable.type == "string" && (
<TextInput label="Value" {...form.getInputProps("stringValue")} />
)}
{configVariable.type == "number" && (
<NumberInput label="Value" {...form.getInputProps("numberValue")} />
)}
{configVariable.type == "boolean" && (
<Select
data={[
{ value: "true", label: "True" },
{ value: "false", label: "False" },
]}
{...form.getInputProps("booleanValue")}
/>
)}
<Space />
<Button
onClick={async () => {
const value =
configVariable.type == "string"
? form.values.stringValue
: configVariable.type == "number"
? form.values.numberValue
: form.values.booleanValue == "true";
await configService
.update(configVariable.key, value)
.then(() => {
getConfigVariables();
modals.closeAll();
})
.catch((e) => toast.error(e.response.data.message));
}}
>
Save
</Button>
</Stack>
);
};
export default showUpdateConfigVariableModal;

View file

@ -0,0 +1,86 @@
import {
Anchor,
Button,
Container,
Paper,
PasswordInput,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import Link from "next/link";
import * as yup from "yup";
import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";
const SignInForm = () => {
const config = useConfig();
const validationSchema = yup.object().shape({
emailOrUsername: yup.string().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
emailOrUsername: "",
password: "",
},
validate: yupResolver(validationSchema),
});
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
};
return (
<Container size={420} my={40}>
<Title
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
Welcome back
</Title>
{config.get("allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
You don't have an account yet?{" "}
<Anchor component={Link} href={"signUp"} size="sm">
{"Sign up"}
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
signIn(values.emailOrUsername, values.password)
)}
>
<TextInput
label="Email or username"
placeholder="you@email.com"
{...form.getInputProps("emailOrUsername")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
mt="md"
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
Sign in
</Button>
</form>
</Paper>
</Container>
);
};
export default SignInForm;

View file

@ -15,17 +15,19 @@ import useConfig from "../../hooks/config.hook";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const SignUpForm = () => {
const config = useConfig();
const validationSchema = yup.object().shape({
email: yup.string().email().required(),
username: yup.string().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
email: "",
username: "",
password: "",
},
validate: yupResolver(validationSchema),
@ -34,12 +36,12 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/upload"))
.then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message));
};
const signUp = (email: string, password: string) => {
const signUp = (email: string, username: string, password: string) => {
authService
.signUp(email, password)
.signUp(email, username, password)
.then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message));
};
@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
fontWeight: 900,
})}
>
{mode == "signUp" ? "Sign up" : "Welcome back"}
Sign up
</Title>
{config.get("allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}>
{mode == "signUp"
? "You have an account already?"
: "You don't have an account yet?"}{" "}
<Anchor
component={Link}
href={mode == "signUp" ? "signIn" : "signUp"}
size="sm"
>
{mode == "signUp" ? "Sign in" : "Sign up"}
You have an account already?{" "}
<Anchor component={Link} href={"signIn"} size="sm">
Sign in
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
mode == "signIn"
? signIn(values.email, values.password)
: signUp(values.email, values.password)
signUp(values.email, values.username, values.password)
)}
>
<TextInput
label="Username"
placeholder="john.doe"
{...form.getInputProps("username")}
/>
<TextInput
label="Email"
placeholder="you@email.com"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
{mode == "signUp" ? "Let's get started" : "Sign in"}
Let's get started
</Button>
</form>
</Paper>
@ -97,4 +97,4 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
);
};
export default AuthForm;
export default SignUpForm;

View file

@ -1,9 +1,12 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link";
import { TbDoorExit, TbLink } from "react-icons/tb";
import { TbDoorExit, TbLink, TbSettings } from "react-icons/tb";
import useUser from "../../hooks/user.hook";
import authService from "../../services/auth.service";
const ActionAvatar = () => {
const user = useUser();
return (
<Menu position="bottom-start" withinPortal>
<Menu.Target>
@ -19,6 +22,16 @@ const ActionAvatar = () => {
>
My shares
</Menu.Item>
{user!.isAdmin && (
<Menu.Item
component={Link}
href="/admin/config"
icon={<TbSettings size={14} />}
>
Administration
</Menu.Item>
)}
<Menu.Item
onClick={async () => {
authService.signOut();

View file

@ -8,9 +8,10 @@ import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar";
import { ConfigContext } from "../hooks/config.hook";
import useConfig, { ConfigContext } from "../hooks/config.hook";
import { UserContext } from "../hooks/user.hook";
import authService from "../services/auth.service";
import configService from "../services/config.service";
@ -23,15 +24,17 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme();
const router = useRouter();
const config = useConfig();
const [colorScheme, setColorScheme] = useState<ColorScheme>();
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const [config, setConfig] = useState<Config[] | null>(null);
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
const getInitalData = async () => {
setIsLoading(true);
setConfig(await configService.getAll());
setConfigVariables(await configService.list());
await authService.refreshAccessToken();
setUser(await userService.getCurrentUser());
setIsLoading(false);
@ -42,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
getInitalData();
}, []);
useEffect(() => {
if (
configVariables &&
configVariables.filter((variable) => variable.key)[0].value == "false" &&
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
) {
router.push(!user ? "/auth/signUp" : "admin/setup");
}
}, [router.asPath]);
useEffect(() => {
setColorScheme(systemTheme);
}, [systemTheme]);
@ -59,7 +72,7 @@ function App({ Component, pageProps }: AppProps) {
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<ConfigContext.Provider value={config}>
<ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header />

View file

@ -0,0 +1,13 @@
import { Space } from "@mantine/core";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
const AdminConfig = () => {
return (
<>
<AdminConfigTable />
<Space h="xl" />
</>
);
};
export default AdminConfig;

View file

@ -0,0 +1,50 @@
import { Button, Stack, Text, Title } from "@mantine/core";
import { useRouter } from "next/router";
import { useState } from "react";
import AdminConfigTable from "../../components/admin/AdminConfigTable";
import Logo from "../../components/Logo";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
import configService from "../../services/config.service";
const Setup = () => {
const router = useRouter();
const config = useConfig();
const user = useUser();
const [isLoading, setIsLoading] = useState(false);
if (!user) {
router.push("/auth/signUp");
return;
} else if (config.get("setupFinished")) {
router.push("/");
return;
}
return (
<>
<Stack align="center">
<Logo height={80} width={80} />
<Title order={2}>Welcome to Pingvin Share</Title>
<Text>Let's customize Pingvin Share for you! </Text>
<AdminConfigTable />
<Button
loading={isLoading}
onClick={async () => {
setIsLoading(true);
await configService.finishSetup();
setIsLoading(false);
window.location.reload();
}}
mb={70}
mt="lg"
>
Let me in!
</Button>
</Stack>
</>
);
};
export default Setup;

View file

@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
@ -12,7 +12,7 @@ const SignIn = () => {
return (
<>
<Meta title="Sign In" />
<AuthForm mode="signIn" />
<SignInForm />
</>
);
}

View file

@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import SignUpForm from "../../components/auth/SignUpForm";
import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook";
@ -16,7 +16,7 @@ const SignUp = () => {
return (
<>
<Meta title="Sign Up" />
<AuthForm mode="signUp" />
<SignUpForm />
</>
);
}

View file

@ -2,15 +2,22 @@ import { getCookie, setCookies } from "cookies-next";
import * as jose from "jose";
import api from "./api.service";
const signIn = async (email: string, password: string) => {
const response = await api.post("auth/signIn", { email, password });
const signIn = async (emailOrUsername: string, password: string) => {
const emailOrUsernameBody = emailOrUsername.includes("@")
? { email: emailOrUsername }
: { username: emailOrUsername };
const response = await api.post("auth/signIn", {
...emailOrUsernameBody,
password,
});
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken);
return response;
};
const signUp = async (email: string, password: string) => {
return await api.post("auth/signUp", { email, password });
const signUp = async (email: string, username: string, password: string) => {
return await api.post("auth/signUp", { email, username, password });
};
const signOut = () => {

View file

@ -1,11 +1,24 @@
import Config from "../types/config.type";
import Config, { AdminConfig } from "../types/config.type";
import api from "./api.service";
const getAll = async (): Promise<Config[]> => {
const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data;
};
const listForAdmin = async (): Promise<AdminConfig[]> => {
return (await api.get("/configs/admin")).data;
};
const update = async (
key: string,
value: string | number | boolean
): Promise<AdminConfig[]> => {
return (await api.patch(`/configs/admin/${key}`, { value })).data;
};
const get = (key: string, configVariables: Config[]): any => {
if (!configVariables) return null;
const configVariable = configVariables.filter(
(variable) => variable.key == key
)[0];
@ -17,7 +30,14 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "string") return configVariable.value;
};
export default {
getAll,
get,
const finishSetup = async (): Promise<AdminConfig[]> => {
return (await api.post("/configs/admin/finishSetup")).data;
};
export default {
list,
listForAdmin,
update,
get,
finishSetup,
};

View file

@ -4,4 +4,10 @@ type Config = {
type: string;
};
export type AdminConfig = Config & {
updatedAt: Date;
secret: boolean;
description: string;
};
export default Config;

View file

@ -3,6 +3,7 @@ export default interface User {
firstName?: string;
lastName?: string;
email: string;
isAdmin: boolean;
}
export interface CurrentUser extends User {}