import { useEffect, useState, type ReactNode } from 'react'; import { api, ApiError } from '../api/client'; import { useI18n } from '../i18n'; import { useSession } from '../state/session'; import { goTo, workspacePath } from '../state/route'; /** * The three screens for someone who cannot get in, or has just proved they can * be reached. They share one card, so they live in one file: asking for a link, * spending it, and confirming an address are one flow with three stops. */ function AuthCard({ tagline, children }: { tagline: string; children: ReactNode }): ReactNode { return (
Wellformed
{tagline}
{children}
); } /** * Ask for a reset link. * * The server answers the same way for an address it knows and one it does not, * so this screen must too — anything else would turn the form into a way to * ask "does this person have an account here?". */ export function ForgotScreen(): ReactNode { const { t } = useI18n(); const [email, setEmail] = useState(''); const [sent, setSent] = useState<'email' | 'none' | null>(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function submit(event: React.FormEvent) { event.preventDefault(); setBusy(true); setError(null); try { const result = await api.forgotPassword(email.trim()); setSent(result.delivery); } catch (err) { setError(err instanceof Error ? err.message : t('auth.forgotFailed')); } finally { setBusy(false); } } if (sent === 'email') { return (

{t('auth.resetSentBefore')} {email.trim()} {t('auth.resetSentAfter')}

{t('auth.backToSignIn')}

); } // No mail transport configured. Saying so beats a "check your inbox" that // will never arrive — this install's operator has to hand the link over. if (sent === 'none') { return (

{t('auth.noMailBefore')} npm run recover -- --email {email.trim()}.

{t('auth.backToSignIn')}

); } return (
void submit(e)}> {error && (
{error}
)}

{t('auth.rememberedBefore')} {t('auth.signInLink')}.

); } /** Spend a reset link: choose a new password, and land signed in. */ export function ResetScreen({ token }: { token: string }): ReactNode { const session = useSession(); const { t } = useI18n(); const [target, setTarget] = useState<{ email: string; name: string } | null>(null); const [dead, setDead] = useState<{ message: string | null } | null>(null); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { let live = true; api .readReset(token) .then((info) => { if (live) setTarget({ email: info.email, name: info.name }); }) .catch((err: unknown) => { if (!live) return; setDead({ message: err instanceof ApiError ? err.message : null }); }); return () => { live = false; }; }, [token]); async function submit(event: React.FormEvent) { event.preventDefault(); setBusy(true); setError(null); try { const next = await api.resetPassword(token, password); // The reset ended every other session, including any this browser held, // and issued a fresh one. Reload through the router so the app boots // from the new session rather than the stale cache. const only = next.workspaces.length === 1 ? next.workspaces[0] : undefined; goTo(only ? workspacePath(only.slug) : '/'); } catch (err) { setError(err instanceof Error ? err.message : t('auth.setPasswordFailed')); setBusy(false); } } if (dead) { return (

{dead.message ?? t('auth.resetLinkDead')}

{t('auth.askNewLink')}.

); } if (!target) return {null}; return (
void submit(e)}> {error && (
{error}
)}

{t('auth.resetEndsSessions')}

{session.account && session.account.email !== target.email && (

{t('auth.signedInAsOther', session.account.email, target.email)}

)}
); } /** Spend a confirmation link. Works signed out — mail is often read elsewhere. */ export function VerifyScreen({ token }: { token: string }): ReactNode { const session = useSession(); const { t } = useI18n(); const [state, setState] = useState< { done: true; email: string } | { done: false; message: string | null } | null >(null); useEffect(() => { let live = true; api .confirmEmail(token) .then(async (result) => { if (!live) return; setState({ done: true, email: result.email }); // The signed-in copy of the account still says unverified. if (session.account) await session.refresh(); }) .catch((err: unknown) => { if (!live) return; setState({ done: false, message: err instanceof ApiError ? err.message : null }); }); return () => { live = false; }; // Runs once per token; `session` changes identity on every refresh. // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); if (!state) return {null}; return (

{state.done ? t('auth.emailConfirmed', state.email) : (state.message ?? t('auth.confirmLinkDead'))}

{session.account ? t('auth.backToWorkspaces') : t('auth.signIn')}

); }