mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-01-29 01:28:59 -05:00
Merge pull request #27 from stonith404/feat/administrator-page
Feat/administrator page
This commit is contained in:
commit
50887b000d
80 changed files with 3525 additions and 1469 deletions
18
.env.example
18
.env.example
|
@ -1,18 +0,0 @@
|
|||
# Read what every environment variable does: https://github.com/stonith404/pingvin-share#environment-variables
|
||||
|
||||
# General
|
||||
APP_URL=http://localhost:3000
|
||||
SHOW_HOME_PAGE=true
|
||||
ALLOW_REGISTRATION=true
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
MAX_FILE_SIZE=1000000000
|
||||
|
||||
# Security
|
||||
JWT_SECRET=long-random-string
|
||||
|
||||
# Email
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EMAIL=pingvin-share@example.com
|
||||
SMTP_PASSWORD=example
|
|
@ -3,10 +3,11 @@
|
|||
We would ❤️ for you to contribute to Pingvin Share and help make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
|
||||
## Getting started
|
||||
|
||||
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch 😊.
|
||||
|
||||
## Submit a Pull Request
|
||||
|
||||
## Submit a Pull Request
|
||||
Once you created a issue and you want to create a pull request, follow this guide.
|
||||
|
||||
Branch naming convention is as following
|
||||
|
@ -74,20 +75,21 @@ The backend is built with [Nest.js](https://nestjs.com) and uses Typescript.
|
|||
#### Setup
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Push the database schema to the database by running `npx prisma db push`
|
||||
2. Install the dependencies with `npm install`
|
||||
3. Push the database schema to the database by running `npx prisma db push`
|
||||
4. Seed the database with `npx prisma db seed`
|
||||
5. Start the backend with `npm run dev`
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Next.js](https://nextjs.org) and uses Typescript.
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Start the backend first
|
||||
2. Open the `frontend` folder
|
||||
3. Duplicate the `.env.example` file, rename the duplicate to `.env` and change the environment variables if needed
|
||||
4. Install the dependencies with `npm install`
|
||||
5. Start the frontend with `npm run dev`
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
You're all set!
|
||||
|
||||
|
|
|
@ -28,7 +28,5 @@ COPY --from=backend-builder /opt/app/prisma ./prisma
|
|||
COPY --from=backend-builder /opt/app/package.json ./
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN npm i -g dotenv-cli
|
||||
|
||||
EXPOSE 3000
|
||||
CMD cd frontend && dotenv node_modules/.bin/next start & cd backend && npm run prod
|
||||
CMD cd frontend && node_modules/.bin/next start & cd backend && npm run prod
|
24
README.md
24
README.md
|
@ -10,6 +10,7 @@ Demo: https://pingvin-share.dev.eliasschneider.com
|
|||
|
||||
## ✨ Features
|
||||
|
||||
- Spin up your instance within 2 minutes
|
||||
- Create a share with files that you can access with a link
|
||||
- No file size limit, only your disk will be your limit
|
||||
- Set a share expiration
|
||||
|
@ -21,31 +22,14 @@ Demo: https://pingvin-share.dev.eliasschneider.com
|
|||
|
||||
> Pleas note that Pingvin Share is in early stage and could include some bugs
|
||||
|
||||
1. Download the `docker-compose.yml` and `.env.example` file.
|
||||
2. Rename the `.env.example` file to `.env` and change the environment variables so that they fit to your environment. If you need help with the environment variables take a look [here](#environment-variables)
|
||||
3. Run `docker-compose up -d`
|
||||
1. Download the `docker-compose.yml` file
|
||||
2. Run `docker-compose up -d`
|
||||
|
||||
The website is now listening available on `http://localhost:3000`, have fun with Pingvin Share 🐧!
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description | Possible values |
|
||||
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| `APP_URL` | On which URL Pingvin Share is available. E.g http://localhost or https://pingvin-share.com. | URL |
|
||||
| `SHOW_HOME_PAGE` | Whether the Pingvin Share home page should be shown. | true/false |
|
||||
| `ALLOW_REGISTRATION` | Whether a new user can create a new account. | true/false |
|
||||
| `ALLOW_UNAUTHENTICATED_SHARES` | Whether a user can create a share without being signed in. | true/false |
|
||||
| `MAX_FILE_SIZE` | Maximum allowed size per file in bytes. | Number |
|
||||
| `JWT_SECRET` | Long random string to sign the JWT's. | Random string |
|
||||
| `EMAIL_RECIPIENTS_ENABLED` | Whether email reciepients are enabled. Only set this to true if you entered the host, port, email and password of your SMTP server. | true/false |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_EMAIL`, `SMTP_PASSWORD` | Credentials for your SMTP server. | - |
|
||||
|
||||
### Upgrade to a new version
|
||||
|
||||
1. Check if your local `docker-compose.yml` and `.env` files are up to date with the files in the repository
|
||||
2. Run `docker compose pull && docker compose up -d` to update your docker container
|
||||
|
||||
> Note: If you installed Pingvin Share before it used Sqlite, you unfortunately have to set up the project from scratch again, sorry for that.
|
||||
Run `docker compose pull && docker compose up -d` to update your docker container
|
||||
|
||||
## 🖤 Contribute
|
||||
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
# General
|
||||
APP_URL=http://localhost:3000
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=5000000000
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
|
||||
# Security
|
||||
JWT_SECRET=random-string
|
||||
|
||||
# Email
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_EMAIL=pingvin-share@example.com
|
||||
SMTP_PASSWORD=example
|
753
backend/package-lock.json
generated
753
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -3,24 +3,27 @@
|
|||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "dotenv -- nest start --watch",
|
||||
"prod": "npx prisma migrate deploy && dotenv node dist/main",
|
||||
"dev": "nest start --watch",
|
||||
"prod": "prisma migrate deploy && prisma db seed && node dist/src/main",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"test:system": "npx prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
|
||||
"test:system": "prisma migrate reset -f && nest start & sleep 10 && newman run ./test/system/newman-system-tests.json"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed/config.seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^9.1.2",
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.1.2",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/jwt": "^9.0.0",
|
||||
"@nestjs/mapped-types": "^1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.1.2",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
"@nestjs/swagger": "^6.1.2",
|
||||
"@nestjs/throttler": "^3.1.0",
|
||||
"archiver": "^5.3.1",
|
||||
"argon2": "^0.29.1",
|
||||
"argon2": "^0.30.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"content-disposition": "^0.5.4",
|
||||
|
@ -33,36 +36,35 @@
|
|||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.5.7"
|
||||
"rxjs": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.1.4",
|
||||
"@nestjs/cli": "^9.1.5",
|
||||
"@nestjs/schematics": "^9.0.3",
|
||||
"@nestjs/testing": "^9.1.2",
|
||||
"@prisma/client": "^4.4.0",
|
||||
"@nestjs/testing": "^9.2.1",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"@types/archiver": "^5.3.1",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.7.23",
|
||||
"@types/node": "^18.11.10",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.40.0",
|
||||
"@typescript-eslint/parser": "^5.40.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "^8.25.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"newman": "^5.3.2",
|
||||
"prettier": "^2.7.1",
|
||||
"prisma": "^4.4.0",
|
||||
"prettier": "^2.8.0",
|
||||
"prisma": "^4.7.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.4.1",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.8.4"
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "^4.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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", "username") SELECT "createdAt", "email", "id", "password", "updatedAt", 'user-' || User.id as "username" 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,10 +12,10 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
email String @unique
|
||||
password String
|
||||
firstName String?
|
||||
lastName String?
|
||||
username String @unique
|
||||
email String @unique
|
||||
password String
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
|
@ -76,3 +76,14 @@ model ShareSecurity {
|
|||
shareId String? @unique
|
||||
share Share? @relation(fields: [shareId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Config {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
key String @id
|
||||
type String
|
||||
value String
|
||||
description String
|
||||
secret Boolean @default(true)
|
||||
locked Boolean @default(false)
|
||||
}
|
||||
|
|
124
backend/prisma/seed/config.seed.ts
Normal file
124
backend/prisma/seed/config.seed.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const configVariables = [
|
||||
{
|
||||
key: "SETUP_FINISHED",
|
||||
description: "Whether the setup has been finished",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "APP_URL",
|
||||
description: "On which URL Pingvin Share is available",
|
||||
type: "string",
|
||||
value: "http://localhost:3000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "SHOW_HOME_PAGE",
|
||||
description: "Whether to show the home page",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_REGISTRATION",
|
||||
description: "Whether registration is allowed",
|
||||
type: "boolean",
|
||||
value: "true",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "ALLOW_UNAUTHENTICATED_SHARES",
|
||||
description: "Whether unauthorized users can create shares",
|
||||
type: "boolean",
|
||||
value: "false",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "MAX_FILE_SIZE",
|
||||
description: "Maximum file size in bytes",
|
||||
type: "number",
|
||||
value: "1000000000",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
key: "JWT_SECRET",
|
||||
description: "Long random string used to sign JWT tokens",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(256).toString("base64"),
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
key: "ENABLE_EMAIL_RECIPIENTS",
|
||||
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: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
for (const variable of configVariables) {
|
||||
const existingConfigVariable = await prisma.config.findUnique({
|
||||
where: { key: variable.key },
|
||||
});
|
||||
|
||||
// Create a new config variable if it doesn't exist
|
||||
if (!existingConfigVariable) {
|
||||
await prisma.config.create({
|
||||
data: variable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the config variable if it doesn't exist anymore
|
||||
const configVariablesFromDatabase = await prisma.config.findMany();
|
||||
|
||||
for (const configVariableFromDatabase of configVariablesFromDatabase) {
|
||||
if (!configVariables.find((v) => v.key == configVariableFromDatabase.key)) {
|
||||
await prisma.config.delete({
|
||||
where: { key: configVariableFromDatabase.key },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
|
@ -1,19 +1,18 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
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 { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { FileController } from "./file/file.controller";
|
||||
import { MulterModule } from "@nestjs/platform-express";
|
||||
import { 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";
|
||||
import { FileModule } from "./file/file.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
import { ShareController } from "./share/share.controller";
|
||||
import { ShareModule } from "./share/share.module";
|
||||
import { UserController } from "./user/user.controller";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -22,21 +21,31 @@ import { EmailModule } from "./email/email.module";
|
|||
FileModule,
|
||||
EmailModule,
|
||||
PrismaModule,
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ConfigModule,
|
||||
UserModule,
|
||||
MulterModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
fileFilter: (req: Request, file, cb) => {
|
||||
const MAX_FILE_SIZE = config.get("MAX_FILE_SIZE");
|
||||
const requestFileSize = parseInt(req.headers["content-length"]);
|
||||
const isValidFileSize = requestFileSize <= MAX_FILE_SIZE;
|
||||
cb(
|
||||
!isValidFileSize &&
|
||||
new HttpException(
|
||||
`File must be smaller than ${MAX_FILE_SIZE} bytes`,
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
),
|
||||
isValidFileSize
|
||||
);
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
ThrottlerModule.forRoot({
|
||||
ttl: 60,
|
||||
limit: 100,
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
JobsService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
controllers: [UserController, ShareController, FileController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
@ -3,14 +3,20 @@ import {
|
|||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { User } from "@prisma/client";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { GetUser } from "./decorator/getUser.decorator";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { JwtGuard } from "./guard/jwt.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
|
@ -21,8 +27,8 @@ export class AuthController {
|
|||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (this.config.get("ALLOW_REGISTRATION") == "false")
|
||||
async signUp(@Body() dto: AuthRegisterDTO) {
|
||||
if (!this.config.get("ALLOW_REGISTRATION"))
|
||||
throw new ForbiddenException("Registration is not allowed");
|
||||
return this.authService.signUp(dto);
|
||||
}
|
||||
|
@ -34,6 +40,12 @@ export class AuthController {
|
|||
return this.authService.signIn(dto);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(@GetUser() user: User, @Body() dto: UpdatePasswordDTO) {
|
||||
await this.authService.updatePassword(user, dto.oldPassword, dto.password);
|
||||
}
|
||||
|
||||
@Post("token")
|
||||
@HttpCode(200)
|
||||
async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) {
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
|
@ -27,7 +28,9 @@ export class AuthService {
|
|||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: !this.config.get("SETUP_FINISHED"),
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -38,16 +41,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 }],
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -60,6 +69,18 @@ export class AuthService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (argon.verify(user.password, oldPassword))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
const hash = await argon.hash(newPassword);
|
||||
|
||||
this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hash },
|
||||
});
|
||||
}
|
||||
|
||||
async createAccessToken(user: User) {
|
||||
return this.jwtService.sign(
|
||||
{
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthRegisterDTO extends UserDTO {}
|
||||
export class AuthRegisterDTO extends PickType(UserDTO, [
|
||||
"email",
|
||||
"username",
|
||||
"password",
|
||||
] as const) {}
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { PickType } from "@nestjs/swagger";
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class AuthSignInDTO extends PickType(UserDTO, [
|
||||
"email",
|
||||
"password",
|
||||
] as const) {}
|
||||
export class AuthSignInDTO extends PickType(UserDTO, ["password"] as const) {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username: string;
|
||||
}
|
||||
|
|
8
backend/src/auth/dto/updatePassword.dto.ts
Normal file
8
backend/src/auth/dto/updatePassword.dto.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
oldPassword: string;
|
||||
}
|
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
13
backend/src/auth/guard/isAdmin.guard.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AdministratorGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext) {
|
||||
const { user }: { user: User } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard("jwt") {
|
||||
constructor() {
|
||||
constructor(private config: ConfigService) {
|
||||
super();
|
||||
}
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
return (await super.canActivate(context)) as boolean;
|
||||
} catch {
|
||||
return process.env.ALLOW_UNAUTHENTICATED_SHARES == "true";
|
||||
return this.config.get("ALLOW_UNAUTHENTICATED_SHARES");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
config.get("JWT_SECRET");
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get("JWT_SECRET"),
|
||||
|
@ -18,7 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||
const user: User = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
47
backend/src/config/config.controller.ts
Normal file
47
backend/src/config/config.controller.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
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";
|
||||
import UpdateConfigDTO from "./dto/updateConfig.dto";
|
||||
|
||||
@Controller("configs")
|
||||
export class ConfigController {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return new ConfigDTO().fromList(await this.configService.list());
|
||||
}
|
||||
|
||||
@Get("admin")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async listForAdmin() {
|
||||
return new AdminConfigDTO().fromList(
|
||||
await this.configService.listForAdmin()
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("admin/:key")
|
||||
@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
backend/src/config/config.module.ts
Normal file
21
backend/src/config/config.module.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { ConfigController } from "./config.controller";
|
||||
import { ConfigService } from "./config.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: "CONFIG_VARIABLES",
|
||||
useFactory: async (prisma: PrismaService) => {
|
||||
return await prisma.config.findMany();
|
||||
},
|
||||
inject: [PrismaService],
|
||||
},
|
||||
ConfigService,
|
||||
],
|
||||
controllers: [ConfigController],
|
||||
exports: [ConfigService],
|
||||
})
|
||||
export class ConfigModule {}
|
70
backend/src/config/config.service.ts
Normal file
70
backend/src/config/config.service.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Config } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
constructor(
|
||||
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
get(key: string): any {
|
||||
const configVariable = this.configVariables.filter(
|
||||
(variable) => variable.key == key
|
||||
)[0];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
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({
|
||||
where: { locked: { equals: false } },
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
return await this.prisma.config.findMany({
|
||||
where: { secret: { equals: false } },
|
||||
});
|
||||
}
|
||||
|
||||
async update(key: string, value: string | number | boolean) {
|
||||
const configVariable = await this.prisma.config.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
if (!configVariable || configVariable.locked)
|
||||
throw new NotFoundException("Config variable not found");
|
||||
|
||||
if (typeof value != configVariable.type)
|
||||
throw new BadRequestException(
|
||||
`Config variable must be of type ${configVariable.type}`
|
||||
);
|
||||
|
||||
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: "SETUP_FINISHED" },
|
||||
data: { value: "true" },
|
||||
});
|
||||
}
|
||||
}
|
25
backend/src/config/dto/adminConfig.dto.ts
Normal file
25
backend/src/config/dto/adminConfig.dto.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { ConfigDTO } from "./config.dto";
|
||||
|
||||
export class AdminConfigDTO extends ConfigDTO {
|
||||
@Expose()
|
||||
secret: boolean;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
description: string;
|
||||
|
||||
from(partial: Partial<AdminConfigDTO>) {
|
||||
return plainToClass(AdminConfigDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
fromList(partial: Partial<AdminConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(AdminConfigDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
18
backend/src/config/dto/config.dto.ts
Normal file
18
backend/src/config/dto/config.dto.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Expose, plainToClass } from "class-transformer";
|
||||
|
||||
export class ConfigDTO {
|
||||
@Expose()
|
||||
key: string;
|
||||
|
||||
@Expose()
|
||||
value: string;
|
||||
|
||||
@Expose()
|
||||
type: string;
|
||||
|
||||
fromList(partial: Partial<ConfigDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(ConfigDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
8
backend/src/config/dto/updateConfig.dto.ts
Normal file
8
backend/src/config/dto/updateConfig.dto.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { IsNotEmpty } from "class-validator";
|
||||
|
||||
class UpdateConfigDTO {
|
||||
@IsNotEmpty()
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
export default UpdateConfigDTO;
|
|
@ -1,38 +1,34 @@
|
|||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { User } from "@prisma/client";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
// create reusable transporter object using the default SMTP transport
|
||||
transporter = nodemailer.createTransport({
|
||||
host: this.config.get("SMTP_HOST"),
|
||||
port: parseInt(this.config.get("SMTP_PORT")),
|
||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||
auth: {
|
||||
user: this.config.get("SMTP_EMAIL"),
|
||||
pass: this.config.get("SMTP_PASSWORD"),
|
||||
},
|
||||
});
|
||||
|
||||
async sendMail(recipientEmail: string, shareId: string, creator: User) {
|
||||
if (this.config.get("EMAIL_RECIPIENTS_ENABLED") == "false")
|
||||
// create reusable transporter object using the default SMTP transport
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: this.config.get("SMTP_HOST"),
|
||||
port: parseInt(this.config.get("SMTP_PORT")),
|
||||
secure: parseInt(this.config.get("SMTP_PORT")) == 465,
|
||||
auth: {
|
||||
user: this.config.get("SMTP_EMAIL"),
|
||||
pass: this.config.get("SMTP_PASSWORD"),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.config.get("ENABLE_EMAIL_RECIPIENTS"))
|
||||
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 this.transporter.sendMail({
|
||||
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 🐧`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import {
|
|||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseFilePipeBuilder,
|
||||
Post,
|
||||
Res,
|
||||
StreamableFile,
|
||||
|
@ -32,13 +31,7 @@ export class FileController {
|
|||
})
|
||||
)
|
||||
async create(
|
||||
@UploadedFile(
|
||||
new ParseFilePipeBuilder()
|
||||
.addMaxSizeValidator({
|
||||
maxSize: parseInt(process.env.MAX_FILE_SIZE),
|
||||
})
|
||||
.build()
|
||||
)
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
@Param("shareId") shareId: string
|
||||
) {
|
||||
|
|
|
@ -3,11 +3,12 @@ import { JwtModule } from "@nestjs/jwt";
|
|||
import { ShareModule } from "src/share/share.module";
|
||||
import { FileController } from "./file.controller";
|
||||
import { FileService } from "./file.service";
|
||||
import { FileValidationPipe } from "./pipe/fileValidation.pipe";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), ShareModule],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
providers: [FileService, FileValidationPipe],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
|
|
|
@ -3,11 +3,11 @@ import {
|
|||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as mime from "mime-types";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
|
@ -80,6 +80,7 @@ export class FileService {
|
|||
|
||||
getFileDownloadUrl(shareId: string, fileId: string) {
|
||||
const downloadToken = this.generateFileDownloadToken(shareId, fileId);
|
||||
|
||||
return `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/api/shares/${shareId}/files/${fileId}?token=${downloadToken}`;
|
||||
|
|
17
backend/src/file/pipe/fileValidation.pipe.ts
Normal file
17
backend/src/file/pipe/fileValidation.pipe.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
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) {
|
||||
if (value.size > this.config.get("MAX_FILE_SIZE"))
|
||||
throw new BadRequestException("File is ");
|
||||
return value;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
@ -35,6 +36,22 @@ export class JobsService {
|
|||
console.log(`job: deleted ${expiredShares.length} expired shares`);
|
||||
}
|
||||
|
||||
@Cron("0 0 * * *")
|
||||
deleteTemporaryFiles() {
|
||||
const files = fs.readdirSync("./data/uploads/_temp");
|
||||
|
||||
for (const file of files) {
|
||||
const stats = fs.statSync(`./data/uploads/_temp/${file}`);
|
||||
const isOlderThanOneDay = moment(stats.mtime)
|
||||
.add(1, "day")
|
||||
.isBefore(moment());
|
||||
|
||||
if (isOlderThanOneDay) fs.rmSync(`./data/uploads/_temp/${file}`);
|
||||
}
|
||||
|
||||
console.log(`job: deleted ${files.length} temporary files`);
|
||||
}
|
||||
|
||||
@Cron("0 * * * *")
|
||||
async deleteExpiredRefreshTokens() {
|
||||
const expiredRefreshTokens = await this.prisma.refreshToken.deleteMany({
|
||||
|
|
|
@ -6,7 +6,7 @@ import { AppModule } from "./app.module";
|
|||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { PrismaClient } from "@prisma/client";
|
|||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient {
|
||||
constructor(config: ConfigService) {
|
||||
constructor() {
|
||||
super({
|
||||
datasources: {
|
||||
db: {
|
||||
|
|
|
@ -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,3 +1,6 @@
|
|||
import { IsString } from "class-validator";
|
||||
|
||||
export class SharePasswordDto {
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
|
|
@ -4,13 +4,13 @@ import {
|
|||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Share, User } from "@prisma/client";
|
||||
import * as archiver from "archiver";
|
||||
import * as argon from "argon2";
|
||||
import * as fs from "fs";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { FileService } from "src/file/file.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
|
15
backend/src/user/dto/createUser.dto.ts
Normal file
15
backend/src/user/dto/createUser.dto.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { Allow } from "class-validator";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class CreateUserDTO extends UserDTO {
|
||||
@Expose()
|
||||
@Allow()
|
||||
isAdmin: boolean;
|
||||
|
||||
from(partial: Partial<CreateUserDTO>) {
|
||||
return plainToClass(CreateUserDTO, partial, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { PickType } from "@nestjs/swagger";
|
||||
import { PickType } from "@nestjs/mapped-types";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class PublicUserDTO extends PickType(UserDTO, ["email"] as const) {}
|
||||
|
|
6
backend/src/user/dto/updateOwnUser.dto.ts
Normal file
6
backend/src/user/dto/updateOwnUser.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { OmitType, PartialType } from "@nestjs/mapped-types";
|
||||
import { UserDTO } from "./user.dto";
|
||||
|
||||
export class UpdateOwnUserDTO extends PartialType(
|
||||
OmitType(UserDTO, ["isAdmin", "password"] as const)
|
||||
) {}
|
4
backend/src/user/dto/updateUser.dto.ts
Normal file
4
backend/src/user/dto/updateUser.dto.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { CreateUserDTO } from "./createUser.dto";
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDTO) {}
|
|
@ -1,26 +1,34 @@
|
|||
import { Expose, plainToClass } from "class-transformer";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
import { IsEmail, Length, Matches, MinLength } from "class-validator";
|
||||
|
||||
export class UserDTO {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string;
|
||||
@Matches("^[a-zA-Z0-9_.]*$", undefined, {
|
||||
message: "Username can only contain letters, numbers, dots and underscores",
|
||||
})
|
||||
@Length(3, 32)
|
||||
username: string;
|
||||
|
||||
@Expose()
|
||||
lastName: string;
|
||||
|
||||
@Expose()
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@Expose()
|
||||
isAdmin: boolean;
|
||||
|
||||
from(partial: Partial<UserDTO>) {
|
||||
return plainToClass(UserDTO, partial, { excludeExtraneousValues: true });
|
||||
}
|
||||
|
||||
fromList(partial: Partial<UserDTO>[]) {
|
||||
return partial.map((part) =>
|
||||
plainToClass(UserDTO, part, { excludeExtraneousValues: true })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,71 @@
|
|||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { GetUser } from "src/auth/decorator/getUser.decorator";
|
||||
import { AdministratorGuard } from "src/auth/guard/isAdmin.guard";
|
||||
import { JwtGuard } from "src/auth/guard/jwt.guard";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateOwnUserDTO } from "./dto/updateOwnUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
import { UserDTO } from "./dto/user.dto";
|
||||
import { UserSevice } from "./user.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UserController {
|
||||
constructor(private userService: UserSevice) {}
|
||||
|
||||
// Own user operations
|
||||
@Get("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async getCurrentUser(@GetUser() user: User) {
|
||||
return new UserDTO().from(user);
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async updateCurrentUser(
|
||||
@GetUser() user: User,
|
||||
@Body() data: UpdateOwnUserDTO
|
||||
) {
|
||||
return new UserDTO().from(await this.userService.update(user.id, data));
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@UseGuards(JwtGuard)
|
||||
async deleteCurrentUser(@GetUser() user: User) {
|
||||
return new UserDTO().from(await this.userService.delete(user.id));
|
||||
}
|
||||
|
||||
// Global user operations
|
||||
@Get()
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async list() {
|
||||
return new UserDTO().fromList(await this.userService.list());
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async create(@Body() user: CreateUserDTO) {
|
||||
return new UserDTO().from(await this.userService.create(user));
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async update(@Param("id") id: string, @Body() user: UpdateUserDto) {
|
||||
return new UserDTO().from(await this.userService.update(id, user));
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(JwtGuard, AdministratorGuard)
|
||||
async delete(@Param("id") id: string) {
|
||||
return new UserDTO().from(await this.userService.delete(id));
|
||||
}
|
||||
}
|
||||
|
|
9
backend/src/user/user.module.ts
Normal file
9
backend/src/user/user.module.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { UserController } from "./user.controller";
|
||||
import { UserSevice } from "./user.service";
|
||||
|
||||
@Module({
|
||||
providers: [UserSevice],
|
||||
controllers: [UserController],
|
||||
})
|
||||
export class UserModule {}
|
65
backend/src/user/user.service.ts
Normal file
65
backend/src/user/user.service.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
import { UserDTO } from "./dto/user.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async list() {
|
||||
return await this.prisma.user.findMany();
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
return await this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDTO) {
|
||||
const hash = await argon.hash(dto.password);
|
||||
try {
|
||||
return await this.prisma.user.create({
|
||||
data: {
|
||||
...dto,
|
||||
password: hash,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, user: UpdateUserDto) {
|
||||
try {
|
||||
const hash = user.password && (await argon.hash(user.password));
|
||||
|
||||
return await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { ...user, password: hash },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof PrismaClientKnownRequestError) {
|
||||
if (e.code == "P2002") {
|
||||
const duplicatedField: string = e.meta.target[0];
|
||||
throw new BadRequestException(
|
||||
`A user with this ${duplicatedField} already exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
return await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
|
@ -36,7 +36,7 @@
|
|||
"header": [],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test\",\n \"email\": \"system@test.org\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
|
||||
"raw": "{\n \"email\": \"system@test.org\",\n \"username\": \"system.test\",\n \"password\": \"J2y8unpJUcJDRv\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
@ -97,7 +97,7 @@
|
|||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"firstName\" : \"System\",\n \"lastName\" : \"Test2\",\n \"email\": \"system2@test.org\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||
"raw": "{\n \"email\": \"system2@test.org\",\n \"username\": \"system.test2\",\n \"password\": \"N44HcHgeuAvfCT\"\n}",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
|
|
|
@ -5,17 +5,5 @@ services:
|
|||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- APP_URL=${APP_URL}
|
||||
- SHOW_HOME_PAGE=${SHOW_HOME_PAGE}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION}
|
||||
- ALLOW_UNAUTHENTICATED_SHARES=${ALLOW_UNAUTHENTICATED_SHARES}
|
||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- EMAIL_RECIPIENTS_ENABLED=${EMAIL_RECIPIENTS_ENABLED}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_EMAIL=${SMTP_EMAIL}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
volumes:
|
||||
- "${PWD}/data:/opt/app/backend/data"
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
SHOW_HOME_PAGE=true
|
||||
ALLOW_REGISTRATION=true
|
||||
MAX_FILE_SIZE=1000000000
|
||||
ALLOW_UNAUTHENTICATED_SHARES=false
|
||||
EMAIL_RECIPIENTS_ENABLED=false
|
|
@ -1,14 +1,5 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
publicRuntimeConfig: {
|
||||
ALLOW_REGISTRATION: process.env.ALLOW_REGISTRATION,
|
||||
SHOW_HOME_PAGE: process.env.SHOW_HOME_PAGE,
|
||||
MAX_FILE_SIZE: process.env.MAX_FILE_SIZE,
|
||||
ALLOW_UNAUTHENTICATED_SHARES: process.env.ALLOW_UNAUTHENTICATED_SHARES,
|
||||
EMAIL_RECIPIENTS_ENABLED: process.env.EMAIL_RECIPIENTS_ENABLED
|
||||
}
|
||||
}
|
||||
|
||||
const withPWA = require("next-pwa")({
|
||||
dest: "public",
|
||||
|
@ -16,4 +7,4 @@ const withPWA = require("next-pwa")({
|
|||
});
|
||||
|
||||
|
||||
module.exports = withPWA(nextConfig);
|
||||
module.exports = withPWA();
|
||||
|
|
1886
frontend/package-lock.json
generated
1886
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -2,47 +2,46 @@
|
|||
"name": "pingvin-share",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "dotenv next dev",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "dotenv next start",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"src/**/*.ts*\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^5.5.2",
|
||||
"@mantine/dropzone": "^5.5.2",
|
||||
"@mantine/form": "^5.5.2",
|
||||
"@mantine/hooks": "^5.5.2",
|
||||
"@mantine/modals": "^5.5.2",
|
||||
"@mantine/next": "^5.5.2",
|
||||
"@mantine/notifications": "^5.5.2",
|
||||
"axios": "^0.26.1",
|
||||
"cookies-next": "^2.0.4",
|
||||
"@mantine/core": "^5.9.1",
|
||||
"@mantine/dropzone": "^5.9.1",
|
||||
"@mantine/form": "^5.9.1",
|
||||
"@mantine/hooks": "^5.9.1",
|
||||
"@mantine/modals": "^5.9.1",
|
||||
"@mantine/next": "^5.9.1",
|
||||
"@mantine/notifications": "^5.9.1",
|
||||
"axios": "^1.2.0",
|
||||
"cookies-next": "^2.1.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^4.8.1",
|
||||
"jose": "^4.11.1",
|
||||
"moment": "^2.29.4",
|
||||
"next": "^13.0.0",
|
||||
"next": "^13.0.6",
|
||||
"next-cookies": "^2.0.3",
|
||||
"next-http-proxy-middleware": "^1.2.4",
|
||||
"next-http-proxy-middleware": "^1.2.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-icons": "^4.7.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "17.0.23",
|
||||
"@types/react": "18.0.4",
|
||||
"@types/react-dom": "18.0.0",
|
||||
"axios": "^0.26.1",
|
||||
"dotenv-cli": "^6.0.0",
|
||||
"eslint": "8.13.0",
|
||||
"eslint-config-next": "^13.0.0",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.9",
|
||||
"axios": "^1.2.0",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-next": "^13.0.6",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"prettier": "^2.7.1",
|
||||
"tar": "^6.1.11",
|
||||
"typescript": "^4.6.3"
|
||||
"prettier": "^2.8.0",
|
||||
"tar": "^6.1.12",
|
||||
"typescript": "^4.9.3"
|
||||
}
|
||||
}
|
||||
|
|
93
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
93
frontend/src/components/admin/AdminConfigTable.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
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 = 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 (
|
||||
<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;
|
86
frontend/src/components/admin/ManageUserTable.tsx
Normal file
86
frontend/src/components/admin/ManageUserTable.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
|
||||
import User from "../../types/user.type";
|
||||
import showUpdateUserModal from "./showUpdateUserModal";
|
||||
|
||||
const ManageUserTable = ({
|
||||
users,
|
||||
getUsers,
|
||||
deleteUser,
|
||||
isLoading,
|
||||
}: {
|
||||
users: User[];
|
||||
getUsers: () => void;
|
||||
deleteUser: (user: User) => void;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "block", overflowX: "auto", whiteSpace: "nowrap" }}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Admin</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading
|
||||
? skeletonRows
|
||||
: users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>{user.isAdmin && <TbCheck />}</td>
|
||||
<td>
|
||||
<Group position="right">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
showUpdateUserModal(modals, user, getUsers)
|
||||
}
|
||||
>
|
||||
<TbEdit />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => deleteUser(user)}
|
||||
>
|
||||
<TbTrash />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const skeletonRows = [...Array(10)].map((v, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Skeleton key={i} height={20} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton key={i} height={20} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton key={i} height={20} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton key={i} height={20} />
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
export default ManageUserTable;
|
88
frontend/src/components/admin/showCreateUserModal.tsx
Normal file
88
frontend/src/components/admin/showCreateUserModal.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
Button,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
import userService from "../../services/user.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const showCreateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Create user</Title>,
|
||||
children: <Body modals={modals} getUsers={getUsers} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({
|
||||
modals,
|
||||
getUsers,
|
||||
}: {
|
||||
modals: ModalsContextProps;
|
||||
getUsers: () => void;
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
isAdmin: false,
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
password: yup.string().min(8),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
console.log(values);
|
||||
userService
|
||||
.create(values)
|
||||
.then(() => {
|
||||
getUsers();
|
||||
modals.closeAll();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput label="Username" {...form.getInputProps("username")} />
|
||||
<TextInput
|
||||
label="Email"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Switch
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Admin privileges"
|
||||
{...form.getInputProps("isAdmin", { type: "checkbox" })}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Create</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default showCreateUserModal;
|
|
@ -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(toast.axiosError);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default showUpdateConfigVariableModal;
|
130
frontend/src/components/admin/showUpdateUserModal.tsx
Normal file
130
frontend/src/components/admin/showUpdateUserModal.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
import userService from "../../services/user.service";
|
||||
import User from "../../types/user.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const showUpdateUserModal = (
|
||||
modals: ModalsContextProps,
|
||||
user: User,
|
||||
getUsers: () => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={5}>Update {user.username}</Title>,
|
||||
children: <Body user={user} modals={modals} getUsers={getUsers} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({
|
||||
user,
|
||||
modals,
|
||||
getUsers,
|
||||
}: {
|
||||
modals: ModalsContextProps;
|
||||
user: User;
|
||||
getUsers: () => void;
|
||||
}) => {
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
initialValues: {
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
password: yup.string().min(8),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<form
|
||||
id="accountForm"
|
||||
onSubmit={accountForm.onSubmit(async (values) => {
|
||||
userService
|
||||
.update(user.id, values)
|
||||
.then(() => {
|
||||
getUsers();
|
||||
modals.closeAll();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
{...accountForm.getInputProps("email")}
|
||||
/>
|
||||
<Switch
|
||||
mt="xs"
|
||||
labelPosition="left"
|
||||
label="Admin privileges"
|
||||
{...accountForm.getInputProps("isAdmin", { type: "checkbox" })}
|
||||
/>
|
||||
</Stack>
|
||||
</form>
|
||||
<Accordion>
|
||||
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
|
||||
<Accordion.Control>Change password</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit(async (values) => {
|
||||
userService
|
||||
.update(user.id, {
|
||||
password: values.password,
|
||||
})
|
||||
.then(() => toast.success("Password changed successfully"))
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Button variant="light" type="submit">
|
||||
Save new password
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Group position="right">
|
||||
<Button type="submit" form="accountForm">
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default showUpdateUserModal;
|
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(toast.axiosError);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
Welcome back
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
<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;
|
|
@ -9,23 +9,25 @@ import {
|
|||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import getConfig from "next/config";
|
||||
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 { publicRuntimeConfig } = getConfig();
|
||||
const SignUpForm = () => {
|
||||
const config = useConfig();
|
||||
|
||||
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
||||
const validationSchema = yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
username: yup.string().min(3).required(),
|
||||
password: yup.string().min(8).required(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
|
@ -34,14 +36,14 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
const signIn = (email: string, password: string) => {
|
||||
authService
|
||||
.signIn(email, password)
|
||||
.then(() => window.location.replace("/upload"))
|
||||
.catch((e) => toast.error(e.response.data.message));
|
||||
.then(() => window.location.replace("/"))
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
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));
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -53,33 +55,31 @@ const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
|
|||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
{mode == "signUp" ? "Sign up" : "Welcome back"}
|
||||
Sign up
|
||||
</Title>
|
||||
{publicRuntimeConfig.ALLOW_REGISTRATION == "true" && (
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
<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, TbUser } 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,19 @@ const ActionAvatar = () => {
|
|||
>
|
||||
My shares
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} href="/account" icon={<TbUser size={14} />}>
|
||||
My account
|
||||
</Menu.Item>
|
||||
{user!.isAdmin && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/admin"
|
||||
icon={<TbSettings size={14} />}
|
||||
>
|
||||
Administration
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
onClick={async () => {
|
||||
authService.signOut();
|
||||
|
|
|
@ -11,15 +11,13 @@ import {
|
|||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import getConfig from "next/config";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import Logo from "../Logo";
|
||||
import ActionAvatar from "./ActionAvatar";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const HEADER_HEIGHT = 60;
|
||||
|
||||
type NavLink = {
|
||||
|
@ -110,6 +108,8 @@ const useStyles = createStyles((theme) => ({
|
|||
|
||||
const NavBar = () => {
|
||||
const user = useUser();
|
||||
const config = useConfig();
|
||||
|
||||
const [opened, toggleOpened] = useDisclosure(false);
|
||||
|
||||
const authenticatedLinks = [
|
||||
|
@ -130,7 +130,7 @@ const NavBar = () => {
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicRuntimeConfig.SHOW_HOME_PAGE == "true")
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
{
|
||||
link: "/",
|
||||
|
@ -139,7 +139,7 @@ const NavBar = () => {
|
|||
...array,
|
||||
]);
|
||||
|
||||
if (publicRuntimeConfig.ALLOW_REGISTRATION == "true")
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
...array,
|
||||
{
|
||||
|
|
|
@ -13,23 +13,6 @@ const FileList = ({
|
|||
shareId: string;
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const skeletonRows = [...Array(5)].map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Skeleton height={30} width={30} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={14} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={14} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={25} width={25} />
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const rows = files.map((file) => (
|
||||
<tr key={file.name}>
|
||||
<td>{file.name}</td>
|
||||
|
@ -69,4 +52,21 @@ const FileList = ({
|
|||
);
|
||||
};
|
||||
|
||||
const skeletonRows = [...Array(5)].map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<Skeleton height={30} width={30} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={14} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={14} />
|
||||
</td>
|
||||
<td>
|
||||
<Skeleton height={25} width={25} />
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
export default FileList;
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import { Button, Center, createStyles, Group, Text } from "@mantine/core";
|
||||
import { Dropzone as MantineDropzone } from "@mantine/dropzone";
|
||||
import getConfig from "next/config";
|
||||
import { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
||||
import { TbCloudUpload, TbUpload } from "react-icons/tb";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import { FileUpload } from "../../types/File.type";
|
||||
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
|
@ -40,27 +38,31 @@ const Dropzone = ({
|
|||
isUploading: boolean;
|
||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
||||
const { classes } = useStyles();
|
||||
const openRef = useRef<() => void>();
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<MantineDropzone
|
||||
maxSize={parseInt(publicRuntimeConfig.MAX_FILE_SIZE!)}
|
||||
// Temporary fix for Dropzone issue (https://github.com/mantinedev/mantine/issues/3115)
|
||||
getFilesFromEvent={(e) => {
|
||||
return Promise.resolve([
|
||||
...((e.target as EventTarget & HTMLInputElement)?.files as any),
|
||||
]);
|
||||
}}
|
||||
maxSize={parseInt(config.get("MAX_FILE_SIZE"))}
|
||||
onReject={(e) => {
|
||||
toast.error(e[0].errors[0].message);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
openRef={openRef as ForwardedRef<() => void>}
|
||||
onDrop={(files) => {
|
||||
if (files.length > 100) {
|
||||
toast.error("You can't upload more than 100 files per share.");
|
||||
} else {
|
||||
const newFiles = files.map((file) => {
|
||||
(file as FileUpload).uploadingProgress = 0;
|
||||
return file as FileUpload;
|
||||
});
|
||||
setFiles(newFiles);
|
||||
}
|
||||
const newFiles = files.map((file) => {
|
||||
(file as FileUpload).uploadingProgress = 0;
|
||||
return file as FileUpload;
|
||||
});
|
||||
setFiles(newFiles);
|
||||
}}
|
||||
className={classes.dropzone}
|
||||
radius="md"
|
||||
|
@ -75,8 +77,7 @@ const Dropzone = ({
|
|||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||
Drag'n'drop files here to start your share. We can accept
|
||||
only files that are less than{" "}
|
||||
{byteStringToHumanSizeString(publicRuntimeConfig.MAX_FILE_SIZE)} in
|
||||
size.
|
||||
{byteStringToHumanSizeString(config.get("MAX_FILE_SIZE"))} in size.
|
||||
</Text>
|
||||
</div>
|
||||
</MantineDropzone>
|
||||
|
|
|
@ -18,7 +18,6 @@ import {
|
|||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import getConfig from "next/config";
|
||||
import { useState } from "react";
|
||||
import { TbAlertCircle } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
|
@ -26,11 +25,13 @@ import shareService from "../../../services/share.service";
|
|||
import { ShareSecurity } from "../../../types/share.type";
|
||||
import ExpirationPreview from "../ExpirationPreview";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
isSignedIn: boolean,
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
ALLOW_UNAUTHENTICATED_SHARES: boolean;
|
||||
ENABLE_EMAIL_RECIPIENTS: boolean;
|
||||
},
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: string,
|
||||
|
@ -42,7 +43,7 @@ const showCreateUploadModal = (
|
|||
title: <Title order={4}>Share</Title>,
|
||||
children: (
|
||||
<CreateUploadModalBody
|
||||
isSignedIn={isSignedIn}
|
||||
options={options}
|
||||
uploadCallback={uploadCallback}
|
||||
/>
|
||||
),
|
||||
|
@ -51,7 +52,7 @@ const showCreateUploadModal = (
|
|||
|
||||
const CreateUploadModalBody = ({
|
||||
uploadCallback,
|
||||
isSignedIn,
|
||||
options,
|
||||
}: {
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
|
@ -59,12 +60,16 @@ const CreateUploadModalBody = ({
|
|||
recipients: string[],
|
||||
security: ShareSecurity
|
||||
) => void;
|
||||
isSignedIn: boolean;
|
||||
options: {
|
||||
isUserSignedIn: boolean;
|
||||
ALLOW_UNAUTHENTICATED_SHARES: boolean;
|
||||
ENABLE_EMAIL_RECIPIENTS: boolean;
|
||||
};
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
|
||||
const [showNotSignedInAlert, setShowNotSignedInAlert] = useState(
|
||||
publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true"
|
||||
options.ENABLE_EMAIL_RECIPIENTS
|
||||
);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
|
@ -93,7 +98,7 @@ const CreateUploadModalBody = ({
|
|||
});
|
||||
return (
|
||||
<Group>
|
||||
{showNotSignedInAlert && !isSignedIn && (
|
||||
{showNotSignedInAlert && !options.isUserSignedIn && (
|
||||
<Alert
|
||||
withCloseButton
|
||||
onClose={() => setShowNotSignedInAlert(false)}
|
||||
|
@ -225,7 +230,7 @@ const CreateUploadModalBody = ({
|
|||
{ExpirationPreview({ form })}
|
||||
</Text>
|
||||
<Accordion>
|
||||
{publicRuntimeConfig.EMAIL_RECIPIENTS_ENABLED == "true" && (
|
||||
{options.ENABLE_EMAIL_RECIPIENTS && (
|
||||
<Accordion.Item value="recipients" sx={{ borderBottom: "none" }}>
|
||||
<Accordion.Control>Email recipients</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
|
|
14
frontend/src/hooks/config.hook.ts
Normal file
14
frontend/src/hooks/config.hook.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import configService from "../services/config.service";
|
||||
import Config from "../types/config.type";
|
||||
|
||||
export const ConfigContext = createContext<Config[] | null>(null);
|
||||
|
||||
const useConfig = () => {
|
||||
const configVariables = useContext(ConfigContext) as Config[];
|
||||
return {
|
||||
get: (key: string) => configService.get(key, configVariables),
|
||||
};
|
||||
};
|
||||
|
||||
export default useConfig;
|
|
@ -8,25 +8,33 @@ 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 useConfig, { ConfigContext } from "../hooks/config.hook";
|
||||
import { UserContext } from "../hooks/user.hook";
|
||||
import authService from "../services/auth.service";
|
||||
import configService from "../services/config.service";
|
||||
import userService from "../services/user.service";
|
||||
import GlobalStyle from "../styles/global.style";
|
||||
import globalStyle from "../styles/mantine.style";
|
||||
import Config from "../types/config.type";
|
||||
import { CurrentUser } from "../types/user.type";
|
||||
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 [configVariables, setConfigVariables] = useState<Config[] | null>(null);
|
||||
|
||||
const getInitalData = async () => {
|
||||
setIsLoading(true);
|
||||
setConfigVariables(await configService.list());
|
||||
await authService.refreshAccessToken();
|
||||
setUser(await userService.getCurrentUser());
|
||||
setIsLoading(false);
|
||||
|
@ -37,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]);
|
||||
|
@ -54,13 +72,15 @@ function App({ Component, pageProps }: AppProps) {
|
|||
{isLoading ? (
|
||||
<LoadingOverlay visible overlayOpacity={1} />
|
||||
) : (
|
||||
<UserContext.Provider value={user}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>
|
||||
<ConfigContext.Provider value={configVariables}>
|
||||
<UserContext.Provider value={user}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</UserContext.Provider>{" "}
|
||||
</ConfigContext.Provider>
|
||||
)}
|
||||
</GlobalLoadingContext.Provider>
|
||||
</ModalsProvider>
|
||||
|
@ -69,9 +89,4 @@ function App({ Component, pageProps }: AppProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// Opts out of static site generation to use publicRuntimeConfig
|
||||
App.getInitialProps = () => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
153
frontend/src/pages/account/index.tsx
Normal file
153
frontend/src/pages/account/index.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useRouter } from "next/router";
|
||||
import * as yup from "yup";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import userService from "../../services/user.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Account = () => {
|
||||
const user = useUser();
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
username: user?.username,
|
||||
email: user?.email,
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email(),
|
||||
username: yup.string().min(3),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const passwordForm = useForm({
|
||||
initialValues: {
|
||||
oldPassword: "",
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
oldPassword: yup.string().min(8),
|
||||
password: yup.string().min(8),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="sm">
|
||||
<Title order={3} mb="xs">
|
||||
My account
|
||||
</Title>
|
||||
<Paper withBorder p="xl">
|
||||
<Title order={5} mb="xs">
|
||||
Account Info
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={accountForm.onSubmit((values) =>
|
||||
userService
|
||||
.updateCurrentUser({
|
||||
username: values.username,
|
||||
email: values.email,
|
||||
})
|
||||
.then(() => toast.success("User updated successfully"))
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
{...accountForm.getInputProps("email")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
Password
|
||||
</Title>
|
||||
<form
|
||||
onSubmit={passwordForm.onSubmit((values) =>
|
||||
authService
|
||||
.updatePassword(values.oldPassword, values.password)
|
||||
.then(() => {
|
||||
toast.success("Password updated successfully");
|
||||
passwordForm.reset();
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label="Old password"
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
{...passwordForm.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right">
|
||||
<Button type="submit">Save</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
<Center mt={80}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
modals.openConfirmModal({
|
||||
title: "Account deletion",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete your account including all your
|
||||
active shares?
|
||||
</Text>
|
||||
),
|
||||
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
await userService.removeCurrentUser();
|
||||
window.location.reload();
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Account;
|
16
frontend/src/pages/admin/config.tsx
Normal file
16
frontend/src/pages/admin/config.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Space, Title } from "@mantine/core";
|
||||
import AdminConfigTable from "../../components/admin/AdminConfigTable";
|
||||
|
||||
const AdminConfig = () => {
|
||||
return (
|
||||
<>
|
||||
<Title mb={30} order={3}>
|
||||
Configuration
|
||||
</Title>
|
||||
<AdminConfigTable />
|
||||
<Space h="xl" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminConfig;
|
62
frontend/src/pages/admin/index.tsx
Normal file
62
frontend/src/pages/admin/index.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { Col, Container, createStyles, Grid, Paper, Text } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { TbSettings, TbUsers } from "react-icons/tb";
|
||||
|
||||
const managementOptions = [
|
||||
{
|
||||
title: "User management",
|
||||
icon: TbUsers,
|
||||
route: "/admin/users",
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
icon: TbSettings,
|
||||
route: "/admin/config",
|
||||
},
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: 90,
|
||||
"&:hover": {
|
||||
boxShadow: `${theme.shadows.sm} !important`,
|
||||
transform: "scale(1.01)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const Admin = () => {
|
||||
const { classes, theme } = useStyles();
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Paper withBorder radius="md" p={40}>
|
||||
<Grid mt="md">
|
||||
{managementOptions.map((item) => {
|
||||
return (
|
||||
<Col xs={6} key={item.route}>
|
||||
<Paper
|
||||
withBorder
|
||||
component={Link}
|
||||
href={item.route}
|
||||
key={item.title}
|
||||
className={classes.item}
|
||||
>
|
||||
<item.icon color={theme.colors.victoria[5]} size={35} />
|
||||
<Text mt={7}>{item.title}</Text>
|
||||
</Paper>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
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("SETUP_FINISHED")) {
|
||||
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;
|
73
frontend/src/pages/admin/users.tsx
Normal file
73
frontend/src/pages/admin/users.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { Button, Group, Space, Text, Title } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TbPlus } from "react-icons/tb";
|
||||
import ManageUserTable from "../../components/admin/ManageUserTable";
|
||||
import showCreateUserModal from "../../components/admin/showCreateUserModal";
|
||||
import userService from "../../services/user.service";
|
||||
import User from "../../types/user.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Users = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const modals = useModals();
|
||||
|
||||
const getUsers = () => {
|
||||
setIsLoading(true);
|
||||
userService.list().then((users) => {
|
||||
setUsers(users);
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = (user: User) => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${user.username}?`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Do you really want to delete <b>{user.username}</b> and all his
|
||||
shares?
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
userService
|
||||
.remove(user.id)
|
||||
.then(() => setUsers(users.filter((v) => v.id != user.id)))
|
||||
.catch(toast.axiosError);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group position="apart" align="baseline" mb={20}>
|
||||
<Title mb={30} order={3}>
|
||||
User management
|
||||
</Title>
|
||||
<Button
|
||||
onClick={() => showCreateUserModal(modals, getUsers)}
|
||||
leftIcon={<TbPlus size={20} />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ManageUserTable
|
||||
users={users}
|
||||
getUsers={getUsers}
|
||||
deleteUser={deleteUser}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Space h="xl" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
|
@ -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,23 +1,22 @@
|
|||
import getConfig from "next/config";
|
||||
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";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const SignUp = () => {
|
||||
const config = useConfig();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
} else if (publicRuntimeConfig.ALLOW_REGISTRATION == "false") {
|
||||
} else if (config.get("ALLOW_REGISTRATION") == "false") {
|
||||
router.replace("/auth/signIn");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Sign Up" />
|
||||
<AuthForm mode="signUp" />
|
||||
<SignUpForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,16 +8,14 @@ import {
|
|||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import getConfig from "next/config";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbCheck } from "react-icons/tb";
|
||||
import Meta from "../components/Meta";
|
||||
import useConfig from "../hooks/config.hook";
|
||||
import useUser from "../hooks/user.hook";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
inner: {
|
||||
display: "flex",
|
||||
|
@ -71,13 +69,14 @@ const useStyles = createStyles((theme) => ({
|
|||
}));
|
||||
|
||||
export default function Home() {
|
||||
const config = useConfig();
|
||||
const user = useUser();
|
||||
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
if (user || publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "true") {
|
||||
if (user || config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
router.replace("/upload");
|
||||
} else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") {
|
||||
} else if (!config.get("SHOW_HOME_PAGE")) {
|
||||
router.replace("/auth/signIn");
|
||||
} else {
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Button, Group } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import axios from "axios";
|
||||
import getConfig from "next/config";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import Meta from "../components/Meta";
|
||||
|
@ -9,13 +8,13 @@ import Dropzone from "../components/upload/Dropzone";
|
|||
import FileList from "../components/upload/FileList";
|
||||
import showCompletedUploadModal from "../components/upload/modals/showCompletedUploadModal";
|
||||
import showCreateUploadModal from "../components/upload/modals/showCreateUploadModal";
|
||||
import useConfig from "../hooks/config.hook";
|
||||
import useUser from "../hooks/user.hook";
|
||||
import shareService from "../services/share.service";
|
||||
import { FileUpload } from "../types/File.type";
|
||||
import { ShareSecurity } from "../types/share.type";
|
||||
import toast from "../utils/toast.util";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
let share: any;
|
||||
|
||||
const Upload = () => {
|
||||
|
@ -23,6 +22,7 @@ const Upload = () => {
|
|||
const modals = useModals();
|
||||
|
||||
const user = useUser();
|
||||
const config = useConfig();
|
||||
const [files, setFiles] = useState<FileUpload[]>([]);
|
||||
const [isUploading, setisUploading] = useState(false);
|
||||
|
||||
|
@ -95,7 +95,7 @@ const Upload = () => {
|
|||
}
|
||||
}
|
||||
}, [files]);
|
||||
if (!user && publicRuntimeConfig.ALLOW_UNAUTHENTICATED_SHARES == "false") {
|
||||
if (!user && !config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return (
|
||||
|
@ -106,7 +106,19 @@ const Upload = () => {
|
|||
loading={isUploading}
|
||||
disabled={files.length <= 0}
|
||||
onClick={() =>
|
||||
showCreateUploadModal(modals, user ? true : false, uploadFiles)
|
||||
showCreateUploadModal(
|
||||
modals,
|
||||
{
|
||||
isUserSignedIn: user ? true : false,
|
||||
ALLOW_UNAUTHENTICATED_SHARES: config.get(
|
||||
"ALLOW_UNAUTHENTICATED_SHARES"
|
||||
),
|
||||
ENABLE_EMAIL_RECIPIENTS: config.get(
|
||||
"ENABLE_EMAIL_RECIPIENTS"
|
||||
),
|
||||
},
|
||||
uploadFiles
|
||||
)
|
||||
}
|
||||
>
|
||||
Share
|
||||
|
|
|
@ -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 = () => {
|
||||
|
@ -37,9 +44,14 @@ const refreshAccessToken = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const updatePassword = async (oldPassword: string, password: string) => {
|
||||
await api.patch("/auth/password", { oldPassword, password });
|
||||
};
|
||||
|
||||
export default {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
refreshAccessToken,
|
||||
updatePassword,
|
||||
};
|
||||
|
|
43
frontend/src/services/config.service.ts
Normal file
43
frontend/src/services/config.service.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import Config, { AdminConfig } from "../types/config.type";
|
||||
import api from "./api.service";
|
||||
|
||||
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];
|
||||
|
||||
if (!configVariable) throw new Error(`Config variable ${key} not found`);
|
||||
|
||||
if (configVariable.type == "number") return parseInt(configVariable.value);
|
||||
if (configVariable.type == "boolean") return configVariable.value == "true";
|
||||
if (configVariable.type == "string") return configVariable.value;
|
||||
};
|
||||
|
||||
const finishSetup = async (): Promise<AdminConfig[]> => {
|
||||
return (await api.post("/configs/admin/finishSetup")).data;
|
||||
};
|
||||
|
||||
export default {
|
||||
list,
|
||||
listForAdmin,
|
||||
update,
|
||||
get,
|
||||
finishSetup,
|
||||
};
|
|
@ -80,7 +80,7 @@ const uploadFile = async (
|
|||
const response = await api.post(`shares/${shareId}/files`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const uploadingProgress = Math.round(
|
||||
(100 * progressEvent.loaded) / progressEvent.total
|
||||
(100 * progressEvent.loaded) / (progressEvent.total ?? 1)
|
||||
);
|
||||
if (uploadingProgress < 100) progressCallBack(uploadingProgress);
|
||||
},
|
||||
|
|
|
@ -1,7 +1,36 @@
|
|||
import { CurrentUser } from "../types/user.type";
|
||||
import {
|
||||
CreateUser,
|
||||
CurrentUser,
|
||||
UpdateCurrentUser,
|
||||
UpdateUser,
|
||||
} from "../types/user.type";
|
||||
import api from "./api.service";
|
||||
import authService from "./auth.service";
|
||||
|
||||
const list = async () => {
|
||||
return (await api.get("/users")).data;
|
||||
};
|
||||
|
||||
const create = async (user: CreateUser) => {
|
||||
return (await api.post("/users", user)).data;
|
||||
};
|
||||
|
||||
const update = async (id: string, user: UpdateUser) => {
|
||||
return (await api.patch(`/users/${id}`, user)).data;
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await api.delete(`/users/${id}`);
|
||||
};
|
||||
|
||||
const updateCurrentUser = async (user: UpdateCurrentUser) => {
|
||||
return (await api.patch("/users/me", user)).data;
|
||||
};
|
||||
|
||||
const removeCurrentUser = async () => {
|
||||
await api.delete("/users/me");
|
||||
};
|
||||
|
||||
const getCurrentUser = async (): Promise<CurrentUser | null> => {
|
||||
try {
|
||||
await authService.refreshAccessToken();
|
||||
|
@ -12,5 +41,11 @@ const getCurrentUser = async (): Promise<CurrentUser | null> => {
|
|||
};
|
||||
|
||||
export default {
|
||||
list,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
getCurrentUser,
|
||||
updateCurrentUser,
|
||||
removeCurrentUser,
|
||||
};
|
||||
|
|
13
frontend/src/types/config.type.ts
Normal file
13
frontend/src/types/config.type.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
type Config = {
|
||||
key: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type AdminConfig = Config & {
|
||||
updatedAt: Date;
|
||||
secret: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export default Config;
|
|
@ -1,8 +1,29 @@
|
|||
export default interface User {
|
||||
type User = {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export interface CurrentUser extends User {}
|
||||
export type CreateUser = {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateUser = {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateCurrentUser = {
|
||||
username?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type CurrentUser = User & {};
|
||||
|
||||
export default User;
|
||||
|
|
|
@ -10,6 +10,9 @@ const error = (message: string) =>
|
|||
message: message,
|
||||
});
|
||||
|
||||
const axiosError = (axiosError: any) =>
|
||||
error(axiosError?.response?.data?.message ?? "An unknown error occured");
|
||||
|
||||
const success = (message: string) =>
|
||||
showNotification({
|
||||
icon: <TbCheck />,
|
||||
|
@ -22,5 +25,6 @@ const success = (message: string) =>
|
|||
const toast = {
|
||||
error,
|
||||
success,
|
||||
axiosError,
|
||||
};
|
||||
export default toast;
|
||||
|
|
Loading…
Add table
Reference in a new issue