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

View file

@ -1,79 +1,8 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import configVariables from "../../src/configVariables";
const prisma = new PrismaClient(); 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() { async function main() {
for (const variable of configVariables) { for (const variable of configVariables) {
const existingConfigVariable = await prisma.config.findUnique({ const existingConfigVariable = await prisma.config.findUnique({
@ -85,14 +14,6 @@ async function main() {
await prisma.config.create({ await prisma.config.create({
data: variable, 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 { ScheduleModule } from "@nestjs/schedule";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { JobsService } from "./jobs/jobs.service"; import { JobsService } from "./jobs/jobs.service";
import { APP_GUARD } from "@nestjs/core"; import { APP_GUARD } from "@nestjs/core";
import { MulterModule } from "@nestjs/platform-express";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { Request } from "express";
import { ConfigModule } from "./config/config.module"; import { ConfigModule } from "./config/config.module";
import { ConfigService } from "./config/config.service"; import { ConfigService } from "./config/config.service";
import { EmailModule } from "./email/email.module"; import { EmailModule } from "./email/email.module";
@ -25,6 +27,24 @@ import { UserController } from "./user/user.controller";
EmailModule, EmailModule,
PrismaModule, PrismaModule,
ConfigModule, 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({ ThrottlerModule.forRoot({
ttl: 60, ttl: 60,
limit: 100, limit: 100,

View file

@ -27,7 +27,9 @@ export class AuthService {
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
email: dto.email, email: dto.email,
username: dto.username,
password: hash, password: hash,
isAdmin: !this.config.get("setupFinished"),
}, },
}); });
@ -38,16 +40,22 @@ export class AuthService {
} catch (e) { } catch (e) {
if (e instanceof PrismaClientKnownRequestError) { if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") { 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) { 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: { 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"; 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"; import { UserDTO } from "src/user/dto/user.dto";
export class AuthSignInDTO extends PickType(UserDTO, [ export class AuthSignInDTO extends PickType(UserDTO, [
"username",
"email", "email",
"password", "password",
] as const) {} ] as const) {}

View file

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

View file

@ -8,7 +8,6 @@ import { PrismaService } from "src/prisma/prisma.service";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
console.log(config.get("jwtSecret"));
config.get("jwtSecret"); config.get("jwtSecret");
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@ -17,11 +16,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
async validate(payload: { sub: string }) { async validate(payload: { sub: string }) {
console.log("vali");
const user: User = await this.prisma.user.findUnique({ const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
}); });
console.log({ user });
return 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 { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
import { JwtGuard } from "src/auth/guard/jwt.guard";
import { ConfigService } from "./config.service"; import { ConfigService } from "./config.service";
import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { AdminConfigDTO } from "./dto/adminConfig.dto";
import { ConfigDTO } from "./dto/config.dto"; import { ConfigDTO } from "./dto/config.dto";
@ -15,7 +24,7 @@ export class ConfigController {
} }
@Get("admin") @Get("admin")
@UseGuards(AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async listForAdmin() { async listForAdmin() {
return new AdminConfigDTO().fromList( return new AdminConfigDTO().fromList(
await this.configService.listForAdmin() await this.configService.listForAdmin()
@ -23,10 +32,16 @@ export class ConfigController {
} }
@Patch("admin/:key") @Patch("admin/:key")
@UseGuards(AdministratorGuard) @UseGuards(JwtGuard, AdministratorGuard)
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
return new AdminConfigDTO().from( return new AdminConfigDTO().from(
await this.configService.update(key, data.value) 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`); if (!configVariable) throw new Error(`Config variable ${key} not found`);
const value = configVariable.value ?? configVariable.default; if (configVariable.type == "number") return parseInt(configVariable.value);
if (configVariable.type == "boolean") return configVariable.value == "true";
if (configVariable.type == "number") return parseInt(value); if (configVariable.type == "string") return configVariable.value;
if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string") return value;
} }
async listForAdmin() { async listForAdmin() {
return await this.prisma.config.findMany(); return await this.prisma.config.findMany({
where: { locked: { equals: false } },
});
} }
async list() { async list() {
const configVariables = await this.prisma.config.findMany({ return await this.prisma.config.findMany({
where: { secret: { equals: false } }, 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) { async update(key: string, value: string | number | boolean) {
@ -57,9 +51,20 @@ export class ConfigService {
`Config variable must be of type ${configVariable.type}` `Config variable must be of type ${configVariable.type}`
); );
return await this.prisma.config.update({ const updatedVariable = await this.prisma.config.update({
where: { key }, where: { key },
data: { value: value.toString() }, 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"; import { ConfigDTO } from "./config.dto";
export class AdminConfigDTO extends ConfigDTO { export class AdminConfigDTO extends ConfigDTO {
@Expose()
default: string;
@Expose() @Expose()
secret: boolean; secret: boolean;
@Expose() @Expose()
updatedAt: Date; updatedAt: Date;
@Expose()
description: string;
from(partial: Partial<AdminConfigDTO>) { from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true }); return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true,
});
} }
fromList(partial: Partial<AdminConfigDTO>[]) { 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"); throw new InternalServerErrorException("Email service disabled");
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; 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({ await transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: "Files shared with you", 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 { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard"; import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
import { FileService } from "./file.service"; import { FileService } from "./file.service";
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
@Controller("shares/:shareId/files") @Controller("shares/:shareId/files")
export class FileController { export class FileController {
@ -32,7 +31,7 @@ export class FileController {
}) })
) )
async create( async create(
@UploadedFile(FileValidationPipe) @UploadedFile()
file: Express.Multer.File, file: Express.Multer.File,
@Param("shareId") shareId: string @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"; import { ConfigService } from "src/config/config.service";
@Injectable() @Injectable()
export class FileValidationPipe implements PipeTransform { export class FileValidationPipe implements PipeTransform {
constructor(private config: ConfigService) {} constructor(private config: ConfigService) {}
async transform(value: any, metadata: ArgumentMetadata) { async transform(value: any, metadata: ArgumentMetadata) {
// "value" is an object containing the file's attributes and metadata if (value.size > this.config.get("maxFileSize"))
console.log(this.config.get("maxFileSize")); throw new BadRequestException("File is ");
const oneKb = 1000; return value;
return value.size < oneKb;
} }
} }

View file

@ -11,7 +11,7 @@ import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO { export class CreateShareDTO {
@IsString() @IsString()
@Matches("^[a-zA-Z0-9_-]*$", undefined, { @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) @Length(3, 50)
id: string; id: string;

View file

@ -1,18 +1,17 @@
import { Expose, plainToClass } from "class-transformer"; import { Expose, plainToClass } from "class-transformer";
import { IsEmail, IsNotEmpty, IsString } from "class-validator"; import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class UserDTO { export class UserDTO {
@Expose() @Expose()
id: string; id: string;
@Expose() @Expose()
firstName: string; @IsOptional()
@IsString()
username: string;
@Expose() @Expose()
lastName: string; @IsOptional()
@Expose()
@IsNotEmpty()
@IsEmail() @IsEmail()
email: string; email: string;
@ -20,6 +19,9 @@ export class UserDTO {
@IsString() @IsString()
password: string; password: string;
@Expose()
isAdmin: boolean;
from(partial: Partial<UserDTO>) { from(partial: Partial<UserDTO>) {
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); 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 authService from "../../services/auth.service";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => { const SignUpForm = () => {
const config = useConfig(); const config = useConfig();
const validationSchema = yup.object().shape({ const validationSchema = yup.object().shape({
email: yup.string().email().required(), email: yup.string().email().required(),
username: yup.string().required(),
password: yup.string().min(8).required(), password: yup.string().min(8).required(),
}); });
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
email: "", email: "",
username: "",
password: "", password: "",
}, },
validate: yupResolver(validationSchema), validate: yupResolver(validationSchema),
@ -34,12 +36,12 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const signIn = (email: string, password: string) => { const signIn = (email: string, password: string) => {
authService authService
.signIn(email, password) .signIn(email, password)
.then(() => window.location.replace("/upload")) .then(() => window.location.replace("/"))
.catch((e) => toast.error(e.response.data.message)); .catch((e) => toast.error(e.response.data.message));
}; };
const signUp = (email: string, password: string) => { const signUp = (email: string, username: string, password: string) => {
authService authService
.signUp(email, password) .signUp(email, username, password)
.then(() => signIn(email, password)) .then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message)); .catch((e) => toast.error(e.response.data.message));
}; };
@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
fontWeight: 900, fontWeight: 900,
})} })}
> >
{mode == "signUp" ? "Sign up" : "Welcome back"} Sign up
</Title> </Title>
{config.get("allowRegistration") && ( {config.get("allowRegistration") && (
<Text color="dimmed" size="sm" align="center" mt={5}> <Text color="dimmed" size="sm" align="center" mt={5}>
{mode == "signUp" You have an account already?{" "}
? "You have an account already?" <Anchor component={Link} href={"signIn"} size="sm">
: "You don't have an account yet?"}{" "} Sign in
<Anchor
component={Link}
href={mode == "signUp" ? "signIn" : "signUp"}
size="sm"
>
{mode == "signUp" ? "Sign in" : "Sign up"}
</Anchor> </Anchor>
</Text> </Text>
)} )}
<Paper withBorder shadow="md" p={30} mt={30} radius="md"> <Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form <form
onSubmit={form.onSubmit((values) => onSubmit={form.onSubmit((values) =>
mode == "signIn" signUp(values.email, values.username, values.password)
? signIn(values.email, values.password)
: signUp(values.email, values.password)
)} )}
> >
<TextInput
label="Username"
placeholder="john.doe"
{...form.getInputProps("username")}
/>
<TextInput <TextInput
label="Email" label="Email"
placeholder="you@email.com" placeholder="you@email.com"
mt="md"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button fullWidth mt="xl" type="submit"> <Button fullWidth mt="xl" type="submit">
{mode == "signUp" ? "Let's get started" : "Sign in"} Let's get started
</Button> </Button>
</form> </form>
</Paper> </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 { ActionIcon, Avatar, Menu } from "@mantine/core";
import Link from "next/link"; 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"; import authService from "../../services/auth.service";
const ActionAvatar = () => { const ActionAvatar = () => {
const user = useUser();
return ( return (
<Menu position="bottom-start" withinPortal> <Menu position="bottom-start" withinPortal>
<Menu.Target> <Menu.Target>
@ -19,6 +22,16 @@ const ActionAvatar = () => {
> >
My shares My shares
</Menu.Item> </Menu.Item>
{user!.isAdmin && (
<Menu.Item
component={Link}
href="/admin/config"
icon={<TbSettings size={14} />}
>
Administration
</Menu.Item>
)}
<Menu.Item <Menu.Item
onClick={async () => { onClick={async () => {
authService.signOut(); authService.signOut();

View file

@ -8,9 +8,10 @@ import { useColorScheme } from "@mantine/hooks";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications"; import { NotificationsProvider } from "@mantine/notifications";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Header from "../components/navBar/NavBar"; 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 { UserContext } from "../hooks/user.hook";
import authService from "../services/auth.service"; import authService from "../services/auth.service";
import configService from "../services/config.service"; import configService from "../services/config.service";
@ -23,15 +24,17 @@ import { GlobalLoadingContext } from "../utils/loading.util";
function App({ Component, pageProps }: AppProps) { function App({ Component, pageProps }: AppProps) {
const systemTheme = useColorScheme(); const systemTheme = useColorScheme();
const router = useRouter();
const config = useConfig();
const [colorScheme, setColorScheme] = useState<ColorScheme>(); const [colorScheme, setColorScheme] = useState<ColorScheme>();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null); const [user, setUser] = useState<CurrentUser | null>(null);
const [config, setConfig] = useState<Config[] | null>(null); const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
const getInitalData = async () => { const getInitalData = async () => {
setIsLoading(true); setIsLoading(true);
setConfig(await configService.getAll()); setConfigVariables(await configService.list());
await authService.refreshAccessToken(); await authService.refreshAccessToken();
setUser(await userService.getCurrentUser()); setUser(await userService.getCurrentUser());
setIsLoading(false); setIsLoading(false);
@ -42,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
getInitalData(); 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(() => { useEffect(() => {
setColorScheme(systemTheme); setColorScheme(systemTheme);
}, [systemTheme]); }, [systemTheme]);
@ -59,7 +72,7 @@ function App({ Component, pageProps }: AppProps) {
{isLoading ? ( {isLoading ? (
<LoadingOverlay visible overlayOpacity={1} /> <LoadingOverlay visible overlayOpacity={1} />
) : ( ) : (
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={configVariables}>
<UserContext.Provider value={user}> <UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} /> <LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Header /> <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 { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm"; import SignInForm from "../../components/auth/SignInForm";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
@ -12,7 +12,7 @@ const SignIn = () => {
return ( return (
<> <>
<Meta title="Sign In" /> <Meta title="Sign In" />
<AuthForm mode="signIn" /> <SignInForm />
</> </>
); );
} }

View file

@ -1,5 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm"; import SignUpForm from "../../components/auth/SignUpForm";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook"; import useConfig from "../../hooks/config.hook";
import useUser from "../../hooks/user.hook"; import useUser from "../../hooks/user.hook";
@ -16,7 +16,7 @@ const SignUp = () => {
return ( return (
<> <>
<Meta title="Sign Up" /> <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 * as jose from "jose";
import api from "./api.service"; import api from "./api.service";
const signIn = async (email: string, password: string) => { const signIn = async (emailOrUsername: string, password: string) => {
const response = await api.post("auth/signIn", { email, password }); const emailOrUsernameBody = emailOrUsername.includes("@")
? { email: emailOrUsername }
: { username: emailOrUsername };
const response = await api.post("auth/signIn", {
...emailOrUsernameBody,
password,
});
setCookies("access_token", response.data.accessToken); setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken); setCookies("refresh_token", response.data.refreshToken);
return response; return response;
}; };
const signUp = async (email: string, password: string) => { const signUp = async (email: string, username: string, password: string) => {
return await api.post("auth/signUp", { email, password }); return await api.post("auth/signUp", { email, username, password });
}; };
const signOut = () => { 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"; import api from "./api.service";
const getAll = async (): Promise<Config[]> => { const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data; 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 => { const get = (key: string, configVariables: Config[]): any => {
if (!configVariables) return null;
const configVariable = configVariables.filter( const configVariable = configVariables.filter(
(variable) => variable.key == key (variable) => variable.key == key
)[0]; )[0];
@ -17,7 +30,14 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "string") return configVariable.value; if (configVariable.type == "string") return configVariable.value;
}; };
export default { const finishSetup = async (): Promise<AdminConfig[]> => {
getAll, return (await api.post("/configs/admin/finishSetup")).data;
get, };
export default {
list,
listForAdmin,
update,
get,
finishSetup,
}; };

View file

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

View file

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