0
Fork 0
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:
Elias Schneider 2023-02-09 18:17:53 +01:00
parent 8ab359b71d
commit 5d1a7f0310
No known key found for this signature in database
GPG key ID: 07E623B294202B6C
20 changed files with 459 additions and 156 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")}
/>

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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