From fa355da5f6242a58474ee32d0bbb25a6c534dd28 Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Thu, 4 Sep 2025 16:21:51 -0300 Subject: [PATCH] feat: implement email verification and password management features --- apps/web/src/components/sign-in-form.tsx | 16 +- apps/web/src/components/sign-up-form.tsx | 27 +- apps/web/src/components/user-menu.tsx | 6 + apps/web/src/lib/auth-client.ts | 57 +++ apps/web/src/routeTree.gen.ts | 55 ++- apps/web/src/routes/password.tsx | 483 +++++++++++++++++++++++ apps/web/src/routes/verify-email.tsx | 237 +++++++++++ 7 files changed, 864 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/routes/password.tsx create mode 100644 apps/web/src/routes/verify-email.tsx diff --git a/apps/web/src/components/sign-in-form.tsx b/apps/web/src/components/sign-in-form.tsx index 15d2dd4..fb8d6ad 100644 --- a/apps/web/src/components/sign-in-form.tsx +++ b/apps/web/src/components/sign-in-form.tsx @@ -2,7 +2,7 @@ const MIN_PASSWORD_LENGTH = 8; import { useForm } from "@tanstack/react-form"; import { useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { toast } from "sonner"; import z from "zod"; import { account, authClient } from "@/lib/auth-client"; @@ -53,7 +53,7 @@ export default function SignInForm({ .string() .min( MIN_PASSWORD_LENGTH, - `Password must be at least ${MIN_PASSWORD_LENGTH} characters` + `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, ), }), }, @@ -134,7 +134,17 @@ export default function SignInForm({ -
+
+
+ + +
+
+
+ + +
); diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 815d2fb..64f5f80 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -38,6 +38,12 @@ export default function UserMenu() { My Account {session.email} + + Verify Email + + + Password + { await authClient.signOut(); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 1f21ec8..3316e4b 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -64,4 +64,61 @@ export const authClient = { return null; } }, + // Email verification helpers + verify: { + // Sends a verification email to the currently logged-in user + sendEmail: async (redirectUrl: string) => { + // Redirect URL must be registered in Appwrite console platform settings + await account.createVerification(redirectUrl); + }, + // Confirms verification using query params (?userId=...&secret=...) + confirm: async ({ userId, secret }: { userId: string; secret: string }) => { + await account.updateVerification(userId, secret); + }, + // Quickly check current user's verification status + status: async (): Promise => { + const me = (await account.get()) as Models.User; + return Boolean(me.emailVerification); + }, + }, + // Password management helpers + password: { + // Change password for the currently logged-in user + change: async ({ + oldPassword, + newPassword, + }: { + oldPassword: string; + newPassword: string; + }) => { + await account.updatePassword(newPassword, oldPassword); + }, + // Password recovery (reset via email) + recover: { + // Start recovery flow: sends email with link to redirectUrl + request: async ({ + email, + redirectUrl, + }: { + email: string; + redirectUrl: string; + }) => { + // Redirect URL must be registered in Appwrite console platform settings + await account.createRecovery(email, redirectUrl); + }, + // Complete recovery using the link's query params and new password + confirm: async ({ + userId, + secret, + password, + }: { + userId: string; + secret: string; + password: string; + }) => { + // Some SDK versions require providing password twice + await account.updateRecovery(userId, secret, password); + }, + }, + }, }; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7158e3a..0a4e827 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,16 +9,28 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as VerifyEmailRouteImport } from './routes/verify-email' import { Route as SpaceRouteImport } from './routes/space' +import { Route as PasswordRouteImport } from './routes/password' import { Route as LoginRouteImport } from './routes/login' import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as IndexRouteImport } from './routes/index' +const VerifyEmailRoute = VerifyEmailRouteImport.update({ + id: '/verify-email', + path: '/verify-email', + getParentRoute: () => rootRouteImport, +} as any) const SpaceRoute = SpaceRouteImport.update({ id: '/space', path: '/space', getParentRoute: () => rootRouteImport, } as any) +const PasswordRoute = PasswordRouteImport.update({ + id: '/password', + path: '/password', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -39,38 +51,66 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute + '/password': typeof PasswordRoute '/space': typeof SpaceRoute + '/verify-email': typeof VerifyEmailRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute + '/password': typeof PasswordRoute '/space': typeof SpaceRoute + '/verify-email': typeof VerifyEmailRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/dashboard': typeof DashboardRoute '/login': typeof LoginRoute + '/password': typeof PasswordRoute '/space': typeof SpaceRoute + '/verify-email': typeof VerifyEmailRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/dashboard' | '/login' | '/space' + fullPaths: + | '/' + | '/dashboard' + | '/login' + | '/password' + | '/space' + | '/verify-email' fileRoutesByTo: FileRoutesByTo - to: '/' | '/dashboard' | '/login' | '/space' - id: '__root__' | '/' | '/dashboard' | '/login' | '/space' + to: '/' | '/dashboard' | '/login' | '/password' | '/space' | '/verify-email' + id: + | '__root__' + | '/' + | '/dashboard' + | '/login' + | '/password' + | '/space' + | '/verify-email' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute DashboardRoute: typeof DashboardRoute LoginRoute: typeof LoginRoute + PasswordRoute: typeof PasswordRoute SpaceRoute: typeof SpaceRoute + VerifyEmailRoute: typeof VerifyEmailRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/verify-email': { + id: '/verify-email' + path: '/verify-email' + fullPath: '/verify-email' + preLoaderRoute: typeof VerifyEmailRouteImport + parentRoute: typeof rootRouteImport + } '/space': { id: '/space' path: '/space' @@ -78,6 +118,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SpaceRouteImport parentRoute: typeof rootRouteImport } + '/password': { + id: '/password' + path: '/password' + fullPath: '/password' + preLoaderRoute: typeof PasswordRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -106,7 +153,9 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DashboardRoute: DashboardRoute, LoginRoute: LoginRoute, + PasswordRoute: PasswordRoute, SpaceRoute: SpaceRoute, + VerifyEmailRoute: VerifyEmailRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/password.tsx b/apps/web/src/routes/password.tsx new file mode 100644 index 0000000..11adbfd --- /dev/null +++ b/apps/web/src/routes/password.tsx @@ -0,0 +1,483 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { + createFileRoute, + Link, + useNavigate, + useSearch, +} from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import z from "zod"; +import Loader from "@/components/loader"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; + +const MIN_PASSWORD_LENGTH = 8; + +const SearchSchema = z.object({ + mode: z.enum(["change", "recover", "confirm"]).optional(), + userId: z.string().optional(), + secret: z.string().optional(), +}); + +export const Route = createFileRoute("/password")({ + validateSearch: SearchSchema, + component: PasswordPage, +}); + +function PasswordPage() { + const navigate = useNavigate(); + const query = useSearch({ from: "/password" }); + const { data: session, isPending: isSessionPending } = + authClient.useSession(); + + const mode = useMemo(() => { + if (query.mode === "confirm" || (query.userId && query.secret)) { + return "confirm" as const; + } + if (query.mode === "recover") { + return "recover" as const; + } + return "change" as const; + }, [query.mode, query.userId, query.secret]); + + if (isSessionPending) { + return ; + } + + // If in confirm mode but missing params, guide user to recover + if (mode === "confirm" && !(query.userId && query.secret)) { + return ( + + Password reset +

+ Your reset link appears to be missing details. Start a new password + recovery request. +

+
+ + +
+
+ ); + } + + if (mode === "confirm" && query.userId && query.secret) { + return ( + + Set a new password + { + toast.success("Password reset successful. Please sign in."); + navigate({ to: "/login" }); + }} + secret={query.secret} + userId={query.userId} + /> + + ); + } + + return ( + + + {mode === "recover" ? ( + <> + Forgot password +

+ Enter your email to receive a password reset link. +

+
+ +
+ + ) : ( + <> + Change password + {session ? ( + <> +

+ Update your password. You will stay signed in. +

+
+ +
+ + ) : ( + <> +

+ You need to sign in to change your password. If you forgot your + password, use recovery instead. +

+
+ + +
+ + )} + + )} +
+ ); +} + +function HeaderTabs({ active }: { active: "change" | "recover" | "confirm" }) { + if (active === "confirm") { + return null; + } + return ( +
+ + +
+ ); +} + +function TabLink>({ + to, + search, + active, + label, +}: { + to: string; + search: TSearch; + active: boolean; + label: string; +}) { + return ( + + ); +} + +function ChangePasswordForm() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + const queryClient = useQueryClient(); + + const schema = z + .object({ + currentPassword: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Must be at least ${MIN_PASSWORD_LENGTH} characters` + ), + newPassword: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Must be at least ${MIN_PASSWORD_LENGTH} characters` + ), + confirm: z.string(), + }) + .refine((v) => v.newPassword === v.confirm, { + message: "Passwords do not match", + path: ["confirm"], + }) + .refine((v) => v.newPassword !== v.currentPassword, { + message: "New password must be different from current password", + path: ["newPassword"], + }); + + const [errors, setErrors] = useState>({}); + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + const parsed = schema.safeParse({ currentPassword, newPassword, confirm }); + if (!parsed.success) { + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + const key = issue.path.join(".") || "form"; + if (!fieldErrors[key]) { + fieldErrors[key] = issue.message; + } + } + setErrors(fieldErrors); + return; + } + setSubmitting(true); + try { + await authClient.password.change({ + oldPassword: currentPassword, + newPassword, + }); + toast.success("Password updated"); + setCurrentPassword(""); + setNewPassword(""); + setConfirm(""); + // Refresh session cache + await queryClient.invalidateQueries({ queryKey: ["session", "me"] }); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to change password"; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + setCurrentPassword(e.target.value)} + type="password" + value={currentPassword} + /> + {errors.currentPassword ? ( +

{errors.currentPassword}

+ ) : null} +
+ +
+ + setNewPassword(e.target.value)} + type="password" + value={newPassword} + /> + {errors.newPassword ? ( +

{errors.newPassword}

+ ) : null} +
+ +
+ + setConfirm(e.target.value)} + type="password" + value={confirm} + /> + {errors.confirm ? ( +

{errors.confirm}

+ ) : null} +
+ +
+ +
+
+ ); +} + +function RecoveryRequestForm() { + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const schema = z.object({ + email: z.string().email("Enter a valid email"), + }); + + const redirectUrl = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + const url = new URL("/password", window.location.origin); + url.searchParams.set("mode", "confirm"); + return url.toString(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const parsed = schema.safeParse({ email }); + if (!parsed.success) { + const msg = parsed.error.issues[0]?.message || "Invalid email"; + setError(msg); + return; + } + setSubmitting(true); + try { + await authClient.password.recover.request({ + email, + redirectUrl, + }); + toast.success("If an account exists, a recovery link has been sent"); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to start recovery"; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + type="email" + value={email} + /> + {error ?

{error}

: null} +
+
+ +
+
+ ); +} + +function ResetConfirmForm({ + userId, + secret, + onSuccess, +}: { + userId: string; + secret: string; + onSuccess: () => void; +}) { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + const schema = z + .object({ + password: z + .string() + .min( + MIN_PASSWORD_LENGTH, + `Must be at least ${MIN_PASSWORD_LENGTH} characters` + ), + confirm: z.string(), + }) + .refine((v) => v.password === v.confirm, { + message: "Passwords do not match", + path: ["confirm"], + }); + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + const parsed = schema.safeParse({ password, confirm }); + if (!parsed.success) { + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + const key = issue.path.join(".") || "form"; + if (!fieldErrors[key]) { + fieldErrors[key] = issue.message; + } + } + setErrors(fieldErrors); + return; + } + setSubmitting(true); + try { + await authClient.password.recover.confirm({ + userId, + secret, + password, + }); + onSuccess(); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to reset password"; + toast.error(msg); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + setPassword(e.target.value)} + type="password" + value={password} + /> + {errors.password ? ( +

{errors.password}

+ ) : null} +
+ +
+ + setConfirm(e.target.value)} + type="password" + value={confirm} + /> + {errors.confirm ? ( +

{errors.confirm}

+ ) : null} +
+ +
+ +
+
+ ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Title({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/apps/web/src/routes/verify-email.tsx b/apps/web/src/routes/verify-email.tsx new file mode 100644 index 0000000..87a268a --- /dev/null +++ b/apps/web/src/routes/verify-email.tsx @@ -0,0 +1,237 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import z from "zod"; +import Loader from "@/components/loader"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +export const Route = createFileRoute("/verify-email")({ + validateSearch: z.object({ + userId: z.string().optional(), + secret: z.string().optional(), + }), + component: VerifyEmailPage, +}); + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: why not +function VerifyEmailPage() { + const { userId, secret } = Route.useSearch(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data: session, isPending: isSessionPending } = + authClient.useSession(); + + const [isConfirming, setIsConfirming] = useState(false); + const [confirmed, setConfirmed] = useState(null); + const [confirmError, setConfirmError] = useState(null); + + const [isSending, setIsSending] = useState(false); + const [isCheckingStatus, setIsCheckingStatus] = useState(false); + const [isVerified, setIsVerified] = useState(null); + + // Avoid double-confirming on React strict mode mounts + const hasAttemptedConfirm = useRef(false); + + // Build redirect URL for the verification email + const redirectUrl = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + const url = new URL("/verify-email", window.location.origin); + return url.toString(); + }, []); + + // Auto-confirm if userId and secret are present + useEffect(() => { + const doConfirm = async () => { + if (!(userId && secret)) { + return; + } + if (hasAttemptedConfirm.current) { + return; + } + + hasAttemptedConfirm.current = true; + setIsConfirming(true); + setConfirmError(null); + try { + await authClient.verify.confirm({ userId, secret }); + setConfirmed(true); + toast.success("Email verified successfully"); + // Refresh the session so UI reflects verification state + await queryClient.invalidateQueries({ queryKey: ["session", "me"] }); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to verify email"; + setConfirmError(msg); + setConfirmed(false); + toast.error(msg); + } finally { + setIsConfirming(false); + } + }; + + doConfirm(); + }, [secret, userId, queryClient]); + + // Check current verification status + useEffect(() => { + const check = async () => { + if (!session) { + setIsVerified(null); + return; + } + setIsCheckingStatus(true); + try { + const ok = await authClient.verify.status(); + setIsVerified(ok); + } catch { + setIsVerified(null); + } finally { + setIsCheckingStatus(false); + } + }; + check(); + }, [session]); + + const handleSendVerification = async () => { + if (!session) { + toast.info("Please sign in to send a verification email"); + navigate({ to: "/login" }); + return; + } + setIsSending(true); + try { + await authClient.verify.sendEmail(redirectUrl); + toast.success("Verification email sent"); + } catch (err) { + const msg = + err instanceof Error + ? err.message + : "Failed to send verification email"; + toast.error(msg); + } finally { + setIsSending(false); + } + }; + + if (isSessionPending || isCheckingStatus || isConfirming) { + return ; + } + + // Render different states based on URL params and outcomes + if (userId && secret) { + if (confirmed) { + return ( + + You're verified 🎉 +

+ Your email has been successfully verified. +

+
+ + +
+
+ ); + } + + if (confirmError) { + return ( + + Verification failed +

{confirmError}

+
+ + +
+
+ ); + } + + // Fallback while confirming + return ; + } + + // No confirmation params present: allow user to trigger verification + if (!session) { + return ( + + Verify your email +

+ You need to be signed in to verify your email. +

+
+ +
+
+ ); + } + + return ( + + Verify your email +

+ {session.email ? `Signed in as ${session.email}` : "Signed in"} +

+ {isVerified ? ( +
+

Your email is already verified.

+
+ +
+
+ ) : ( +
+

+ Click the button below and check your inbox to complete + verification. +

+
+ + +
+
+ )} +
+ ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Title({ children }: { children: React.ReactNode }) { + return

{children}

; +}