mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-02-19 01:55:48 -05:00
feat: add setup wizard
This commit is contained in:
parent
493705e4ef
commit
b579b8f330
32 changed files with 689 additions and 179 deletions
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
|
||||
- Added the required column `username` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "Config" (
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"type" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"secret" BOOLEAN NOT NULL DEFAULT true,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "password", "updatedAt") SELECT "createdAt", "email", "id", "password", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
|
@ -12,11 +12,10 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
email String @unique
|
||||
password String
|
||||
isAdministrator Boolean @default(false)
|
||||
firstName String?
|
||||
lastName String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
|
@ -81,10 +80,10 @@ model ShareSecurity {
|
|||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
key String @id
|
||||
type String
|
||||
value String?
|
||||
default String
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
key String @id
|
||||
type String
|
||||
value String
|
||||
description String
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
}
|
||||
|
|
|
@ -1,79 +1,8 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import configVariables from "../../src/configVariables";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const configVariables = [
|
||||
{
|
||||
key: "setupFinished",
|
||||
type: "boolean",
|
||||
default: "false",
|
||||
secret: false,
|
||||
locked: true
|
||||
},
|
||||
{
|
||||
key: "appUrl",
|
||||
type: "string",
|
||||
default: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "showHomePage",
|
||||
type: "boolean",
|
||||
default: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "allowRegistration",
|
||||
type: "boolean",
|
||||
default: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "allowUnauthenticatedShares",
|
||||
type: "boolean",
|
||||
default: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "maxFileSize",
|
||||
type: "number",
|
||||
default: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "jwtSecret",
|
||||
type: "string",
|
||||
default: "long-random-string",
|
||||
locked: true
|
||||
},
|
||||
{
|
||||
key: "emailRecipientsEnabled",
|
||||
type: "boolean",
|
||||
default: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "smtpHost",
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
{
|
||||
key: "smtpPort",
|
||||
type: "number",
|
||||
default: "",
|
||||
},
|
||||
{
|
||||
key: "smtpEmail",
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
{
|
||||
key: "smtpPassword",
|
||||
type: "string",
|
||||
default: "",
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
for (const variable of configVariables) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
|
@ -85,14 +14,6 @@ async function main() {
|
|||
await prisma.config.create({
|
||||
data: variable,
|
||||
});
|
||||
} else {
|
||||
// Update the config variable if the default value has changed
|
||||
if (existingConfigVariable.default != variable.default) {
|
||||
await prisma.config.update({
|
||||
where: { key: variable.key },
|
||||
data: { default: variable.default },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { HttpException, HttpStatus, Module } from "@nestjs/common";
|
||||
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { JobsService } from "./jobs/jobs.service";
|
||||
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { MulterModule } from "@nestjs/platform-express";
|
||||
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { ConfigService } from "./config/config.service";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
|
@ -25,6 +27,24 @@ import { UserController } from "./user/user.controller";
|
|||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule,
|
||||
MulterModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
fileFilter: (req: Request, file, cb) => {
|
||||
const maxFileSize = config.get("maxFileSize");
|
||||
const requestFileSize = parseInt(req.headers["content-length"]);
|
||||
const isValidFileSize = requestFileSize <= maxFileSize;
|
||||
cb(
|
||||
!isValidFileSize &&
|
||||
new HttpException(
|
||||
`File must be smaller than ${maxFileSize} bytes`,
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
),
|
||||
isValidFileSize
|
||||
);
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
|
|
|
@ -27,7 +27,9 @@ export class AuthService {
|
|||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: !this.config.get("setupFinished"),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -38,16 +40,22 @@ export class AuthService {
|
|||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
throw new BadRequestException("Credentials taken");
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(dto: AuthSignInDTO) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
if (!dto.email && !dto.username)
|
||||
throw new BadRequestException("Email or username is required");
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: dto.email,
|
||||
OR: [{ email: dto.email }, { username: dto.username }],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,17 @@
|
|||
import { PickType } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEmail, Length, Matches } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthRegisterDTO extends UserDTO {}
|
||||
export class AuthRegisterDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@Expose()
|
||||
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
|
||||
message: "Username can only contain letters, numbers, dots and underscores",
|
||||
})
|
||||
@Length(3, 32)
|
||||
username: string;
|
||||
|
||||
@Expose()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { PickType } from "@nestjs/swagger";
|
|||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInDTO extends PickType(UserDTO, [
|
||||
"username",
|
||||
"email",
|
||||
"password",
|
||||
] as const) {}
|
||||
|
|
|
@ -3,9 +3,11 @@ import { User } from "@prisma/client";
|
|||
|
||||
@Injectable()
|
||||
export class AdministratorGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
canActivate(context: ExecutionContext) {
|
||||
const { user }: { user: User } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user) return false;
|
||||
return user.isAdministrator;
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
console.log(config.get("jwtSecret"));
|
||||
config.get("jwtSecret");
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
|
@ -17,11 +16,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||
}
|
||||
|
||||
async validate(payload: { sub: string }) {
|
||||
console.log("vali");
|
||||
const user: User = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
console.log({ user });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { AdminConfigDTO } from "./dto/adminConfig.dto";
|
||||
import { ConfigDTO } from "./dto/config.dto";
|
||||
|
@ -15,7 +24,7 @@ export class ConfigController {
|
|||
}
|
||||
|
||||
@Get("admin")
|
||||
@UseGuards(AdministratorGuard)
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async listForAdmin() {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.listForAdmin()
|
||||
|
@ -23,10 +32,16 @@ export class ConfigController {
|
|||
}
|
||||
|
||||
@Patch("admin/:key")
|
||||
@UseGuards(AdministratorGuard)
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async update(@Param("key") key: string, @Body() data: UpdateConfigDTO) {
|
||||
return new AdminConfigDTO().from(
|
||||
await this.configService.update(key, data.value)
|
||||
);
|
||||
}
|
||||
|
||||
@Post("admin/finishSetup")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async finishSetup() {
|
||||
return await this.configService.finishSetup();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,27 +21,21 @@ export class ConfigService {
|
|||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
const value = configVariable.value ?? configVariable.default;
|
||||
|
||||
if (configVariable.type == "number") return parseInt(value);
|
||||
if (configVariable.type == "boolean") return value == "true";
|
||||
if (configVariable.type == "string") return value;
|
||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||
if (configVariable.type == "string") return configVariable.value;
|
||||
}
|
||||
|
||||
async listForAdmin() {
|
||||
return await this.prisma.config.findMany();
|
||||
return await this.prisma.config.findMany({
|
||||
where: { locked: { equals: false } },
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
const configVariables = await this.prisma.config.findMany({
|
||||
return await this.prisma.config.findMany({
|
||||
where: { secret: { equals: false } },
|
||||
});
|
||||
|
||||
return configVariables.map((configVariable) => {
|
||||
if (!configVariable.value) configVariable.value = configVariable.default;
|
||||
|
||||
return configVariable;
|
||||
});
|
||||
}
|
||||
|
||||
async update(key: string, value: string | number | boolean) {
|
||||
|
@ -57,9 +51,20 @@ export class ConfigService {
|
|||
`Config variable must be of type ${configVariable.type}`
|
||||
);
|
||||
|
||||
return await this.prisma.config.update({
|
||||
const updatedVariable = await this.prisma.config.update({
|
||||
where: { key },
|
||||
data: { value: value.toString() },
|
||||
});
|
||||
|
||||
this.configVariables = await this.prisma.config.findMany();
|
||||
|
||||
return updatedVariable;
|
||||
}
|
||||
|
||||
async finishSetup() {
|
||||
return await this.prisma.config.update({
|
||||
where: { key: "setupFinished" },
|
||||
data: { value: "true" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,19 @@ import { Expose, plainToClass } from "class-transformer";
|
|||
import { ConfigDTO } from "./config.dto";
|
||||
|
||||
export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
default: string;
|
||||
|
||||
@Expose()
|
||||
secret: boolean;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
description: string;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, { excludeExtraneousValues: true });
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
|
|
88
backend/src/configVariables.ts
Normal file
88
backend/src/configVariables.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables = [
|
||||
{
|
||||
key: "setupFinished",
|
||||
description: "Whether the setup has been finished",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "appUrl",
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "showHomePage",
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "allowRegistration",
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "allowUnauthenticatedShares",
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "maxFileSize",
|
||||
description: "Maximum file size in bytes",
|
||||
type: "number",
|
||||
value: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "jwtSecret",
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "emailRecipientsEnabled",
|
||||
description:
|
||||
"Whether to send emails to recipients. Only set this to true if you entered the host, port, email and password of your SMTP server.",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "smtpHost",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "smtpPort",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "smtpEmail",
|
||||
description: "Email address of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "smtpPassword",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
];
|
||||
|
||||
export default configVariables;
|
|
@ -23,17 +23,13 @@ export class EmailService {
|
|||
throw new InternalServerErrorException("Email service disabled");
|
||||
|
||||
const shareUrl = `${this.config.get("APP_URL")}/share/${shareId}`;
|
||||
const creatorIdentifier = creator
|
||||
? creator.firstName && creator.lastName
|
||||
? `${creator.firstName} ${creator.lastName}`
|
||||
: creator.email
|
||||
: "A Pingvin Share user";
|
||||
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: "Files shared with you",
|
||||
text: `Hey!\n${creatorIdentifier} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
|
||||
text: `Hey!\n${creator.username} shared some files with you. View or dowload the files with this link: ${shareUrl}.\n Shared securely with Pingvin Share 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ import { ShareDTO } from "src/share/dto/share.dto";
|
|||
import { ShareOwnerGuard } from "src/share/guard/shareOwner.guard";
|
||||
import { ShareSecurityGuard } from "src/share/guard/shareSecurity.guard";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
|
||||
|
||||
@Controller("shares/:shareId/files")
|
||||
export class FileController {
|
||||
|
@ -32,7 +31,7 @@ export class FileController {
|
|||
})
|
||||
)
|
||||
async create(
|
||||
@UploadedFile(FileValidationPipe)
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class FileValidationPipe implements PipeTransform {
|
||||
constructor(private config: ConfigService) {}
|
||||
async transform(value: any, metadata: ArgumentMetadata) {
|
||||
// "value" is an object containing the file's attributes and metadata
|
||||
console.log(this.config.get("maxFileSize"));
|
||||
const oneKb = 1000;
|
||||
return value.size < oneKb;
|
||||
if (value.size > this.config.get("maxFileSize"))
|
||||
throw new BadRequestException("File is ");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import { ShareSecurityDTO } from "./shareSecurity.dto";
|
|||
export class CreateShareDTO {
|
||||
@IsString()
|
||||
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
|
||||
message: "ID only can contain letters, numbers, underscores and hyphens",
|
||||
message: "ID can only contain letters, numbers, underscores and hyphens",
|
||||
})
|
||||
@Length(3, 50)
|
||||
id: string;
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UserDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@Expose()
|
||||
lastName: string;
|
||||
|
||||
@Expose()
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
|
@ -20,6 +19,9 @@ export class UserDTO {
|
|||
@IsString()
|
||||
password: string;
|
||||
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
|
95
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
95
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { ActionIcon, Code, Group, Skeleton, Table, Text } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbEdit, TbLock } from "react-icons/tb";
|
||||
import configService from "../../services/config.service";
|
||||
import { AdminConfig as AdminConfigType } from "../../types/config.type";
|
||||
import showUpdateConfigVariableModal from "./showUpdateConfigVariableModal";
|
||||
|
||||
const AdminConfigTable = () => {
|
||||
const modals = useModals();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [configVariables, setConfigVariables] = useState<AdminConfigType[]>([]);
|
||||
|
||||
const getConfigVariables = () => {
|
||||
setIsLoading(true);
|
||||
configService.listForAdmin().then((configVariables) => {
|
||||
setConfigVariables(configVariables);
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getConfigVariables();
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<Table verticalSpacing="sm" horizontalSpacing="xl" withBorder>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: configVariables.map((element) => (
|
||||
<tr key={element.key}>
|
||||
<td style={{ maxWidth: "200px" }}>
|
||||
<Code>{element.key}</Code> {element.secret && <TbLock />}{" "}
|
||||
<br />
|
||||
<Text size="xs" color="dimmed">
|
||||
{" "}
|
||||
{element.description}
|
||||
</Text>
|
||||
</td>
|
||||
<td>{element.value}</td>
|
||||
|
||||
<td>
|
||||
<Group position="right">
|
||||
<ActionIcon
|
||||
color="primary"
|
||||
variant="light"
|
||||
size={25}
|
||||
onClick={() =>
|
||||
showUpdateConfigVariableModal(
|
||||
modals,
|
||||
element,
|
||||
getConfigVariables
|
||||
)
|
||||
}
|
||||
>
|
||||
<TbEdit />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfigTable;
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
Button,
|
||||
Code,
|
||||
NumberInput,
|
||||
Select,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import configService from "../../services/config.service";
|
||||
import { AdminConfig } from "../../types/config.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const showUpdateConfigVariableModal = (
|
||||
modals: ModalsContextProps,
|
||||
configVariable: AdminConfig,
|
||||
getConfigVariables: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <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,
|
||||
numberValue: parseInt(configVariable.value),
|
||||
booleanValue: configVariable.value,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Stack align="stretch">
|
||||
<Text>
|
||||
Set <Code>{configVariable.key}</Code> to
|
||||
</Text>
|
||||
{configVariable.type == "string" && (
|
||||
<TextInput label="Value" {...form.getInputProps("stringValue")} />
|
||||
)}
|
||||
{configVariable.type == "number" && (
|
||||
<NumberInput label="Value" {...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 == "number"
|
||||
? form.values.numberValue
|
||||
: form.values.booleanValue == "true";
|
||||
|
||||
await configService
|
||||
.update(configVariable.key, value)
|
||||
.then(() => {
|
||||
getConfigVariables();
|
||||
modals.closeAll();
|
||||
})
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default showUpdateConfigVariableModal;
|
86
frontend/src/components/auth/SignInForm.tsx
Normal file
86
frontend/src/components/auth/SignInForm.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const SignInForm = () => {
|
||||
const config = useConfig();
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
.signIn(email, password)
|
||||
.then(() => window.location.replace("/"))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
Welcome back
|
||||
</Title>
|
||||
{config.get("allowRegistration") && (
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
You don't have an account yet?{" "}
|
||||
<Anchor component={Link} href={"signUp"} size="sm">
|
||||
{"Sign up"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
signIn(values.emailOrUsername, values.password)
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Email or username"
|
||||
placeholder="you@email.com"
|
||||
{...form.getInputProps("emailOrUsername")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInForm;
|
|
@ -15,17 +15,19 @@ import useConfig from "../../hooks/config.hook";
|
|||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
const SignUpForm = () => {
|
||||
const config = useConfig();
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
username: yup.string().required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
|
@ -34,12 +36,12 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
.signIn(email, password)
|
||||
.then(() => window.location.replace("/upload"))
|
||||
.then(() => window.location.replace("/"))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
};
|
||||
const signUp = (email: string, password: string) => {
|
||||
const signUp = (email: string, username: string, password: string) => {
|
||||
authService
|
||||
.signUp(email, password)
|
||||
.signUp(email, username, password)
|
||||
.then(() => signIn(email, password))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
};
|
||||
|
@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
{mode == "signUp" ? "Sign up" : "Welcome back"}
|
||||
Sign up
|
||||
</Title>
|
||||
{config.get("allowRegistration") && (
|
||||
<Text color="dimmed" size="sm" align="center" mt={5}>
|
||||
{mode == "signUp"
|
||||
? "You have an account already?"
|
||||
: "You don't have an account yet?"}{" "}
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={mode == "signUp" ? "signIn" : "signUp"}
|
||||
size="sm"
|
||||
>
|
||||
{mode == "signUp" ? "Sign in" : "Sign up"}
|
||||
You have an account already?{" "}
|
||||
<Anchor component={Link} href={"signIn"} size="sm">
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
mode == "signIn"
|
||||
? signIn(values.email, values.password)
|
||||
: signUp(values.email, values.password)
|
||||
signUp(values.email, values.username, values.password)
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="john.doe"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@email.com"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
@ -89,7 +89,7 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
{mode == "signUp" ? "Let's get started" : "Sign in"}
|
||||
Let's get started
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
|
@ -97,4 +97,4 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default AuthForm;
|
||||
export default SignUpForm;
|
|
@ -1,9 +1,12 @@
|
|||
import { ActionIcon, Avatar, Menu } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { TbDoorExit, TbLink } from "react-icons/tb";
|
||||
import { TbDoorExit, TbLink, TbSettings } from "react-icons/tb";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
|
||||
const ActionAvatar = () => {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
|
@ -19,6 +22,16 @@ const ActionAvatar = () => {
|
|||
>
|
||||
My shares
|
||||
</Menu.Item>
|
||||
{user!.isAdmin && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/admin/config"
|
||||
icon={<TbSettings size={14} />}
|
||||
>
|
||||
Administration
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
onClick={async () => {
|
||||
authService.signOut();
|
||||
|
|
|
@ -8,9 +8,10 @@ import { useColorScheme } from "@mantine/hooks";
|
|||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { NotificationsProvider } from "@mantine/notifications";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import { ConfigContext } from "../hooks/config.hook";
|
||||
import useConfig, { ConfigContext } from "../hooks/config.hook";
|
||||
import { UserContext } from "../hooks/user.hook";
|
||||
import authService from "../services/auth.service";
|
||||
import configService from "../services/config.service";
|
||||
|
@ -23,15 +24,17 @@ import { GlobalLoadingContext } from "../utils/loading.util";
|
|||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const systemTheme = useColorScheme();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [config, setConfig] = useState<Config[] | null>(null);
|
||||
const [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
||||
|
||||
const getInitalData = async () => {
|
||||
setIsLoading(true);
|
||||
setConfig(await configService.getAll());
|
||||
setConfigVariables(await configService.list());
|
||||
await authService.refreshAccessToken();
|
||||
setUser(await userService.getCurrentUser());
|
||||
setIsLoading(false);
|
||||
|
@ -42,6 +45,16 @@ function App({ Component, pageProps }: AppProps) {
|
|||
getInitalData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
configVariables &&
|
||||
configVariables.filter((variable) => variable.key)[0].value == "false" &&
|
||||
!["/auth/signUp", "/admin/setup"].includes(router.asPath)
|
||||
) {
|
||||
router.push(!user ? "/auth/signUp" : "admin/setup");
|
||||
}
|
||||
}, [router.asPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(systemTheme);
|
||||
}, [systemTheme]);
|
||||
|
@ -59,7 +72,7 @@ function App({ Component, pageProps }: AppProps) {
|
|||
{isLoading ? (
|
||||
<LoadingOverlay visible overlayOpacity={1} />
|
||||
) : (
|
||||
<ConfigContext.Provider value={config}>
|
||||
<ConfigContext.Provider value={configVariables}>
|
||||
<UserContext.Provider value={user}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
|
|
13
frontend/src/pages/admin/config.tsx
Normal file
13
frontend/src/pages/admin/config.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Space } from "@mantine/core";
|
||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||
|
||||
const AdminConfig = () => {
|
||||
return (
|
||||
<>
|
||||
<AdminConfigTable />
|
||||
<Space h="xl" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfig;
|
50
frontend/src/pages/admin/setup.tsx
Normal file
50
frontend/src/pages/admin/setup.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||
import Logo from "../../components/Logo";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import configService from "../../services/config.service";
|
||||
|
||||
const Setup = () => {
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
const user = useUser();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
router.push("/auth/signUp");
|
||||
return;
|
||||
} else if (config.get("setupFinished")) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack align="center">
|
||||
<Logo height={80} width={80} />
|
||||
<Title order={2}>Welcome to Pingvin Share</Title>
|
||||
<Text>Let's customize Pingvin Share for you! </Text>
|
||||
<AdminConfigTable />
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
await configService.finishSetup();
|
||||
setIsLoading(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
mb={70}
|
||||
mt="lg"
|
||||
>
|
||||
Let me in!
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
|
@ -1,5 +1,5 @@
|
|||
import { useRouter } from "next/router";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import SignInForm from "../../components/auth/SignInForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
|
@ -12,7 +12,7 @@ const SignIn = () => {
|
|||
return (
|
||||
<>
|
||||
<Meta title="Sign In" />
|
||||
<AuthForm mode="signIn" />
|
||||
<SignInForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useRouter } from "next/router";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import SignUpForm from "../../components/auth/SignUpForm";
|
||||
import Meta from "../../components/Meta";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
@ -16,7 +16,7 @@ const SignUp = () => {
|
|||
return (
|
||||
<>
|
||||
<Meta title="Sign Up" />
|
||||
<AuthForm mode="signUp" />
|
||||
<SignUpForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,15 +2,22 @@ import { getCookie, setCookies } from "cookies-next";
|
|||
import * as jose from "jose";
|
||||
import api from "./api.service";
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
const response = await api.post("auth/signIn", { email, password });
|
||||
const signIn = async (emailOrUsername: string, password: string) => {
|
||||
const emailOrUsernameBody = emailOrUsername.includes("@")
|
||||
? { email: emailOrUsername }
|
||||
: { username: emailOrUsername };
|
||||
|
||||
const response = await api.post("auth/signIn", {
|
||||
...emailOrUsernameBody,
|
||||
password,
|
||||
});
|
||||
setCookies("access_token", response.data.accessToken);
|
||||
setCookies("refresh_token", response.data.refreshToken);
|
||||
return response;
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
return await api.post("auth/signUp", { email, password });
|
||||
const signUp = async (email: string, username: string, password: string) => {
|
||||
return await api.post("auth/signUp", { email, username, password });
|
||||
};
|
||||
|
||||
const signOut = () => {
|
||||
|
|
|
@ -1,11 +1,24 @@
|
|||
import Config from "../types/config.type";
|
||||
import Config, { AdminConfig } from "../types/config.type";
|
||||
import api from "./api.service";
|
||||
|
||||
const getAll = async (): Promise<Config[]> => {
|
||||
const list = async (): Promise<Config[]> => {
|
||||
return (await api.get("/configs")).data;
|
||||
};
|
||||
|
||||
const listForAdmin = async (): Promise<AdminConfig[]> => {
|
||||
return (await api.get("/configs/admin")).data;
|
||||
};
|
||||
|
||||
const update = async (
|
||||
key: string,
|
||||
value: string | number | boolean
|
||||
): Promise<AdminConfig[]> => {
|
||||
return (await api.patch(`/configs/admin/${key}`, { value })).data;
|
||||
};
|
||||
|
||||
const get = (key: string, configVariables: Config[]): any => {
|
||||
if (!configVariables) return null;
|
||||
|
||||
const configVariable = configVariables.filter(
|
||||
(variable) => variable.key == key
|
||||
)[0];
|
||||
|
@ -17,7 +30,14 @@ const get = (key: string, configVariables: Config[]): any => {
|
|||
if (configVariable.type == "string") return configVariable.value;
|
||||
};
|
||||
|
||||
export default {
|
||||
getAll,
|
||||
get,
|
||||
const finishSetup = async (): Promise<AdminConfig[]> => {
|
||||
return (await api.post("/configs/admin/finishSetup")).data;
|
||||
};
|
||||
|
||||
export default {
|
||||
list,
|
||||
listForAdmin,
|
||||
update,
|
||||
get,
|
||||
finishSetup,
|
||||
};
|
||||
|
|
|
@ -4,4 +4,10 @@ type Config = {
|
|||
type: string;
|
||||
};
|
||||
|
||||
export type AdminConfig = Config & {
|
||||
updatedAt: Date;
|
||||
secret: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
|
|
@ -3,6 +3,7 @@ export default interface User {
|
|||
firstName?: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentUser extends User {}
|
||||
|
|
Loading…
Add table
Reference in a new issue