'use client'; import { Suspense, useState, useSyncExternalStore } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { BrandedAuthShell } from '@/components/shared/branded-auth-shell'; import { FormErrorSummary } from '@/components/forms/form-error-summary'; import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error'; const MIN_LENGTH = 9; const passwordSchema = z .object({ password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`), confirmPassword: z.string().min(1, 'Please confirm your password'), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], }); type SetPasswordFormData = z.infer; /** * H-03: tokens travel in the URL fragment (`#token=…`) so they never land * in HTTP access logs or HTTP-Referer headers. Pre-fragment links still * carry `?token=…` and stay functional until every outstanding invite * expires - drop the `?token=` fallback after that grace period. */ function readTokenFromUrl(): string { if (typeof window === 'undefined') return ''; const hash = window.location.hash.replace(/^#/, ''); if (hash) { const params = new URLSearchParams(hash); const fromFragment = params.get('token'); if (fromFragment) return fromFragment; } const search = new URLSearchParams(window.location.search); return search.get('token') ?? ''; } const subscribeNoop = () => () => undefined; function SetPasswordInner() { const router = useRouter(); // useSyncExternalStore so the fragment-only token is read post-hydration // (server snapshot returns null; client returns the actual value). const token = useSyncExternalStore( subscribeNoop, () => readTokenFromUrl(), () => null, ); const [isLoading, setIsLoading] = useState(false); const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(passwordSchema), }); const submitWithScroll = useFormScrollToError(handleSubmit, errors); async function onSubmit(data: SetPasswordFormData) { if (!token) { toast.error('Invalid or missing reset token. Please request a new link.'); return; } setIsLoading(true); try { const response = await fetch('/api/auth/set-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password: data.password }), }); if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { message?: string; error?: string; }; toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.'); return; } toast.success('Password set successfully. You can now sign in.'); router.push('/login'); } catch { toast.error('Something went wrong. Please try again.'); } finally { setIsLoading(false); } } // Pre-hydration: token is null. Show a loading placeholder so the user // doesn't see a flash of "Link is missing" while the fragment is being // read on the client. if (token === null) { return (
Loading…
); } if (!token) { return (

Link is missing or invalid

Please use the link from the email we sent you. If the link is broken, ask your administrator for a new one.

Back to sign in
); } return (

Set your password

Choose a password for your CRM account

At least {MIN_LENGTH} characters.

{errors.password &&

{errors.password.message}

}
{errors.confirmPassword && (

{errors.confirmPassword.message}

)}
); } export default function SetPasswordPage() { return ( {null}}> ); }