web: PRO needs a proven address — email verification gates the studio

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+52 -7
View File
@@ -4,29 +4,53 @@ import { api } from '../api';
// Email + password only: the cookie is set by the API container, this dialog
// just drives it. The demo is always one click away — the app is usable signed
// out, and only export resolution / saving are gated (see config/tiers.ts).
// out, and only the PRO half is gated (see config/tiers.ts).
//
// Three faces, one form. `verify` is the third: the account exists but the
// address it gave has not been confirmed, so until the visitor opens the link
// it is served exactly like a guest. A fresh signup lands on that face by
// itself — telling someone their account is ready when it is not is the one
// thing this dialog must not do.
export function AuthModal({
initialMode = 'login',
email,
onClose,
onDone,
}: {
initialMode?: 'login' | 'signup';
initialMode?: 'login' | 'signup' | 'verify';
// The account already signed in, when the studio opens the verify face from
// its own banner.
email?: string;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useI18n();
const [mode, setMode] = useState<'login' | 'signup'>(initialMode);
const [email, setEmail] = useState('');
const [mode, setMode] = useState<'login' | 'signup' | 'verify'>(initialMode);
const [address, setAddress] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await (mode === 'login' ? api.login(email, password) : api.signup(email, password));
if (mode === 'verify') {
await api.resendVerification();
setSent(true);
return;
}
const { user } = await (mode === 'login'
? api.login(address, password)
: api.signup(address, password));
setAddress(user.email);
if (!user.verified) {
if (mode === 'signup') setSent(true); // signup mails the first link itself
setMode('verify');
return;
}
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -35,6 +59,26 @@ export function AuthModal({
}
};
if (mode === 'verify') {
const who = email || address;
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" onSubmit={submit}>
<h2>{t('auth.verifyTitle')}</h2>
<p className="hint">{t('auth.verifyBody', { email: who })}</p>
<p className="hint">{t('auth.verifyProHint')}</p>
{sent ? <p className="hint ok">{t('auth.verifySent')}</p> : null}
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" data-key="verify-resend" disabled={busy}>
{busy ? t('auth.busy') : t('pro.resend')}
</button>
<button type="button" className="btn" onClick={onDone}>{t('auth.verifiedDone')}</button>
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
</form>
</div>
);
}
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" onSubmit={submit}>
@@ -45,8 +89,8 @@ export function AuthModal({
required
autoComplete="email"
placeholder={t('auth.email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
<input
className="field"
@@ -59,6 +103,7 @@ export function AuthModal({
onChange={(e) => setPassword(e.target.value)}
/>
<p className="hint">{t('auth.passwordHint')}</p>
{mode === 'signup' ? <p className="hint">{t('auth.signupVerifyHint')}</p> : null}
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" disabled={busy}>
{busy ? t('auth.busy') : mode === 'login' ? t('nav.login') : t('nav.signup')}