feat: add forgot and reset password functionality

Implement forgot and reset password features, including form components, hooks for form handling, and layout/page components. This allows users to request a password reset and set a new password securely.
This commit is contained in:
Daniel Luiz Alves
2025-04-14 16:55:36 -03:00
parent 7d6e484c2b
commit 5aea36fd98
12 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ForgotPasswordFormProps } from "../types";
export function ForgotPasswordForm({ form, onSubmit }: ForgotPasswordFormProps) {
const t = useTranslations();
const isSubmitting = form.formState.isSubmitting;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("forgotPassword.emailLabel")}</FormLabel>
<FormControl>
<Input
{...field}
type="email"
placeholder={t("forgotPassword.emailPlaceholder")}
disabled={isSubmitting}
className="bg-transparent backdrop-blur-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" disabled={isSubmitting} size="lg" type="submit">
{isSubmitting ? t("forgotPassword.sending") : t("forgotPassword.submit")}
</Button>
<div className="mt-4 text-center">
<Link className="text-muted-foreground hover:text-primary text-sm" href="/login">
{t("forgotPassword.backToLogin")}
</Link>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,16 @@
import { IconLock } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
export function ForgotPasswordHeader() {
const t = useTranslations();
return (
<div className="space-y-2 text-center">
<div className="flex items-center justify-center gap-2">
<IconLock className="h-6 w-6" />
<h1 className="text-2xl font-bold tracking-tight">{t("forgotPassword.title")}</h1>
</div>
<p className="text-muted-foreground">{t("forgotPassword.description")}</p>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { requestPasswordReset } from "@/http/endpoints";
export type ForgotPasswordFormData = {
email: string;
};
export function useForgotPassword() {
const t = useTranslations();
const router = useRouter();
const forgotPasswordSchema = z.object({
email: z.string().email(t("validation.invalidEmail")),
});
const form = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
const onSubmit = async (data: ForgotPasswordFormData) => {
try {
await requestPasswordReset(data);
toast.success(t("forgotPassword.resetInstructions"));
router.push("/login");
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data?.message) {
toast.error(t(err.response.data.message));
} else {
toast.error(t("common.unexpectedError"));
}
}
};
return {
form,
onSubmit,
};
}

View File

@@ -0,0 +1,25 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps {
children: React.ReactNode;
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr";
return {
title: `${t("forgotPassword.pageTitle")} | ${appName}`,
};
}
export default function ForgotPasswordLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,32 @@
"use client";
import { motion } from "framer-motion";
import { DefaultFooter } from "@/components/ui/default-footer";
import { StaticBackgroundLights } from "../login/components/static-background-lights";
import { ForgotPasswordForm } from "./components/forgot-password-form";
import { ForgotPasswordHeader } from "./components/forgot-password-header";
import { useForgotPassword } from "./hooks/use-forgot-password";
export default function ForgotPasswordPage() {
const forgotPassword = useForgotPassword();
return (
<div className="relative flex min-h-screen flex-col">
<div className="flex flex-1 items-center justify-center">
<StaticBackgroundLights />
<div className="relative z-10 w-full max-w-md space-y-4 px-4 py-12">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-default-200 bg-black/20 p-8"
initial={{ opacity: 0, y: 20 }}
>
<ForgotPasswordHeader />
<ForgotPasswordForm form={forgotPassword.form} onSubmit={forgotPassword.onSubmit} />
</motion.div>
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { UseFormReturn } from "react-hook-form";
import { ForgotPasswordFormData } from "../hooks/use-forgot-password";
export interface ForgotPasswordFormProps {
form: UseFormReturn<ForgotPasswordFormData>;
onSubmit: (data: ForgotPasswordFormData) => Promise<void>;
}

View File

@@ -0,0 +1,94 @@
import Link from "next/link";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ResetPasswordFormProps } from "../types";
export function ResetPasswordForm({
form,
isPasswordVisible,
isConfirmPasswordVisible,
onTogglePassword,
onToggleConfirmPassword,
onSubmit,
}: ResetPasswordFormProps) {
const t = useTranslations();
const isSubmitting = form.formState.isSubmitting;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("resetPassword.form.newPassword")}</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={isPasswordVisible ? "text" : "password"}
placeholder={t("resetPassword.form.newPasswordPlaceholder")}
disabled={isSubmitting}
className="bg-transparent backdrop-blur-md pr-10"
/>
<button
type="button"
onClick={onTogglePassword}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{isPasswordVisible ? <IconEye size={20} /> : <IconEyeOff size={20} />}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t("resetPassword.form.confirmPassword")}</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={isConfirmPasswordVisible ? "text" : "password"}
placeholder={t("resetPassword.form.confirmPasswordPlaceholder")}
disabled={isSubmitting}
className="bg-transparent backdrop-blur-md pr-10"
/>
<button
type="button"
onClick={onToggleConfirmPassword}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{isConfirmPasswordVisible ? <IconEye size={20} /> : <IconEyeOff size={20} />}
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" disabled={isSubmitting} size="lg" type="submit">
{isSubmitting ? t("resetPassword.form.resetting") : t("resetPassword.form.submit")}
</Button>
<div className="mt-4 text-center">
<Link className="text-muted-foreground hover:text-primary text-sm" href="/login">
{t("resetPassword.form.backToLogin")}
</Link>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,16 @@
import { IconLock } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
export function ResetPasswordHeader() {
const t = useTranslations();
return (
<div className="space-y-2 text-center">
<div className="flex items-center justify-center gap-2">
<IconLock className="text-2xl" />
<h1 className="text-2xl font-bold tracking-tight">{t("resetPassword.header.title")}</h1>
</div>
<p className="text-default-500">{t("resetPassword.header.description")}</p>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { resetPassword } from "@/http/endpoints";
const createSchema = (t: (key: string) => string) =>
z
.object({
password: z.string().min(8, t("validation.passwordLength")),
confirmPassword: z.string().min(8, t("validation.passwordLength")),
})
.refine((data) => data.password === data.confirmPassword, {
message: t("validation.passwordsMatch"),
path: ["confirmPassword"],
});
export type ResetPasswordFormData = z.infer<ReturnType<typeof createSchema>>;
export function useResetPassword() {
const t = useTranslations();
const schema = createSchema(t);
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] = useState(false);
const form = useForm<ResetPasswordFormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: ResetPasswordFormData) => {
if (!token) return;
try {
await resetPassword({
token,
password: data.password,
});
toast.success(t("resetPassword.messages.success"));
router.push("/login");
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data?.error) {
toast.error(t("resetPassword.errors.serverError"));
} else {
toast.error(t("common.unexpectedError"));
}
}
};
return {
token,
form,
isPasswordVisible,
isConfirmPasswordVisible,
setIsPasswordVisible,
setIsConfirmPasswordVisible,
onSubmit,
};
}

View File

@@ -0,0 +1,25 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { getAllConfigs } from "@/http/endpoints";
import { Config } from "@/types/layout";
interface LayoutProps {
children: React.ReactNode;
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
const response = await getAllConfigs();
const appNameConfig = response.data.configs.find((config: Config) => config.key === "appName");
const appName = appNameConfig?.value || "Palmr";
return {
title: `${t("resetPassword.pageTitle")} | ${appName}`,
};
}
export default function ResetPasswordLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { DefaultFooter } from "@/components/ui/default-footer";
import { StaticBackgroundLights } from "../login/components/static-background-lights";
import { ResetPasswordForm } from "./components/reset-password-form";
import { ResetPasswordHeader } from "./components/reset-password-header";
import { useResetPassword } from "./hooks/use-reset-password";
export default function ResetPasswordPage() {
const t = useTranslations();
const router = useRouter();
const resetPassword = useResetPassword();
useEffect(() => {
if (!resetPassword.token) {
toast.error(t("resetPassword.errors.invalidToken"));
router.push("/login");
}
}, [resetPassword.token, router, t]);
return (
<div className="relative flex min-h-screen flex-col">
<div className="flex flex-1 items-center justify-center">
<StaticBackgroundLights />
<div className="relative z-10 w-full max-w-md space-y-4 px-4 py-12">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="rounded-2xl border border-default-200 bg-black/20 p-8"
initial={{ opacity: 0, y: 20 }}
>
<ResetPasswordHeader />
<ResetPasswordForm
form={resetPassword.form}
isConfirmPasswordVisible={resetPassword.isConfirmPasswordVisible}
isPasswordVisible={resetPassword.isPasswordVisible}
onSubmit={resetPassword.onSubmit}
onToggleConfirmPassword={() =>
resetPassword.setIsConfirmPasswordVisible(!resetPassword.isConfirmPasswordVisible)
}
onTogglePassword={() => resetPassword.setIsPasswordVisible(!resetPassword.isPasswordVisible)}
/>
</motion.div>
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { UseFormReturn } from "react-hook-form";
import { ResetPasswordFormData } from "../hooks/use-reset-password";
export interface ResetPasswordFormProps {
form: UseFormReturn<ResetPasswordFormData>;
isPasswordVisible: boolean;
isConfirmPasswordVisible: boolean;
onTogglePassword: () => void;
onToggleConfirmPassword: () => void;
onSubmit: (data: ResetPasswordFormData) => Promise<void>;
}