Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 (
<div className="login">
<div className="login-card">
<div className="wordmark">
Well<span>formed</span>
</div>
<div className="tagline">{tagline}</div>
{children}
</div>
</div>
);
}
/**
* 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<string | null>(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 (
<AuthCard tagline={t('auth.checkEmailTagline')}>
<p className="hint">
{t('auth.resetSentBefore')}
<span className="mono">{email.trim()}</span>
{t('auth.resetSentAfter')}
</p>
<p className="hint">
<a href="/signin">{t('auth.backToSignIn')}</a>
</p>
</AuthCard>
);
}
// 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 (
<AuthCard tagline={t('auth.askOperatorTagline')}>
<p className="hint">
{t('auth.noMailBefore')}
<span className="mono">npm run recover -- --email {email.trim()}</span>.
</p>
<p className="hint">
<a href="/signin">{t('auth.backToSignIn')}</a>
</p>
</AuthCard>
);
}
return (
<AuthCard tagline={t('auth.forgotTagline')}>
<form onSubmit={(e) => void submit(e)}>
{error && (
<div className="formerror" role="alert">
{error}
</div>
)}
<label>
{t('common.email')}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
autoFocus
/>
</label>
<button className="btn primary" type="submit" disabled={busy || !email.trim()}>
{busy ? t('common.sending') : t('auth.sendResetLink')}
</button>
</form>
<p className="hint">
{t('auth.rememberedBefore')}
<a href="/signin">{t('auth.signInLink')}</a>.
</p>
</AuthCard>
);
}
/** 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<string | null>(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 (
<AuthCard tagline={t('auth.linkSpentTagline')}>
<p className="hint">{dead.message ?? t('auth.resetLinkDead')}</p>
<p className="hint">
<a href="/forgot">{t('auth.askNewLink')}</a>.
</p>
</AuthCard>
);
}
if (!target) return <AuthCard tagline={t('auth.checkingLink')}>{null}</AuthCard>;
return (
<AuthCard tagline={t('auth.newPasswordFor', target.email)}>
<form onSubmit={(e) => void submit(e)}>
{error && (
<div className="formerror" role="alert">
{error}
</div>
)}
<label>
{t('account.newPassword')}
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
autoFocus
/>
{password.length > 0 && password.length < 10 && (
<span className="hint bad">{t('account.tooShort')}</span>
)}
</label>
<button className="btn primary" type="submit" disabled={busy || password.length < 10}>
{busy ? t('auth.settingPassword') : t('auth.setAndSignIn')}
</button>
</form>
<p className="hint">{t('auth.resetEndsSessions')}</p>
{session.account && session.account.email !== target.email && (
<p className="hint bad">{t('auth.signedInAsOther', session.account.email, target.email)}</p>
)}
</AuthCard>
);
}
/** 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 <AuthCard tagline={t('auth.confirming')}>{null}</AuthCard>;
return (
<AuthCard tagline={state.done ? t('auth.addressConfirmedTagline') : t('auth.linkSpentTagline')}>
<p className="hint">
{state.done
? t('auth.emailConfirmed', state.email)
: (state.message ?? t('auth.confirmLinkDead'))}
</p>
<p className="hint">
<a href="/">{session.account ? t('auth.backToWorkspaces') : t('auth.signIn')}</a>
</p>
</AuthCard>
);
}