Skip to content

Commit

Permalink
feat(client): added ability to reset password
Browse files Browse the repository at this point in the history
  • Loading branch information
Ivo committed May 4, 2023
1 parent 1561325 commit c780d68
Show file tree
Hide file tree
Showing 13 changed files with 472 additions and 70 deletions.
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
},
"[prisma]": {
"editor.defaultFormatter": "Prisma.prisma",
"editor.formatOnSave": true
},
"css.lint.unknownAtRules": "ignore",
"python.formatting.provider": "none",
"typescript.preferences.importModuleSpecifier": "non-relative",
Expand Down
28 changes: 19 additions & 9 deletions apps/client/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ datasource db {
}

generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
}

enum Role {
Expand All @@ -13,11 +13,21 @@ enum Role {
}

model User {
id String @id @default(cuid())
email String @unique
username String @unique
password String
name String?
active Boolean @default(false)
role Role @default(USER)
}
id String @id @default(cuid())
email String @unique
username String @unique
password String
name String?
active Boolean @default(false)
role Role @default(USER)
passwordResets UserPasswordResets[]
}

model UserPasswordResets {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime @default(dbgenerated("NOW() + interval '10 min'"))
completed Boolean @default(false)
createdAt DateTime @default(now())
}
31 changes: 31 additions & 0 deletions apps/client/public/locales/en-US/reset-password.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"reset-form": {
"title": "Reset Password",
"password": {
"title": "New Password",
"placeholder": "******",
"errors": {
"min-length": "Your password must be at least 6 characters long"
}
},
"errors": {
"failed": "We couldn't change your password. Either this link is expired and/or already used or the user to which this link was issued to doesn't exist."
},
"reseted": "Your password has been reset. You can now sign in and continue using Visual Dynamics"
},
"request-form": {
"title": "Request Password Reset Link",
"identifier": {
"title": "Username or Email",
"placeholder": "user@uni.edu",
"errors": {
"empty": "This field is required."
}
},
"errors": {
"no-user": "Couldn't find an user/account with this username/email"
},
"success": "We've mailed you a reset password link. It'll be valid for 10 minutes only."
},
"title": "Reset Password"
}
55 changes: 3 additions & 52 deletions apps/client/src/components/Container/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,13 @@
import { ReactNode, useLayoutEffect, useState } from "react";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";

import { Breadcrumb } from "@app/components/Breadcrumb";
import { BreadcrumbItem } from "@app/components/Breadcrumb/BreadcrumbItem";
import { ReactNode } from "react";

interface IMain {
children: ReactNode;
}

export function Main({ children }: IMain) {
const router = useRouter();
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
const { t } = useTranslation(["common"]);

useLayoutEffect(() => {
const pathWithoutQuery = router.asPath.split("?")[0];
let pathArray = pathWithoutQuery.split("/");
pathArray.shift();

pathArray = pathArray.filter((path) => path !== "");

const breadcrumbs = pathArray.map((path, index) => {
const href = "/" + pathArray.slice(0, index + 1).join("/");
return {
href,
label: path
};
});

setBreadcrumbs(breadcrumbs);
}, [router.asPath]);

return (
<main className="flex flex-1 flex-col bg-zinc-100 text-gray-800 transition-all duration-150 dark:bg-zinc-950 dark:text-gray-100 lg:overflow-y-auto lg:rounded-tl-3xl lg:border lg:border-l-gray-400 lg:border-t-gray-400 dark:lg:border-l-gray-600 dark:lg:border-t-gray-600">
<div className="sticky top-[4.5rem] z-10 flex gap-x-2 bg-zinc-100 px-6 shadow-sm shadow-gray-300 transition-all duration-150 dark:bg-zinc-950 dark:shadow-gray-900 lg:top-0">
<Breadcrumb>
<BreadcrumbItem href="/">{t("common:app-name")}</BreadcrumbItem>
{breadcrumbs ? (
breadcrumbs.map((breadcrumb, index) => (
<BreadcrumbItem
key={breadcrumb.href + index}
href={breadcrumb.href}
>
{breadcrumb.label.replace("-", " ")}
</BreadcrumbItem>
))
) : (
<BreadcrumbItem
key="loading-breadcrumb"
href="#"
>
{t("common:loading")}
</BreadcrumbItem>
)}
</Breadcrumb>
</div>
<div className="flex flex-1 flex-col gap-4 px-6 py-2">{children}</div>
<main className="flex flex-1 flex-col border-b-transparent border-r-transparent bg-zinc-100 text-zinc-800 transition-all duration-150 dark:bg-zinc-950 dark:text-zinc-100 lg:overflow-y-auto lg:rounded-tl-3xl lg:border lg:border-l-zinc-400 lg:border-t-zinc-400 dark:lg:border-l-zinc-600 dark:lg:border-t-zinc-600">
<div className="flex flex-1 flex-col gap-4 p-6">{children}</div>
</main>
);
}
86 changes: 86 additions & 0 deletions apps/client/src/components/Forms/ResetPassword/Request.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { AlertTriangle, MailCheck } from "lucide-react";
import { Mail } from "lucide-react";
import { useTranslation } from "next-i18next";

import { Button } from "@app/components/Button";
import { Input } from "@app/components/Input";
import { Spinner } from "@app/components/Spinner";
import {
ResetPasswordRequestFormSchema,
ResetPasswordRequestFormSchemaType
} from "@app/schemas/pages/reset-password/request.zod";

export function ResetPasswordRequestForm() {
const { t } = useTranslation(["reset-password"]);
const [errored, setErrored] = useState(false);
const [mailSent, setMailSent] = useState(false);
const {
register,
reset,
formState: { errors, isSubmitting },
handleSubmit
} = useForm<ResetPasswordRequestFormSchemaType>({
resolver: zodResolver(ResetPasswordRequestFormSchema)
});

const handleReuest: SubmitHandler<
ResetPasswordRequestFormSchemaType
> = async ({ identifier }) => {
setErrored(false);
await axios
.post("/api/users/password/reset", {
identifier
})
.then(() => {
reset();
setMailSent(true);
})
.catch(() => setErrored(true));
};

if (mailSent) {
return (
<div className="flex flex-1 flex-col items-center justify-center">
<MailCheck className="h-28 w-28 stroke-primary-600" />
<h2 className="text-xl font-medium">
{t("reset-password:request-form.success")}
</h2>
</div>
);
}

return (
<form
className="mt-10 flex flex-col gap-y-2.5 lg:mx-auto lg:w-1/2"
onSubmit={handleSubmit(handleReuest)}
>
{errored ? (
<div className="flex gap-x-2 rounded-lg border border-orange-500 bg-orange-400/20 p-2">
<AlertTriangle className="stroke-orange-600 dark:stroke-orange-200" />
<p className="text-orange-600 dark:text-orange-200">
{t(`reset-password:request-form.errors.no-user`)}
</p>
</div>
) : null}
<Input
error={errors.identifier}
disabled={isSubmitting}
label={t("reset-password:request-form.identifier.title")}
placeholder={t("reset-password:request-form.identifier.placeholder")}
{...register("identifier")}
/>
<Button
disabled={isSubmitting}
LeftIcon={!isSubmitting ? Mail : undefined}
type="submit"
>
{isSubmitting ? <Spinner /> : null}
{t("reset-password:request-form.title")}
</Button>
</form>
);
}
92 changes: 92 additions & 0 deletions apps/client/src/components/Forms/ResetPassword/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { AlertTriangle, Key, UserCheck } from "lucide-react";
import { useTranslation } from "next-i18next";

import { Button } from "@app/components/Button";
import { Input } from "@app/components/Input";
import { Spinner } from "@app/components/Spinner";
import {
ResetPasswordFormSchema,
ResetPasswordFormSchemaType
} from "@app/schemas/pages/reset-password/reset.zod";

interface ResetPasswordFormProps {
resetId: string;
}

export function ResetPasswordForm({ resetId }: ResetPasswordFormProps) {
const { t } = useTranslation(["reset-password"]);
const [errored, setErrored] = useState(false);
const [isReseted, setIsReseted] = useState(false);
const {
register,
reset,
formState: { errors, isSubmitting },
handleSubmit
} = useForm<ResetPasswordFormSchemaType>({
resolver: zodResolver(ResetPasswordFormSchema),
defaultValues: {
resetId
}
});

const handleReset: SubmitHandler<ResetPasswordFormSchemaType> = async ({
password,
resetId
}) => {
setErrored(false);
await axios
.put("/api/users/password/reset", {
resetId,
password
})
.then(() => {
reset();
setIsReseted(true);
})
.catch(() => setErrored(true));
};

return (
<form
className="mt-10 flex flex-col gap-y-2.5 lg:mx-auto lg:w-1/2"
onSubmit={handleSubmit(handleReset)}
>
{errored ? (
<div className="flex gap-x-2 rounded-lg border border-orange-500 bg-orange-400/20 p-2">
<AlertTriangle className="min-h-[1.75rem] min-w-[1.75rem] stroke-orange-600 dark:stroke-orange-200" />
<p className="text-orange-600 dark:text-orange-200">
{t(`reset-password:reset-form.errors.failed`)}
</p>
</div>
) : null}
{isReseted ? (
<div className="flex gap-x-2 rounded-lg border border-primary-500 bg-primary-400/20 p-2">
<UserCheck className="min-h-[1.75rem] min-w-[1.75rem] stroke-primary-600 dark:stroke-primary-200" />
<p className="text-primary-600 dark:text-primary-200">
{t(`reset-password:reset-form.reseted`)}
</p>
</div>
) : null}
<Input
error={errors.password}
disabled={isSubmitting || isReseted}
label={t("reset-password:reset-form.password.title")}
placeholder={t("reset-password:reset-form.password.placeholder")}
type="password"
{...register("password")}
/>
<Button
disabled={isSubmitting || isReseted}
LeftIcon={!isSubmitting ? Key : undefined}
type="submit"
>
{isSubmitting ? <Spinner /> : null}
{t("reset-password:reset-form.title")}
</Button>
</form>
);
}
19 changes: 11 additions & 8 deletions apps/client/src/components/Forms/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Lock, LogIn, UserPlus } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { signIn } from "next-auth/react";
import { useTranslation } from "next-i18next";
Expand Down Expand Up @@ -88,14 +89,16 @@ export function SignInForm() {
{t("signin:title")}
</Button>
<div className="flex gap-x-2">
<TextButton
className="text-sm"
iconClassName="h-4 w-4"
LeftIcon={Lock}
type="button"
>
{t("signin:lost-password")}
</TextButton>
<Link href="/reset-password">
<TextButton
className="text-sm"
iconClassName="h-4 w-4"
LeftIcon={Lock}
type="button"
>
{t("signin:lost-password")}
</TextButton>
</Link>
<TextButton
className="text-sm"
iconClassName="h-4 w-4"
Expand Down
3 changes: 2 additions & 1 deletion apps/client/src/hooks/useSignOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function useSignOut() {
const nonRedirectPages = [
{ path: "/about", exact: false },
{ path: "/", exact: true },
{ path: "/blog", exact: false }
{ path: "/blog", exact: false },
{ path: "/reset-password", exact: false }
];

useEffect(() => {
Expand Down

0 comments on commit c780d68

Please sign in to comment.