'use client'; import Link from 'next/link'; import { useState, useSyncExternalStore } from 'react'; import { CheckCircle2, Loader2 } from 'lucide-react'; 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'; interface PasswordSetFormProps { /** API endpoint that accepts `{ token, password }` and sets / resets the password. */ endpoint: string; title: string; description: string; successTitle: string; successDescription: string; submitLabel: string; } const MIN_LENGTH = 9; /** * Shared form used by both the activation and password-reset flows. * * The activation/reset token is read from the URL fragment (`#token=…`) * — not the query string — so the token never travels to the server, * never lands in proxy / reverse-proxy logs, never sits in the Referer * header, and is invisible to upstream CDN/cache layers. The browser * still sends it on form-submit via the explicit POST body. * * Pre-2026-05-14 the token was passed as `?token=…`; the legacy * search-param read is kept as a fallback so links sent before the * switchover still work for the remaining TTL. * * Empty / missing tokens land the user in an explicit error state * instead of submitting a doomed request. */ 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; } // Back-compat: pre-fragment links still carry `?token=…`. Drop after // every outstanding activation/reset link past TTL has been consumed. const search = new URLSearchParams(window.location.search); return search.get('token') ?? ''; } // Tiny no-op subscriber — we don't actually need to react to // hashchange events (the token is set once when the user lands on the // page). The store integration is here so React knows the source is // external and skips the SSR / hydration mismatch warning. const subscribe = () => () => undefined; export function PasswordSetForm({ endpoint, title, description, successTitle, successDescription, submitLabel, }: PasswordSetFormProps) { // Read the token via useSyncExternalStore — fragment is client-only // so the server-snapshot returns `null` and the client snapshot reads // window.location.hash post-hydration. `null` distinguishes "not yet // hydrated" from "missing"; empty string means "hydrated, none found". const token = useSyncExternalStore( subscribe, () => readTokenFromUrl(), () => null, ); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [done, setDone] = useState(false); const tooShort = password.length > 0 && password.length < MIN_LENGTH; const mismatch = confirm.length > 0 && password !== confirm; const canSubmit = !!token && password.length >= MIN_LENGTH && password === confirm && !loading; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!canSubmit) return; setLoading(true); setError(''); try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); setError((data as { error?: string }).error ?? 'Something went wrong. Please try again.'); return; } setDone(true); } catch { setError('Unable to connect. Please try again.'); } finally { setLoading(false); } } 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, request a new one.

Request a new link
); } if (done) { return (

{successTitle}

{successDescription}

Sign in
); } return (

{title}

{description}

setPassword(e.target.value)} required autoFocus autoComplete="new-password" minLength={MIN_LENGTH} disabled={loading} />

At least {MIN_LENGTH} characters.

{tooShort && (

Password must be at least {MIN_LENGTH} characters.

)}
setConfirm(e.target.value)} required autoComplete="new-password" disabled={loading} /> {mismatch &&

Passwords don't match.

}
{error &&

{error}

}
); }