From 13f98cc32c804c786c71b10dc4cf029d7795be76 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 14 Nov 2022 17:03:45 +0100 Subject: [PATCH 01/19] feat: add administrator guard --- backend/prisma/schema.prisma | 9 +++++---- backend/src/auth/guard/isAdmin.guard.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 backend/src/auth/guard/isAdmin.guard.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0512c99..bb2b8af 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,10 +12,11 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - email String @unique - password String - firstName String? - lastName String? + email String @unique + password String + isAdministrator Boolean @default(false) + firstName String? + lastName String? shares Share[] refreshTokens RefreshToken[] diff --git a/backend/src/auth/guard/isAdmin.guard.ts b/backend/src/auth/guard/isAdmin.guard.ts new file mode 100644 index 0000000..b3bf251 --- /dev/null +++ b/backend/src/auth/guard/isAdmin.guard.ts @@ -0,0 +1,10 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; + +@Injectable() +export class AdministratorGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const { user }: { user: User } = context.switchToHttp().getRequest(); + return user.isAdministrator; + } +} From 1b5e53ff7ee00228eda6dc5c62d5cd8c3752b03b Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 28 Nov 2022 15:04:32 +0100 Subject: [PATCH 02/19] feat: add new config strategy to backend --- backend/package.json | 3 + backend/prisma/schema.prisma | 11 ++ backend/prisma/seed/config.seed.ts | 118 +++++++++++++++++++ backend/src/app.module.ts | 16 ++- backend/src/auth/auth.controller.ts | 6 +- backend/src/auth/auth.service.ts | 4 +- backend/src/auth/guard/jwt.guard.ts | 8 +- backend/src/auth/strategy/jwt.strategy.ts | 9 +- backend/src/config/config.controller.ts | 18 +++ backend/src/config/config.module.ts | 21 ++++ backend/src/config/config.service.ts | 41 +++++++ backend/src/config/dto/config.dto.ts | 18 +++ backend/src/email/email.service.ts | 35 +++--- backend/src/file/file.controller.ts | 10 +- backend/src/file/file.module.ts | 3 +- backend/src/file/file.service.ts | 12 +- backend/src/file/pipe/fileValidation.pipe.ts | 13 ++ backend/src/prisma/prisma.service.ts | 2 +- backend/src/share/share.service.ts | 6 +- 19 files changed, 304 insertions(+), 50 deletions(-) create mode 100644 backend/prisma/seed/config.seed.ts create mode 100644 backend/src/config/config.controller.ts create mode 100644 backend/src/config/config.module.ts create mode 100644 backend/src/config/config.service.ts create mode 100644 backend/src/config/dto/config.dto.ts create mode 100644 backend/src/file/pipe/fileValidation.pipe.ts diff --git a/backend/package.json b/backend/package.json index 9dd0a4b..f349aa9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,6 +9,9 @@ "format": "prettier --write 'src/**/*.ts'", "test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json" }, + "prisma": { + "seed": "ts-node prisma/seed/config.seed.ts" + }, "dependencies": { "@nestjs/common": "^9.1.2", "@nestjs/config": "^2.2.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bb2b8af..d997f58 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -77,3 +77,14 @@ model ShareSecurity { shareId String? @unique share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade) } + +model Config { + updatedAt DateTime @updatedAt + + key String @id + type String + value String? + default String + secret Boolean @default(true) + locked Boolean @default(false) +} diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts new file mode 100644 index 0000000..269d7fa --- /dev/null +++ b/backend/prisma/seed/config.seed.ts @@ -0,0 +1,118 @@ +import { PrismaClient } from "@prisma/client"; + +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({ + where: { key: variable.key }, + }); + + // Create a new config variable if it doesn't exist + if (!existingConfigVariable) { + 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 }, + }); + } + } + } + + // Delete the config variable if it doesn't exist anymore + const configVariablesFromDatabase = await prisma.config.findMany(); + + for (const configVariableFromDatabase of configVariablesFromDatabase) { + if (!configVariables.find((v) => v.key == configVariableFromDatabase.key)) { + await prisma.config.delete({ + where: { key: configVariableFromDatabase.key }, + }); + } + } +} +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d93252f..8de599b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,11 +1,14 @@ import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; + import { ScheduleModule } from "@nestjs/schedule"; import { AuthModule } from "./auth/auth.module"; import { JobsService } from "./jobs/jobs.service"; import { APP_GUARD } from "@nestjs/core"; import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { ConfigModule } from "./config/config.module"; +import { ConfigService } from "./config/config.service"; +import { EmailModule } from "./email/email.module"; import { FileController } from "./file/file.controller"; import { FileModule } from "./file/file.module"; import { PrismaModule } from "./prisma/prisma.module"; @@ -13,7 +16,6 @@ import { PrismaService } from "./prisma/prisma.service"; import { ShareController } from "./share/share.controller"; import { ShareModule } from "./share/share.module"; import { UserController } from "./user/user.controller"; -import { EmailModule } from "./email/email.module"; @Module({ imports: [ @@ -22,7 +24,7 @@ import { EmailModule } from "./email/email.module"; FileModule, EmailModule, PrismaModule, - ConfigModule.forRoot({ isGlobal: true }), + ConfigModule, ThrottlerModule.forRoot({ ttl: 60, limit: 100, @@ -30,8 +32,16 @@ import { EmailModule } from "./email/email.module"; ScheduleModule.forRoot(), ], providers: [ + ConfigService, PrismaService, JobsService, + { + provide: "CONFIG_VARIABLES", + useFactory: async (prisma: PrismaService) => { + return await prisma.config.findMany(); + }, + inject: [PrismaService], + }, { provide: APP_GUARD, useClass: ThrottlerGuard, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 5c722ec..f1d544a 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -5,8 +5,8 @@ import { HttpCode, Post, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { Throttle } from "@nestjs/throttler"; +import { ConfigService } from "src/config/config.service"; import { AuthService } from "./auth.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; @@ -21,8 +21,8 @@ export class AuthController { @Throttle(10, 5 * 60) @Post("signUp") - signUp(@Body() dto: AuthRegisterDTO) { - if (this.config.get("ALLOW_REGISTRATION") == "false") + async signUp(@Body() dto: AuthRegisterDTO) { + if (!this.config.get("allowRegistration")) throw new ForbiddenException("Registration is not allowed"); return this.authService.signUp(dto); } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d192589..67d4e65 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -3,12 +3,12 @@ import { Injectable, UnauthorizedException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { User } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import * as argon from "argon2"; import * as moment from "moment"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; @@ -68,7 +68,7 @@ export class AuthService { }, { expiresIn: "15min", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } diff --git a/backend/src/auth/guard/jwt.guard.ts b/backend/src/auth/guard/jwt.guard.ts index 20b684d..07854dd 100644 --- a/backend/src/auth/guard/jwt.guard.ts +++ b/backend/src/auth/guard/jwt.guard.ts @@ -1,15 +1,17 @@ -import { ExecutionContext } from "@nestjs/common"; +import { ExecutionContext, Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; +import { ConfigService } from "src/config/config.service"; +@Injectable() export class JwtGuard extends AuthGuard("jwt") { - constructor() { + constructor(private config: ConfigService) { super(); } async canActivate(context: ExecutionContext): Promise { try { return (await super.canActivate(context)) as boolean; } catch { - return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true"; + return this.config.get("allowUnauthenticatedShares"); } } } diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index 4401420..2425d9f 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -1,24 +1,27 @@ import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { User } from "@prisma/client"; import { ExtractJwt, Strategy } from "passport-jwt"; +import { ConfigService } from "src/config/config.service"; 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(), - secretOrKey: config.get("JWT_SECRET"), + secretOrKey: config.get("jwtSecret"), }); } 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; } } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts new file mode 100644 index 0000000..27291e8 --- /dev/null +++ b/backend/src/config/config.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from "@nestjs/common"; +import { ConfigService } from "./config.service"; +import { ConfigDTO } from "./dto/config.dto"; + +@Controller("configs") +export class ConfigController { + constructor(private configService: ConfigService) {} + + @Get() + async list() { + return new ConfigDTO().fromList(await this.configService.list()) + } + + @Get("admin") + async listForAdmin() { + return await this.configService.listForAdmin(); + } +} diff --git a/backend/src/config/config.module.ts b/backend/src/config/config.module.ts new file mode 100644 index 0000000..c19f599 --- /dev/null +++ b/backend/src/config/config.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ConfigController } from "./config.controller"; +import { ConfigService } from "./config.service"; + +@Global() +@Module({ + providers: [ + { + provide: "CONFIG_VARIABLES", + useFactory: async (prisma: PrismaService) => { + return await prisma.config.findMany(); + }, + inject: [PrismaService], + }, + ConfigService, + ], + controllers: [ConfigController], + exports: [ConfigService], +}) +export class ConfigModule {} diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts new file mode 100644 index 0000000..9e94f4e --- /dev/null +++ b/backend/src/config/config.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Config } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ConfigService { + constructor( + @Inject("CONFIG_VARIABLES") private configVariables: Config[], + private prisma: PrismaService + ) {} + + get(key: string): any { + const configVariable = this.configVariables.filter( + (variable) => variable.key == key + )[0]; + + 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; + } + + async listForAdmin() { + return await this.prisma.config.findMany(); + } + + async list() { + const configVariables = await this.prisma.config.findMany({ + where: { secret: { equals: false } }, + }); + + return configVariables.map((configVariable) => { + if (!configVariable.value) configVariable.value = configVariable.default; + + return configVariable; + }); + } +} diff --git a/backend/src/config/dto/config.dto.ts b/backend/src/config/dto/config.dto.ts new file mode 100644 index 0000000..1c2a779 --- /dev/null +++ b/backend/src/config/dto/config.dto.ts @@ -0,0 +1,18 @@ +import { Expose, plainToClass } from "class-transformer"; + +export class ConfigDTO { + @Expose() + key: string; + + @Expose() + value: string; + + @Expose() + type: string; + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(ConfigDTO, part, { excludeExtraneousValues: true }) + ); + } +} diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 336f505..110212f 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -1,34 +1,35 @@ import { Injectable, InternalServerErrorException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { User } from "@prisma/client"; import * as nodemailer from "nodemailer"; +import { ConfigService } from "src/config/config.service"; @Injectable() export class EmailService { constructor(private config: ConfigService) {} - // create reusable transporter object using the default SMTP transport - transporter = nodemailer.createTransport({ - host: this.config.get("SMTP_HOST"), - port: parseInt(this.config.get("SMTP_PORT")), - secure: parseInt(this.config.get("SMTP_PORT")) == 465, - auth: { - user: this.config.get("SMTP_EMAIL"), - pass: this.config.get("SMTP_PASSWORD"), - }, - }); - async sendMail(recipientEmail: string, shareId: string, creator: User) { - if (this.config.get("EMAIL_RECIPIENTS_ENABLED") == "false") + // create reusable transporter object using the default SMTP transport + const transporter = nodemailer.createTransport({ + host: this.config.get("SMTP_HOST"), + port: parseInt(this.config.get("SMTP_PORT")), + secure: parseInt(this.config.get("SMTP_PORT")) == 465, + auth: { + user: this.config.get("SMTP_EMAIL"), + pass: this.config.get("SMTP_PASSWORD"), + }, + }); + + if (!this.config.get("emailRecepientsEnabled")) throw new InternalServerErrorException("Email service disabled"); const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; - const creatorIdentifier = creator ? - creator.firstName && creator.lastName + const creatorIdentifier = creator + ? creator.firstName && creator.lastName ? `${creator.firstName} ${creator.lastName}` - : creator.email : "A Pingvin Share user"; + : creator.email + : "A Pingvin Share user"; - await this.transporter.sendMail({ + await transporter.sendMail({ from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, to: recipientEmail, subject: "Files shared with you", diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index 0d9c9f8..f9607bf 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -2,7 +2,6 @@ import { Controller, Get, Param, - ParseFilePipeBuilder, Post, Res, StreamableFile, @@ -19,6 +18,7 @@ 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,13 +32,7 @@ export class FileController { }) ) async create( - @UploadedFile( - new ParseFilePipeBuilder() - .addMaxSizeValidator({ - maxSize: parseInt(process.env.MAX_FILE_SIZE), - }) - .build() - ) + @UploadedFile(FileValidationPipe) file: Express.Multer.File, @Param("shareId") shareId: string ) { diff --git a/backend/src/file/file.module.ts b/backend/src/file/file.module.ts index f3be62b..84b89b4 100644 --- a/backend/src/file/file.module.ts +++ b/backend/src/file/file.module.ts @@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt"; import { ShareModule } from "src/share/share.module"; import { FileController } from "./file.controller"; import { FileService } from "./file.service"; +import { FileValidationPipe } from "./pipe/fileValidation.pipe"; @Module({ imports: [JwtModule.register({}), ShareModule], controllers: [FileController], - providers: [FileService], + providers: [FileService, FileValidationPipe], exports: [FileService], }) export class FileModule {} diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 2437687..cf662c3 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -3,11 +3,11 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { randomUUID } from "crypto"; import * as fs from "fs"; import * as mime from "mime-types"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() @@ -78,14 +78,14 @@ export class FileService { return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); } - getFileDownloadUrl(shareId: string, fileId: string) { + async getFileDownloadUrl(shareId: string, fileId: string) { const downloadToken = this.generateFileDownloadToken(shareId, fileId); return `${this.config.get( "APP_URL" )}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`; } - generateFileDownloadToken(shareId: string, fileId: string) { + async generateFileDownloadToken(shareId: string, fileId: string) { if (fileId == "zip") fileId = undefined; return this.jwtService.sign( @@ -95,15 +95,15 @@ export class FileService { }, { expiresIn: "10min", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } - verifyFileDownloadToken(shareId: string, token: string) { + async verifyFileDownloadToken(shareId: string, token: string) { try { const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), }); return claims.shareId == shareId; } catch { diff --git a/backend/src/file/pipe/fileValidation.pipe.ts b/backend/src/file/pipe/fileValidation.pipe.ts new file mode 100644 index 0000000..964bd83 --- /dev/null +++ b/backend/src/file/pipe/fileValidation.pipe.ts @@ -0,0 +1,13 @@ +import { ArgumentMetadata, 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; + } +} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index eb1f1d2..215fa4e 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -4,7 +4,7 @@ import { PrismaClient } from "@prisma/client"; @Injectable() export class PrismaService extends PrismaClient { - constructor(config: ConfigService) { + constructor() { super({ datasources: { db: { diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 572e7ba..777ea73 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -4,13 +4,13 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { Share, User } from "@prisma/client"; import * as archiver from "archiver"; import * as argon from "argon2"; import * as fs from "fs"; import * as moment from "moment"; +import { ConfigService } from "src/config/config.service"; import { EmailService } from "src/email/email.service"; import { FileService } from "src/file/file.service"; import { PrismaService } from "src/prisma/prisma.service"; @@ -235,7 +235,7 @@ export class ShareService { }, { expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), } ); } @@ -247,7 +247,7 @@ export class ShareService { try { const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("jwtSecret"), // Ignore expiration if expiration is 0 ignoreExpiration: moment(expiration).isSame(0), }); From 493705e4ef21cb638620b0037b9ff2cec8046c95 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 28 Nov 2022 17:50:36 +0100 Subject: [PATCH 03/19] feat: add add new config strategy to frontend --- .env.example | 18 ------------- backend/.env.example | 15 ----------- backend/src/auth/guard/isAdmin.guard.ts | 1 + backend/src/config/config.controller.ts | 20 +++++++++++--- backend/src/config/config.service.ts | 26 ++++++++++++++++++- backend/src/config/dto/adminConfig.dto.ts | 23 ++++++++++++++++ backend/src/config/dto/updateConfig.dto.ts | 8 ++++++ frontend/.env.example | 5 ---- frontend/next.config.js | 11 +------- frontend/src/components/auth/AuthForm.tsx | 8 +++--- frontend/src/components/navBar/NavBar.tsx | 10 +++---- frontend/src/components/upload/Dropzone.tsx | 11 ++++---- .../upload/modals/showCreateUploadModal.tsx | 25 +++++++++++------- frontend/src/hooks/config.hook.ts | 14 ++++++++++ frontend/src/pages/_app.tsx | 26 ++++++++++--------- frontend/src/pages/auth/signUp.tsx | 7 +++-- frontend/src/pages/index.tsx | 9 +++---- frontend/src/pages/upload.tsx | 18 ++++++++++--- frontend/src/services/config.service.ts | 23 ++++++++++++++++ frontend/src/types/config.type.ts | 7 +++++ 20 files changed, 183 insertions(+), 102 deletions(-) delete mode 100644 .env.example delete mode 100644 backend/.env.example create mode 100644 backend/src/config/dto/adminConfig.dto.ts create mode 100644 backend/src/config/dto/updateConfig.dto.ts delete mode 100644 frontend/.env.example create mode 100644 frontend/src/hooks/config.hook.ts create mode 100644 frontend/src/services/config.service.ts create mode 100644 frontend/src/types/config.type.ts diff --git a/.env.example b/.env.example deleted file mode 100644 index 325aeb4..0000000 --- a/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables - -# General -APP_URL=http://localhost:3000 -SHOW_HOME_PAGE=true -ALLOW_REGISTRATION=true -ALLOW_UNAUTHENTICATED_SHARES=false -MAX_FILE_SIZE=1000000000 - -# Security -JWT_SECRET=long-random-string - -# Email -EMAIL_RECIPIENTS_ENABLED=false -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_EMAIL=pingvin-share@example.com -SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 25ac4b9..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# General -APP_URL=http://localhost:3000 -ALLOW_REGISTRATION=true -MAX_FILE_SIZE=5000000000 -ALLOW_UNAUTHENTICATED_SHARES=false - -# Security -JWT_SECRET=random-string - -# Email -EMAIL_RECIPIENTS_ENABLED=false -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_EMAIL=pingvin-share@example.com -SMTP_PASSWORD=example \ No newline at end of file diff --git a/backend/src/auth/guard/isAdmin.guard.ts b/backend/src/auth/guard/isAdmin.guard.ts index b3bf251..16bd84b 100644 --- a/backend/src/auth/guard/isAdmin.guard.ts +++ b/backend/src/auth/guard/isAdmin.guard.ts @@ -5,6 +5,7 @@ import { User } from "@prisma/client"; export class AdministratorGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const { user }: { user: User } = context.switchToHttp().getRequest(); + if (!user) return false; return user.isAdministrator; } } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts index 27291e8..9e49900 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -1,6 +1,9 @@ -import { Controller, Get } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common"; +import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { ConfigService } from "./config.service"; +import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { ConfigDTO } from "./dto/config.dto"; +import UpdateConfigDTO from "./dto/updateConfig.dto"; @Controller("configs") export class ConfigController { @@ -8,11 +11,22 @@ export class ConfigController { @Get() async list() { - return new ConfigDTO().fromList(await this.configService.list()) + return new ConfigDTO().fromList(await this.configService.list()); } @Get("admin") + @UseGuards(AdministratorGuard) async listForAdmin() { - return await this.configService.listForAdmin(); + return new AdminConfigDTO().fromList( + await this.configService.listForAdmin() + ); + } + + @Patch("admin/:key") + @UseGuards(AdministratorGuard) + async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { + return new AdminConfigDTO().from( + await this.configService.update(key, data.value) + ); } } diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 9e94f4e..a844bd0 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { Config } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; @@ -38,4 +43,23 @@ export class ConfigService { return configVariable; }); } + + async update(key: string, value: string | number | boolean) { + const configVariable = await this.prisma.config.findUnique({ + where: { key }, + }); + + if (!configVariable || configVariable.locked) + throw new NotFoundException("Config variable not found"); + + if (typeof value != configVariable.type) + throw new BadRequestException( + `Config variable must be of type ${configVariable.type}` + ); + + return await this.prisma.config.update({ + where: { key }, + data: { value: value.toString() }, + }); + } } diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts new file mode 100644 index 0000000..2cd135d --- /dev/null +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -0,0 +1,23 @@ +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; + + from(partial: Partial) { + return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true }); + } + + fromList(partial: Partial[]) { + return partial.map((part) => + plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true }) + ); + } +} diff --git a/backend/src/config/dto/updateConfig.dto.ts b/backend/src/config/dto/updateConfig.dto.ts new file mode 100644 index 0000000..3460bd2 --- /dev/null +++ b/backend/src/config/dto/updateConfig.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty } from "class-validator"; + +class UpdateConfigDTO { + @IsNotEmpty() + value: string | number | boolean; +} + +export default UpdateConfigDTO; diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 8050237..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -SHOW_HOME_PAGE=true -ALLOW_REGISTRATION=true -MAX_FILE_SIZE=1000000000 -ALLOW_UNAUTHENTICATED_SHARES=false -EMAIL_RECIPIENTS_ENABLED=false \ No newline at end of file diff --git a/frontend/next.config.js b/frontend/next.config.js index d0c3fc5..caaec0d 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,14 +1,5 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - publicRuntimeConfig: { - ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION, - SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE, - MAX_FILE_SIZE: process.env.MAX_FILE_SIZE, - ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES, - EMAIL_RECIPIENTS_ENABLED: process.env.EMAIL_RECIPIENTS_ENABLED - } -} const withPWA = require("next-pwa")({ dest: "public", @@ -16,4 +7,4 @@ const withPWA = require("next-pwa")({ }); -module.exports = withPWA(nextConfig); +module.exports = withPWA(); diff --git a/frontend/src/components/auth/AuthForm.tsx b/frontend/src/components/auth/AuthForm.tsx index 2642b56..f8d5068 100644 --- a/frontend/src/components/auth/AuthForm.tsx +++ b/frontend/src/components/auth/AuthForm.tsx @@ -9,15 +9,15 @@ import { Title, } from "@mantine/core"; import { useForm, yupResolver } from "@mantine/form"; -import getConfig from "next/config"; 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 { publicRuntimeConfig } = getConfig(); - const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => { + const config = useConfig(); + const validationSchema = yup.object().shape({ email: yup.string().email().required(), password: yup.string().min(8).required(), @@ -55,7 +55,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => { > {mode == "signUp" ? "Sign up" : "Welcome back"} - {publicRuntimeConfig.ALLOW_REGISTRATION == "true" && ( + {config.get("allowRegistration") && ( {mode == "signUp" ? "You have an account already?" diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/navBar/NavBar.tsx index 6ce0218..619434d 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/navBar/NavBar.tsx @@ -11,15 +11,13 @@ import { Transition, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import getConfig from "next/config"; import Link from "next/link"; import { ReactNode, useEffect, useState } from "react"; +import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; import Logo from "../Logo"; import ActionAvatar from "./ActionAvatar"; -const { publicRuntimeConfig } = getConfig(); - const HEADER_HEIGHT = 60; type NavLink = { @@ -110,6 +108,8 @@ const useStyles = createStyles((theme) => ({ const NavBar = () => { const user = useUser(); + const config = useConfig(); + const [opened, toggleOpened] = useDisclosure(false); const authenticatedLinks = [ @@ -130,7 +130,7 @@ const NavBar = () => { ]); useEffect(() => { - if (publicRuntimeConfig.SHOW_HOME_PAGE == "true") + if (config.get("showHomePage")) setUnauthenticatedLinks((array) => [ { link: "/", @@ -139,7 +139,7 @@ const NavBar = () => { ...array, ]); - if (publicRuntimeConfig.ALLOW_REGISTRATION == "true") + if (config.get("allowRegistration")) setUnauthenticatedLinks((array) => [ ...array, { diff --git a/frontend/src/components/upload/Dropzone.tsx b/frontend/src/components/upload/Dropzone.tsx index 4556798..7aba326 100644 --- a/frontend/src/components/upload/Dropzone.tsx +++ b/frontend/src/components/upload/Dropzone.tsx @@ -1,14 +1,12 @@ import { Button, Center, createStyles, Group, Text } from "@mantine/core"; import { Dropzone as MantineDropzone } from "@mantine/dropzone"; -import getConfig from "next/config"; import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react"; import { TbCloudUpload, TbUpload } from "react-icons/tb"; +import useConfig from "../../hooks/config.hook"; import { FileUpload } from "../../types/File.type"; import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util"; import toast from "../../utils/toast.util"; -const { publicRuntimeConfig } = getConfig(); - const useStyles = createStyles((theme) => ({ wrapper: { position: "relative", @@ -40,12 +38,14 @@ const Dropzone = ({ isUploading: boolean; setFiles: Dispatch>; }) => { + const config = useConfig(); + const { classes } = useStyles(); const openRef = useRef<() => void>(); return (
{ toast.error(e[0].errors[0].message); }} @@ -75,8 +75,7 @@ const Dropzone = ({ Drag'n'drop files here to start your share. We can accept only files that are less than{" "} - {byteStringToHumanSizeString(publicRuntimeConfig.MAX_FILE_SIZE)} in - size. + {byteStringToHumanSizeString(config.get("maxFileSize"))} in size.
diff --git a/frontend/src/components/upload/modals/showCreateUploadModal.tsx b/frontend/src/components/upload/modals/showCreateUploadModal.tsx index d660b4a..1cc93d5 100644 --- a/frontend/src/components/upload/modals/showCreateUploadModal.tsx +++ b/frontend/src/components/upload/modals/showCreateUploadModal.tsx @@ -18,7 +18,6 @@ import { import { useForm, yupResolver } from "@mantine/form"; import { useModals } from "@mantine/modals"; import { ModalsContextProps } from "@mantine/modals/lib/context"; -import getConfig from "next/config"; import { useState } from "react"; import { TbAlertCircle } from "react-icons/tb"; import * as yup from "yup"; @@ -26,11 +25,13 @@ import shareService from "../../../services/share.service"; import { ShareSecurity } from "../../../types/share.type"; import ExpirationPreview from "../ExpirationPreview"; -const { publicRuntimeConfig } = getConfig(); - const showCreateUploadModal = ( modals: ModalsContextProps, - isSignedIn: boolean, + options: { + isUserSignedIn: boolean; + allowUnauthenticatedShares: boolean; + emailRecipientsEnabled: boolean; + }, uploadCallback: ( id: string, expiration: string, @@ -42,7 +43,7 @@ const showCreateUploadModal = ( title: Share, children: ( ), @@ -51,7 +52,7 @@ const showCreateUploadModal = ( const CreateUploadModalBody = ({ uploadCallback, - isSignedIn, + options, }: { uploadCallback: ( id: string, @@ -59,12 +60,16 @@ const CreateUploadModalBody = ({ recipients: string[], security: ShareSecurity ) => void; - isSignedIn: boolean; + options: { + isUserSignedIn: boolean; + allowUnauthenticatedShares: boolean; + emailRecipientsEnabled: boolean; + }; }) => { const modals = useModals(); const [showNotSignedInAlert, setShowNotSignedInAlert] = useState( - publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true" + options.emailRecipientsEnabled ); const validationSchema = yup.object().shape({ @@ -93,7 +98,7 @@ const CreateUploadModalBody = ({ }); return ( - {showNotSignedInAlert && !isSignedIn && ( + {showNotSignedInAlert && !options.isUserSignedIn && ( setShowNotSignedInAlert(false)} @@ -225,7 +230,7 @@ const CreateUploadModalBody = ({ {ExpirationPreview({ form })}
- {publicRuntimeConfig.EMAIL_RECIPIENTS_ENABLED == "true" && ( + {options.emailRecipientsEnabled && ( Email recipients diff --git a/frontend/src/hooks/config.hook.ts b/frontend/src/hooks/config.hook.ts new file mode 100644 index 0000000..8f3c271 --- /dev/null +++ b/frontend/src/hooks/config.hook.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; +import configService from "../services/config.service"; +import Config from "../types/config.type"; + +export const ConfigContext = createContext(null); + +const useConfig = () => { + const configVariables = useContext(ConfigContext) as Config[]; + return { + get: (key: string) => configService.get(key, configVariables), + }; +}; + +export default useConfig; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 19815af..b7cf4b8 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -10,11 +10,14 @@ import { NotificationsProvider } from "@mantine/notifications"; import type { AppProps } from "next/app"; import { useEffect, useState } from "react"; import Header from "../components/navBar/NavBar"; +import { ConfigContext } from "../hooks/config.hook"; import { UserContext } from "../hooks/user.hook"; import authService from "../services/auth.service"; +import configService from "../services/config.service"; import userService from "../services/user.service"; import GlobalStyle from "../styles/global.style"; import globalStyle from "../styles/mantine.style"; +import Config from "../types/config.type"; import { CurrentUser } from "../types/user.type"; import { GlobalLoadingContext } from "../utils/loading.util"; @@ -24,9 +27,11 @@ function App({ Component, pageProps }: AppProps) { const [colorScheme, setColorScheme] = useState(); const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); + const [config, setConfig] = useState(null); const getInitalData = async () => { setIsLoading(true); + setConfig(await configService.getAll()); await authService.refreshAccessToken(); setUser(await userService.getCurrentUser()); setIsLoading(false); @@ -54,13 +59,15 @@ function App({ Component, pageProps }: AppProps) { {isLoading ? ( ) : ( - - -
- - - - + + + +
+ + + + {" "} + )} @@ -69,9 +76,4 @@ function App({ Component, pageProps }: AppProps) { ); } -// Opts out of static site generation to use publicRuntimeConfig -App.getInitialProps = () => { - return {}; -}; - export default App; diff --git a/frontend/src/pages/auth/signUp.tsx b/frontend/src/pages/auth/signUp.tsx index b4e4464..afeb013 100644 --- a/frontend/src/pages/auth/signUp.tsx +++ b/frontend/src/pages/auth/signUp.tsx @@ -1,17 +1,16 @@ -import getConfig from "next/config"; import { useRouter } from "next/router"; import AuthForm from "../../components/auth/AuthForm"; import Meta from "../../components/Meta"; +import useConfig from "../../hooks/config.hook"; import useUser from "../../hooks/user.hook"; -const { publicRuntimeConfig } = getConfig(); - const SignUp = () => { + const config = useConfig(); const user = useUser(); const router = useRouter(); if (user) { router.replace("/"); - } else if (publicRuntimeConfig.ALLOW_REGISTRATION == "false") { + } else if (config.get("allowRegistration") == "false") { router.replace("/auth/signIn"); } else { return ( diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 62dfecf..b122ba4 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -8,16 +8,14 @@ import { ThemeIcon, Title, } from "@mantine/core"; -import getConfig from "next/config"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { TbCheck } from "react-icons/tb"; import Meta from "../components/Meta"; +import useConfig from "../hooks/config.hook"; import useUser from "../hooks/user.hook"; -const { publicRuntimeConfig } = getConfig(); - const useStyles = createStyles((theme) => ({ inner: { display: "flex", @@ -71,13 +69,14 @@ const useStyles = createStyles((theme) => ({ })); export default function Home() { + const config = useConfig(); const user = useUser(); const { classes } = useStyles(); const router = useRouter(); - if (user || publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true") { + if (user || config.get("allowUnauthenticatedShares")) { router.replace("/upload"); - } else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") { + } else if (!config.get("showHomePage")) { router.replace("/auth/signIn"); } else { return ( diff --git a/frontend/src/pages/upload.tsx b/frontend/src/pages/upload.tsx index 9a2664e..06aff04 100644 --- a/frontend/src/pages/upload.tsx +++ b/frontend/src/pages/upload.tsx @@ -1,7 +1,6 @@ import { Button, Group } from "@mantine/core"; import { useModals } from "@mantine/modals"; import axios from "axios"; -import getConfig from "next/config"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import Meta from "../components/Meta"; @@ -9,13 +8,13 @@ import Dropzone from "../components/upload/Dropzone"; import FileList from "../components/upload/FileList"; import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal"; import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal"; +import useConfig from "../hooks/config.hook"; import useUser from "../hooks/user.hook"; import shareService from "../services/share.service"; import { FileUpload } from "../types/File.type"; import { ShareSecurity } from "../types/share.type"; import toast from "../utils/toast.util"; -const { publicRuntimeConfig } = getConfig(); let share: any; const Upload = () => { @@ -23,6 +22,7 @@ const Upload = () => { const modals = useModals(); const user = useUser(); + const config = useConfig(); const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); @@ -95,7 +95,7 @@ const Upload = () => { } } }, [files]); - if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") { + if (!user && !config.get("allowUnauthenticatedShares")) { router.replace("/"); } else { return ( @@ -106,7 +106,17 @@ const Upload = () => { loading={isUploading} disabled={files.length <= 0} onClick={() => - showCreateUploadModal(modals, user ? true : false, uploadFiles) + showCreateUploadModal( + modals, + { + isUserSignedIn: user ? true : false, + allowUnauthenticatedShares: config.get( + "allowUnauthenticatedShares" + ), + emailRecipientsEnabled: config.get("emailRecipientsEnabled"), + }, + uploadFiles + ) } > Share diff --git a/frontend/src/services/config.service.ts b/frontend/src/services/config.service.ts new file mode 100644 index 0000000..141706f --- /dev/null +++ b/frontend/src/services/config.service.ts @@ -0,0 +1,23 @@ +import Config from "../types/config.type"; +import api from "./api.service"; + +const getAll = async (): Promise => { + return (await api.get("/configs")).data; +}; + +const get = (key: string, configVariables: Config[]): any => { + const configVariable = configVariables.filter( + (variable) => variable.key == key + )[0]; + + if (!configVariable) throw new Error(`Config variable ${key} not found`); + + if (configVariable.type == "number") return parseInt(configVariable.value); + if (configVariable.type == "boolean") return configVariable.value == "true"; + if (configVariable.type == "string") return configVariable.value; +}; + +export default { + getAll, + get, +}; diff --git a/frontend/src/types/config.type.ts b/frontend/src/types/config.type.ts new file mode 100644 index 0000000..6ba71ab --- /dev/null +++ b/frontend/src/types/config.type.ts @@ -0,0 +1,7 @@ +type Config = { + key: string; + value: string; + type: string; +}; + +export default Config; From b579b8f3309e2d7070e6a82c5da76ab8029bee11 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 1 Dec 2022 23:07:49 +0100 Subject: [PATCH 04/19] feat: add setup wizard --- .../migration.sql | 37 +++++++ backend/prisma/schema.prisma | 21 ++-- backend/prisma/seed/config.seed.ts | 81 +--------------- backend/src/app.module.ts | 22 ++++- backend/src/auth/auth.service.ts | 14 ++- backend/src/auth/dto/authRegister.dto.ts | 16 +++- backend/src/auth/dto/authSignIn.dto.ts | 1 + backend/src/auth/guard/isAdmin.guard.ts | 6 +- backend/src/auth/strategy/jwt.strategy.ts | 3 - backend/src/config/config.controller.ts | 21 +++- backend/src/config/config.service.ts | 33 ++++--- backend/src/config/dto/adminConfig.dto.ts | 10 +- backend/src/configVariables.ts | 88 +++++++++++++++++ backend/src/email/email.service.ts | 8 +- backend/src/file/file.controller.ts | 3 +- backend/src/file/pipe/fileValidation.pipe.ts | 14 ++- backend/src/share/dto/createShare.dto.ts | 2 +- backend/src/user/dto/user.dto.ts | 14 +-- .../src/components/admin/AdminConfigTable.tsx | 95 ++++++++++++++++++ .../admin/showUpdateConfigVariableModal.tsx | 96 +++++++++++++++++++ frontend/src/components/auth/SignInForm.tsx | 86 +++++++++++++++++ .../auth/{AuthForm.tsx => SignUpForm.tsx} | 38 ++++---- .../src/components/navBar/ActionAvatar.tsx | 15 ++- frontend/src/pages/_app.tsx | 21 +++- frontend/src/pages/admin/config.tsx | 13 +++ frontend/src/pages/admin/setup.tsx | 50 ++++++++++ frontend/src/pages/auth/signIn.tsx | 4 +- frontend/src/pages/auth/signUp.tsx | 4 +- frontend/src/services/auth.service.ts | 15 ++- frontend/src/services/config.service.ts | 30 +++++- frontend/src/types/config.type.ts | 6 ++ frontend/src/types/user.type.ts | 1 + 32 files changed, 689 insertions(+), 179 deletions(-) create mode 100644 backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql create mode 100644 backend/src/configVariables.ts create mode 100644 frontend/src/components/admin/AdminConfigTable.tsx create mode 100644 frontend/src/components/admin/showUpdateConfigVariableModal.tsx create mode 100644 frontend/src/components/auth/SignInForm.tsx rename frontend/src/components/auth/{AuthForm.tsx => SignUpForm.tsx} (70%) create mode 100644 frontend/src/pages/admin/config.tsx create mode 100644 frontend/src/pages/admin/setup.tsx diff --git a/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql b/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql new file mode 100644 index 0000000..3278009 --- /dev/null +++ b/backend/prisma/migrations/20221201220540_config_and_admin_functionalities/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d997f58..c2fb2e1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) } diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 269d7fa..a43b14d 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -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 }, - }); - } } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8de599b..dcfefeb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 67d4e65..f9da9ae 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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 }], }, }); diff --git a/backend/src/auth/dto/authRegister.dto.ts b/backend/src/auth/dto/authRegister.dto.ts index 6335ac7..14a80e7 100644 --- a/backend/src/auth/dto/authRegister.dto.ts +++ b/backend/src/auth/dto/authRegister.dto.ts @@ -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; +} diff --git a/backend/src/auth/dto/authSignIn.dto.ts b/backend/src/auth/dto/authSignIn.dto.ts index 61aec2b..a6cf7cf 100644 --- a/backend/src/auth/dto/authSignIn.dto.ts +++ b/backend/src/auth/dto/authSignIn.dto.ts @@ -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) {} diff --git a/backend/src/auth/guard/isAdmin.guard.ts b/backend/src/auth/guard/isAdmin.guard.ts index 16bd84b..2e69ced 100644 --- a/backend/src/auth/guard/isAdmin.guard.ts +++ b/backend/src/auth/guard/isAdmin.guard.ts @@ -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; } } diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index 2425d9f..d2172ca 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -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; } } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts index 9e49900..9ed77fb 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -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(); + } } diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index a844bd0..04b1974 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -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" }, + }); } } diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts index 2cd135d..ab32c19 100644 --- a/backend/src/config/dto/adminConfig.dto.ts +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -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) { - return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true }); + return plainToClass(AdminConfigDTO, partial, { + excludeExtraneousValues: true, + }); } fromList(partial: Partial[]) { diff --git a/backend/src/configVariables.ts b/backend/src/configVariables.ts new file mode 100644 index 0000000..b11be41 --- /dev/null +++ b/backend/src/configVariables.ts @@ -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; diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 110212f..4c56fc6 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -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 🐧`, }); } } diff --git a/backend/src/file/file.controller.ts b/backend/src/file/file.controller.ts index f9607bf..3bb6d61 100644 --- a/backend/src/file/file.controller.ts +++ b/backend/src/file/file.controller.ts @@ -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 ) { diff --git a/backend/src/file/pipe/fileValidation.pipe.ts b/backend/src/file/pipe/fileValidation.pipe.ts index 964bd83..6a0ec86 100644 --- a/backend/src/file/pipe/fileValidation.pipe.ts +++ b/backend/src/file/pipe/fileValidation.pipe.ts @@ -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; } } diff --git a/backend/src/share/dto/createShare.dto.ts b/backend/src/share/dto/createShare.dto.ts index 644b116..ecb817a 100644 --- a/backend/src/share/dto/createShare.dto.ts +++ b/backend/src/share/dto/createShare.dto.ts @@ -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; diff --git a/backend/src/user/dto/user.dto.ts b/backend/src/user/dto/user.dto.ts index ae6411e..479c99d 100644 --- a/backend/src/user/dto/user.dto.ts +++ b/backend/src/user/dto/user.dto.ts @@ -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) { return plainToClass(UserDTO, partial, { excludeExtraneousValues: true }); } diff --git a/frontend/src/components/admin/AdminConfigTable.tsx b/frontend/src/components/admin/AdminConfigTable.tsx new file mode 100644 index 0000000..570f5aa --- /dev/null +++ b/frontend/src/components/admin/AdminConfigTable.tsx @@ -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([]); + + const getConfigVariables = () => { + setIsLoading(true); + configService.listForAdmin().then((configVariables) => { + setConfigVariables(configVariables); + setIsLoading(false); + }); + }; + + useEffect(() => { + getConfigVariables(); + }, []); + + const skeletonRows = [...Array(9)].map((c, i) => ( + + + + + + + + + + + + + + + + )); + + return ( + + + + + + + + + + {isLoading + ? skeletonRows + : configVariables.map((element) => ( + + + + + + + ))} + +
KeyValue
+ {element.key} {element.secret && }{" "} +
+ + {" "} + {element.description} + +
{element.value} + + + showUpdateConfigVariableModal( + modals, + element, + getConfigVariables + ) + } + > + + + +
+ ); +}; + +export default AdminConfigTable; diff --git a/frontend/src/components/admin/showUpdateConfigVariableModal.tsx b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx new file mode 100644 index 0000000..f74e174 --- /dev/null +++ b/frontend/src/components/admin/showUpdateConfigVariableModal.tsx @@ -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: Update configuration variable, + children: ( + + ), + }); +}; + +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 ( + + + Set {configVariable.key} to + + {configVariable.type == "string" && ( + + )} + {configVariable.type == "number" && ( + + )} + {configVariable.type == "boolean" && ( +