From fddad3ef708c27052a8bf46f3076286d102f6d7e Mon Sep 17 00:00:00 2001 From: Elias Schneider <58886915+stonith404@users.noreply.github.com> Date: Sat, 4 Mar 2023 23:29:00 +0100 Subject: [PATCH] feat: custom branding (#112) * add first concept * remove setup status * split config page in multiple components * add custom branding docs * add test email button * fix invalid email from header * add migration * mount images to host * update docs * remove unused endpoint * run formatter --- .gitignore | 2 +- Dockerfile | 5 +- README.md | 19 +- .../20230303091601_v0_11_0/migration.sql | 94 ++++ backend/prisma/schema.prisma | 6 +- backend/prisma/seed/config.seed.ts | 428 +++++++++--------- backend/src/auth/auth.controller.ts | 2 +- backend/src/auth/auth.service.ts | 8 +- backend/src/auth/guard/jwt.guard.ts | 2 +- backend/src/auth/strategy/jwt.strategy.ts | 4 +- backend/src/config/config.controller.ts | 26 +- backend/src/config/config.service.ts | 57 ++- backend/src/config/dto/adminConfig.dto.ts | 6 +- backend/src/email/email.service.ts | 58 ++- backend/src/file/file.service.ts | 2 +- .../reverseShare/reverseShare.controller.ts | 2 +- .../src/reverseShare/reverseShare.service.ts | 2 +- backend/src/share/share.service.ts | 6 +- backend/src/user/user.module.ts | 2 +- docker-compose.yml | 1 + frontend/public/{ => img}/favicon.ico | Bin .../public/{ => img}/icons/icon-128x128.png | Bin .../public/{ => img}/icons/icon-144x144.png | Bin .../public/{ => img}/icons/icon-152x152.png | Bin .../public/{ => img}/icons/icon-192x192.png | Bin .../public/{ => img}/icons/icon-384x384.png | Bin .../public/{ => img}/icons/icon-48x48.png | Bin .../public/{ => img}/icons/icon-512x512.png | Bin .../public/{ => img}/icons/icon-72x72.png | Bin .../public/{ => img}/icons/icon-96x96.png | Bin .../{ => img}/icons/icon-white-128x128.png | Bin frontend/public/img/logo-bg-white.png | Bin 4364 -> 0 bytes frontend/public/img/logo.png | Bin 0 -> 32632 bytes frontend/public/img/logo.svg | 1 - .../{opengraph-default.png => opengraph.png} | Bin frontend/public/manifest.json | 18 +- frontend/src/components/Logo.tsx | 34 +- frontend/src/components/Meta.tsx | 6 +- .../admin/configuration/AdminConfigTable.tsx | 153 ------- .../configuration/ConfigurationHeader.tsx | 54 +++ .../configuration/ConfigurationNavBar.tsx | 97 ++++ .../admin/users/showCreateUserModal.tsx | 13 +- frontend/src/components/auth/SignInForm.tsx | 4 +- frontend/src/components/auth/SignUpForm.tsx | 10 +- .../{navBar => header}/ActionAvatar.tsx | 0 .../{navBar/NavBar.tsx => header/Header.tsx} | 18 +- .../{navBar => header}/NavbarShareMenu.tsx | 0 frontend/src/components/share/FileList.tsx | 6 +- frontend/src/middleware.ts | 26 +- frontend/src/pages/_app.tsx | 29 +- frontend/src/pages/_document.tsx | 10 +- frontend/src/pages/account/reverseShares.tsx | 10 +- frontend/src/pages/account/shares.tsx | 6 +- frontend/src/pages/admin/config.tsx | 18 - .../src/pages/admin/config/[category].tsx | 148 ++++++ frontend/src/pages/admin/config/index.tsx | 15 + frontend/src/pages/admin/intro.tsx | 59 +++ frontend/src/pages/admin/setup.tsx | 23 - frontend/src/pages/admin/users.tsx | 2 +- frontend/src/pages/api/[...all].tsx | 2 +- .../resetPassword/[resetPasswordToken].tsx | 1 - frontend/src/pages/index.tsx | 9 +- frontend/src/pages/upload/index.tsx | 8 +- frontend/src/services/config.service.ts | 6 +- frontend/src/types/config.type.ts | 7 +- frontend/src/utils/string.util.ts | 6 +- 66 files changed, 908 insertions(+), 623 deletions(-) create mode 100644 backend/prisma/migrations/20230303091601_v0_11_0/migration.sql rename frontend/public/{ => img}/favicon.ico (100%) rename frontend/public/{ => img}/icons/icon-128x128.png (100%) rename frontend/public/{ => img}/icons/icon-144x144.png (100%) rename frontend/public/{ => img}/icons/icon-152x152.png (100%) rename frontend/public/{ => img}/icons/icon-192x192.png (100%) rename frontend/public/{ => img}/icons/icon-384x384.png (100%) rename frontend/public/{ => img}/icons/icon-48x48.png (100%) rename frontend/public/{ => img}/icons/icon-512x512.png (100%) rename frontend/public/{ => img}/icons/icon-72x72.png (100%) rename frontend/public/{ => img}/icons/icon-96x96.png (100%) rename frontend/public/{ => img}/icons/icon-white-128x128.png (100%) delete mode 100644 frontend/public/img/logo-bg-white.png create mode 100644 frontend/public/img/logo.png delete mode 100644 frontend/public/img/logo.svg rename frontend/public/img/{opengraph-default.png => opengraph.png} (100%) delete mode 100644 frontend/src/components/admin/configuration/AdminConfigTable.tsx create mode 100644 frontend/src/components/admin/configuration/ConfigurationHeader.tsx create mode 100644 frontend/src/components/admin/configuration/ConfigurationNavBar.tsx rename frontend/src/components/{navBar => header}/ActionAvatar.tsx (100%) rename frontend/src/components/{navBar/NavBar.tsx => header/Header.tsx} (92%) rename frontend/src/components/{navBar => header}/NavbarShareMenu.tsx (100%) delete mode 100644 frontend/src/pages/admin/config.tsx create mode 100644 frontend/src/pages/admin/config/[category].tsx create mode 100644 frontend/src/pages/admin/config/index.tsx create mode 100644 frontend/src/pages/admin/intro.tsx delete mode 100644 frontend/src/pages/admin/setup.tsx diff --git a/.gitignore b/.gitignore index 89d47396..73672119 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ yarn-error.log* /data/ # Jetbrains specific (webstorm) -.idea/**/** \ No newline at end of file +.idea/**/** diff --git a/Dockerfile b/Dockerfile index bcdb42a3..2a5ebfd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,10 +35,9 @@ RUN apt-get update && apt-get install -y openssl WORKDIR /opt/app/frontend COPY --from=frontend-builder /opt/app/public ./public -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=frontend-builder /opt/app/.next/standalone ./ COPY --from=frontend-builder /opt/app/.next/static ./.next/static +COPY --from=frontend-builder /opt/app/public/img /tmp/img WORKDIR /opt/app/backend COPY --from=backend-builder /opt/app/node_modules ./node_modules @@ -48,4 +47,4 @@ COPY --from=backend-builder /opt/app/package.json ./ WORKDIR /opt/app EXPOSE 3000 -CMD node frontend/server.js & cd backend && npm run prod \ No newline at end of file +CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod \ No newline at end of file diff --git a/README.md b/README.md index 1f28b224..6ad30142 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Pingvin Share is self-hosted file sharing platform and an alternative for WeTran 1. Download the `docker-compose.yml` file 2. Run `docker-compose up -d` -The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧! +The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧! ### Stand-alone Installation @@ -57,7 +57,7 @@ npm run build pm2 start --name="pingvin-share-frontend" npm -- run start ``` -The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧! +The website is now listening on `http://localhost:3000`, have fun with Pingvin Share 🐧! ### Integrations @@ -94,6 +94,21 @@ docker compose up -d ``` 2. Repeat the steps from the [installation guide](#stand-alone-installation) except the `git clone` step. +### Custom branding + +#### Name + +You can change the name of the app by visiting the admin configuration page and changing the `App Name`. + +#### Logo + +You can change the logo of the app by replacing the images in the `/data/images` (or with the standalone installation `/frontend/public/img`) folder with your own logo. The folder contains the following images: + +- `logo.png` - The logo in the header and home page +- `favicon.png` - The favicon +- `opengraph.png` - The image used for sharing on social media +- `icons/*` - The icons used for the PWA + ## 🖤 Contribute You're very welcome to contribute to Pingvin Share! Follow the [contribution guide](/CONTRIBUTING.md) to get started. diff --git a/backend/prisma/migrations/20230303091601_v0_11_0/migration.sql b/backend/prisma/migrations/20230303091601_v0_11_0/migration.sql new file mode 100644 index 00000000..e326a0a6 --- /dev/null +++ b/backend/prisma/migrations/20230303091601_v0_11_0/migration.sql @@ -0,0 +1,94 @@ +/* + Warnings: + + - The primary key for the `Config` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `key` on the `Config` table. All the data in the column will be lost. + - Added the required column `name` 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, + "name" TEXT NOT NULL, + "category" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "obscured" BOOLEAN NOT NULL DEFAULT false, + "secret" BOOLEAN NOT NULL DEFAULT true, + "locked" BOOLEAN NOT NULL DEFAULT false, + "order" INTEGER NOT NULL, + + PRIMARY KEY ("name", "category") +); +-- INSERT INTO "new_Config" ("category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") SELECT "category", "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value" FROM "Config"; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'internal', 'jwtSecret', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'JWT_SECRET'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'general', 'appUrl', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'APP_URL'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'general', 'showHomePage', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHOW_HOME_PAGE'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'share', 'allowRegistration', "description", "locked", "obscured", 0, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_REGISTRATION'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'share', 'allowUnauthenticatedShares', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ALLOW_UNAUTHENTICATED_SHARES'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'share', 'maxSize', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'MAX_SHARE_SIZE'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'enableShareEmailRecipients', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'ENABLE_SHARE_EMAIL_RECIPIENTS'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'shareRecipientsSubject', "description", "locked", "obscured", 2, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_SUBJECT'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'shareRecipientsMessage', "description", "locked", "obscured", 3, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SHARE_RECEPIENTS_EMAIL_MESSAGE'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'reverseShareSubject', "description", "locked", "obscured", 4, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_SUBJECT'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'reverseShareMessage', "description", "locked", "obscured", 5, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'REVERSE_SHARE_EMAIL_MESSAGE'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'resetPasswordSubject', "description", "locked", "obscured", 6, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_SUBJECT'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'email', 'resetPasswordMessage', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'RESET_PASSWORD_EMAIL_MESSAGE'; + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'enabled', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_ENABLED'; + + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'host', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_HOST'; + + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'port', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PORT'; + + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'email', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_EMAIL'; + + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'username', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_USERNAME'; + + +INSERT INTO new_Config ("category", "name" , "description", "locked", "obscured", "order", "secret", "type", "updatedAt", "value") +SELECT 'smtp', 'password', "description", "locked", "obscured", 1, "secret", "type", "updatedAt", "value" FROM Config WHERE key = 'SMTP_PASSWORD'; + + +DROP TABLE "Config"; +ALTER TABLE "new_Config" RENAME TO "Config"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 00089a09..1d5c26b6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -131,13 +131,15 @@ model ShareSecurity { model Config { updatedAt DateTime @updatedAt - key String @id + name String + category String type String value String description String - category String obscured Boolean @default(false) secret Boolean @default(true) locked Boolean @default(false) order Int + + @@id([name, category]) } diff --git a/backend/prisma/seed/config.seed.ts b/backend/prisma/seed/config.seed.ts index 3fecd571..71c518a4 100644 --- a/backend/prisma/seed/config.seed.ts +++ b/backend/prisma/seed/config.seed.ts @@ -1,260 +1,244 @@ import { Prisma, PrismaClient } from "@prisma/client"; import * as crypto from "crypto"; -const configVariables: Prisma.ConfigCreateInput[] = [ - { - order: 0, - key: "SETUP_STATUS", - description: "Status of the setup wizard", - type: "string", - value: "STARTED", // STARTED, REGISTERED, FINISHED - category: "internal", - secret: false, - locked: true, +const configVariables: ConfigVariables = { + internal: { + jwtSecret: { + description: "Long random string used to sign JWT tokens", + type: "string", + value: crypto.randomBytes(256).toString("base64"), + locked: true, + }, }, - { - order: 0, - key: "JWT_SECRET", - description: "Long random string used to sign JWT tokens", - type: "string", - value: crypto.randomBytes(256).toString("base64"), - category: "internal", - locked: true, - }, - { - order: 1, - key: "APP_URL", - description: "On which URL Pingvin Share is available", - type: "string", - value: "http://localhost:3000", - category: "general", - secret: false, - }, - { - order: 2, - key: "SHOW_HOME_PAGE", - description: "Whether to show the home page", - type: "boolean", - value: "true", - category: "general", - secret: false, - }, - { - order: 3, - key: "ALLOW_REGISTRATION", - description: "Whether registration is allowed", - type: "boolean", - value: "true", - category: "share", - secret: false, - }, - { - order: 4, - key: "ALLOW_UNAUTHENTICATED_SHARES", - description: "Whether unauthorized users can create shares", - type: "boolean", - value: "false", - category: "share", - secret: false, - }, - { - order: 5, + general: { + appName: { + description: "Name of the application", + type: "string", + value: "Pingvin Share", + secret: false, + }, + appUrl: { + description: "On which URL Pingvin Share is available", + type: "string", + value: "http://localhost:3000", - key: "MAX_SHARE_SIZE", - description: "Maximum share size in bytes", - type: "number", - value: "1073741824", - category: "share", - secret: false, + secret: false, + }, + showHomePage: { + description: "Whether to show the home page", + type: "boolean", + value: "true", + secret: false, + }, }, + share: { + allowRegistration: { + description: "Whether registration is allowed", + type: "boolean", + value: "true", - { - order: 6, - key: "ENABLE_SHARE_EMAIL_RECIPIENTS", - description: - "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", - type: "boolean", - value: "false", - category: "email", - secret: false, + secret: false, + }, + allowUnauthenticatedShares: { + description: "Whether unauthorized users can create shares", + type: "boolean", + value: "false", + + secret: false, + }, + maxSize: { + description: "Maximum share size in bytes", + type: "number", + value: "1073741824", + + secret: false, + }, }, - { - order: 7, - key: "SHARE_RECEPIENTS_EMAIL_SUBJECT", - description: - "Subject of the email which gets sent to the share recipients.", - type: "string", - value: "Files shared with you", - category: "email", + email: { + enableShareEmailRecipients: { + description: + "Whether to allow emails to share recipients. Only enable this if you have enabled SMTP.", + type: "boolean", + value: "false", + + secret: false, + }, + shareRecipientsSubject: { + description: + "Subject of the email which gets sent to the share recipients.", + type: "string", + value: "Files shared with you", + }, + shareRecipientsMessage: { + description: + "Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", + 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 🐧", + }, + reverseShareSubject: { + description: + "Subject of the email which gets sent when someone created a share with your reverse share link.", + type: "string", + value: "Reverse share link used", + }, + reverseShareMessage: { + description: + "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", + type: "text", + value: + "Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧", + }, + resetPasswordSubject: { + description: + "Subject of the email which gets sent when a user requests a password reset.", + type: "string", + value: "Pingvin Share password reset", + }, + resetPasswordMessage: { + description: + "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", + type: "text", + value: + "Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧", + }, + inviteSubject: { + description: + "Subject of the email which gets sent when an admin invites an user.", + type: "string", + value: "Pingvin Share invite", + }, + inviteMessage: { + description: + "Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.", + type: "text", + value: + "Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧", + }, }, - { - order: 8, - key: "SHARE_RECEPIENTS_EMAIL_MESSAGE", - description: - "Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.", - 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", + smtp: { + enabled: { + description: + "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", + type: "boolean", + value: "false", + secret: false, + }, + host: { + description: "Host of the SMTP server", + type: "string", + value: "", + }, + port: { + description: "Port of the SMTP server", + type: "number", + value: "0", + }, + email: { + description: "Email address which the emails get sent from", + type: "string", + value: "", + }, + username: { + description: "Username of the SMTP server", + type: "string", + value: "", + }, + password: { + description: "Password of the SMTP server", + type: "string", + value: "", + obscured: true, + }, }, - { - order: 9, - key: "REVERSE_SHARE_EMAIL_SUBJECT", - description: - "Subject of the email which gets sent when someone created a share with your reverse share link.", - type: "string", - value: "Reverse share link used", - category: "email", - }, - { - order: 10, - key: "REVERSE_SHARE_EMAIL_MESSAGE", - description: - "Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.", - type: "text", - value: - "Hey!\nA share was just created with your reverse share link: {shareUrl}\nShared securely with Pingvin Share 🐧", - category: "email", - }, - { - order: 11, - key: "RESET_PASSWORD_EMAIL_SUBJECT", - description: - "Subject of the email which gets sent when a user requests a password reset.", - type: "string", - value: "Pingvin Share password reset", - category: "email", - }, - { - order: 12, - key: "RESET_PASSWORD_EMAIL_MESSAGE", - description: - "Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.", - type: "text", - value: - "Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧", - category: "email", - }, - { - order: 13, - key: "INVITE_EMAIL_SUBJECT", - description: - "Subject of the email which gets sent when an admin invites an user.", - type: "string", - value: "Pingvin Share invite", - category: "email", - }, - { - order: 14, - key: "INVITE_EMAIL_MESSAGE", - description: - "Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.", - type: "text", - value: - "Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧", - category: "email", - }, - { - order: 15, - key: "SMTP_ENABLED", - description: - "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", - type: "boolean", - value: "false", - category: "smtp", - secret: false, - }, - { - order: 16, - key: "SMTP_HOST", - description: "Host of the SMTP server", - type: "string", - value: "", - category: "smtp", - }, - { - order: 17, - key: "SMTP_PORT", - description: "Port of the SMTP server", - type: "number", - value: "0", - category: "smtp", - }, - { - order: 18, - key: "SMTP_EMAIL", - description: "Email address which the emails get sent from", - type: "string", - value: "", - category: "smtp", - }, - { - order: 19, - key: "SMTP_USERNAME", - description: "Username of the SMTP server", - type: "string", - value: "", - category: "smtp", - }, - { - order: 20, - key: "SMTP_PASSWORD", - description: "Password of the SMTP server", - type: "string", - value: "", - obscured: true, - category: "smtp", - }, -]; +}; + +type ConfigVariables = { + [category: string]: { + [variable: string]: Omit< + Prisma.ConfigCreateInput, + "name" | "category" | "order" + >; + }; +}; const prisma = new PrismaClient(); -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, +async function seedConfigVariables() { + for (const [category, configVariablesOfCategory] of Object.entries( + configVariables + )) { + let order = 0; + for (const [name, properties] of Object.entries( + configVariablesOfCategory + )) { + const existingConfigVariable = await prisma.config.findUnique({ + where: { name_category: { name, category } }, }); + + // Create a new config variable if it doesn't exist + if (!existingConfigVariable) { + await prisma.config.create({ + data: { + order, + name, + ...properties, + category, + }, + }); + } + order++; } } +} - const configVariablesFromDatabase = await prisma.config.findMany(); +async function migrateConfigVariables() { + const existingConfigVariables = await prisma.config.findMany(); - // Delete the config variable if it doesn't exist anymore - for (const configVariableFromDatabase of configVariablesFromDatabase) { - const configVariable = configVariables.find( - (v) => v.key == configVariableFromDatabase.key - ); + for (const existingConfigVariable of existingConfigVariables) { + const configVariable = + configVariables[existingConfigVariable.category]?.[ + existingConfigVariable.name + ]; if (!configVariable) { await prisma.config.delete({ - where: { key: configVariableFromDatabase.key }, + where: { + name_category: { + name: existingConfigVariable.name, + category: existingConfigVariable.category, + }, + }, }); // Update the config variable if the metadata changed } else if ( JSON.stringify({ ...configVariable, - key: configVariableFromDatabase.key, - value: configVariableFromDatabase.value, - }) != JSON.stringify(configVariableFromDatabase) + name: existingConfigVariable.name, + category: existingConfigVariable.category, + value: existingConfigVariable.value, + }) != JSON.stringify(existingConfigVariable) ) { await prisma.config.update({ - where: { key: configVariableFromDatabase.key }, + where: { + name_category: { + name: existingConfigVariable.name, + category: existingConfigVariable.category, + }, + }, data: { ...configVariable, - key: configVariableFromDatabase.key, - value: configVariableFromDatabase.value, + name: existingConfigVariable.name, + category: existingConfigVariable.category, + value: existingConfigVariable.value, }, }); } } } -main() + +seedConfigVariables() + .then(() => migrateConfigVariables()) .then(async () => { await prisma.$disconnect(); }) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 2a9d5622..bb9c10d9 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -42,7 +42,7 @@ export class AuthController { @Body() dto: AuthRegisterDTO, @Res({ passthrough: true }) response: Response ) { - if (!this.config.get("ALLOW_REGISTRATION")) + if (!this.config.get("share.allowRegistration")) throw new ForbiddenException("Registration is not allowed"); const result = await this.authService.signUp(dto); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 7ddbcf3e..cd7f81ad 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -25,7 +25,7 @@ export class AuthService { ) {} async signUp(dto: AuthRegisterDTO) { - const isFirstUser = this.config.get("SETUP_STATUS") == "STARTED"; + const isFirstUser = (await this.prisma.user.count()) == 0; const hash = await argon.hash(dto.password); try { @@ -38,10 +38,6 @@ export class AuthService { }, }); - if (isFirstUser) { - await this.config.changeSetupStatus("REGISTERED"); - } - const { refreshToken, refreshTokenId } = await this.createRefreshToken( user.id ); @@ -161,7 +157,7 @@ export class AuthService { }, { expiresIn: "15min", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("internal.jwtSecret"), } ); } diff --git a/backend/src/auth/guard/jwt.guard.ts b/backend/src/auth/guard/jwt.guard.ts index 39ecd131..7db8f092 100644 --- a/backend/src/auth/guard/jwt.guard.ts +++ b/backend/src/auth/guard/jwt.guard.ts @@ -11,7 +11,7 @@ export class JwtGuard extends AuthGuard("jwt") { try { return (await super.canActivate(context)) as boolean; } catch { - return this.config.get("ALLOW_UNAUTHENTICATED_SHARES"); + return this.config.get("share.allowUnauthenticatedShares"); } } } diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index 5ed085a3..b167af08 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -9,10 +9,10 @@ import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(config: ConfigService, private prisma: PrismaService) { - config.get("JWT_SECRET"); + config.get("internal.jwtSecret"); super({ jwtFromRequest: JwtStrategy.extractJWT, - secretOrKey: config.get("JWT_SECRET"), + secretOrKey: config.get("internal.jwtSecret"), }); } diff --git a/backend/src/config/config.controller.ts b/backend/src/config/config.controller.ts index e7488731..486ac2e5 100644 --- a/backend/src/config/config.controller.ts +++ b/backend/src/config/config.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; import { SkipThrottle } from "@nestjs/throttler"; import { AdministratorGuard } from "src/auth/guard/isAdmin.guard"; import { JwtGuard } from "src/auth/guard/jwt.guard"; @@ -22,24 +30,20 @@ export class ConfigController { return new ConfigDTO().fromList(await this.configService.list()); } - @Get("admin") + @Get("admin/:category") @UseGuards(JwtGuard, AdministratorGuard) - async listForAdmin() { + async getByCategory(@Param("category") category: string) { return new AdminConfigDTO().fromList( - await this.configService.listForAdmin() + await this.configService.getByCategory(category) ); } @Patch("admin") @UseGuards(JwtGuard, AdministratorGuard) async updateMany(@Body() data: UpdateConfigDTO[]) { - await this.configService.updateMany(data); - } - - @Post("admin/finishSetup") - @UseGuards(JwtGuard, AdministratorGuard) - async finishSetup() { - return await this.configService.changeSetupStatus("FINISHED"); + return new AdminConfigDTO().fromList( + await this.configService.updateMany(data) + ); } @Post("admin/testEmail") diff --git a/backend/src/config/config.service.ts b/backend/src/config/config.service.ts index 150f8772..4580c819 100644 --- a/backend/src/config/config.service.ts +++ b/backend/src/config/config.service.ts @@ -14,9 +14,9 @@ export class ConfigService { private prisma: PrismaService ) {} - get(key: string): any { + get(key: `${string}.${string}`): any { const configVariable = this.configVariables.filter( - (variable) => variable.key == key + (variable) => `${variable.category}.${variable.name}` == key )[0]; if (!configVariable) throw new Error(`Config variable ${key} not found`); @@ -27,30 +27,51 @@ export class ConfigService { return configVariable.value; } - async listForAdmin() { - return await this.prisma.config.findMany({ + async getByCategory(category: string) { + const configVariables = await this.prisma.config.findMany({ orderBy: { order: "asc" }, - where: { locked: { equals: false } }, + where: { category, locked: { equals: false } }, + }); + + return configVariables.map((variable) => { + return { + key: `${variable.category}.${variable.name}`, + ...variable, + }; }); } async list() { - return await this.prisma.config.findMany({ + const configVariables = await this.prisma.config.findMany({ where: { secret: { equals: false } }, }); + + return configVariables.map((variable) => { + return { + key: `${variable.category}.${variable.name}`, + ...variable, + }; + }); } async updateMany(data: { key: string; value: string | number | boolean }[]) { + const response: Config[] = []; + for (const variable of data) { - await this.update(variable.key, variable.value); + response.push(await this.update(variable.key, variable.value)); } - return data; + return response; } async update(key: string, value: string | number | boolean) { const configVariable = await this.prisma.config.findUnique({ - where: { key }, + where: { + name_category: { + category: key.split(".")[0], + name: key.split(".")[1], + }, + }, }); if (!configVariable || configVariable.locked) @@ -67,7 +88,12 @@ export class ConfigService { } const updatedVariable = await this.prisma.config.update({ - where: { key }, + where: { + name_category: { + category: key.split(".")[0], + name: key.split(".")[1], + }, + }, data: { value: value.toString() }, }); @@ -75,15 +101,4 @@ export class ConfigService { return updatedVariable; } - - async changeSetupStatus(status: "STARTED" | "REGISTERED" | "FINISHED") { - const updatedVariable = await this.prisma.config.update({ - where: { key: "SETUP_STATUS" }, - data: { value: status }, - }); - - this.configVariables = await this.prisma.config.findMany(); - - return updatedVariable; - } } diff --git a/backend/src/config/dto/adminConfig.dto.ts b/backend/src/config/dto/adminConfig.dto.ts index dcb2491f..322df3b1 100644 --- a/backend/src/config/dto/adminConfig.dto.ts +++ b/backend/src/config/dto/adminConfig.dto.ts @@ -2,6 +2,9 @@ import { Expose, plainToClass } from "class-transformer"; import { ConfigDTO } from "./config.dto"; export class AdminConfigDTO extends ConfigDTO { + @Expose() + name: string; + @Expose() secret: boolean; @@ -14,9 +17,6 @@ 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/email/email.service.ts b/backend/src/email/email.service.ts index ff89a442..7db97cef 100644 --- a/backend/src/email/email.service.ts +++ b/backend/src/email/email.service.ts @@ -8,16 +8,16 @@ export class EmailService { constructor(private config: ConfigService) {} getTransporter() { - if (!this.config.get("SMTP_ENABLED")) + if (!this.config.get("smtp.enabled")) throw new InternalServerErrorException("SMTP is disabled"); return nodemailer.createTransport({ - host: this.config.get("SMTP_HOST"), - port: parseInt(this.config.get("SMTP_PORT")), - secure: parseInt(this.config.get("SMTP_PORT")) == 465, + host: this.config.get("smtp.host"), + port: this.config.get("smtp.port"), + secure: this.config.get("smtp.port") == 465, auth: { - user: this.config.get("SMTP_USERNAME"), - pass: this.config.get("SMTP_PASSWORD"), + user: this.config.get("smtp.username"), + pass: this.config.get("smtp.password"), }, }); } @@ -27,17 +27,19 @@ export class EmailService { shareId: string, creator?: User ) { - if (!this.config.get("ENABLE_SHARE_EMAIL_RECIPIENTS")) + if (!this.config.get("email.enableShareEmailRecipients")) throw new InternalServerErrorException("Email service disabled"); - const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; + const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; await this.getTransporter().sendMail({ - from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + from: `"${this.config.get("general.appName")}" <${this.config.get( + "smtp.email" + )}>`, to: recipientEmail, - subject: this.config.get("SHARE_RECEPIENTS_EMAIL_SUBJECT"), + subject: this.config.get("email.shareRecipientsSubject"), text: this.config - .get("SHARE_RECEPIENTS_EMAIL_MESSAGE") + .get("email.shareRecipientsMessage") .replaceAll("\\n", "\n") .replaceAll("{creator}", creator?.username ?? "Someone") .replaceAll("{shareUrl}", shareUrl), @@ -45,14 +47,16 @@ export class EmailService { } async sendMailToReverseShareCreator(recipientEmail: string, shareId: string) { - const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`; + const shareUrl = `${this.config.get("general.appUrl")}/share/${shareId}`; await this.getTransporter().sendMail({ - from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + from: `"${this.config.get("general.appName")}" <${this.config.get( + "smtp.email" + )}>`, to: recipientEmail, - subject: this.config.get("REVERSE_SHARE_EMAIL_SUBJECT"), + subject: this.config.get("email.reverseShareSubject"), text: this.config - .get("REVERSE_SHARE_EMAIL_MESSAGE") + .get("email.reverseShareMessage") .replaceAll("\\n", "\n") .replaceAll("{shareUrl}", shareUrl), }); @@ -60,28 +64,32 @@ export class EmailService { async sendResetPasswordEmail(recipientEmail: string, token: string) { const resetPasswordUrl = `${this.config.get( - "APP_URL" + "general.appUrl" )}/auth/resetPassword/${token}`; await this.getTransporter().sendMail({ - from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + from: `"${this.config.get("general.appName")}" <${this.config.get( + "smtp.email" + )}>`, to: recipientEmail, - subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"), + subject: this.config.get("email.resetPasswordSubject"), text: this.config - .get("RESET_PASSWORD_EMAIL_MESSAGE") + .get("email.resetPasswordMessage") .replaceAll("{url}", resetPasswordUrl), }); } async sendInviteEmail(recipientEmail: string, password: string) { - const loginUrl = `${this.config.get("APP_URL")}/auth/signIn`; + const loginUrl = `${this.config.get("general.appUrl")}/auth/signIn`; await this.getTransporter().sendMail({ - from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + from: `"${this.config.get("general.appName")}" <${this.config.get( + "smtp.email" + )}>`, to: recipientEmail, - subject: this.config.get("INVITE_EMAIL_SUBJECT"), + subject: this.config.get("email.inviteSubject"), text: this.config - .get("INVITE_EMAIL_MESSAGE") + .get("email.inviteMessage") .replaceAll("{url}", loginUrl) .replaceAll("{password}", password), }); @@ -90,7 +98,9 @@ export class EmailService { async sendTestMail(recipientEmail: string) { try { await this.getTransporter().sendMail({ - from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, + from: `"${this.config.get("general.appName")}" <${this.config.get( + "smtp.email" + )}>`, to: recipientEmail, subject: "Test email", text: "This is a test email", diff --git a/backend/src/file/file.service.ts b/backend/src/file/file.service.ts index 0d778818..be0cddc4 100644 --- a/backend/src/file/file.service.ts +++ b/backend/src/file/file.service.ts @@ -67,7 +67,7 @@ export class FileService { const shareSizeSum = fileSizeSum + diskFileSize + buffer.byteLength; if ( - shareSizeSum > this.config.get("MAX_SHARE_SIZE") || + shareSizeSum > this.config.get("share.maxSize") || (share.reverseShare?.maxShareSize && shareSizeSum > parseInt(share.reverseShare.maxShareSize)) ) { diff --git a/backend/src/reverseShare/reverseShare.controller.ts b/backend/src/reverseShare/reverseShare.controller.ts index 9bc67f39..32afcaf4 100644 --- a/backend/src/reverseShare/reverseShare.controller.ts +++ b/backend/src/reverseShare/reverseShare.controller.ts @@ -31,7 +31,7 @@ export class ReverseShareController { async create(@Body() body: CreateReverseShareDTO, @GetUser() user: User) { const token = await this.reverseShareService.create(body, user.id); - const link = `${this.config.get("APP_URL")}/upload/${token}`; + const link = `${this.config.get("general.appUrl")}/upload/${token}`; return { token, link }; } diff --git a/backend/src/reverseShare/reverseShare.service.ts b/backend/src/reverseShare/reverseShare.service.ts index 10a11d5d..156f6ed0 100644 --- a/backend/src/reverseShare/reverseShare.service.ts +++ b/backend/src/reverseShare/reverseShare.service.ts @@ -24,7 +24,7 @@ export class ReverseShareService { ) .toDate(); - const globalMaxShareSize = this.config.get("MAX_SHARE_SIZE"); + const globalMaxShareSize = this.config.get("share.maxSize"); if (globalMaxShareSize < data.maxShareSize) throw new BadRequestException( diff --git a/backend/src/share/share.service.ts b/backend/src/share/share.service.ts index 36bb476a..321e1559 100644 --- a/backend/src/share/share.service.ts +++ b/backend/src/share/share.service.ts @@ -153,7 +153,7 @@ export class ShareService { if ( share.reverseShare && - this.config.get("SMTP_ENABLED") && + this.config.get("smtp.enabled") && share.reverseShare.sendEmailNotification ) { await this.emailService.sendMailToReverseShareCreator( @@ -303,7 +303,7 @@ export class ShareService { }, { expiresIn: moment(expiration).diff(new Date(), "seconds") + "s", - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("internal.jwtSecret"), } ); } @@ -315,7 +315,7 @@ export class ShareService { try { const claims = this.jwtService.verify(token, { - secret: this.config.get("JWT_SECRET"), + secret: this.config.get("internal.jwtSecret"), // Ignore expiration if expiration is 0 ignoreExpiration: moment(expiration).isSame(0), }); diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 4ca2e946..56be04e9 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -4,7 +4,7 @@ import { UserController } from "./user.controller"; import { UserSevice } from "./user.service"; @Module({ - imports:[EmailModule], + imports: [EmailModule], providers: [UserSevice], controllers: [UserController], }) diff --git a/docker-compose.yml b/docker-compose.yml index cc38e888..2784d581 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - 3000:3000 volumes: - "./data:/opt/app/backend/data" + - "./data/images:/opt/app/frontend/public/img" # Optional: If you add ClamAV, uncomment the following to have ClamAV start first. # depends_on: # clamav: diff --git a/frontend/public/favicon.ico b/frontend/public/img/favicon.ico similarity index 100% rename from frontend/public/favicon.ico rename to frontend/public/img/favicon.ico diff --git a/frontend/public/icons/icon-128x128.png b/frontend/public/img/icons/icon-128x128.png similarity index 100% rename from frontend/public/icons/icon-128x128.png rename to frontend/public/img/icons/icon-128x128.png diff --git a/frontend/public/icons/icon-144x144.png b/frontend/public/img/icons/icon-144x144.png similarity index 100% rename from frontend/public/icons/icon-144x144.png rename to frontend/public/img/icons/icon-144x144.png diff --git a/frontend/public/icons/icon-152x152.png b/frontend/public/img/icons/icon-152x152.png similarity index 100% rename from frontend/public/icons/icon-152x152.png rename to frontend/public/img/icons/icon-152x152.png diff --git a/frontend/public/icons/icon-192x192.png b/frontend/public/img/icons/icon-192x192.png similarity index 100% rename from frontend/public/icons/icon-192x192.png rename to frontend/public/img/icons/icon-192x192.png diff --git a/frontend/public/icons/icon-384x384.png b/frontend/public/img/icons/icon-384x384.png similarity index 100% rename from frontend/public/icons/icon-384x384.png rename to frontend/public/img/icons/icon-384x384.png diff --git a/frontend/public/icons/icon-48x48.png b/frontend/public/img/icons/icon-48x48.png similarity index 100% rename from frontend/public/icons/icon-48x48.png rename to frontend/public/img/icons/icon-48x48.png diff --git a/frontend/public/icons/icon-512x512.png b/frontend/public/img/icons/icon-512x512.png similarity index 100% rename from frontend/public/icons/icon-512x512.png rename to frontend/public/img/icons/icon-512x512.png diff --git a/frontend/public/icons/icon-72x72.png b/frontend/public/img/icons/icon-72x72.png similarity index 100% rename from frontend/public/icons/icon-72x72.png rename to frontend/public/img/icons/icon-72x72.png diff --git a/frontend/public/icons/icon-96x96.png b/frontend/public/img/icons/icon-96x96.png similarity index 100% rename from frontend/public/icons/icon-96x96.png rename to frontend/public/img/icons/icon-96x96.png diff --git a/frontend/public/icons/icon-white-128x128.png b/frontend/public/img/icons/icon-white-128x128.png similarity index 100% rename from frontend/public/icons/icon-white-128x128.png rename to frontend/public/img/icons/icon-white-128x128.png diff --git a/frontend/public/img/logo-bg-white.png b/frontend/public/img/logo-bg-white.png deleted file mode 100644 index 7e5fbf72284b5ae3bdfa8bf5b43aee407d20f6e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4364 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

fyu$1KP`warnHx zn~73MR2cpKZ_N){XWX~nL$f8%8~^us87M1YBk2ECb>;C;weNf6EtOEo65&-z5sK_< zQ4)o+WE${vTtKV$TmcbeHz~0i6?`$5)8KS5F-$!`WxJJ!dIASgG&yl+3gsUA9n5@LvvC zPm!tLKIqkDYgX+#YQMB3^_^^KW|QY!Hu(Pw$Ee9ZSpZLYzN!Jyt=aGmykJF+Zb>s! zCW(*@{)0K)G0H^W+&@i!axJrqB=hn0CtHp_;I(`)Rx)FmSLYqNlwZ{FSD8c6wDP!l zT#8FKnF4CTJN=phO>S9VAt05CW7E}>lS7&bPA$n&zfwa$?#7+lD0mi*o>lUrB7=i3 zm~)eKo!hS76j&80F_YdTn|*ADpxKAplvlN%u4I&G4@rl)=fWkK7L7->+!z#}76 zQe>`t#}q!S%Yp1guQSj-ck%VVl=e2&Q~2!C!SjV&!g)8InXV19bJ>5f1V!VSJw__` zv(_la1YG9v3o@JC4nItXiQh$)rmci$=6@&eBs&2Q8i1w*Tc{#Nif=bTkpj$MSL8E` zDKY6JoYX~;@*(WV?=QU5m6f94vJX^jEX5AlA3FlzjP{uPT(z1mTes(cKATg~KE`ra z2<6&$w*VxfO(q>WjJ;1kKiQPxn>zFLr{_FL-}w#IGy16lq0F5r$nK_mux;|&I?-Ej zf(T{$t+w$d)ilN(xXZ&2QZ0!v>r(R{&sn~JuvS?@=ga~;p_VvyG8b0kR)wlBS@5Hc z3@&?DKdoZ!l-V%_cJiQlFm3m5CZI$1$LxHCtdpM|F%Osv`8NAI>CE1^?ndQPvP&D{ zaF&)lI%8{!oi0SXVMa~Y5)q>pmmk?Tod2<8>(y~L7qIh)ZcFD}DA{#g*WXxNdQfDS zebNV3#&V*jBZj)fC=nE;(=|73pi8$-lSz~~*o8Q|P&9nBQ1sr&WKvgnvX^OL$YUVZ zN23J>v}*W)rXpm%wavvzZW6X~%uRy=BTvzPmEClZqc@TKVrwOmA$%L&nE<_O3e{8nR6y82&RNJ!5 zq$~gHVlQBW@06>Q4b+UCY|ty$s@27;^w4sNcBk*DGjT9e$2qPPNBh^H<-ssP8>sOO zG7xRSlUQZwJ24CjT9jY+uwqMdtzNOKDjQs_CU+66;&2CwO~q-zBU9Zp+eQ0Nb04(D zUZGQzx@2~WLsUS>beh}kSEafX$cc+T(iJZFuFl~XPoav=(AW-1*ljBcZKyg69SJ66RwBL z8iM375fuRN;-SP`FKHKGjS>e!#4x>9(Oq4(dYQ$g`aao*GS}6Uz|4849u)2@G-_^K z5i!cD{lq^{=h0WjP%S}j(>+$ZJEtmIauGWbc=AN|tA0Ydx>1GP9N*_DA1!DFCgvaa<77zu zde~(rV5ab_q2qS|Mt(e_A2c7ka^D;}$G}EuSox;Qv+Ewa?JJ>yTf^odC27)&ZbPlt zfhbAHo9qUT%&!$Yf2`tB#LI$ zG)G>R;}q$t8*IG>m^SHOCrX?q$oP@h-(~I?sC^2;gfZ>Xg+SxoSx(;1zQobf3g~DAV#w~d zfpQt}(lSNwdFb@2S*%78CK{Q>gN@p^;EPA!=e}-~{-1>ans>iE5U8524ot&G^^P*= zDB|vkr12NJ)wf=b*WIALAKqONa5Q0dVc)(dHi*5}Y_svW#S?j{l`|BnR{@%K@@LF< zS|U8PfcHe{C=tmJ@OInuH(yG8?+L%ZvwLVg?IQO7+ka@45;+VU%1xyAinY1?`1=2OMF? zN8SYm;C?}+EeHAS#PgKWr&F)e&fan0Cek?^#zMnke|K%@2uL!}2Ih@|^zpZ(jyH z&xcI{1)}wSf%w47p%GBD=B*Lha{hT=_bE{ZsT1UO^H+6kJxg2#&~V|aBdJih58c4%%KVGy-3sjy~R|sybLpwxa=iuG-e6~37^B}}= zkNwS(fVvFfacQS`B!Xo=dN_q$c6%U%KAKEG<<$u-sY#uV9-JuxcO6=LS&H~^?yP+Qm*@i9J_+B-5jZ+64XVUO>&ZMgdl#4J zVsI`wu_1jxaP zo8+BlaBW5wU~_iSLQ1n|MPO6xOB`2FcD3{j3Inl^;p(9Q{GBnJ;`)*4gupo z8GTjdoO(sEbV+29^pX|pIe7Lhb=lw{ohfr`BhUOYb0ND@S@+`AI_`$sEFo9Vaw}9S z9#Q$%Fhb$Z+2fZdli$B@zcMIG@-02uYF#2-;;`kBjN9mTyB*suy%ZDY+R_27?amP* z4cH0VI8Vd|>EcfcmBCtvh~eHu+$&G0_u4N14wpe`b7&}x32wSx43J80Zc3Lu`F-tZ z=!+}RCjQzH5q@!kH!*~y8I4;rBaY`1v!ZPsFI|E*<;5*Li(6leG-iem&VN>Z>s``C z_ma}5B%z!@>Z>SZ@4>CcAnmP50igz?mG5-%7N+h{__YNYf%V|BB%GpwGJ8 z++EhaNy-(cS{=5e-BYu7f6MS0P#^BQfO znswdnlCLW9&cfK;tqs-{8OE5-i$Z(1Hu=Qg#{vEndFvYQ+}{)!cxi$eK7r(ro@$e{ zks%LC?K=9u@nl4^Dy7{z=Xs4iM ziy_R3)TW&jtse zvJ?ZeM)52;{WZkSfZg!qlXZ6Z3tCxaf6}s`cq|wu$U9vT_J9RGr`NLzKn97lxdFmJ zbs7Njl>0@e)CTWO(?2cqHkAX?#~;#MfD2SjVn)qDN*wtY+L=GD%SJCN<>La4r2Xsa z%2I8!vD7bEXDJJ*Bh(aRCj{`C57B@1HE`M5V$Lm;y%UTQht^PyRDxm5s8nX+3^u4C?V}Q)r`#s&gMby`%jxolq~US(8#F{A2Od!!Gq3 zMl4zMI;w(O1?=62UELb{cg$5_di32PK=b(ca0eO=c&GJ~ zvB`V44jh?+mcQs>f9!XLcsXz6{b6eluJd8KS;zQyRSI2Rka-3&qgX(9cl6hUsPV45 zc;ES@q*y8R5u1db z^!%%-R{LZ~7M*J?^GVsjxAI%}#ET9m{7=?kwnIx2xEg9>aya%s3DJBpY9Bqi=&TOx zJ%)U4HaIT2+gkEhyUo=hi6`YS>);68K+*>z8}q{v-Yu}n6N4unPkltO;Hp0zdh+kO zLheXU)#h38(gPc$H+e{piLlU!?{wA&v(Y;XEHtebw^(L&d)6*dqyh|~-w3MM8$&>P z3-cyV91{oORRGmf`d#M(?FVE%93ck_ScIKc_3bV4xld~|9aIw7NB>+-ZFh7Ghd-?q zW2uH}ZzmSBkNvp^8U6tBH#eez^xM-0M)rp+x>UPLs1B{q{^9h-^8tTN{43>qYm0oo zNA10)e0I#?K=buouO`%{CPZbYVwdIXIyp>0*L*;Qb%qT0Yi-W(KW^mSeTjOw2>CM! z^+4%QI~81ru?r13-~CI@QyaW*(#DM$3~I%25gXNv=7;+j;hG!g|89&iR&~ekX*0r; zl9DdQ#|{Ane&1=gk#|(vG$SBENGiUOn{0^7jHOnZbSeJi)g1%H;e4#zRkWX-W}SQ*DX`nLQAJH&d>~=+Z|ZEHe4WeekKEK-RQTiSTON@oWw|`TNGv^B zSt*u36(s6+Hd{Ije-z*PO;HRG;l90X{q#wJ9DNm0WasFoD_ zK(cz1ahLUaDY2!s_s!C+?IzaNB2v0v4NRUuM2xH*o?x@!_xmE3#R(!V`Eg(-2f&xo zm#e$3ZL6R5d??BC3Ye7v48r7u6GIZ_1fkN>(#iqIs#lAE?g5npuri&#WYPL3D?TP% zO;Qzfrb!3_^D1CxAgyQ5q#s^gt0{DN zOb|>8A@w8ydPbQvfTg-{ae|P)`^x(?(X^tf@v!-1xfw0~M9#hLvrS05z{4q9_hkC6 zK0qFwqzAMHC^GOCI_{Q1S5l%ZF6>=uz^vVIsXg#AerN(*S5HDScw#)-IcyK-j20ro zwbIY)-hXt;dlkV6F$L=$Ko>2ZOY0AXz|}?5fLA3$X?zX_(ubi%>!kX!8!)?roc|oQ ze%55w*lhW!+u?rYndIev0Y#zM(`TSFTcLHSU8!|L=5)tCDB7zLOBrlNGqgy-5ulu= zc<(0k^W#G7TEMx_wBkI-HJ`&FJS~I9jkEhn9vh-uq#PRm150KNuEcmeEvuVsYiz3G zWIZq?*McFZFNQQdr^ImTAZFTYAFzfVzWRHz*49SKf$U}lH17_E$sO2s1ju6Hq>k|n z9p`fQ0Yron(f_6EOI|;K8oU zBkgumA=!%@Jngb}hoCv~C!o~Bv_4bU*!5QYb$tzIPrMgOuK{F1k4n6lD+E~P6bHU1 zOY?gVa%KPUbK59`<fP>^(8gC(XIYD>!dYN9Bi?KtK`v1EjAHJ#*rFX^uP8T=!o>vD&*T@U~_5waL z6Q!ta>gp|KmPWH*@c}E^51=%QajIau)z5E*jSa)Ys4DsFgKcKfgvg$LNU7;zqQfmM{*6NAFH2%LKV1yeoY@GHVg2hSQ-A~BtZ!l&=n0&= zGQ$+&4{$7Rk?sHa(>2>IKfuK}eKV5t{h@OU_)CqroMyfsgM*U7NkS+>{U2NIwzn|E zxT%Qhd{}-_gM--}j<+6>(h~IZdEX4znK61K+OPM}Gn{zdPZj+yY`_`|)??71l)Qk< zJis^!WNJUxrm^x!r^!8lufJP9l(7k&*T@&s%A!u|(l5yH^q@<@?F#z{Nno`AbT5)N>qTls)RYWa%hYW$Xz83_uJd4B+R znG`0%YQ5)@mdQi!jk3)X3@;ByD+H>cCR`3O9xZ=yBu+#o^-rI@%4ONli$A;w%Zrw` zT(xEb-DHdY+%+)1GApM@=tHaH45xjqDcixOyuMyx&4_G-l!MMr2HIcRC7q`706!gG zcd_0dP7y7#_;a%mHHQd}X{otCKbU(~|7KqQKyP25 zH@5W|1?|qvPc>TVT$6F&Yl zo#L|g2d>}s?iGzO%xGqP52{$BpIJ2T`~BmLylzfb(Jym1M4NqHtu*yEP-mkfzf%|< zALS}7%z>Zb`=1|a(@t=UM*Z@^Ok)_wP$ayL^&`mF2EW(IydU?+ z-^1Ptrm(P*A-s>XVy#i<)_7cQ$fq45Pnz}g@S4meCOQg)o~#Xve?5N z2>ly|kJWdKG>IYNQz2RYG$YRHQlKYfX{WMrx7^8iqRqtA8=+b^6=ViR4XlU6>OG%4 z)YJr1h=`^~Aa4q2ELaW>Qh9X|l9%nVia*aW#Q+&k6L#8WMTx9&0CJ+kUF6&;t#z(( zV-F3;Ja5`z*E*fqV}x%v=D5yq{tt6^P;T!PaduHk1gK{vVrOz$!LvWI4~#ol!?HOi^fE@c~vOeXI^WK-e-? z;5D#+&*QduWU0L3y9^myRIxe$*$FuY&?Nt4nlqBb#$$|E5(Bvc*ilwZs$BM;4ICm7 zEjLGMcuNk@=)(zXj-FnU?u2?ctFsQ$9P+(I9k9=Z1Iy~ zlwwMUx4p?g1fa7;V0C0G3#lP;gi=!Kk6NX*eF_u$rIk3YVnZom3;KcF0?o0>UJ4Qa zoOOZGi$pl14%;u`%G`W2$J0O04P~>>0{8$sj0QR~{Q1j$`0z zPy;oxD-yFmRcAh&C*|DFQ=u$HRg+v7Ao~SU-}e^()h49w&085L6zT(3Vc+NDsgif8I_v8oGUam@v(1z)_~wi3`gc#vwDy z-v+V2p6%oMe0VTIwK?Yu>u>2IpdzCGcONvql4uWn2;V>SVkYV1>Kym?Cy&~5FC3kv zKK5*b>7A|Pjhp*EWKKm6H$?d}=yKh-PiEW|KDpWY=af0DzgMI>))h#Zyt}3T4igXpvHM~9pW(%Sfx~+8hHEIb0^#UYAe^eEpSD@MtTg9P zbcTGC%LiRZMYA*zL}rh)?a^DE^B$&B-mjm^{16IHcExj#@G%#8-8r}_Dj!6xEoj)b z^IlDkSmqagko#$Bb3Uh}X)$U%Bkv`6f1RS?rfB1M-+Ij;cy4{?Zv745KqBd!;CSiJ zulEn8LD;lb-g%Se=JTvSF=cJB$5u=7D!3xR#k^i@Z4MkJYtw1)=&#^Y0(hszXm}5X zbrxc=?VOBY$UB-{?qe-f9>_aGPs4X`cRYz3+NF6yqX6UE$t1tG8!jmQ^kCS~8ap|| z-C8N1T!w+ki!|5w`;q^gV-0<_?BF$j^_&Gc&;9IE0ca z_@L}p4AaLy_P7=?`>;K^x^^J~)uJo8pM99%ggO8rrzcNvpZFja;wB6X>eH|3%_)hq z$#So5f(_H-==CO}#5>!5aDJ*KfR|uJL)HjDr^@KXYaHw5^0{V}z-7?DxWi3zP_2}O zmH*0LSAjc}GdNvIN?93N{wD?ZFYkV+`@eDmXLL!E*STg1m%edCj-5{a9WFgUaoq{3 z+35bVV^qB^T4 z5vT@Vzn&qehnd8CnFW*mdu`_nXFN9C6FsRFm+A9;m?);b8tnbN`p|5&zPpy1oyYbB zO|(CKAtm6}$15)Pj(HZBSex7r<-g1uK;7X-U%Y>>yS-_(1c=nH>+DxU@Dp$e!3*Y# z_VxRt`VHh;am#%j6xGlHSJ6dACfhgN;5{&Du`jQ*T7&G7$~(q)F#)mm&QWVOin<7N z92k8F;ygNVr%Mx+=^G4I54OOWfyD8U{cO)s?nSX+Mp6c?)c927X?>i|bJ6yRY2pkA;-A-_{4?0K{D&A4uFy z=tLfB?Hu3<(ObWtIy9CbAAlug{o9&Bmx&l#@*=gGTI@l51C^zpU-|y5Vv}OPQGIF2 z^HedpJBdqP>JoMW4$ENV0cX+o^owhG9*p6vSb=OTRgJkIcSiFVYDhn(CeyJFm(P+- z3vQ1;V>aK|v5?Pc^p)G2Vm_i)6Bmv+@m`p`60l%bw11~}_`D#>@l%OGYGIUM(qfh) zCD-&Xs$(-&@q;J}-+=B!)v@8TO{yHb6bHPVPUs(s zv|B#kFq6K5_^Nlk`+d*MwU&(Zad<83JMxi5!K{lR-1+XaRIHP;bJ!^^ZsHe~V-$hx zgWbhWj`wWEm+1JAJXpkxcaTw;#zR|uTZ^+TX z0dpjFgWr;EgIDVz{(ir|6>&h-C;&t|NsClXUNlRA_;n|Sh+uuHQ6a&UUd2`~D%0!| zN*?)e+kpjsh6CP1NRZ-vpyR6kmCLp+Drf&d+=(N3Z915U9e%0ADcTU)B^ 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 MyN{F#@0k4ef0$Ip*Z=?k literal 0 HcmV?d00001 diff --git a/frontend/public/img/logo.svg b/frontend/public/img/logo.svg deleted file mode 100644 index 4f1f7a7b..00000000 --- a/frontend/public/img/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/public/img/opengraph-default.png b/frontend/public/img/opengraph.png similarity index 100% rename from frontend/public/img/opengraph-default.png rename to frontend/public/img/opengraph.png diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index d8dd5f1d..a7b5fefe 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -8,55 +8,55 @@ "start_url": "/", "icons": [ { - "src": "icons/icon-72x72.png", + "src": "img/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-96x96.png", + "src": "img/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-96x96.png", + "src": "img/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-128x128.png", + "src": "img/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-144x144.png", + "src": "img/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-152x152.png", + "src": "img/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-192x192.png", + "src": "img/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-384x384.png", + "src": "img/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any maskable" }, { - "src": "icons/icon-512x512.png", + "src": "img/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx index 7fea40e3..c69c50c0 100644 --- a/frontend/src/components/Logo.tsx +++ b/frontend/src/components/Logo.tsx @@ -1,34 +1,6 @@ +import Image from "next/image"; + const Logo = ({ height, width }: { height: number; width: number }) => { - return ( - - - - - - - - - - - ); + return logo; }; export default Logo; diff --git a/frontend/src/components/Meta.tsx b/frontend/src/components/Meta.tsx index da5c8907..4ef2c95d 100644 --- a/frontend/src/components/Meta.tsx +++ b/frontend/src/components/Meta.tsx @@ -1,4 +1,5 @@ import Head from "next/head"; +import useConfig from "../hooks/config.hook"; const Meta = ({ title, @@ -7,7 +8,9 @@ const Meta = ({ title: string; description?: string; }) => { - const metaTitle = `${title} - Pingvin Share`; + const config = useConfig(); + + const metaTitle = `${title} - ${config.get("general.appName")}`; return ( @@ -19,7 +22,6 @@ const Meta = ({ description ?? "An open-source and self-hosted sharing platform." } /> - diff --git a/frontend/src/components/admin/configuration/AdminConfigTable.tsx b/frontend/src/components/admin/configuration/AdminConfigTable.tsx deleted file mode 100644 index 1a2acced..00000000 --- a/frontend/src/components/admin/configuration/AdminConfigTable.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { - Box, - Button, - Group, - Paper, - Space, - Stack, - Text, - Title, -} from "@mantine/core"; -import { useMediaQuery } from "@mantine/hooks"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import useConfig from "../../../hooks/config.hook"; -import configService from "../../../services/config.service"; -import { - AdminConfigGroupedByCategory, - UpdateConfig, -} from "../../../types/config.type"; -import { - capitalizeFirstLetter, - configVariableToFriendlyName, -} from "../../../utils/string.util"; -import toast from "../../../utils/toast.util"; - -import AdminConfigInput from "./AdminConfigInput"; -import TestEmailButton from "./TestEmailButton"; - -const AdminConfigTable = () => { - const config = useConfig(); - const router = useRouter(); - const isMobile = useMediaQuery("(max-width: 560px)"); - - const [updatedConfigVariables, setUpdatedConfigVariables] = useState< - UpdateConfig[] - >([]); - - useEffect(() => { - if (config.get("SETUP_STATUS") != "FINISHED") { - config.refresh(); - } - }, []); - - const updateConfigVariable = (configVariable: UpdateConfig) => { - const index = updatedConfigVariables.findIndex( - (item) => item.key === configVariable.key - ); - if (index > -1) { - updatedConfigVariables[index] = configVariable; - } else { - setUpdatedConfigVariables([...updatedConfigVariables, configVariable]); - } - }; - - const [configVariablesByCategory, setCofigVariablesByCategory] = - useState({}); - - const getConfigVariables = async () => { - await configService.listForAdmin().then((configVariables) => { - const configVariablesByCategory = configVariables.reduce( - (categories: any, item) => { - const category = categories[item.category] || []; - category.push(item); - categories[item.category] = category; - return categories; - }, - {} - ); - setCofigVariablesByCategory(configVariablesByCategory); - }); - }; - - const saveConfigVariables = async () => { - if (config.get("SETUP_STATUS") == "REGISTERED") { - await configService - .updateMany(updatedConfigVariables) - .then(async () => { - await configService.finishSetup(); - router.reload(); - }) - .catch(toast.axiosError); - } else { - await configService - .updateMany(updatedConfigVariables) - .then(() => { - setUpdatedConfigVariables([]); - toast.success("Configurations updated successfully"); - }) - .catch(toast.axiosError); - } - config.refresh(); - }; - - useEffect(() => { - getConfigVariables(); - }, []); - - return ( - - {Object.entries(configVariablesByCategory).map( - ([category, configVariables]) => { - return ( - - - {capitalizeFirstLetter(category)} - - {configVariables.map((configVariable) => ( - <> - - - - {configVariableToFriendlyName(configVariable.key)} - - - {configVariable.description} - - - - - - - - - - - ))} - {category == "smtp" && ( - - - - )} - - ); - } - )} - - - - - ); -}; - -export default AdminConfigTable; diff --git a/frontend/src/components/admin/configuration/ConfigurationHeader.tsx b/frontend/src/components/admin/configuration/ConfigurationHeader.tsx new file mode 100644 index 00000000..5f4640f6 --- /dev/null +++ b/frontend/src/components/admin/configuration/ConfigurationHeader.tsx @@ -0,0 +1,54 @@ +import { + Burger, + Button, + Group, + Header, + MediaQuery, + Text, + useMantineTheme, +} from "@mantine/core"; +import Link from "next/link"; +import { Dispatch, SetStateAction } from "react"; +import useConfig from "../../../hooks/config.hook"; +import Logo from "../../Logo"; + +const ConfigurationHeader = ({ + isMobileNavBarOpened, + setIsMobileNavBarOpened, +}: { + isMobileNavBarOpened: boolean; + setIsMobileNavBarOpened: Dispatch>; +}) => { + const config = useConfig(); + const theme = useMantineTheme(); + return ( +

+
+ + setIsMobileNavBarOpened((o) => !o)} + size="sm" + color={theme.colors.gray[6]} + mr="xl" + /> + + + + + + {config.get("general.appName")} + + + + + + +
+
+ ); +}; + +export default ConfigurationHeader; diff --git a/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx new file mode 100644 index 00000000..904ccee9 --- /dev/null +++ b/frontend/src/components/admin/configuration/ConfigurationNavBar.tsx @@ -0,0 +1,97 @@ +import { + Box, + Button, + createStyles, + Group, + MediaQuery, + Navbar, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import Link from "next/link"; +import { Dispatch, SetStateAction } from "react"; +import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb"; + +const categories = [ + { name: "General", icon: }, + { name: "Email", icon: }, + { name: "Share", icon: }, + { name: "SMTP", icon: }, +]; + +const useStyles = createStyles((theme) => ({ + activeLink: { + backgroundColor: theme.fn.variant({ + variant: "light", + color: theme.primaryColor, + }).background, + color: theme.fn.variant({ variant: "light", color: theme.primaryColor }) + .color, + + borderRadius: theme.radius.sm, + fontWeight: 600, + }, +})); + +const ConfigurationNavBar = ({ + categoryId, + isMobileNavBarOpened, + setIsMobileNavBarOpened, +}: { + categoryId: string; + isMobileNavBarOpened: boolean; + setIsMobileNavBarOpened: Dispatch>; +}) => { + const { classes } = useStyles(); + return ( + + ); +}; + +export default ConfigurationNavBar; diff --git a/frontend/src/components/admin/users/showCreateUserModal.tsx b/frontend/src/components/admin/users/showCreateUserModal.tsx index bacd673b..e2bb6718 100644 --- a/frontend/src/components/admin/users/showCreateUserModal.tsx +++ b/frontend/src/components/admin/users/showCreateUserModal.tsx @@ -79,12 +79,13 @@ const Body = ({ })} /> )} - {form.values.setPasswordManually || !smtpEnabled && ( - - )} + {form.values.setPasswordManually || + (!smtpEnabled && ( + + ))} { Welcome back - {config.get("ALLOW_REGISTRATION") && ( + {config.get("share.allowRegistration") && ( You don't have an account yet?{" "} @@ -131,7 +131,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => { {...form.getInputProps("totp")} /> )} - {config.get("SMTP_ENABLED") && ( + {config.get("smtp.enabled") && ( Forgot password? diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index 5b65827b..a629354d 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -41,8 +41,12 @@ const SignUpForm = () => { await authService .signUp(email, username, password) .then(async () => { - await refreshUser(); - router.replace("/upload"); + const user = await refreshUser(); + if (user?.isAdmin) { + router.replace("/admin/intro"); + } else { + router.replace("/upload"); + } }) .catch(toast.axiosError); }; @@ -52,7 +56,7 @@ const SignUpForm = () => { Sign up - {config.get("ALLOW_REGISTRATION") && ( + {config.get("share.allowRegistration") && ( You have an account already?{" "} diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/header/ActionAvatar.tsx similarity index 100% rename from frontend/src/components/navBar/ActionAvatar.tsx rename to frontend/src/components/header/ActionAvatar.tsx diff --git a/frontend/src/components/navBar/NavBar.tsx b/frontend/src/components/header/Header.tsx similarity index 92% rename from frontend/src/components/navBar/NavBar.tsx rename to frontend/src/components/header/Header.tsx index 188bdb02..cc5ae1af 100644 --- a/frontend/src/components/navBar/NavBar.tsx +++ b/frontend/src/components/header/Header.tsx @@ -4,7 +4,7 @@ import { Container, createStyles, Group, - Header, + Header as MantineHeader, Paper, Stack, Text, @@ -108,7 +108,7 @@ const useStyles = createStyles((theme) => ({ }, })); -const NavBar = () => { +const Header = () => { const { user } = useUser(); const router = useRouter(); const config = useConfig(); @@ -141,20 +141,20 @@ const NavBar = () => { }, ]; - if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) { + if (config.get("share.allowUnauthenticatedShares")) { unauthenticatedLinks.unshift({ link: "/upload", label: "Upload", }); } - if (config.get("SHOW_HOME_PAGE")) + if (config.get("general.showHomePage")) unauthenticatedLinks.unshift({ link: "/", label: "Home", }); - if (config.get("ALLOW_REGISTRATION")) + if (config.get("share.allowRegistration")) unauthenticatedLinks.push({ link: "/auth/signUp", label: "Sign up", @@ -187,12 +187,12 @@ const NavBar = () => { ); return ( -
+ - Pingvin Share + {config.get("general.appName")} @@ -212,8 +212,8 @@ const NavBar = () => { )} -
+ ); }; -export default NavBar; +export default Header; diff --git a/frontend/src/components/navBar/NavbarShareMenu.tsx b/frontend/src/components/header/NavbarShareMenu.tsx similarity index 100% rename from frontend/src/components/navBar/NavbarShareMenu.tsx rename to frontend/src/components/header/NavbarShareMenu.tsx diff --git a/frontend/src/components/share/FileList.tsx b/frontend/src/components/share/FileList.tsx index b6014ae4..b822f6aa 100644 --- a/frontend/src/components/share/FileList.tsx +++ b/frontend/src/components/share/FileList.tsx @@ -33,9 +33,9 @@ const FileList = ({ const modals = useModals(); const copyFileLink = (file: FileMetaData) => { - const link = `${config.get("APP_URL")}/api/shares/${share.id}/files/${ - file.id - }`; + const link = `${config.get("general.appUrl")}/api/shares/${ + share.id + }/files/${file.id}`; if (window.isSecureContext) { clipboard.copy(link); diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 844c3025..7a3a5ffc 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -15,9 +15,8 @@ export async function middleware(request: NextRequest) { const routes = { unauthenticated: new Routes(["/auth/*", "/"]), public: new Routes(["/share/*", "/upload/*"]), - setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]), admin: new Routes(["/admin/*"]), - account: new Routes(["/account/*"]), + account: new Routes(["/account*"]), disabled: new Routes([]), }; @@ -45,41 +44,28 @@ export async function middleware(request: NextRequest) { user = null; } - if (!getConfig("ALLOW_REGISTRATION")) { + if (!getConfig("share.allowRegistration")) { routes.disabled.routes.push("/auth/signUp"); } - if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) { + if (getConfig("share.allowUnauthenticatedShares")) { routes.public.routes = ["*"]; } - if (!getConfig("SMTP_ENABLED")) { + if (!getConfig("smtp.enabled")) { routes.disabled.routes.push("/auth/resetPassword*"); } - if (getConfig("SETUP_STATUS") == "FINISHED") { - routes.disabled.routes.push("/admin/setup"); - } - // prettier-ignore const rules = [ // Disabled routes { condition: routes.disabled.contains(route), path: "/", - }, - // Setup status - { - condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp", - path: "/auth/signUp", - }, - { - condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route) && user?.isAdmin, - path: "/admin/setup", }, // Authenticated state { - condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"), + condition: user && routes.unauthenticated.contains(route) && !getConfig("share.allowUnauthenticatedShares"), path: "/upload", }, // Unauthenticated state @@ -98,7 +84,7 @@ export async function middleware(request: NextRequest) { }, // Home page { - condition: (!getConfig("SHOW_HOME_PAGE") || user) && route == "/", + condition: (!getConfig("general.showHomePage") || user) && route == "/", path: "/upload", }, ]; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 8a4c551c..198f4f7f 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -11,8 +11,9 @@ import axios from "axios"; import { getCookie, setCookie } from "cookies-next"; import { GetServerSidePropsContext } from "next"; import type { AppProps } from "next/app"; +import { useRouter } from "next/router"; import { useEffect, useState } from "react"; -import Header from "../components/navBar/NavBar"; +import Header from "../components/header/Header"; import { ConfigContext } from "../hooks/config.hook"; import usePreferences from "../hooks/usePreferences"; import { UserContext } from "../hooks/user.hook"; @@ -24,17 +25,26 @@ import globalStyle from "../styles/mantine.style"; import Config from "../types/config.type"; import { CurrentUser } from "../types/user.type"; +const excludeDefaultLayoutRoutes = ["/admin/config/[category]"]; + function App({ Component, pageProps }: AppProps) { const systemTheme = useColorScheme(pageProps.colorScheme); + const router = useRouter(); + const [colorScheme, setColorScheme] = useState(systemTheme); const preferences = usePreferences(); const [user, setUser] = useState(pageProps.user); + const [route, setRoute] = useState(pageProps.route); const [configVariables, setConfigVariables] = useState( pageProps.configVariables ); + useEffect(() => { + setRoute(router.pathname); + }, [router.pathname]); + useEffect(() => { setInterval(async () => await authService.refreshAccessToken(), 30 * 1000); }, []); @@ -86,10 +96,16 @@ function App({ Component, pageProps }: AppProps) { }, }} > -
- + {excludeDefaultLayoutRoutes.includes(route) ? ( - + ) : ( + <> +
+ + + + + )} @@ -105,12 +121,13 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { let pageProps: { user?: CurrentUser; configVariables?: Config[]; + route?: string; colorScheme: ColorScheme; } = { + route: ctx.resolvedUrl, colorScheme: (getCookie("mantine-color-scheme", ctx) as ColorScheme) ?? "light", }; - if (ctx.req) { const cookieHeader = ctx.req.headers.cookie; @@ -123,6 +140,8 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { pageProps.configVariables = ( await axios(`http://localhost:8080/api/configs`) ).data; + + pageProps.route = ctx.req.url; } return { pageProps }; diff --git a/frontend/src/pages/_document.tsx b/frontend/src/pages/_document.tsx index 86be27b0..2702a2a4 100644 --- a/frontend/src/pages/_document.tsx +++ b/frontend/src/pages/_document.tsx @@ -11,11 +11,15 @@ export default class _Document extends Document { - + + - + - + diff --git a/frontend/src/pages/account/reverseShares.tsx b/frontend/src/pages/account/reverseShares.tsx index 14829dc5..6d8541fc 100644 --- a/frontend/src/pages/account/reverseShares.tsx +++ b/frontend/src/pages/account/reverseShares.tsx @@ -67,7 +67,7 @@ const MyShares = () => { onClick={() => showCreateReverseShareModal( modals, - config.get("SMTP_ENABLED"), + config.get("smtp.enabled"), getReverseShares ) } @@ -129,9 +129,9 @@ const MyShares = () => { onClick={() => { if (window.isSecureContext) { clipboard.copy( - `${config.get("APP_URL")}/share/${ - share.id - }` + `${config.get( + "general.appUrl" + )}/share/${share.id}` ); toast.success( "The share link was copied to the keyboard." @@ -140,7 +140,7 @@ const MyShares = () => { showShareLinkModal( modals, share.id, - config.get("APP_URL") + config.get("general.appUrl") ); } }} diff --git a/frontend/src/pages/account/shares.tsx b/frontend/src/pages/account/shares.tsx index 8a00807e..abd4d446 100644 --- a/frontend/src/pages/account/shares.tsx +++ b/frontend/src/pages/account/shares.tsx @@ -84,7 +84,9 @@ const MyShares = () => { onClick={() => { if (window.isSecureContext) { clipboard.copy( - `${config.get("APP_URL")}/share/${share.id}` + `${config.get("general.appUrl")}/share/${ + share.id + }` ); toast.success( "Your link was copied to the keyboard." @@ -93,7 +95,7 @@ const MyShares = () => { showShareLinkModal( modals, share.id, - config.get("APP_URL") + config.get("general.appUrl") ); } }} diff --git a/frontend/src/pages/admin/config.tsx b/frontend/src/pages/admin/config.tsx deleted file mode 100644 index e92613b0..00000000 --- a/frontend/src/pages/admin/config.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Space, Title } from "@mantine/core"; -import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable"; -import Meta from "../../components/Meta"; - -const AdminConfig = () => { - return ( - <> - - - Configuration - - - - - ); -}; - -export default AdminConfig; diff --git a/frontend/src/pages/admin/config/[category].tsx b/frontend/src/pages/admin/config/[category].tsx new file mode 100644 index 00000000..b66b85fb --- /dev/null +++ b/frontend/src/pages/admin/config/[category].tsx @@ -0,0 +1,148 @@ +import { + AppShell, + Box, + Button, + Container, + Group, + Stack, + Text, + Title, + useMantineTheme, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; + +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import AdminConfigInput from "../../../components/admin/configuration/AdminConfigInput"; +import ConfigurationHeader from "../../../components/admin/configuration/ConfigurationHeader"; +import ConfigurationNavBar from "../../../components/admin/configuration/ConfigurationNavBar"; +import TestEmailButton from "../../../components/admin/configuration/TestEmailButton"; +import CenterLoader from "../../../components/core/CenterLoader"; +import Meta from "../../../components/Meta"; +import useConfig from "../../../hooks/config.hook"; +import configService from "../../../services/config.service"; +import { AdminConfig, UpdateConfig } from "../../../types/config.type"; +import { + capitalizeFirstLetter, + configVariableToFriendlyName, +} from "../../../utils/string.util"; +import toast from "../../../utils/toast.util"; + +export default function AppShellDemo() { + const theme = useMantineTheme(); + const router = useRouter(); + + const [isMobileNavBarOpened, setIsMobileNavBarOpened] = useState(false); + const isMobile = useMediaQuery("(max-width: 560px)"); + const config = useConfig(); + + const categoryId = router.query.category as string; + + const [configVariables, setConfigVariables] = useState(); + const [updatedConfigVariables, setUpdatedConfigVariables] = useState< + UpdateConfig[] + >([]); + + const saveConfigVariables = async () => { + await configService + .updateMany(updatedConfigVariables) + .then(() => { + setUpdatedConfigVariables([]); + toast.success("Configurations updated successfully"); + }) + .catch(toast.axiosError); + config.refresh(); + }; + + const updateConfigVariable = (configVariable: UpdateConfig) => { + const index = updatedConfigVariables.findIndex( + (item) => item.key === configVariable.key + ); + if (index > -1) { + updatedConfigVariables[index] = configVariable; + } else { + setUpdatedConfigVariables([...updatedConfigVariables, configVariable]); + } + }; + + useEffect(() => { + configService.getByCategory(categoryId).then((configVariables) => { + setConfigVariables(configVariables); + }); + }, [categoryId]); + + return ( + <> + + + } + header={ + + } + > + + {!configVariables ? ( + + ) : ( + <> + + + {capitalizeFirstLetter(categoryId)} + + {configVariables.map((configVariable) => ( + + + + {configVariableToFriendlyName(configVariable.name)} + + + {configVariable.description} + + + + + + + + ))} + + + {categoryId == "smtp" && ( + + )} + + + + )} + + + + ); +} diff --git a/frontend/src/pages/admin/config/index.tsx b/frontend/src/pages/admin/config/index.tsx new file mode 100644 index 00000000..5de1d44a --- /dev/null +++ b/frontend/src/pages/admin/config/index.tsx @@ -0,0 +1,15 @@ +export function getServerSideProps() { + return { + redirect: { + permanent: false, + destination: "/admin/config/general", + }, + props: {}, + }; +} + +const Config = () => { + return null; +}; + +export default Config; diff --git a/frontend/src/pages/admin/intro.tsx b/frontend/src/pages/admin/intro.tsx new file mode 100644 index 00000000..2b598ae5 --- /dev/null +++ b/frontend/src/pages/admin/intro.tsx @@ -0,0 +1,59 @@ +import { + Anchor, + Button, + Center, + Container, + Stack, + Text, + Title, +} from "@mantine/core"; +import Link from "next/link"; +import Logo from "../../components/Logo"; +import Meta from "../../components/Meta"; + +const Intro = () => { + return ( + <> + + + +
+ +
+
+ Welcome to Pingvin Share +
+ + If you enjoy Pingvin Share please ⭐️ it on{" "} + + GitHub + {" "} + or{" "} + + buy me a coffee + {" "} + if you want to support my work. + + Enough talked, have fun with Pingvin Share! + How to you want to continue? + + + + +
+
+ + ); +}; + +export default Intro; diff --git a/frontend/src/pages/admin/setup.tsx b/frontend/src/pages/admin/setup.tsx deleted file mode 100644 index e61eeb6e..00000000 --- a/frontend/src/pages/admin/setup.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Box, Stack, Text, Title } from "@mantine/core"; -import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable"; - -import Logo from "../../components/Logo"; -import Meta from "../../components/Meta"; - -const Setup = () => { - return ( - <> - - - - Welcome to Pingvin Share - Let's customize Pingvin Share for you! - - - - - - ); -}; - -export default Setup; diff --git a/frontend/src/pages/admin/users.tsx b/frontend/src/pages/admin/users.tsx index 017269ea..3a673da2 100644 --- a/frontend/src/pages/admin/users.tsx +++ b/frontend/src/pages/admin/users.tsx @@ -58,7 +58,7 @@ const Users = () => {