diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 20:50:19 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-12 12:20:41 +0000 |
| commit | 140311d7419fa2192e5149df8f589c3c3733a399 (patch) | |
| tree | ddf532bbf09e4f7c947854b5515c0e8674030645 /apps/web | |
| parent | 385f9f0b055678420e820b8ed30e595871630e58 (diff) | |
| download | karakeep-140311d7419fa2192e5149df8f589c3c3733a399.tar.zst | |
feat: Support forget and reset password
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/forgot-password/page.tsx | 22 | ||||
| -rw-r--r-- | apps/web/app/reset-password/page.tsx | 36 | ||||
| -rw-r--r-- | apps/web/components/signin/CredentialsForm.tsx | 9 | ||||
| -rw-r--r-- | apps/web/components/signin/ForgotPasswordForm.tsx | 149 | ||||
| -rw-r--r-- | apps/web/components/signin/ResetPasswordForm.tsx | 181 |
5 files changed, 397 insertions, 0 deletions
diff --git a/apps/web/app/forgot-password/page.tsx b/apps/web/app/forgot-password/page.tsx new file mode 100644 index 00000000..1faa8967 --- /dev/null +++ b/apps/web/app/forgot-password/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import KarakeepLogo from "@/components/KarakeepIcon"; +import ForgotPasswordForm from "@/components/signin/ForgotPasswordForm"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function ForgotPasswordPage() { + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <div className="w-full max-w-md space-y-8"> + <div className="flex items-center justify-center"> + <KarakeepLogo height={80} /> + </div> + <ForgotPasswordForm /> + </div> + </div> + ); +} diff --git a/apps/web/app/reset-password/page.tsx b/apps/web/app/reset-password/page.tsx new file mode 100644 index 00000000..1d05606e --- /dev/null +++ b/apps/web/app/reset-password/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import KarakeepLogo from "@/components/KarakeepIcon"; +import ResetPasswordForm from "@/components/signin/ResetPasswordForm"; +import { getServerAuthSession } from "@/server/auth"; + +interface ResetPasswordPageProps { + searchParams: { + token?: string; + }; +} + +export default async function ResetPasswordPage({ + searchParams, +}: ResetPasswordPageProps) { + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + + const { token } = searchParams; + + if (!token) { + redirect("/signin"); + } + + return ( + <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8"> + <div className="w-full max-w-md space-y-8"> + <div className="flex items-center justify-center"> + <KarakeepLogo height={80} /> + </div> + <ResetPasswordForm token={token} /> + </div> + </div> + ); +} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 1ad240a7..a3b854b7 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -142,6 +142,15 @@ export default function CredentialsForm() { > Sign In </ActionButton> + + <div className="text-center"> + <Link + href="/forgot-password" + className="text-sm text-muted-foreground underline hover:text-primary" + > + Forgot your password? + </Link> + </div> </form> </Form> diff --git a/apps/web/components/signin/ForgotPasswordForm.tsx b/apps/web/components/signin/ForgotPasswordForm.tsx new file mode 100644 index 00000000..29d55f2b --- /dev/null +++ b/apps/web/components/signin/ForgotPasswordForm.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { AlertCircle, CheckCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const forgotPasswordSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}); + +export default function ForgotPasswordForm() { + const [isSubmitted, setIsSubmitted] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const router = useRouter(); + + const form = useForm<z.infer<typeof forgotPasswordSchema>>({ + resolver: zodResolver(forgotPasswordSchema), + }); + + const forgotPasswordMutation = api.users.forgotPassword.useMutation(); + + const onSubmit = async (values: z.infer<typeof forgotPasswordSchema>) => { + try { + setErrorMessage(""); + await forgotPasswordMutation.mutateAsync(values); + setIsSubmitted(true); + } catch (error) { + if (error instanceof TRPCClientError) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unexpected error occurred. Please try again."); + } + } + }; + + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + {isSubmitted ? "Check your email" : "Forgot your password?"} + </CardTitle> + <CardDescription> + {isSubmitted + ? "If an account with that email exists, we've sent you a password reset link." + : "Enter your email address and we'll send you a link to reset your password."} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {isSubmitted ? ( + <> + <div className="flex items-center justify-center"> + <CheckCircle className="h-12 w-12 text-green-600" /> + </div> + <Alert> + <AlertDescription className="text-center"> + If an account with that email exists, we've sent you a + password reset link. + </AlertDescription> + </Alert> + <ActionButton + variant="outline" + loading={false} + onClick={() => router.push("/signin")} + className="w-full" + > + Back to Sign In + </ActionButton> + </> + ) : ( + <> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-4" + > + {errorMessage && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{errorMessage}</AlertDescription> + </Alert> + )} + + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + type="email" + placeholder="Enter your email address" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <ActionButton + type="submit" + loading={form.formState.isSubmitting} + className="w-full" + > + Send Reset Link + </ActionButton> + </form> + </Form> + + <div className="text-center"> + <ActionButton + variant="ghost" + loading={false} + onClick={() => router.push("/signin")} + className="w-full" + > + Back to Sign In + </ActionButton> + </div> + </> + )} + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/signin/ResetPasswordForm.tsx b/apps/web/components/signin/ResetPasswordForm.tsx new file mode 100644 index 00000000..d4d8a285 --- /dev/null +++ b/apps/web/components/signin/ResetPasswordForm.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { AlertCircle, CheckCircle } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { zResetPasswordSchema } from "@karakeep/shared/types/users"; + +const resetPasswordSchema = z + .object({ + confirmPassword: z.string(), + }) + .merge(zResetPasswordSchema.pick({ newPassword: true })) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +interface ResetPasswordFormProps { + token: string; +} + +export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { + const [isSuccess, setIsSuccess] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const router = useRouter(); + + const form = useForm<z.infer<typeof resetPasswordSchema>>({ + resolver: zodResolver(resetPasswordSchema), + }); + + const resetPasswordMutation = api.users.resetPassword.useMutation(); + + const onSubmit = async (values: z.infer<typeof resetPasswordSchema>) => { + try { + setErrorMessage(""); + await resetPasswordMutation.mutateAsync({ + token, + newPassword: values.newPassword, + }); + setIsSuccess(true); + } catch (error) { + if (error instanceof TRPCClientError) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unexpected error occurred. Please try again."); + } + } + }; + + return ( + <Card className="w-full"> + <CardHeader className="text-center"> + <CardTitle className="text-2xl font-bold"> + {isSuccess ? "Password reset successful" : "Reset your password"} + </CardTitle> + <CardDescription> + {isSuccess + ? "Your password has been successfully reset. You can now sign in with your new password." + : "Enter your new password below."} + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {isSuccess ? ( + <> + <div className="flex items-center justify-center"> + <CheckCircle className="h-12 w-12 text-green-600" /> + </div> + <Alert> + <AlertDescription className="text-center"> + Your password has been successfully reset. You can now sign in + with your new password. + </AlertDescription> + </Alert> + <ActionButton + loading={false} + onClick={() => router.push("/signin")} + className="w-full" + > + Go to Sign In + </ActionButton> + </> + ) : ( + <> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-4" + > + {errorMessage && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{errorMessage}</AlertDescription> + </Alert> + )} + + <FormField + control={form.control} + name="newPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Enter your new password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm your new password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <ActionButton + type="submit" + loading={form.formState.isSubmitting} + className="w-full" + > + Reset Password + </ActionButton> + </form> + </Form> + + <div className="text-center"> + <ActionButton + variant="ghost" + loading={false} + onClick={() => router.push("/signin")} + className="w-full" + > + Back to Sign In + </ActionButton> + </div> + </> + )} + </CardContent> + </Card> + ); +} |
