mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 04:53:26 +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