Compare commits

..

6 Commits

Author SHA1 Message Date
Elias Schneider
85551dc3d3 release: 0.5.0 2022-12-30 14:41:23 +01:00
Elias Schneider
5bc4f902f6 feat: improve config UI (#69)
* add first concept

* completed configuration ui update

* add button for testing email configuration

* improve mobile layout

* add migration

* run formatter

* delete unnecessary modal

* remove unused comment
2022-12-30 14:40:23 +01:00
Elias Schneider
e5b50f855c fix: refresh token gets deleted on session end 2022-12-26 12:57:54 +01:00
Elias Schneider
b73144295b refactor: extract totp operations in seperate service 2022-12-26 12:43:36 +01:00
Elias Schneider
ef21bac59b feat: manually switch color scheme 2022-12-24 23:58:31 +01:00
Elias Schneider
cabaee588b feat: custom mail subject 2022-12-23 10:57:09 +01:00
36 changed files with 849 additions and 550 deletions

View File

@@ -1,3 +1,17 @@
## [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)

View File

@@ -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;

View File

@@ -101,6 +101,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)

View File

@@ -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",
}, },
]; ];

View File

@@ -11,6 +11,7 @@ import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
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";
@@ -25,6 +26,7 @@ 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
) {} ) {}
@@ -47,7 +49,7 @@ export class AuthController {
@Post("signIn/totp") @Post("signIn/totp")
@HttpCode(200) @HttpCode(200)
signInTotp(@Body() dto: AuthSignInTotpDTO) { signInTotp(@Body() dto: AuthSignInTotpDTO) {
return this.authService.signInTotp(dto); return this.authTotpService.signInTotp(dto);
} }
@Patch("password") @Patch("password")
@@ -65,23 +67,22 @@ export class AuthController {
return { accessToken }; return { accessToken };
} }
// TODO: Implement recovery codes to disable 2FA just in case someone gets locked out
@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);
} }
} }

View File

@@ -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 {}

View File

@@ -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 {
@@ -81,61 +77,6 @@ export class AuthService {
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
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 };
}
async updatePassword(user: User, oldPassword: string, newPassword: string) { async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword))) if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password"); throw new ForbiddenException("Invalid password");
@@ -192,151 +133,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;
}
} }

View File

@@ -0,0 +1,226 @@
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 accessToken = await this.authService.createAccessToken(user);
const refreshToken = await this.authService.createRefreshToken(user.id);
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;
}
}

View File

@@ -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) {}

View File

@@ -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) {

View File

@@ -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);
}
} }

View File

@@ -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",

View File

@@ -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 },

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from "class-validator";
export class TestEmailDTO {
@IsEmail()
@IsNotEmpty()
email: string;
}

View File

@@ -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;

View File

@@ -7,9 +7,7 @@ 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) { transporter = nodemailer.createTransport({
// create reusable transporter object using the default SMTP transport
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,
@@ -19,15 +17,16 @@ export class EmailService {
}, },
}); });
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.transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`, from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail, to: recipientEmail,
subject: "Files shared with you", subject: 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 +34,13 @@ export class EmailService {
.replaceAll("{shareUrl}", shareUrl), .replaceAll("{shareUrl}", shareUrl),
}); });
} }
async sendTestMail(recipientEmail: string) {
await this.transporter.sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: "Test email",
text: "This is a test email",
});
}
} }

View 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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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%" }}
onChange={(e) => onValueChange(configVariable, e.target.value)}
{...form.getInputProps("stringValue")}
/>
) : (
<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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");

View File

@@ -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);
}; };

View 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;

View File

@@ -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,9 @@ 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>(); const [colorScheme, setColorScheme] = useState<ColorScheme>("light");
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 +58,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 (
@@ -64,6 +70,10 @@ function App({ Component, pageProps }: AppProps) {
withGlobalStyles withGlobalStyles
withNormalizeCSS withNormalizeCSS
theme={{ colorScheme, ...globalStyle }} theme={{ colorScheme, ...globalStyle }}
>
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={(value) => setColorScheme(value ?? "light")}
> >
<GlobalStyle /> <GlobalStyle />
<NotificationsProvider> <NotificationsProvider>
@@ -85,6 +95,7 @@ function App({ Component, pageProps }: AppProps) {
</GlobalLoadingContext.Provider> </GlobalLoadingContext.Provider>
</ModalsProvider> </ModalsProvider>
</NotificationsProvider> </NotificationsProvider>
</ColorSchemeProvider>
</MantineProvider> </MantineProvider>
); );
} }

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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>
</> </>
); );

View File

@@ -11,6 +11,12 @@ const signIn = async (emailOrUsername: string, password: string) => {
...emailOrUsernameBody, ...emailOrUsernameBody,
password, password,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
@@ -30,11 +36,24 @@ const signInTotp = async (
totp, totp,
loginToken, loginToken,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
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 });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response;
}; };
const signOut = () => { const signOut = () => {
@@ -45,14 +64,14 @@ const signOut = () => {
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
try { try {
const currentAccessToken = getCookie("access_token") as string; const accessToken = getCookie("access_token") as string;
if (
currentAccessToken &&
(jose.decodeJwt(currentAccessToken).exp ?? 0) * 1000 <
Date.now() + 2 * 60 * 1000
) {
const refreshToken = getCookie("refresh_token"); const refreshToken = getCookie("refresh_token");
if (
(accessToken &&
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 <
Date.now() + 2 * 60 * 1000) ||
(refreshToken && !accessToken)
) {
const response = await api.post("auth/token", { refreshToken }); const response = await api.post("auth/token", { refreshToken });
setCookie("access_token", response.data.accessToken); setCookie("access_token", response.data.accessToken);
} }

View File

@@ -1,4 +1,4 @@
import Config, { AdminConfig } from "../types/config.type"; 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 +9,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 +24,23 @@ 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 });
};
export default { export default {
list, list,
listForAdmin, listForAdmin,
update, updateMany,
get, get,
finishSetup, finishSetup,
sendTestEmail,
}; };

View File

@@ -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;

View 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);
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.4.0", "version": "0.5.0",
"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",