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 (
{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 (
{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')}
);
}