mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-22 22:02:00 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}
|
25
apps/app/src/app/forgot-password/layout.tsx
Normal file
25
apps/app/src/app/forgot-password/layout.tsx
Normal 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}</>;
|
||||
}
|
32
apps/app/src/app/forgot-password/page.tsx
Normal file
32
apps/app/src/app/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
8
apps/app/src/app/forgot-password/types/index.ts
Normal file
8
apps/app/src/app/forgot-password/types/index.ts
Normal 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>;
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
69
apps/app/src/app/reset-password/hooks/use-reset-password.ts
Normal file
69
apps/app/src/app/reset-password/hooks/use-reset-password.ts
Normal 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,
|
||||
};
|
||||
}
|
25
apps/app/src/app/reset-password/layout.tsx
Normal file
25
apps/app/src/app/reset-password/layout.tsx
Normal 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}</>;
|
||||
}
|
55
apps/app/src/app/reset-password/page.tsx
Normal file
55
apps/app/src/app/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
12
apps/app/src/app/reset-password/types/index.ts
Normal file
12
apps/app/src/app/reset-password/types/index.ts
Normal 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>;
|
||||
}
|
Reference in New Issue
Block a user