From 5bc4f902f6218a09423491404806a4b7fb865c98 Mon Sep 17 00:00:00 2001 From: Elias Schneider <58886915+stonith404@users.noreply.github.com> Date: Fri, 30 Dec 2022 14:40:23 +0100 Subject: [PATCH] feat: improve config UI (#69) * add first concept * completed configuration ui update * add button for testing email configuration * improve mobile layout * add migration * run formatter * delete unnecessary modal * remove unused comment --- .../migration.sql | 56 +++++++ backend/prisma/schema.prisma | 1 + backend/prisma/seed/config.seed.ts | 18 ++- backend/src/auth/authTotp.service.ts | 4 +- backend/src/config/config.controller.ts | 31 ++-- backend/src/config/config.module.ts | 2 + backend/src/config/config.service.ts | 8 + backend/src/config/dto/adminConfig.dto.ts | 3 + backend/src/config/dto/testEmail.dto.ts | 7 + backend/src/config/dto/updateConfig.dto.ts | 5 +- backend/src/email/email.service.ts | 32 ++-- .../account/showEnableTotpModal.tsx | 3 - .../src/components/admin/AdminConfigTable.tsx | 115 -------------- .../admin/configuration/AdminConfigInput.tsx | 76 ++++++++++ .../admin/configuration/AdminConfigTable.tsx | 140 ++++++++++++++++++ .../admin/configuration/TestEmailButton.tsx | 27 ++++ .../admin/showUpdateConfigVariableModal.tsx | 108 -------------- frontend/src/pages/admin/config.tsx | 2 +- frontend/src/pages/admin/setup.tsx | 22 +-- frontend/src/services/auth.service.ts | 6 + frontend/src/services/config.service.ts | 19 ++- frontend/src/types/config.type.ts | 18 +++ frontend/src/utils/string.util.ts | 10 ++ 23 files changed, 429 insertions(+), 284 deletions(-) create mode 100644 backend/prisma/migrations/20221230120407_category_attribute_config/migration.sql create mode 100644 backend/src/config/dto/testEmail.dto.ts delete mode 100644 frontend/src/components/admin/AdminConfigTable.tsx create mode 100644 frontend/src/components/admin/configuration/AdminConfigInput.tsx create mode 100644 frontend/src/components/admin/configuration/AdminConfigTable.tsx create mode 100644 frontend/src/components/admin/configuration/TestEmailButton.tsx delete mode 100644 frontend/src/components/admin/showUpdateConfigVariableModal.tsx create mode 100644 frontend/src/utils/string.util.ts diff --git a/backend/prisma/migrations/20221230120407_category_attribute_config/migration.sql b/backend/prisma/migrations/20221230120407_category_attribute_config/migration.sql new file mode 100644 index 00000000..74856d7b --- /dev/null +++ b/backend/prisma/migrations/20221230120407_category_attribute_config/migration.sql @@ -0,0 +1,56 @@ +/* + Warnings: + + - Added the required column `category` to the `Config` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Config" ( + "updatedAt" DATETIME NOT NULL, + "key" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT, + "obscured" BOOLEAN NOT NULL DEFAULT false, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; + + UPDATE config SET category = "internal" WHERE key = "SETUP_FINISHED"; + UPDATE config SET category = "internal" WHERE key = "TOTP_SECRET"; + UPDATE config SET category = "internal" WHERE key = "JWT_SECRET"; + UPDATE config SET category = "general" WHERE key = "APP_URL"; + UPDATE config SET category = "general" WHERE key = "SHOW_HOME_PAGE"; + UPDATE config SET category = "share" WHERE key = "ALLOW_REGISTRATION"; + UPDATE config SET category = "share" WHERE key = "ALLOW_UNAUTHENTICATED_SHARES"; + UPDATE config SET category = "share" WHERE key = "MAX_FILE_SIZE"; + UPDATE config SET category = "email" WHERE key = "ENABLE_EMAIL_RECIPIENTS"; + UPDATE config SET category = "email" WHERE key = "EMAIL_MESSAGE"; + UPDATE config SET category = "email" WHERE key = "EMAIL_SUBJECT"; + UPDATE config SET category = "email" WHERE key = "SMTP_HOST"; + UPDATE config SET category = "email" WHERE key = "SMTP_PORT"; + UPDATE config SET category = "email" WHERE key = "SMTP_EMAIL"; + UPDATE config SET category = "email" WHERE key = "SMTP_USERNAME"; + UPDATE config SET category = "email" WHERE key = "SMTP_PASSWORD"; + +CREATE TABLE "new_Config" ( + "updatedAt" DATETIME NOT NULL, + "key" TEXT NOT NULL PRIMARY KEY, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "category" TEXT NOT NULL, + "obscured" BOOLEAN NOT NULL DEFAULT false, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO "new_Config" ("description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category") SELECT "description", "key", "locked", "obscured", "secret", "type", "updatedAt", "value", "category" FROM "Config"; +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 65031048..9a315368 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -101,6 +101,7 @@ model Config { type String value String description String + category String obscured Boolean @default(false) 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 a6dda01d..68e4ac10 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -7,6 +7,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Whether the setup has been finished", type: "boolean", value: "false", + category: "internal", secret: false, locked: true, }, @@ -15,6 +16,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "On which URL Pingvin Share is available", type: "string", value: "http://localhost:3000", + category: "general", secret: false, }, { @@ -22,6 +24,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Whether to show the home page", type: "boolean", value: "true", + category: "general", secret: false, }, { @@ -29,6 +32,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Whether registration is allowed", type: "boolean", value: "true", + category: "share", secret: false, }, { @@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Whether unauthorized users can create shares", type: "boolean", value: "false", + category: "share", secret: false, }, { @@ -43,6 +48,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Maximum file size in bytes", type: "number", value: "1000000000", + category: "share", secret: false, }, { @@ -50,6 +56,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "Long random string used to sign JWT tokens", type: "string", value: crypto.randomBytes(256).toString("base64"), + category: "internal", locked: true, }, { @@ -57,6 +64,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ description: "A 16 byte random string used to generate TOTP secrets", type: "string", value: crypto.randomBytes(16).toString("base64"), + category: "internal", locked: true, }, { @@ -65,6 +73,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ "Whether to send emails to recipients. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", type: "boolean", value: "false", + category: "email", secret: false, }, { @@ -74,36 +83,42 @@ const configVariables: Prisma.ConfigCreateInput[] = [ type: "text", value: "Hey!\n{creator} shared some files with you. View or download the files with this link: {shareUrl}\nShared securely with Pingvin Share 🐧", + category: "email", }, { key: "EMAIL_SUBJECT", description: "Subject of the email which gets sent to the recipients.", type: "string", value: "Files shared with you", + category: "email", }, { key: "SMTP_HOST", description: "Host of the SMTP server", type: "string", value: "", + category: "email", }, { key: "SMTP_PORT", description: "Port of the SMTP server", type: "number", - value: "", + value: "0", + category: "email", }, { key: "SMTP_EMAIL", description: "Email address which the emails get sent from", type: "string", value: "", + category: "email", }, { key: "SMTP_USERNAME", description: "Username of the SMTP server", type: "string", value: "", + category: "email", }, { key: "SMTP_PASSWORD", @@ -111,6 +126,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [ type: "string", value: "", obscured: true, + category: "email", }, ]; diff --git a/backend/src/auth/authTotp.service.ts b/backend/src/auth/authTotp.service.ts index 738aca66..883659dd 100644 --- a/backend/src/auth/authTotp.service.ts +++ b/backend/src/auth/authTotp.service.ts @@ -1,18 +1,20 @@ import { BadRequestException, ForbiddenException, + Injectable, UnauthorizedException, } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { User } from "@prisma/client"; import * as argon from "argon2"; import * as crypto from "crypto"; import { authenticator, totp } from "otplib"; import * as qrcode from "qrcode-svg"; +import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; import { AuthService } from "./auth.service"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; +@Injectable() export class AuthTotpService { constructor( private config: ConfigService, diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts index 9ed77fb5..ec2281c4 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -1,22 +1,19 @@ -import { - Body, - Controller, - Get, - Param, - Patch, - Post, - UseGuards, -} from "@nestjs/common"; +import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard"; +import { EmailService } from "src/email/email.service"; import { ConfigService } from "./config.service"; import { AdminConfigDTO } from "./dto/adminConfig.dto"; import { ConfigDTO } from "./dto/config.dto"; +import { TestEmailDTO } from "./dto/testEmail.dto"; import UpdateConfigDTO from "./dto/updateConfig.dto"; @Controller("configs") export class ConfigController { - constructor(private configService: ConfigService) {} + constructor( + private configService: ConfigService, + private emailService: EmailService + ) {} @Get() async list() { @@ -31,12 +28,10 @@ export class ConfigController { ); } - @Patch("admin/:key") + @Patch("admin") @UseGuards(JwtGuard, AdministratorGuard) - async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) { - return new AdminConfigDTO().from( - await this.configService.update(key, data.value) - ); + async updateMany(@Body() data: UpdateConfigDTO[]) { + await this.configService.updateMany(data); } @Post("admin/finishSetup") @@ -44,4 +39,10 @@ export class ConfigController { async finishSetup() { return await this.configService.finishSetup(); } + + @Post("admin/testEmail") + @UseGuards(JwtGuard, AdministratorGuard) + async testEmail(@Body() { email }: TestEmailDTO) { + await this.emailService.sendTestMail(email); + } } diff --git a/backend/src/config/config.module.ts b/backend/src/config/config.module.ts index c19f5993..c6f31401 100644 --- a/backend/src/config/config.module.ts +++ b/backend/src/config/config.module.ts @@ -1,10 +1,12 @@ import { Global, Module } from "@nestjs/common"; +import { EmailModule } from "src/email/email.module"; import { PrismaService } from "src/prisma/prisma.service"; import { ConfigController } from "./config.controller"; import { ConfigService } from "./config.service"; @Global() @Module({ + imports: [EmailModule], providers: [ { provide: "CONFIG_VARIABLES", diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 8ee78f50..6c7516b7 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -39,6 +39,14 @@ export class ConfigService { }); } + async updateMany(data: { key: string; value: string | number | boolean }[]) { + for (const variable of data) { + await this.update(variable.key, variable.value); + } + + return data; + } + async update(key: string, value: string | number | boolean) { const configVariable = await this.prisma.config.findUnique({ where: { key }, diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts index c358bc6f..dcb2491f 100644 --- a/backend/src/config/dto/adminConfig.dto.ts +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO { @Expose() obscured: boolean; + @Expose() + category: string; + from(partial: Partial) { return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true, diff --git a/backend/src/config/dto/testEmail.dto.ts b/backend/src/config/dto/testEmail.dto.ts new file mode 100644 index 00000000..527d5d07 --- /dev/null +++ b/backend/src/config/dto/testEmail.dto.ts @@ -0,0 +1,7 @@ +import { IsEmail, IsNotEmpty } from "class-validator"; + +export class TestEmailDTO { + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/backend/src/config/dto/updateConfig.dto.ts b/backend/src/config/dto/updateConfig.dto.ts index 4be8c663..c4b6804a 100644 --- a/backend/src/config/dto/updateConfig.dto.ts +++ b/backend/src/config/dto/updateConfig.dto.ts @@ -1,6 +1,9 @@ -import { IsNotEmpty, ValidateIf } from "class-validator"; +import { IsNotEmpty, IsString, ValidateIf } from "class-validator"; class UpdateConfigDTO { + @IsString() + key: string; + @IsNotEmpty() @ValidateIf((dto) => dto.value !== "") value: string | number | boolean; diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts index 411099d6..e6a6231b 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -7,24 +7,23 @@ import { ConfigService } from "src/config/config.service"; export class EmailService { constructor(private config: ConfigService) {} - async sendMail(recipientEmail: string, shareId: string, creator: User) { - // 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_USERNAME"), - pass: this.config.get("SMTP_PASSWORD"), - }, - }); + 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_USERNAME"), + pass: this.config.get("SMTP_PASSWORD"), + }, + }); + async sendMail(recipientEmail: string, shareId: string, creator: User) { if (!this.config.get("ENABLE_EMAIL_RECIPIENTS")) throw new InternalServerErrorException("Email service disabled"); const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; - await transporter.sendMail({ + await this.transporter.sendMail({ from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, to: recipientEmail, subject: this.config.get("EMAIL_SUBJECT"), @@ -35,4 +34,13 @@ export class EmailService { .replaceAll("{shareUrl}", shareUrl), }); } + + async sendTestMail(recipientEmail: string) { + await this.transporter.sendMail({ + from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + to: recipientEmail, + subject: "Test email", + text: "This is a test email", + }); + } } diff --git a/frontend/src/components/account/showEnableTotpModal.tsx b/frontend/src/components/account/showEnableTotpModal.tsx index 1e3828c4..9d1828e8 100644 --- a/frontend/src/components/account/showEnableTotpModal.tsx +++ b/frontend/src/components/account/showEnableTotpModal.tsx @@ -47,9 +47,6 @@ const CreateEnableTotpModal = ({ refreshUser: () => {}; }) => { const modals = useModals(); - const user = useUser(); - - console.log(user.user); const validationSchema = yup.object().shape({ code: yup diff --git a/frontend/src/components/admin/AdminConfigTable.tsx b/frontend/src/components/admin/AdminConfigTable.tsx deleted file mode 100644 index e7141fa5..00000000 --- a/frontend/src/components/admin/AdminConfigTable.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { - ActionIcon, - Box, - 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 = async () => { - await configService.listForAdmin().then((configVariables) => { - setConfigVariables(configVariables); - }); - }; - - useEffect(() => { - setIsLoading(true); - getConfigVariables().then(() => setIsLoading(false)); - }, []); - - const skeletonRows = [...Array(9)].map((c, i) => ( - - - - - - - - - - - - - - - - )); - - return ( - - - - - - - - - - - {isLoading - ? skeletonRows - : configVariables.map((configVariable) => ( - - - - - - ))} - -
KeyValue
- {configVariable.key}{" "} - {configVariable.secret && }
- - {configVariable.description} - -
- - {configVariable.obscured - ? "•".repeat(configVariable.value.length) - : configVariable.value} - - - - - showUpdateConfigVariableModal( - modals, - configVariable, - getConfigVariables - ) - } - > - - - -
-
- ); -}; - -export default AdminConfigTable; diff --git a/frontend/src/components/admin/configuration/AdminConfigInput.tsx b/frontend/src/components/admin/configuration/AdminConfigInput.tsx new file mode 100644 index 00000000..1a23d90c --- /dev/null +++ b/frontend/src/components/admin/configuration/AdminConfigInput.tsx @@ -0,0 +1,76 @@ +import { + NumberInput, + PasswordInput, + Stack, + Switch, + Textarea, + TextInput, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { AdminConfig, UpdateConfig } from "../../../types/config.type"; + +const AdminConfigInput = ({ + configVariable, + updateConfigVariable, +}: { + configVariable: AdminConfig; + updateConfigVariable: (variable: UpdateConfig) => void; +}) => { + const form = useForm({ + initialValues: { + stringValue: configVariable.value, + textValue: configVariable.value, + numberValue: parseInt(configVariable.value), + booleanValue: configVariable.value == "true", + }, + }); + + const onValueChange = (configVariable: AdminConfig, value: any) => { + form.setFieldValue(`${configVariable.type}Value`, value); + updateConfigVariable({ key: configVariable.key, value: value }); + }; + + return ( + + {configVariable.type == "string" && + (configVariable.obscured ? ( + onValueChange(configVariable, e.target.value)} + {...form.getInputProps("stringValue")} + /> + ) : ( + onValueChange(configVariable, e.target.value)} + /> + ))} + + {configVariable.type == "text" && ( +