feat!: reset password with email

This commit is contained in:
Elias Schneider
2023-02-09 18:17:53 +01:00
parent 8ab359b71d
commit 5d1a7f0310
20 changed files with 459 additions and 156 deletions

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,