mirror of
https://github.com/stonith404/pingvin-share.git
synced 2025-02-19 01:55:48 -05:00
feat!: reset password with email
This commit is contained in:
parent
8ab359b71d
commit
5d1a7f0310
20 changed files with 459 additions and 156 deletions
|
@ -0,0 +1,14 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ResetPasswordToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Disable TOTP as secret isn't encrypted anymore
|
||||
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");
|
|
@ -22,9 +22,10 @@ model User {
|
|||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
|
@ -49,6 +50,16 @@ model LoginToken {
|
|||
used Boolean @default(false)
|
||||
}
|
||||
|
||||
model ResetPasswordToken {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
@ -21,15 +21,6 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "internal",
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
order: 0,
|
||||
key: "TOTP_SECRET",
|
||||
description: "A 16 byte random string used to generate TOTP secrets",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(16).toString("base64"),
|
||||
category: "internal",
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
key: "APP_URL",
|
||||
|
@ -89,6 +80,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
},
|
||||
{
|
||||
order: 7,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
|
@ -98,16 +98,16 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
order: 9,
|
||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
value: "Reverse share link used",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
order: 10,
|
||||
key: "REVERSE_SHARE_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
|
@ -117,16 +117,27 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
||||
order: 11,
|
||||
key: "RESET_PASSWORD_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
value: "Reverse share link used",
|
||||
value: "Pingvin Share password reset",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
order: 12,
|
||||
key: "RESET_PASSWORD_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
||||
category: "email",
|
||||
},
|
||||
|
||||
{
|
||||
order: 13,
|
||||
key: "SMTP_ENABLED",
|
||||
description:
|
||||
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||
|
@ -136,7 +147,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 12,
|
||||
order: 14,
|
||||
key: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
|
@ -144,7 +155,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 13,
|
||||
order: 15,
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
|
@ -152,7 +163,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 14,
|
||||
order: 16,
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
|
@ -160,7 +171,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 15,
|
||||
order: 17,
|
||||
key: "SMTP_USERNAME",
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
|
@ -168,7 +179,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
|||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 16,
|
||||
order: 18,
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
|
@ -21,6 +22,7 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
|||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
import { EnableTotpDTO } from "./dto/enableTotp.dto";
|
||||
import { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||
import { TokenDTO } from "./dto/token.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||
|
@ -34,8 +36,8 @@ export class AuthController {
|
|||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
@Throttle(10, 5 * 60)
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
|
@ -54,8 +56,8 @@ export class AuthController {
|
|||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
|
@ -74,8 +76,8 @@ export class AuthController {
|
|||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn/totp")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
|
@ -92,6 +94,20 @@ export class AuthController {
|
|||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
return await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
imports: [JwtModule.register({}), EmailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
|
|
|
@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
|||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
|
@ -19,7 +20,8 @@ export class AuthService {
|
|||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
|
@ -87,6 +89,50 @@ export class AuthService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async requestResetPassword(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
include: { resetPasswordToken: true },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token: user.resetPasswordToken.token },
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await this.prisma.resetPasswordToken.create({
|
||||
data: {
|
||||
expiresAt: moment().add(1, "hour").toDate(),
|
||||
user: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { resetPasswordToken: { token } },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||
|
||||
const newPasswordHash = await argon.hash(newPassword);
|
||||
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newPasswordHash },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
|
|
@ -6,10 +6,8 @@ import {
|
|||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
|
@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
|||
@Injectable()
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
@ -57,9 +54,7 @@ export class AuthTotpService {
|
|||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (dto.totp !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
|
@ -81,41 +76,6 @@ export class AuthTotpService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
encryptTotpSecret(totpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
|
||||
let encrypted = cipher.update(totpSecret);
|
||||
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
@ -132,7 +92,6 @@ export class AuthTotpService {
|
|||
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const secret = authenticator.generateSecret();
|
||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
|
@ -144,7 +103,7 @@ export class AuthTotpService {
|
|||
where: { id: user.id },
|
||||
data: {
|
||||
totpEnabled: true,
|
||||
totpSecret: encryptedSecret,
|
||||
totpSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -177,9 +136,7 @@ export class AuthTotpService {
|
|||
throw new BadRequestException("TOTP is not in progress");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
|
@ -208,9 +165,7 @@ export class AuthTotpService {
|
|||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
|
|
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
|
@ -58,6 +58,21 @@ export class EmailService {
|
|||
});
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("RESET_PASSWORD_EMAIL_MESSAGE")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
|
|
|
@ -4,7 +4,6 @@ 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 {
|
||||
|
|
|
@ -35,6 +35,12 @@ const AdminConfigTable = () => {
|
|||
UpdateConfig[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.get("SETUP_STATUS") != "FINISHED") {
|
||||
config.refresh();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConfigVariable = (configVariable: UpdateConfig) => {
|
||||
const index = updatedConfigVariables.findIndex(
|
||||
(item) => item.key === configVariable.key
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
|
@ -91,13 +92,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Welcome back
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
|
@ -118,7 +113,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||
>
|
||||
<TextInput
|
||||
label="Email or username"
|
||||
placeholder="you@email.com"
|
||||
placeholder="Your email or username"
|
||||
{...form.getInputProps("emailOrUsername")}
|
||||
/>
|
||||
<PasswordInput
|
||||
|
@ -136,6 +131,13 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
|||
{...form.getInputProps("totp")}
|
||||
/>
|
||||
)}
|
||||
{config.get("SMTP_ENABLED") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<Button fullWidth mt="xl" type="submit">
|
||||
Sign in
|
||||
</Button>
|
||||
|
|
|
@ -49,13 +49,7 @@ const SignUpForm = () => {
|
|||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title
|
||||
align="center"
|
||||
sx={(theme) => ({
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontWeight: 900,
|
||||
})}
|
||||
>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
Sign up
|
||||
</Title>
|
||||
{config.get("ALLOW_REGISTRATION") && (
|
||||
|
@ -74,12 +68,12 @@ const SignUpForm = () => {
|
|||
>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="john.doe"
|
||||
placeholder="Your username"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="you@email.com"
|
||||
placeholder="Your email"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
@ -109,11 +110,18 @@ const useStyles = createStyles((theme) => ({
|
|||
|
||||
const NavBar = () => {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [opened, toggleOpened] = useDisclosure(false);
|
||||
|
||||
const authenticatedLinks = [
|
||||
const [currentRoute, setCurrentRoute] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentRoute(router.pathname);
|
||||
}, [router.pathname]);
|
||||
|
||||
const authenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
|
@ -126,32 +134,31 @@ const NavBar = () => {
|
|||
},
|
||||
];
|
||||
|
||||
const [unauthenticatedLinks, setUnauthenticatedLinks] = useState<NavLink[]>([
|
||||
let unauthenticatedLinks: NavLink[] = [
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
{
|
||||
link: "/",
|
||||
label: "Home",
|
||||
},
|
||||
...array,
|
||||
]);
|
||||
if (config.get("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
});
|
||||
}
|
||||
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
setUnauthenticatedLinks((array) => [
|
||||
...array,
|
||||
{
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
if (config.get("SHOW_HOME_PAGE"))
|
||||
unauthenticatedLinks.unshift({
|
||||
link: "/",
|
||||
label: "Home",
|
||||
});
|
||||
|
||||
if (config.get("ALLOW_REGISTRATION"))
|
||||
unauthenticatedLinks.push({
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
});
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
const items = (
|
||||
|
@ -170,9 +177,7 @@ const NavBar = () => {
|
|||
href={link.link ?? ""}
|
||||
onClick={() => toggleOpened.toggle()}
|
||||
className={cx(classes.link, {
|
||||
[classes.linkActive]:
|
||||
typeof window != "undefined" &&
|
||||
window.location.pathname == link.link,
|
||||
[classes.linkActive]: currentRoute == link.link,
|
||||
})}
|
||||
>
|
||||
{link.label}
|
||||
|
|
|
@ -4,14 +4,14 @@ import { ConfigHook } from "../types/config.type";
|
|||
|
||||
export const ConfigContext = createContext<ConfigHook>({
|
||||
configVariables: [],
|
||||
refresh: () => {},
|
||||
refresh: async () => {},
|
||||
});
|
||||
|
||||
const useConfig = () => {
|
||||
const configContext = useContext(ConfigContext);
|
||||
return {
|
||||
get: (key: string) => configService.get(key, configContext.configVariables),
|
||||
refresh: () => configContext.refresh(),
|
||||
refresh: async () => configContext.refresh(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -12,6 +12,15 @@ export const config = {
|
|||
};
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const routes = {
|
||||
unauthenticated: new Routes(["/auth/signIn", "/auth/resetPassword*", "/"]),
|
||||
public: new Routes(["/share/*", "/upload/*"]),
|
||||
setupStatusRegistered: new Routes(["/auth/*", "/admin/setup"]),
|
||||
admin: new Routes(["/admin/*"]),
|
||||
account: new Routes(["/account/*"]),
|
||||
disabledRoutes: new Routes([]),
|
||||
};
|
||||
|
||||
// Get config from backend
|
||||
const config = await (
|
||||
await fetch("http://localhost:8080/api/configs")
|
||||
|
@ -21,14 +30,6 @@ export async function middleware(request: NextRequest) {
|
|||
return configService.get(key, config);
|
||||
};
|
||||
|
||||
const containsRoute = (routes: string[], url: string) => {
|
||||
for (const route of routes) {
|
||||
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(url))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const route = request.nextUrl.pathname;
|
||||
let user: { isAdmin: boolean } | null = null;
|
||||
const accessToken = request.cookies.get("access_token")?.value;
|
||||
|
@ -44,57 +45,51 @@ export async function middleware(request: NextRequest) {
|
|||
user = null;
|
||||
}
|
||||
|
||||
const unauthenticatedRoutes = ["/auth/signIn", "/"];
|
||||
let publicRoutes = ["/share/*", "/upload/*"];
|
||||
const setupStatusRegisteredRoutes = ["/auth/*", "/admin/setup"];
|
||||
const adminRoutes = ["/admin/*"];
|
||||
const accountRoutes = ["/account/*"];
|
||||
|
||||
if (getConfig("ALLOW_REGISTRATION")) {
|
||||
unauthenticatedRoutes.push("/auth/signUp");
|
||||
if (!getConfig("ALLOW_REGISTRATION")) {
|
||||
routes.disabledRoutes.routes.push("/auth/signUp");
|
||||
}
|
||||
|
||||
if (getConfig("ALLOW_UNAUTHENTICATED_SHARES")) {
|
||||
publicRoutes = ["*"];
|
||||
routes.public.routes = ["*"];
|
||||
}
|
||||
|
||||
const isPublicRoute = containsRoute(publicRoutes, route);
|
||||
const isUnauthenticatedRoute = containsRoute(unauthenticatedRoutes, route);
|
||||
const isAdminRoute = containsRoute(adminRoutes, route);
|
||||
const isAccountRoute = containsRoute(accountRoutes, route);
|
||||
const isSetupStatusRegisteredRoute = containsRoute(
|
||||
setupStatusRegisteredRoutes,
|
||||
route
|
||||
);
|
||||
if (!getConfig("SMTP_ENABLED")) {
|
||||
routes.disabledRoutes.routes.push("/auth/resetPassword*");
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
const rules = [
|
||||
// Disabled routes
|
||||
{
|
||||
condition: routes.disabledRoutes.contains(route),
|
||||
path: "/",
|
||||
},
|
||||
// Setup status
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "STARTED" && route != "/auth/signUp",
|
||||
path: "/auth/signUp",
|
||||
},
|
||||
{
|
||||
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !isSetupStatusRegisteredRoute,
|
||||
condition: getConfig("SETUP_STATUS") == "REGISTERED" && !routes.setupStatusRegistered.contains(route),
|
||||
path: user ? "/admin/setup" : "/auth/signIn",
|
||||
},
|
||||
// Authenticated state
|
||||
{
|
||||
condition: user && isUnauthenticatedRoute,
|
||||
condition: user && routes.unauthenticated.contains(route) && !getConfig("ALLOW_UNAUTHENTICATED_SHARES"),
|
||||
path: "/upload",
|
||||
},
|
||||
// Unauthenticated state
|
||||
{
|
||||
condition: !user && !isPublicRoute && !isUnauthenticatedRoute,
|
||||
condition: !user && !routes.public.contains(route) && !routes.unauthenticated.contains(route),
|
||||
path: "/auth/signIn",
|
||||
},
|
||||
{
|
||||
condition: !user && isAccountRoute,
|
||||
condition: !user && routes.account.contains(route),
|
||||
path: "/upload",
|
||||
},
|
||||
// Admin privileges
|
||||
{
|
||||
condition: isAdminRoute && !user?.isAdmin,
|
||||
condition: routes.admin.contains(route) && !user?.isAdmin,
|
||||
path: "/upload",
|
||||
},
|
||||
// Home page
|
||||
|
@ -103,7 +98,6 @@ export async function middleware(request: NextRequest) {
|
|||
path: "/upload",
|
||||
},
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.condition) {
|
||||
let { path } = rule;
|
||||
|
@ -115,3 +109,17 @@ export async function middleware(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class to check if a route matches a list of routes
|
||||
class Routes {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
constructor(public routes: string[]) {}
|
||||
|
||||
contains(_route: string) {
|
||||
for (const route of this.routes) {
|
||||
if (new RegExp("^" + route.replace(/\*/g, ".*") + "$").test(_route))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useRouter } from "next/router";
|
||||
import * as yup from "yup";
|
||||
import authService from "../../../services/auth.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
control: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const ResetPassword = () => {
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
password: yup.string().min(8).required(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const resetPasswordToken = router.query.resetPasswordToken as string;
|
||||
|
||||
return (
|
||||
<Container size={460} my={30}>
|
||||
<Title order={2} weight={900} align="center">
|
||||
Reset password
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" align="center">
|
||||
Enter your new password
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
console.log(resetPasswordToken);
|
||||
authService
|
||||
.resetPassword(resetPasswordToken, values.password)
|
||||
.then(() => {
|
||||
toast.success("Your password has been reset successfully.");
|
||||
|
||||
router.push("/auth/signIn");
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
})}
|
||||
>
|
||||
<PasswordInput
|
||||
label="New password"
|
||||
placeholder="••••••••••"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Group position="right" mt="lg">
|
||||
<Button type="submit" className={classes.control}>
|
||||
Reset password
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
107
frontend/src/pages/auth/resetPassword/index.tsx
Normal file
107
frontend/src/pages/auth/resetPassword/index.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { TbArrowLeft } from "react-icons/tb";
|
||||
import * as yup from "yup";
|
||||
import authService from "../../../services/auth.service";
|
||||
import toast from "../../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
title: {
|
||||
fontSize: 26,
|
||||
fontWeight: 900,
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
},
|
||||
|
||||
controls: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
flexDirection: "column-reverse",
|
||||
},
|
||||
},
|
||||
|
||||
control: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const ResetPassword = () => {
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
email: yup.string().email().required(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size={460} my={30}>
|
||||
<Title order={2} weight={900} align="center">
|
||||
Forgot your password?
|
||||
</Title>
|
||||
<Text color="dimmed" size="sm" align="center">
|
||||
Enter your email to get a reset link
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} radius="md" mt="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
authService
|
||||
.requestResetPassword(values.email)
|
||||
.then(() => {
|
||||
toast.success("The email has been sent.");
|
||||
router.push("/auth/signIn");
|
||||
})
|
||||
.catch(toast.axiosError)
|
||||
)}
|
||||
>
|
||||
<TextInput
|
||||
label="Your email"
|
||||
placeholder="Your email"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<Group position="apart" mt="lg" className={classes.controls}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
color="dimmed"
|
||||
size="sm"
|
||||
className={classes.control}
|
||||
href={"/auth/signIn"}
|
||||
>
|
||||
<Center inline>
|
||||
<TbArrowLeft size={12} />
|
||||
<Box ml={5}>Back to login page</Box>
|
||||
</Center>
|
||||
</Anchor>
|
||||
<Button type="submit" className={classes.control}>
|
||||
Reset password
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
|
@ -10,8 +10,11 @@ import {
|
|||
} from "@mantine/core";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { TbCheck } from "react-icons/tb";
|
||||
import Meta from "../components/Meta";
|
||||
import useUser from "../hooks/user.hook";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
inner: {
|
||||
|
@ -67,6 +70,17 @@ const useStyles = createStyles((theme) => ({
|
|||
|
||||
export default function Home() {
|
||||
const { classes } = useStyles();
|
||||
const { refreshUser } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
// If the user is already logged in, redirect to the upload page
|
||||
useEffect(() => {
|
||||
refreshUser().then((user) => {
|
||||
if (user) {
|
||||
router.replace("/upload");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -60,6 +60,14 @@ const refreshAccessToken = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const requestResetPassword = async (email: string) => {
|
||||
await api.post(`/auth/resetPassword/${email}`);
|
||||
};
|
||||
|
||||
const resetPassword = async (token: string, password: string) => {
|
||||
await api.post("/auth/resetPassword", { token, password });
|
||||
};
|
||||
|
||||
const updatePassword = async (oldPassword: string, password: string) => {
|
||||
await api.patch("/auth/password", { oldPassword, password });
|
||||
};
|
||||
|
@ -95,6 +103,8 @@ export default {
|
|||
signOut,
|
||||
refreshAccessToken,
|
||||
updatePassword,
|
||||
requestResetPassword,
|
||||
resetPassword,
|
||||
enableTOTP,
|
||||
verifyTOTP,
|
||||
disableTOTP,
|
||||
|
|
Loading…
Add table
Reference in a new issue