Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f591cd60 | ||
|
|
f836a0a3cd | ||
|
|
11174656e4 | ||
|
|
faea1abcc4 | ||
|
|
71658ad39d | ||
|
|
167f0f8c7a | ||
|
|
85551dc3d3 | ||
|
|
5bc4f902f6 | ||
|
|
e5b50f855c | ||
|
|
b73144295b | ||
|
|
ef21bac59b | ||
|
|
cabaee588b |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,3 +1,30 @@
|
|||||||
|
### [0.5.1](https://github.com/stonith404/pingvin-share/compare/v0.5.0...v0.5.1) (2023-01-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* show version and show button if new release is available on admin page ([71658ad](https://github.com/stonith404/pingvin-share/commit/71658ad39d7e3638de659e8230fad4e05f60fdd8))
|
||||||
|
* use cookies for authentication ([faea1ab](https://github.com/stonith404/pingvin-share/commit/faea1abcc4b533f391feaed427e211fef9166fe4))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* email configuration updated without restart ([1117465](https://github.com/stonith404/pingvin-share/commit/11174656e425c4be60e4f7b1ea8463678e5c60d2))
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/stonith404/pingvin-share/compare/v0.4.0...v0.5.0) (2022-12-30)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* custom mail subject ([cabaee5](https://github.com/stonith404/pingvin-share/commit/cabaee588b50877872d210c870bfb9c95b541921))
|
||||||
|
* improve config UI ([#69](https://github.com/stonith404/pingvin-share/issues/69)) ([5bc4f90](https://github.com/stonith404/pingvin-share/commit/5bc4f902f6218a09423491404806a4b7fb865c98))
|
||||||
|
* manually switch color scheme ([ef21bac](https://github.com/stonith404/pingvin-share/commit/ef21bac59b11dc68649ab3b195dcb89d2b192e7b))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* refresh token gets deleted on session end ([e5b50f8](https://github.com/stonith404/pingvin-share/commit/e5b50f855c02aa4b5c9ee873dd5a7ab25759972d))
|
||||||
|
|
||||||
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
|
## [0.4.0](https://github.com/stonith404/pingvin-share/compare/v0.3.6...v0.4.0) (2022-12-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
60
backend/package-lock.json
generated
60
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^9.2.1",
|
"@nestjs/common": "^9.2.1",
|
||||||
"@nestjs/config": "^2.2.0",
|
"@nestjs/config": "^2.2.0",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"@nestjs/schematics": "^9.0.3",
|
"@nestjs/schematics": "^9.0.3",
|
||||||
"@nestjs/testing": "^9.2.1",
|
"@nestjs/testing": "^9.2.1",
|
||||||
"@types/archiver": "^5.3.1",
|
"@types/archiver": "^5.3.1",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.14",
|
"@types/express": "^4.17.14",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
@@ -1151,6 +1153,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookiejar": {
|
"node_modules/@types/cookiejar": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||||
@@ -2635,6 +2646,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
|
||||||
|
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.4.1",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
@@ -8413,6 +8444,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/cookie-parser": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/cookiejar": {
|
"@types/cookiejar": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||||
@@ -9570,6 +9610,22 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
|
||||||
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
|
||||||
},
|
},
|
||||||
|
"cookie-parser": {
|
||||||
|
"version": "1.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
|
||||||
|
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
|
||||||
|
"requires": {
|
||||||
|
"cookie": "0.4.1",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie-signature": {
|
"cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share-backend",
|
"name": "pingvin-share-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
"content-disposition": "^0.5.4",
|
"content-disposition": "^0.5.4",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@nestjs/schematics": "^9.0.3",
|
"@nestjs/schematics": "^9.0.3",
|
||||||
"@nestjs/testing": "^9.2.1",
|
"@nestjs/testing": "^9.2.1",
|
||||||
"@types/archiver": "^5.3.1",
|
"@types/archiver": "^5.3.1",
|
||||||
|
"@types/cookie-parser": "^1.4.3",
|
||||||
"@types/cron": "^2.0.0",
|
"@types/cron": "^2.0.0",
|
||||||
"@types/express": "^4.17.14",
|
"@types/express": "^4.17.14",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_RefreshToken" ("createdAt", "expiresAt", "token", "userId") SELECT "createdAt", "expiresAt", "token", "userId" FROM "RefreshToken";
|
||||||
|
DROP TABLE "RefreshToken";
|
||||||
|
ALTER TABLE "new_RefreshToken" RENAME TO "RefreshToken";
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||||
|
PRAGMA foreign_key_check;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -27,7 +27,8 @@ model User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
token String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
token String @unique @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
@@ -101,6 +102,7 @@ model Config {
|
|||||||
type String
|
type String
|
||||||
value String
|
value String
|
||||||
description String
|
description String
|
||||||
|
category String
|
||||||
obscured Boolean @default(false)
|
obscured Boolean @default(false)
|
||||||
secret Boolean @default(true)
|
secret Boolean @default(true)
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Whether the setup has been finished",
|
description: "Whether the setup has been finished",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "false",
|
value: "false",
|
||||||
|
category: "internal",
|
||||||
secret: false,
|
secret: false,
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
@@ -15,6 +16,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "On which URL Pingvin Share is available",
|
description: "On which URL Pingvin Share is available",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "http://localhost:3000",
|
value: "http://localhost:3000",
|
||||||
|
category: "general",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -22,6 +24,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Whether to show the home page",
|
description: "Whether to show the home page",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "true",
|
value: "true",
|
||||||
|
category: "general",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,6 +32,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Whether registration is allowed",
|
description: "Whether registration is allowed",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "true",
|
value: "true",
|
||||||
|
category: "share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,6 +40,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Whether unauthorized users can create shares",
|
description: "Whether unauthorized users can create shares",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
value: "false",
|
value: "false",
|
||||||
|
category: "share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,6 +48,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Maximum file size in bytes",
|
description: "Maximum file size in bytes",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "1000000000",
|
value: "1000000000",
|
||||||
|
category: "share",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,6 +56,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "Long random string used to sign JWT tokens",
|
description: "Long random string used to sign JWT tokens",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: crypto.randomBytes(256).toString("base64"),
|
value: crypto.randomBytes(256).toString("base64"),
|
||||||
|
category: "internal",
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -57,6 +64,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
description: "A 16 byte random string used to generate TOTP secrets",
|
description: "A 16 byte random string used to generate TOTP secrets",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: crypto.randomBytes(16).toString("base64"),
|
value: crypto.randomBytes(16).toString("base64"),
|
||||||
|
category: "internal",
|
||||||
locked: true,
|
locked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,37 +73,52 @@ 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.",
|
"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",
|
type: "boolean",
|
||||||
value: "false",
|
value: "false",
|
||||||
|
category: "email",
|
||||||
secret: false,
|
secret: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "EMAIL_MESSAGE",
|
key: "EMAIL_MESSAGE",
|
||||||
description: "Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
description:
|
||||||
|
"Message which gets sent to the recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||||
type: "text",
|
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 🐧",
|
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",
|
key: "SMTP_HOST",
|
||||||
description: "Host of the SMTP server",
|
description: "Host of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
|
category: "email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "SMTP_PORT",
|
key: "SMTP_PORT",
|
||||||
description: "Port of the SMTP server",
|
description: "Port of the SMTP server",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "",
|
value: "0",
|
||||||
|
category: "email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "SMTP_EMAIL",
|
key: "SMTP_EMAIL",
|
||||||
description: "Email address which the emails get sent from",
|
description: "Email address which the emails get sent from",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
|
category: "email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "SMTP_USERNAME",
|
key: "SMTP_USERNAME",
|
||||||
description: "Username of the SMTP server",
|
description: "Username of the SMTP server",
|
||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
|
category: "email",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "SMTP_PASSWORD",
|
key: "SMTP_PASSWORD",
|
||||||
@@ -103,6 +126,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||||||
type: "string",
|
type: "string",
|
||||||
value: "",
|
value: "",
|
||||||
obscured: true,
|
obscured: true,
|
||||||
|
category: "email",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,22 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Throttle } from "@nestjs/throttler";
|
import { Throttle } from "@nestjs/throttler";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
|
import { Request, Response } from "express";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { GetUser } from "./decorator/getUser.decorator";
|
import { GetUser } from "./decorator/getUser.decorator";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
|
||||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||||
import { JwtGuard } from "./guard/jwt.guard";
|
import { JwtGuard } from "./guard/jwt.guard";
|
||||||
@@ -25,29 +29,65 @@ import { JwtGuard } from "./guard/jwt.guard";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private authTotpService: AuthTotpService,
|
||||||
private config: ConfigService
|
private config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signUp")
|
@Post("signUp")
|
||||||
async signUp(@Body() dto: AuthRegisterDTO) {
|
async signUp(
|
||||||
|
@Body() dto: AuthRegisterDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||||
throw new ForbiddenException("Registration is not allowed");
|
throw new ForbiddenException("Registration is not allowed");
|
||||||
return this.authService.signUp(dto);
|
const result = await this.authService.signUp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.accessToken,
|
||||||
|
result.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signIn")
|
@Post("signIn")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
signIn(@Body() dto: AuthSignInDTO) {
|
async signIn(
|
||||||
return this.authService.signIn(dto);
|
@Body() dto: AuthSignInDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authService.signIn(dto);
|
||||||
|
|
||||||
|
if (result.accessToken && result.refreshToken) {
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.accessToken,
|
||||||
|
result.refreshToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throttle(10, 5 * 60)
|
@Throttle(10, 5 * 60)
|
||||||
@Post("signIn/totp")
|
@Post("signIn/totp")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
signInTotp(@Body() dto: AuthSignInTotpDTO) {
|
async signInTotp(
|
||||||
return this.authService.signInTotp(dto);
|
@Body() dto: AuthSignInTotpDTO,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
const result = await this.authTotpService.signInTotp(dto);
|
||||||
|
|
||||||
|
response = this.addTokensToResponse(
|
||||||
|
response,
|
||||||
|
result.accessToken,
|
||||||
|
result.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("password")
|
@Patch("password")
|
||||||
@@ -58,30 +98,64 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post("token")
|
@Post("token")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
async refreshAccessToken(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
if (!request.cookies.refresh_token) throw new UnauthorizedException();
|
||||||
|
|
||||||
const accessToken = await this.authService.refreshAccessToken(
|
const accessToken = await this.authService.refreshAccessToken(
|
||||||
body.refreshToken
|
request.cookies.refresh_token
|
||||||
);
|
);
|
||||||
|
response.cookie("access_token", accessToken, { httpOnly: true });
|
||||||
return { accessToken };
|
return { accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement recovery codes to disable 2FA just in case someone gets locked out
|
@Post("signOut")
|
||||||
|
async signOut(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response
|
||||||
|
) {
|
||||||
|
await this.authService.signOut(request.cookies.access_token);
|
||||||
|
response.cookie("access_token", "accessToken", { maxAge: -1 });
|
||||||
|
response.cookie("refresh_token", "", {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post("totp/enable")
|
@Post("totp/enable")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
|
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) {
|
||||||
return this.authService.enableTotp(user, body.password);
|
return this.authTotpService.enableTotp(user, body.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("totp/verify")
|
@Post("totp/verify")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
async verifyTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||||
return this.authService.verifyTotp(user, body.password, body.code);
|
return this.authTotpService.verifyTotp(user, body.password, body.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("totp/disable")
|
@Post("totp/disable")
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
async disableTotp(@GetUser() user: User, @Body() body: VerifyTotpDTO) {
|
||||||
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
|
||||||
return this.authService.disableTotp(user, body.password, body.code);
|
return this.authTotpService.disableTotp(user, body.password, body.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTokensToResponse(
|
||||||
|
response: Response,
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string
|
||||||
|
) {
|
||||||
|
response.cookie("access_token", accessToken);
|
||||||
|
response.cookie("refresh_token", refreshToken, {
|
||||||
|
path: "/api/auth/token",
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 60 * 24 * 30 * 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Module } from "@nestjs/common";
|
|||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { AuthTotpService } from "./authTotp.service";
|
||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JwtModule.register({})],
|
imports: [JwtModule.register({})],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import { ConfigService } from "src/config/config.service";
|
|||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { authenticator, totp } from "otplib";
|
|
||||||
import * as qrcode from "qrcode-svg";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -38,8 +34,10 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
user.id
|
||||||
|
);
|
||||||
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -75,63 +73,10 @@ export class AuthService {
|
|||||||
return { loginToken };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
user.id
|
||||||
|
);
|
||||||
return { accessToken, refreshToken };
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
}
|
|
||||||
|
|
||||||
async signInTotp(dto: AuthSignInTotpDTO) {
|
|
||||||
if (!dto.email && !dto.username)
|
|
||||||
throw new BadRequestException("Email or username is required");
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [{ email: dto.email }, { username: dto.username }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !(await argon.verify(user.password, dto.password)))
|
|
||||||
throw new UnauthorizedException("Wrong email or password");
|
|
||||||
|
|
||||||
const token = await this.prisma.loginToken.findFirst({
|
|
||||||
where: {
|
|
||||||
token: dto.loginToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!token || token.userId != user.id || token.used)
|
|
||||||
throw new UnauthorizedException("Invalid login token");
|
|
||||||
|
|
||||||
if (token.expiresAt < new Date())
|
|
||||||
throw new UnauthorizedException("Login token expired");
|
|
||||||
|
|
||||||
// Check the TOTP code
|
|
||||||
const { totpSecret } = await this.prisma.user.findUnique({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: { totpSecret: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!totpSecret) {
|
|
||||||
throw new BadRequestException("TOTP is not enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (dto.totp !== expected) {
|
|
||||||
throw new BadRequestException("Invalid code");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the login token to used
|
|
||||||
await this.prisma.loginToken.update({
|
|
||||||
where: { token: token.token },
|
|
||||||
data: { used: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const accessToken = await this.createAccessToken(user);
|
|
||||||
const refreshToken = await this.createRefreshToken(user.id);
|
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
@@ -148,11 +93,12 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccessToken(user: User) {
|
async createAccessToken(user: User, refreshTokenId: string) {
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
refreshTokenId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expiresIn: "15min",
|
expiresIn: "15min",
|
||||||
@@ -161,6 +107,14 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signOut(accessToken: string) {
|
||||||
|
const { refreshTokenId } = this.jwtService.decode(accessToken) as {
|
||||||
|
refreshTokenId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.refreshToken.delete({ where: { id: refreshTokenId } });
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAccessToken(refreshToken: string) {
|
async refreshAccessToken(refreshToken: string) {
|
||||||
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
|
||||||
where: { token: refreshToken },
|
where: { token: refreshToken },
|
||||||
@@ -170,17 +124,18 @@ export class AuthService {
|
|||||||
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
return this.createAccessToken(refreshTokenMetaData.user);
|
return this.createAccessToken(
|
||||||
|
refreshTokenMetaData.user,
|
||||||
|
refreshTokenMetaData.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string) {
|
||||||
const refreshToken = (
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
await this.prisma.refreshToken.create({
|
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
||||||
data: { userId, expiresAt: moment().add(3, "months").toDate() },
|
});
|
||||||
})
|
|
||||||
).token;
|
|
||||||
|
|
||||||
return refreshToken;
|
return { refreshTokenId: id, refreshToken: token };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createLoginToken(userId: string) {
|
async createLoginToken(userId: string) {
|
||||||
@@ -192,151 +147,4 @@ export class AuthService {
|
|||||||
|
|
||||||
return loginToken;
|
return loginToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptTotpSecret(totpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
||||||
|
|
||||||
let encrypted = cipher.update(totpSecret);
|
|
||||||
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
|
|
||||||
return encrypted.toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
|
||||||
let iv = this.config.get("TOTP_SECRET");
|
|
||||||
iv = Buffer.from(iv, "base64");
|
|
||||||
const key = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(String(password))
|
|
||||||
.digest("base64")
|
|
||||||
.substr(0, 32);
|
|
||||||
|
|
||||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
||||||
let decrypted = decipher.update(encryptedText);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
|
|
||||||
return decrypted.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableTotp(user: User, password: string) {
|
|
||||||
if (!(await argon.verify(user.password, password)))
|
|
||||||
throw new ForbiddenException("Invalid password");
|
|
||||||
|
|
||||||
// Check if we have a secret already
|
|
||||||
const { totpVerified } = await this.prisma.user.findUnique({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: { totpVerified: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totpVerified) {
|
|
||||||
throw new BadRequestException("TOTP is already enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Maybe make the issuer configurable with env vars?
|
|
||||||
const secret = authenticator.generateSecret();
|
|
||||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
|
||||||
|
|
||||||
const otpURL = totp.keyuri(
|
|
||||||
user.username || user.email,
|
|
||||||
"pingvin-share",
|
|
||||||
secret
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
totpEnabled: true,
|
|
||||||
totpSecret: encryptedSecret,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Maybe we should generate the QR code on the client rather than the server?
|
|
||||||
const qrCode = new qrcode({
|
|
||||||
content: otpURL,
|
|
||||||
container: "svg-viewbox",
|
|
||||||
join: true,
|
|
||||||
}).svg();
|
|
||||||
|
|
||||||
return {
|
|
||||||
totpAuthUrl: otpURL,
|
|
||||||
totpSecret: secret,
|
|
||||||
qrCode:
|
|
||||||
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
|
|
||||||
async verifyTotp(user: User, password: string, code: string) {
|
|
||||||
if (!(await argon.verify(user.password, password)))
|
|
||||||
throw new ForbiddenException("Invalid password");
|
|
||||||
|
|
||||||
const { totpSecret } = await this.prisma.user.findUnique({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: { totpSecret: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!totpSecret) {
|
|
||||||
throw new BadRequestException("TOTP is not in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
|
||||||
throw new BadRequestException("Invalid code");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
totpVerified: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableTotp(user: User, password: string, code: string) {
|
|
||||||
if (!(await argon.verify(user.password, password)))
|
|
||||||
throw new ForbiddenException("Invalid password");
|
|
||||||
|
|
||||||
const { totpSecret } = await this.prisma.user.findUnique({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: { totpSecret: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!totpSecret) {
|
|
||||||
throw new BadRequestException("TOTP is not enabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
|
||||||
|
|
||||||
const expected = authenticator.generate(decryptedSecret);
|
|
||||||
|
|
||||||
if (code !== expected) {
|
|
||||||
throw new BadRequestException("Invalid code");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
totpVerified: false,
|
|
||||||
totpEnabled: false,
|
|
||||||
totpSecret: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
230
backend/src/auth/authTotp.service.ts
Normal file
230
backend/src/auth/authTotp.service.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
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,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private authService: AuthService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async signInTotp(dto: AuthSignInTotpDTO) {
|
||||||
|
if (!dto.email && !dto.username)
|
||||||
|
throw new BadRequestException("Email or username is required");
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email: dto.email }, { username: dto.username }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await argon.verify(user.password, dto.password)))
|
||||||
|
throw new UnauthorizedException("Wrong email or password");
|
||||||
|
|
||||||
|
const token = await this.prisma.loginToken.findFirst({
|
||||||
|
where: {
|
||||||
|
token: dto.loginToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token || token.userId != user.id || token.used)
|
||||||
|
throw new UnauthorizedException("Invalid login token");
|
||||||
|
|
||||||
|
if (token.expiresAt < new Date())
|
||||||
|
throw new UnauthorizedException("Login token expired");
|
||||||
|
|
||||||
|
// Check the TOTP code
|
||||||
|
const { totpSecret } = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpSecret: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!totpSecret) {
|
||||||
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
||||||
|
|
||||||
|
const expected = authenticator.generate(decryptedSecret);
|
||||||
|
|
||||||
|
if (dto.totp !== expected) {
|
||||||
|
throw new BadRequestException("Invalid code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the login token to used
|
||||||
|
await this.prisma.loginToken.update({
|
||||||
|
where: { token: token.token },
|
||||||
|
data: { used: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { refreshToken, refreshTokenId } =
|
||||||
|
await this.authService.createRefreshToken(user.id);
|
||||||
|
const accessToken = await this.authService.createAccessToken(
|
||||||
|
user,
|
||||||
|
refreshTokenId
|
||||||
|
);
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptTotpSecret(totpSecret: string, password: string) {
|
||||||
|
let iv = this.config.get("TOTP_SECRET");
|
||||||
|
iv = Buffer.from(iv, "base64");
|
||||||
|
const key = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(String(password))
|
||||||
|
.digest("base64")
|
||||||
|
.substr(0, 32);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(totpSecret);
|
||||||
|
|
||||||
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||||
|
|
||||||
|
return encrypted.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
||||||
|
let iv = this.config.get("TOTP_SECRET");
|
||||||
|
iv = Buffer.from(iv, "base64");
|
||||||
|
const key = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(String(password))
|
||||||
|
.digest("base64")
|
||||||
|
.substr(0, 32);
|
||||||
|
|
||||||
|
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||||
|
let decrypted = decipher.update(encryptedText);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
|
||||||
|
return decrypted.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async enableTotp(user: User, password: string) {
|
||||||
|
if (!(await argon.verify(user.password, password)))
|
||||||
|
throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
|
// Check if we have a secret already
|
||||||
|
const { totpVerified } = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totpVerified) {
|
||||||
|
throw new BadRequestException("TOTP is already enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Maybe make the issuer configurable with env vars?
|
||||||
|
const secret = authenticator.generateSecret();
|
||||||
|
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
||||||
|
|
||||||
|
const otpURL = totp.keyuri(
|
||||||
|
user.username || user.email,
|
||||||
|
"pingvin-share",
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
totpEnabled: true,
|
||||||
|
totpSecret: encryptedSecret,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Maybe we should generate the QR code on the client rather than the server?
|
||||||
|
const qrCode = new qrcode({
|
||||||
|
content: otpURL,
|
||||||
|
container: "svg-viewbox",
|
||||||
|
join: true,
|
||||||
|
}).svg();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totpAuthUrl: otpURL,
|
||||||
|
totpSecret: secret,
|
||||||
|
qrCode:
|
||||||
|
"data:image/svg+xml;base64," + Buffer.from(qrCode).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Maybe require a token to verify that the user who started enabling totp is the one who is verifying it?
|
||||||
|
async verifyTotp(user: User, password: string, code: string) {
|
||||||
|
if (!(await argon.verify(user.password, password)))
|
||||||
|
throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
|
const { totpSecret } = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpSecret: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!totpSecret) {
|
||||||
|
throw new BadRequestException("TOTP is not in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||||
|
|
||||||
|
const expected = authenticator.generate(decryptedSecret);
|
||||||
|
|
||||||
|
if (code !== expected) {
|
||||||
|
throw new BadRequestException("Invalid code");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
totpVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableTotp(user: User, password: string, code: string) {
|
||||||
|
if (!(await argon.verify(user.password, password)))
|
||||||
|
throw new ForbiddenException("Invalid password");
|
||||||
|
|
||||||
|
const { totpSecret } = await this.prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { totpSecret: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!totpSecret) {
|
||||||
|
throw new BadRequestException("TOTP is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||||
|
|
||||||
|
const expected = authenticator.generate(decryptedSecret);
|
||||||
|
|
||||||
|
if (code !== expected) {
|
||||||
|
throw new BadRequestException("Invalid code");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
totpVerified: false,
|
||||||
|
totpEnabled: false,
|
||||||
|
totpSecret: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
export class EnableTotpDTO extends PickType(UserDTO, ["password"] as const) {}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { IsNotEmpty } from "class-validator";
|
|
||||||
|
|
||||||
export class RefreshAccessTokenDTO {
|
|
||||||
@IsNotEmpty()
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PickType } from "@nestjs/mapped-types";
|
import { PickType } from "@nestjs/mapped-types";
|
||||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
import { IsString } from "class-validator";
|
||||||
import { UserDTO } from "src/user/dto/user.dto";
|
import { UserDTO } from "src/user/dto/user.dto";
|
||||||
|
|
||||||
export class VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) {
|
export class VerifyTotpDTO extends PickType(UserDTO, ["password"] as const) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { Request } from "express";
|
||||||
|
import { Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from "src/config/config.service";
|
import { ConfigService } from "src/config/config.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
@@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||||
config.get("JWT_SECRET");
|
config.get("JWT_SECRET");
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: JwtStrategy.extractJWT,
|
||||||
secretOrKey: config.get("JWT_SECRET"),
|
secretOrKey: config.get("JWT_SECRET"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static extractJWT(req: Request) {
|
||||||
|
if (!req.cookies.access_token) return null;
|
||||||
|
return req.cookies.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
async validate(payload: { sub: string }) {
|
async validate(payload: { sub: string }) {
|
||||||
const user: User = await this.prisma.user.findUnique({
|
const user: User = await this.prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import {
|
import { Body, Controller, Get, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||||
|
import { EmailService } from "src/email/email.service";
|
||||||
import { ConfigService } from "./config.service";
|
import { ConfigService } from "./config.service";
|
||||||
import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
||||||
import { ConfigDTO } from "./dto/config.dto";
|
import { ConfigDTO } from "./dto/config.dto";
|
||||||
|
import { TestEmailDTO } from "./dto/testEmail.dto";
|
||||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||||
|
|
||||||
@Controller("configs")
|
@Controller("configs")
|
||||||
export class ConfigController {
|
export class ConfigController {
|
||||||
constructor(private configService: ConfigService) {}
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private emailService: EmailService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list() {
|
async list() {
|
||||||
@@ -31,12 +28,10 @@ export class ConfigController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("admin/:key")
|
@Patch("admin")
|
||||||
@UseGuards(JwtGuard, AdministratorGuard)
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
|
async updateMany(@Body() data: UpdateConfigDTO[]) {
|
||||||
return new AdminConfigDTO().from(
|
await this.configService.updateMany(data);
|
||||||
await this.configService.update(key, data.value)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("admin/finishSetup")
|
@Post("admin/finishSetup")
|
||||||
@@ -44,4 +39,10 @@ export class ConfigController {
|
|||||||
async finishSetup() {
|
async finishSetup() {
|
||||||
return await this.configService.finishSetup();
|
return await this.configService.finishSetup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("admin/testEmail")
|
||||||
|
@UseGuards(JwtGuard, AdministratorGuard)
|
||||||
|
async testEmail(@Body() { email }: TestEmailDTO) {
|
||||||
|
await this.emailService.sendTestMail(email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { ConfigController } from "./config.controller";
|
import { ConfigController } from "./config.controller";
|
||||||
import { ConfigService } from "./config.service";
|
import { ConfigService } from "./config.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [EmailModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: "CONFIG_VARIABLES",
|
provide: "CONFIG_VARIABLES",
|
||||||
|
|||||||
@@ -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) {
|
async update(key: string, value: string | number | boolean) {
|
||||||
const configVariable = await this.prisma.config.findUnique({
|
const configVariable = await this.prisma.config.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export class AdminConfigDTO extends ConfigDTO {
|
|||||||
@Expose()
|
@Expose()
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
category: string;
|
||||||
|
|
||||||
from(partial: Partial<AdminConfigDTO>) {
|
from(partial: Partial<AdminConfigDTO>) {
|
||||||
return plainToClass(AdminConfigDTO, partial, {
|
return plainToClass(AdminConfigDTO, partial, {
|
||||||
excludeExtraneousValues: true,
|
excludeExtraneousValues: true,
|
||||||
|
|||||||
7
backend/src/config/dto/testEmail.dto.ts
Normal file
7
backend/src/config/dto/testEmail.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEmail, IsNotEmpty } from "class-validator";
|
||||||
|
|
||||||
|
export class TestEmailDTO {
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { IsNotEmpty, ValidateIf } from "class-validator";
|
import { IsNotEmpty, IsString, ValidateIf } from "class-validator";
|
||||||
|
|
||||||
class UpdateConfigDTO {
|
class UpdateConfigDTO {
|
||||||
|
@IsString()
|
||||||
|
key: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ValidateIf((dto) => dto.value !== "")
|
@ValidateIf((dto) => dto.value !== "")
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { ConfigService } from "src/config/config.service";
|
|||||||
export class EmailService {
|
export class EmailService {
|
||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
getTransporter() {
|
||||||
// create reusable transporter object using the default SMTP transport
|
return nodemailer.createTransport({
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: this.config.get("SMTP_HOST"),
|
host: this.config.get("SMTP_HOST"),
|
||||||
port: parseInt(this.config.get("SMTP_PORT")),
|
port: parseInt(this.config.get("SMTP_PORT")),
|
||||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||||
@@ -18,16 +17,18 @@ export class EmailService {
|
|||||||
pass: this.config.get("SMTP_PASSWORD"),
|
pass: this.config.get("SMTP_PASSWORD"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||||
throw new InternalServerErrorException("Email service disabled");
|
throw new InternalServerErrorException("Email service disabled");
|
||||||
|
|
||||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
await this.getTransporter().sendMail({
|
||||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
subject: "Files shared with you",
|
subject: this.config.get("EMAIL_SUBJECT"),
|
||||||
text: this.config
|
text: this.config
|
||||||
.get("EMAIL_MESSAGE")
|
.get("EMAIL_MESSAGE")
|
||||||
.replaceAll("\\n", "\n")
|
.replaceAll("\\n", "\n")
|
||||||
@@ -35,4 +36,13 @@ export class EmailService {
|
|||||||
.replaceAll("{shareUrl}", shareUrl),
|
.replaceAll("{shareUrl}", shareUrl),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendTestMail(recipientEmail: string) {
|
||||||
|
await this.getTransporter().sendMail({
|
||||||
|
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: "Test email",
|
||||||
|
text: "This is a test email",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory, Reflector } from "@nestjs/core";
|
import { NestFactory, Reflector } from "@nestjs/core";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import * as cookieParser from "cookie-parser";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ async function bootstrap() {
|
|||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
app.set("trust proxy", true);
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "84a95987-2997-429a-aba6-d38289b0b76a",
|
"_postman_id": "4b16228d-41ef-4c6b-8a0b-294a30a4cfc2",
|
||||||
"name": "Pingvin Share Testing",
|
"name": "Pingvin Share Testing",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
"_exporter_id": "17822132"
|
"_exporter_id": "17822132"
|
||||||
@@ -18,12 +18,12 @@
|
|||||||
"exec": [
|
"exec": [
|
||||||
"if(pm.response.to.have.status(201)){",
|
"if(pm.response.to.have.status(201)){",
|
||||||
" const token = pm.response.json()[\"accessToken\"]",
|
" const token = pm.response.json()[\"accessToken\"]",
|
||||||
" pm.collectionVariables.set(\"USER_AUTH_TOKEN\", token)",
|
|
||||||
"",
|
|
||||||
" // Get user id",
|
" // Get user id",
|
||||||
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
" const jwtPayload = JSON.parse(atob(token.split('.')[1]));",
|
||||||
" const userId = jwtPayload[\"sub\"]",
|
" const userId = jwtPayload[\"sub\"]",
|
||||||
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
" pm.collectionVariables.set(\"USER_ID\", userId)",
|
||||||
|
"",
|
||||||
|
" pm.collectionVariables.set(\"COOKIES\", pm.response.headers.get(\"Set-Cookie\"))",
|
||||||
"}",
|
"}",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
" pm.expect(responseBody).to.have.property(\"accessToken\")",
|
||||||
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
" pm.expect(responseBody).to.have.property(\"refreshToken\")",
|
||||||
"});",
|
"});",
|
||||||
|
"",
|
||||||
""
|
""
|
||||||
],
|
],
|
||||||
"type": "text/javascript"
|
"type": "text/javascript"
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system2.test\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||||
"options": {
|
"options": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"language": "json"
|
"language": "json"
|
||||||
@@ -1556,23 +1557,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"bearer": [
|
|
||||||
{
|
|
||||||
"key": "token",
|
|
||||||
"value": "{{USER_AUTH_TOKEN}}",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"event": [
|
"event": [
|
||||||
{
|
{
|
||||||
"listen": "prerequest",
|
"listen": "prerequest",
|
||||||
"script": {
|
"script": {
|
||||||
"type": "text/javascript",
|
"type": "text/javascript",
|
||||||
"exec": [
|
"exec": [
|
||||||
""
|
"pm.request.addHeader(\"Cookie\", pm.collectionVariables.get(\"COOKIES\"))"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
const { version } = require('./package.json');
|
||||||
|
|
||||||
const withPWA = require("next-pwa")({
|
const withPWA = require("next-pwa")({
|
||||||
dest: "public",
|
dest: "public",
|
||||||
disable: process.env.NODE_ENV == "development",
|
disable: process.env.NODE_ENV == "development",
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = withPWA({ output: "standalone" });
|
module.exports = withPWA({
|
||||||
|
output: "standalone", env: {
|
||||||
|
VERSION: version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.10.5",
|
"@emotion/react": "^11.10.5",
|
||||||
"@emotion/server": "^11.10.0",
|
"@emotion/server": "^11.10.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share-frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.5.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
67
frontend/src/components/account/ThemeSwitcher.tsx
Normal file
67
frontend/src/components/account/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
ColorScheme,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useColorScheme } from "@mantine/hooks";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TbDeviceLaptop, TbMoon, TbSun } from "react-icons/tb";
|
||||||
|
import usePreferences from "../../hooks/usePreferences";
|
||||||
|
|
||||||
|
const ThemeSwitcher = () => {
|
||||||
|
const preferences = usePreferences();
|
||||||
|
const [colorScheme, setColorScheme] = useState(
|
||||||
|
preferences.get("colorScheme")
|
||||||
|
);
|
||||||
|
const { toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const systemColorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<SegmentedControl
|
||||||
|
value={colorScheme}
|
||||||
|
onChange={(value) => {
|
||||||
|
preferences.set("colorScheme", value);
|
||||||
|
setColorScheme(value);
|
||||||
|
toggleColorScheme(
|
||||||
|
value == "system" ? systemColorScheme : (value as ColorScheme)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Center>
|
||||||
|
<TbMoon size={16} />
|
||||||
|
<Box ml={10}>Dark</Box>
|
||||||
|
</Center>
|
||||||
|
),
|
||||||
|
value: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Center>
|
||||||
|
<TbSun size={16} />
|
||||||
|
<Box ml={10}>Light</Box>
|
||||||
|
</Center>
|
||||||
|
),
|
||||||
|
value: "light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Center>
|
||||||
|
<TbDeviceLaptop size={16} />
|
||||||
|
<Box ml={10}>System</Box>
|
||||||
|
</Center>
|
||||||
|
),
|
||||||
|
value: "system",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeSwitcher;
|
||||||
@@ -47,9 +47,6 @@ const CreateEnableTotpModal = ({
|
|||||||
refreshUser: () => {};
|
refreshUser: () => {};
|
||||||
}) => {
|
}) => {
|
||||||
const modals = useModals();
|
const modals = useModals();
|
||||||
const user = useUser();
|
|
||||||
|
|
||||||
console.log(user.user);
|
|
||||||
|
|
||||||
const validationSchema = yup.object().shape({
|
const validationSchema = yup.object().shape({
|
||||||
code: yup
|
code: yup
|
||||||
|
|||||||
@@ -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<AdminConfigType[]>([]);
|
|
||||||
|
|
||||||
const getConfigVariables = async () => {
|
|
||||||
await configService.listForAdmin().then((configVariables) => {
|
|
||||||
setConfigVariables(configVariables);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
getConfigVariables().then(() => setIsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const skeletonRows = [...Array(9)].map((c, i) => (
|
|
||||||
<tr key={i}>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={18} width={80} mb="sm" />
|
|
||||||
<Skeleton height={30} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Skeleton height={18} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<Group position="right">
|
|
||||||
<Skeleton height={25} width={25} />
|
|
||||||
</Group>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "block", overflowX: "auto" }}>
|
|
||||||
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Key</th>
|
|
||||||
<th>Value</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{isLoading
|
|
||||||
? skeletonRows
|
|
||||||
: configVariables.map((configVariable) => (
|
|
||||||
<tr key={configVariable.key}>
|
|
||||||
<td style={{ maxWidth: "200px" }}>
|
|
||||||
<Code>{configVariable.key}</Code>{" "}
|
|
||||||
{configVariable.secret && <TbLock />} <br />
|
|
||||||
<Text size="xs" color="dimmed">
|
|
||||||
{configVariable.description}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
maxWidth: "40ch",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{configVariable.obscured
|
|
||||||
? "•".repeat(configVariable.value.length)
|
|
||||||
: configVariable.value}
|
|
||||||
</Text>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Group position="right">
|
|
||||||
<ActionIcon
|
|
||||||
color="primary"
|
|
||||||
variant="light"
|
|
||||||
size={25}
|
|
||||||
onClick={() =>
|
|
||||||
showUpdateConfigVariableModal(
|
|
||||||
modals,
|
|
||||||
configVariable,
|
|
||||||
getConfigVariables
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TbEdit />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminConfigTable;
|
|
||||||
@@ -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 (
|
||||||
|
<Stack align="end">
|
||||||
|
{configVariable.type == "string" &&
|
||||||
|
(configVariable.obscured ? (
|
||||||
|
<PasswordInput
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
{...form.getInputProps("stringValue")}
|
||||||
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
{...form.getInputProps("stringValue")}
|
||||||
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{configVariable.type == "text" && (
|
||||||
|
<Textarea
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
autosize
|
||||||
|
{...form.getInputProps("textValue")}
|
||||||
|
onChange={(e) => onValueChange(configVariable, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{configVariable.type == "number" && (
|
||||||
|
<NumberInput
|
||||||
|
{...form.getInputProps("numberValue")}
|
||||||
|
onChange={(number) => onValueChange(configVariable, number)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{configVariable.type == "boolean" && (
|
||||||
|
<>
|
||||||
|
<Switch
|
||||||
|
{...form.getInputProps("booleanValue", { type: "checkbox" })}
|
||||||
|
onChange={(e) => onValueChange(configVariable, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminConfigInput;
|
||||||
140
frontend/src/components/admin/configuration/AdminConfigTable.tsx
Normal file
140
frontend/src/components/admin/configuration/AdminConfigTable.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Space,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
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 isMobile = useMediaQuery("(max-width: 560px)");
|
||||||
|
|
||||||
|
let updatedConfigVariables: UpdateConfig[] = [];
|
||||||
|
|
||||||
|
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||||
|
const index = updatedConfigVariables.findIndex(
|
||||||
|
(item) => item.key === configVariable.key
|
||||||
|
);
|
||||||
|
if (index > -1) {
|
||||||
|
updatedConfigVariables[index] = configVariable;
|
||||||
|
} else {
|
||||||
|
updatedConfigVariables.push(configVariable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [configVariablesByCategory, setCofigVariablesByCategory] =
|
||||||
|
useState<AdminConfigGroupedByCategory>({});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getConfigVariables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb="lg">
|
||||||
|
{Object.entries(configVariablesByCategory).map(
|
||||||
|
([category, configVariables]) => {
|
||||||
|
return (
|
||||||
|
<Paper key={category} withBorder p="lg" mb="xl">
|
||||||
|
<Title mb="xs" order={3}>
|
||||||
|
{capitalizeFirstLetter(category)}
|
||||||
|
</Title>
|
||||||
|
{configVariables.map((configVariable) => (
|
||||||
|
<>
|
||||||
|
<Group position="apart">
|
||||||
|
<Stack
|
||||||
|
style={{ maxWidth: isMobile ? "100%" : "40%" }}
|
||||||
|
spacing={0}
|
||||||
|
>
|
||||||
|
<Title order={6}>
|
||||||
|
{configVariableToFriendlyName(configVariable.key)}
|
||||||
|
</Title>
|
||||||
|
<Text color="dimmed" size="sm" mb="xs">
|
||||||
|
{configVariable.description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack></Stack>
|
||||||
|
<Box style={{ width: isMobile ? "100%" : "50%" }}>
|
||||||
|
<AdminConfigInput
|
||||||
|
key={configVariable.key}
|
||||||
|
updateConfigVariable={updateConfigVariable}
|
||||||
|
configVariable={configVariable}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Space h="lg" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{category == "email" && (
|
||||||
|
<Group position="right">
|
||||||
|
<TestEmailButton />
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
<Group position="right">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (config.get("SETUP_FINISHED")) {
|
||||||
|
configService
|
||||||
|
.updateMany(updatedConfigVariables)
|
||||||
|
.then(() =>
|
||||||
|
toast.success("Configurations updated successfully")
|
||||||
|
)
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
} else {
|
||||||
|
configService
|
||||||
|
.updateMany(updatedConfigVariables)
|
||||||
|
.then(async () => {
|
||||||
|
await configService.finishSetup();
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(toast.axiosError);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminConfigTable;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import useUser from "../../../hooks/user.hook";
|
||||||
|
import configService from "../../../services/config.service";
|
||||||
|
import toast from "../../../utils/toast.util";
|
||||||
|
|
||||||
|
const TestEmailButton = () => {
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() =>
|
||||||
|
configService
|
||||||
|
.sendTestEmail(user!.email)
|
||||||
|
.then(() => toast.success("Email sent successfully"))
|
||||||
|
.catch(() =>
|
||||||
|
toast.error(
|
||||||
|
"Failed to send the email. Please check the backend logs for more information."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Send test email
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default TestEmailButton;
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import {
|
|
||||||
Button,
|
|
||||||
Code,
|
|
||||||
NumberInput,
|
|
||||||
PasswordInput,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Textarea,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useForm } from "@mantine/form";
|
|
||||||
import { useModals } from "@mantine/modals";
|
|
||||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
|
||||||
import configService from "../../services/config.service";
|
|
||||||
import { AdminConfig } from "../../types/config.type";
|
|
||||||
import toast from "../../utils/toast.util";
|
|
||||||
|
|
||||||
const showUpdateConfigVariableModal = (
|
|
||||||
modals: ModalsContextProps,
|
|
||||||
configVariable: AdminConfig,
|
|
||||||
getConfigVariables: () => void
|
|
||||||
) => {
|
|
||||||
return modals.openModal({
|
|
||||||
title: <Title order={5}>Update configuration variable</Title>,
|
|
||||||
children: (
|
|
||||||
<Body
|
|
||||||
configVariable={configVariable}
|
|
||||||
getConfigVariables={getConfigVariables}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const Body = ({
|
|
||||||
configVariable,
|
|
||||||
getConfigVariables,
|
|
||||||
}: {
|
|
||||||
configVariable: AdminConfig;
|
|
||||||
getConfigVariables: () => void;
|
|
||||||
}) => {
|
|
||||||
const modals = useModals();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: {
|
|
||||||
stringValue: configVariable.value,
|
|
||||||
textValue: configVariable.value,
|
|
||||||
numberValue: parseInt(configVariable.value),
|
|
||||||
booleanValue: configVariable.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Stack align="stretch">
|
|
||||||
<Text>
|
|
||||||
Set <Code>{configVariable.key}</Code> to
|
|
||||||
</Text>
|
|
||||||
{configVariable.type == "string" &&
|
|
||||||
(configVariable.obscured ? (
|
|
||||||
<PasswordInput {...form.getInputProps("stringValue")} />
|
|
||||||
) : (
|
|
||||||
<TextInput {...form.getInputProps("stringValue")} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{configVariable.type == "text" && (
|
|
||||||
<Textarea autosize {...form.getInputProps("textValue")} />
|
|
||||||
)}
|
|
||||||
{configVariable.type == "number" && (
|
|
||||||
<NumberInput {...form.getInputProps("numberValue")} />
|
|
||||||
)}
|
|
||||||
{configVariable.type == "boolean" && (
|
|
||||||
<Select
|
|
||||||
data={[
|
|
||||||
{ value: "true", label: "True" },
|
|
||||||
{ value: "false", label: "False" },
|
|
||||||
]}
|
|
||||||
{...form.getInputProps("booleanValue")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Space />
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
const value =
|
|
||||||
configVariable.type == "string"
|
|
||||||
? form.values.stringValue
|
|
||||||
: configVariable.type == "text"
|
|
||||||
? form.values.textValue
|
|
||||||
: configVariable.type == "number"
|
|
||||||
? form.values.numberValue
|
|
||||||
: form.values.booleanValue == "true";
|
|
||||||
|
|
||||||
await configService
|
|
||||||
.update(configVariable.key, value)
|
|
||||||
.then(() => {
|
|
||||||
getConfigVariables();
|
|
||||||
modals.closeAll();
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default showUpdateConfigVariableModal;
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { showNotification } from "@mantine/notifications";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import { setCookie } from "cookies-next";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TbInfoCircle } from "react-icons/tb";
|
import { TbInfoCircle } from "react-icons/tb";
|
||||||
@@ -59,8 +58,6 @@ const SignInForm = () => {
|
|||||||
});
|
});
|
||||||
setLoginToken(response.data["loginToken"]);
|
setLoginToken(response.data["loginToken"]);
|
||||||
} else {
|
} else {
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken);
|
|
||||||
window.location.replace("/");
|
window.location.replace("/");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -70,11 +67,7 @@ const SignInForm = () => {
|
|||||||
const signInTotp = (email: string, password: string, totp: string) => {
|
const signInTotp = (email: string, password: string, totp: string) => {
|
||||||
authService
|
authService
|
||||||
.signInTotp(email, password, totp, loginToken)
|
.signInTotp(email, password, totp, loginToken)
|
||||||
.then((response) => {
|
.then(() => window.location.replace("/"))
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken);
|
|
||||||
window.location.replace("/");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error?.response?.data?.message == "Login token expired") {
|
if (error?.response?.data?.message == "Login token expired") {
|
||||||
toast.error("Login token expired");
|
toast.error("Login token expired");
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm, yupResolver } from "@mantine/form";
|
import { useForm, yupResolver } from "@mantine/form";
|
||||||
import { setCookie } from "cookies-next";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
@@ -37,11 +36,7 @@ const SignUpForm = () => {
|
|||||||
const signUp = (email: string, username: string, password: string) => {
|
const signUp = (email: string, username: string, password: string) => {
|
||||||
authService
|
authService
|
||||||
.signUp(email, username, password)
|
.signUp(email, username, password)
|
||||||
.then((response) => {
|
.then(() => window.location.replace("/"))
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
setCookie("refresh_token", response.data.refreshToken);
|
|
||||||
window.location.replace("/");
|
|
||||||
})
|
|
||||||
.catch(toast.axiosError);
|
.catch(toast.axiosError);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const ActionAvatar = () => {
|
|||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
authService.signOut();
|
await authService.signOut();
|
||||||
}}
|
}}
|
||||||
icon={<TbDoorExit size={14} />}
|
icon={<TbDoorExit size={14} />}
|
||||||
>
|
>
|
||||||
|
|||||||
30
frontend/src/hooks/usePreferences.ts
Normal file
30
frontend/src/hooks/usePreferences.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const defaultPreferences = [
|
||||||
|
{
|
||||||
|
key: "colorScheme",
|
||||||
|
value: "system",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const get = (key: string) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const preferences = JSON.parse(localStorage.getItem("preferences") ?? "{}");
|
||||||
|
return (
|
||||||
|
preferences[key] ??
|
||||||
|
defaultPreferences.find((p) => p.key == key)?.value ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (key: string, value: string) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const preferences = JSON.parse(localStorage.getItem("preferences") ?? "{}");
|
||||||
|
preferences[key] = value;
|
||||||
|
localStorage.setItem("preferences", JSON.stringify(preferences));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const usePreferences = () => {
|
||||||
|
return { get, set };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePreferences;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
|
ColorSchemeProvider,
|
||||||
Container,
|
Container,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
MantineProvider,
|
MantineProvider,
|
||||||
@@ -11,7 +12,8 @@ import type { AppProps } from "next/app";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Header from "../components/navBar/NavBar";
|
import Header from "../components/navBar/NavBar";
|
||||||
import useConfig, { ConfigContext } from "../hooks/config.hook";
|
import { ConfigContext } from "../hooks/config.hook";
|
||||||
|
import usePreferences from "../hooks/usePreferences";
|
||||||
import { UserContext } from "../hooks/user.hook";
|
import { UserContext } from "../hooks/user.hook";
|
||||||
import authService from "../services/auth.service";
|
import authService from "../services/auth.service";
|
||||||
import configService from "../services/config.service";
|
import configService from "../services/config.service";
|
||||||
@@ -25,9 +27,8 @@ import { GlobalLoadingContext } from "../utils/loading.util";
|
|||||||
function App({ Component, pageProps }: AppProps) {
|
function App({ Component, pageProps }: AppProps) {
|
||||||
const systemTheme = useColorScheme();
|
const systemTheme = useColorScheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const config = useConfig();
|
const preferences = usePreferences();
|
||||||
|
const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>();
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
||||||
@@ -56,7 +57,11 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
}, [router.asPath]);
|
}, [router.asPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setColorScheme(systemTheme);
|
setColorScheme(
|
||||||
|
preferences.get("colorScheme") == "system"
|
||||||
|
? systemTheme
|
||||||
|
: preferences.get("colorScheme")
|
||||||
|
);
|
||||||
}, [systemTheme]);
|
}, [systemTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -65,26 +70,31 @@ function App({ Component, pageProps }: AppProps) {
|
|||||||
withNormalizeCSS
|
withNormalizeCSS
|
||||||
theme={{ colorScheme, ...globalStyle }}
|
theme={{ colorScheme, ...globalStyle }}
|
||||||
>
|
>
|
||||||
<GlobalStyle />
|
<ColorSchemeProvider
|
||||||
<NotificationsProvider>
|
colorScheme={colorScheme}
|
||||||
<ModalsProvider>
|
toggleColorScheme={(value) => setColorScheme(value ?? "light")}
|
||||||
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
|
>
|
||||||
{isLoading ? (
|
<GlobalStyle />
|
||||||
<LoadingOverlay visible overlayOpacity={1} />
|
<NotificationsProvider>
|
||||||
) : (
|
<ModalsProvider>
|
||||||
<ConfigContext.Provider value={configVariables}>
|
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
|
||||||
<UserContext.Provider value={{ user, setUser }}>
|
{isLoading ? (
|
||||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
<LoadingOverlay visible overlayOpacity={1} />
|
||||||
<Header />
|
) : (
|
||||||
<Container>
|
<ConfigContext.Provider value={configVariables}>
|
||||||
<Component {...pageProps} />
|
<UserContext.Provider value={{ user, setUser }}>
|
||||||
</Container>
|
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||||
</UserContext.Provider>{" "}
|
<Header />
|
||||||
</ConfigContext.Provider>
|
<Container>
|
||||||
)}
|
<Component {...pageProps} />
|
||||||
</GlobalLoadingContext.Provider>
|
</Container>
|
||||||
</ModalsProvider>
|
</UserContext.Provider>
|
||||||
</NotificationsProvider>
|
</ConfigContext.Provider>
|
||||||
|
)}
|
||||||
|
</GlobalLoadingContext.Provider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
|
</ColorSchemeProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useRouter } from "next/router";
|
|||||||
import { Tb2Fa } from "react-icons/tb";
|
import { Tb2Fa } from "react-icons/tb";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||||
|
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import authService from "../../services/auth.service";
|
import authService from "../../services/auth.service";
|
||||||
import userService from "../../services/user.service";
|
import userService from "../../services/user.service";
|
||||||
@@ -164,8 +165,6 @@ const Account = () => {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="totp" pt="xs">
|
<Tabs.Panel value="totp" pt="xs">
|
||||||
{/* TODO: This is ugly, make it prettier */}
|
|
||||||
{/* If we have totp enabled, show different text */}
|
|
||||||
{user.totpVerified ? (
|
{user.totpVerified ? (
|
||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
@@ -236,8 +235,13 @@ const Account = () => {
|
|||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<Paper withBorder p="xl" mt="lg">
|
||||||
<Center mt={80}>
|
<Title order={5} mb="xs">
|
||||||
|
Color scheme
|
||||||
|
</Title>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</Paper>
|
||||||
|
<Center mt={80} mb="lg">
|
||||||
<Stack>
|
<Stack>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Space, Title } from "@mantine/core";
|
import { Space, Title } from "@mantine/core";
|
||||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||||
|
|
||||||
const AdminConfig = () => {
|
const AdminConfig = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Col, createStyles, Grid, Paper, Text } from "@mantine/core";
|
import {
|
||||||
|
Center,
|
||||||
|
Col,
|
||||||
|
createStyles,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TbSettings, TbUsers } from "react-icons/tb";
|
import { useEffect, useState } from "react";
|
||||||
|
import { TbRefresh, TbSettings, TbUsers } from "react-icons/tb";
|
||||||
const managementOptions = [
|
import configService from "../../services/config.service";
|
||||||
{
|
|
||||||
title: "User management",
|
|
||||||
icon: TbUsers,
|
|
||||||
route: "/admin/users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Configuration",
|
|
||||||
icon: TbSettings,
|
|
||||||
route: "/admin/config",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
item: {
|
item: {
|
||||||
@@ -33,27 +31,69 @@ const useStyles = createStyles((theme) => ({
|
|||||||
const Admin = () => {
|
const Admin = () => {
|
||||||
const { classes, theme } = useStyles();
|
const { classes, theme } = useStyles();
|
||||||
|
|
||||||
|
const [managementOptions, setManagementOptions] = useState([
|
||||||
|
{
|
||||||
|
title: "User management",
|
||||||
|
icon: TbUsers,
|
||||||
|
route: "/admin/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configuration",
|
||||||
|
icon: TbSettings,
|
||||||
|
route: "/admin/config",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configService.isNewReleaseAvailable().then((isNewReleaseAvailable) => {
|
||||||
|
if (isNewReleaseAvailable) {
|
||||||
|
setManagementOptions([
|
||||||
|
...managementOptions,
|
||||||
|
{
|
||||||
|
title: "Update",
|
||||||
|
icon: TbRefresh,
|
||||||
|
route:
|
||||||
|
"https://github.com/stonith404/pingvin-share/releases/tag/v0.5.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p={40}>
|
<>
|
||||||
<Grid mt="md">
|
<Title mb={30} order={3}>
|
||||||
{managementOptions.map((item) => {
|
Administration
|
||||||
return (
|
</Title>
|
||||||
<Col xs={6} key={item.route}>
|
<Stack justify="space-between" style={{ height: "calc(100vh - 180px)" }}>
|
||||||
<Paper
|
<Paper withBorder p={40}>
|
||||||
withBorder
|
<Grid>
|
||||||
component={Link}
|
{managementOptions.map((item) => {
|
||||||
href={item.route}
|
return (
|
||||||
key={item.title}
|
<Col xs={6} key={item.route}>
|
||||||
className={classes.item}
|
<Paper
|
||||||
>
|
withBorder
|
||||||
<item.icon color={theme.colors.victoria[8]} size={35} />
|
component={Link}
|
||||||
<Text mt={7}>{item.title}</Text>
|
href={item.route}
|
||||||
</Paper>
|
key={item.title}
|
||||||
</Col>
|
className={classes.item}
|
||||||
);
|
>
|
||||||
})}
|
<item.icon color={theme.colors.victoria[8]} size={35} />
|
||||||
</Grid>
|
<Text mt={7}>{item.title}</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Center>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Version {process.env.VERSION}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Box, Button, Stack, Text, Title } from "@mantine/core";
|
import { Box, Stack, Text, Title } from "@mantine/core";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import AdminConfigTable from "../../components/admin/configuration/AdminConfigTable";
|
||||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
|
||||||
import Logo from "../../components/Logo";
|
import Logo from "../../components/Logo";
|
||||||
import useConfig from "../../hooks/config.hook";
|
import useConfig from "../../hooks/config.hook";
|
||||||
import useUser from "../../hooks/user.hook";
|
import useUser from "../../hooks/user.hook";
|
||||||
import configService from "../../services/config.service";
|
|
||||||
|
|
||||||
const Setup = () => {
|
const Setup = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.push("/auth/signUp");
|
router.push("/auth/signUp");
|
||||||
return;
|
return;
|
||||||
@@ -31,19 +28,6 @@ const Setup = () => {
|
|||||||
<Box style={{ width: "100%" }}>
|
<Box style={{ width: "100%" }}>
|
||||||
<AdminConfigTable />
|
<AdminConfigTable />
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
|
||||||
loading={isLoading}
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
await configService.finishSetup();
|
|
||||||
setIsLoading(false);
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
mb={70}
|
|
||||||
mt="lg"
|
|
||||||
>
|
|
||||||
Let me in
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import axios, { AxiosError } from "axios";
|
import axios from "axios";
|
||||||
import { getCookie } from "cookies-next";
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const accessToken = getCookie("access_token");
|
|
||||||
if (accessToken) {
|
|
||||||
config!.headers!.Authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error: AxiosError) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCookie, setCookie } from "cookies-next";
|
import { getCookie } from "cookies-next";
|
||||||
import * as jose from "jose";
|
import * as jose from "jose";
|
||||||
import api from "./api.service";
|
import api from "./api.service";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ const signIn = async (emailOrUsername: string, password: string) => {
|
|||||||
...emailOrUsernameBody,
|
...emailOrUsernameBody,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,33 +31,31 @@ const signInTotp = async (
|
|||||||
totp,
|
totp,
|
||||||
loginToken,
|
loginToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signUp = async (email: string, username: string, password: string) => {
|
const signUp = async (email: string, username: string, password: string) => {
|
||||||
return await api.post("auth/signUp", { email, username, password });
|
const response = await api.post("auth/signUp", { email, username, password });
|
||||||
|
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signOut = () => {
|
const signOut = async () => {
|
||||||
setCookie("access_token", null);
|
await api.post("/auth/signOut");
|
||||||
setCookie("refresh_token", null);
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAccessToken = async () => {
|
const refreshAccessToken = async () => {
|
||||||
try {
|
try {
|
||||||
const currentAccessToken = getCookie("access_token") as string;
|
const accessToken = getCookie("access_token") as string;
|
||||||
if (
|
if (
|
||||||
currentAccessToken &&
|
!accessToken ||
|
||||||
(jose.decodeJwt(currentAccessToken).exp ?? 0) * 1000 <
|
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 < Date.now() + 2 * 60 * 1000
|
||||||
Date.now() + 2 * 60 * 1000
|
|
||||||
) {
|
) {
|
||||||
const refreshToken = getCookie("refresh_token");
|
await api.post("/auth/token");
|
||||||
|
|
||||||
const response = await api.post("auth/token", { refreshToken });
|
|
||||||
setCookie("access_token", response.data.accessToken);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
console.info("Refresh token invalid or expired");
|
console.info("Refresh token invalid or expired");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Config, { AdminConfig } from "../types/config.type";
|
import axios from "axios";
|
||||||
|
import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
|
||||||
import api from "./api.service";
|
import api from "./api.service";
|
||||||
|
|
||||||
const list = async (): Promise<Config[]> => {
|
const list = async (): Promise<Config[]> => {
|
||||||
@@ -9,11 +10,8 @@ const listForAdmin = async (): Promise<AdminConfig[]> => {
|
|||||||
return (await api.get("/configs/admin")).data;
|
return (await api.get("/configs/admin")).data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = async (
|
const updateMany = async (data: UpdateConfig[]): Promise<AdminConfig[]> => {
|
||||||
key: string,
|
return (await api.patch("/configs/admin", data)).data;
|
||||||
value: string | number | boolean
|
|
||||||
): Promise<AdminConfig[]> => {
|
|
||||||
return (await api.patch(`/configs/admin/${key}`, { value })).data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const get = (key: string, configVariables: Config[]): any => {
|
const get = (key: string, configVariables: Config[]): any => {
|
||||||
@@ -27,17 +25,33 @@ const get = (key: string, configVariables: Config[]): any => {
|
|||||||
|
|
||||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||||
if (configVariable.type == "string" || configVariable.type == "text") return configVariable.value;
|
if (configVariable.type == "string" || configVariable.type == "text")
|
||||||
|
return configVariable.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishSetup = async (): Promise<AdminConfig[]> => {
|
const finishSetup = async (): Promise<AdminConfig[]> => {
|
||||||
return (await api.post("/configs/admin/finishSetup")).data;
|
return (await api.post("/configs/admin/finishSetup")).data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendTestEmail = async (email: string) => {
|
||||||
|
await api.post("/configs/admin/testEmail", { email });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNewReleaseAvailable = async () => {
|
||||||
|
const response = (
|
||||||
|
await axios.get(
|
||||||
|
"https://api.github.com/repos/stonith404/pingvin-share/releases/latest"
|
||||||
|
)
|
||||||
|
).data;
|
||||||
|
return response.tag_name.replace("v", "") != process.env.VERSION;
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
list,
|
list,
|
||||||
listForAdmin,
|
listForAdmin,
|
||||||
update,
|
updateMany,
|
||||||
get,
|
get,
|
||||||
finishSetup,
|
finishSetup,
|
||||||
|
sendTestEmail,
|
||||||
|
isNewReleaseAvailable,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,29 @@ type Config = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateConfig = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminConfig = Config & {
|
export type AdminConfig = Config & {
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
obscured: boolean;
|
obscured: boolean;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminConfigGroupedByCategory = {
|
||||||
|
[key: string]: [
|
||||||
|
Config & {
|
||||||
|
updatedAt: Date;
|
||||||
|
secret: boolean;
|
||||||
|
description: string;
|
||||||
|
obscured: boolean;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Config;
|
export default Config;
|
||||||
|
|||||||
10
frontend/src/utils/string.util.ts
Normal file
10
frontend/src/utils/string.util.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const configVariableToFriendlyName = (variable: string) => {
|
||||||
|
return variable
|
||||||
|
.split("_")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const capitalizeFirstLetter = (string: string) => {
|
||||||
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pingvin-share",
|
"name": "pingvin-share",
|
||||||
"version": "0.4.0",
|
"version": "0.5.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
"format": "cd frontend && npm run format && cd ../backend && npm run format",
|
||||||
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint",
|
||||||
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
|
"version": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s && git add CHANGELOG.md",
|
||||||
"release:patch": "npm version patch -m 'release: %s' && git push && git push --tags",
|
"release:patch": "cd backend && npm version patch --commit-hooks false && cd ../frontend && npm version patch --commit-hooks false && cd .. && git add . && npm version patch --force -m 'release: %s' && git push && git push --tags",
|
||||||
"release:minor": "npm version minor -m 'release: %s' && git push && git push --tags",
|
"release:minor": "cd backend && npm version minor --commit-hooks false && cd ../frontend && npm version minor --commit-hooks false && cd .. && git add . && npm version minor --force -m 'release: %s' && git push && git push --tags",
|
||||||
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
|
"deploy:dev": "docker buildx build --push --tag stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 ."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user