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
+121 -26
View File
@@ -59,6 +59,12 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'wallframe-landscape', label: 'frame.wallLand' },
];
// The printed frames are PRO, the plain export is not — the same split the
// phone build draws (see src/utils/entitlement.ts there). Everything else the
// gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in
// paramDefs (its own `pro` flag), and the export tier in TIERS.
const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe', 'wallframe-landscape'];
// FREE draws the frame on the canvas and lets the user drag its corners, so
// every ratio is offered here — no ratio is applied until APPLY.
const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9'];
@@ -329,6 +335,11 @@ export function Workspace() {
const [exporting, setExporting] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
// PRO is the old "signed in", and then some: the account has to have proven
// the address it signed up with. Until it does, this build serves it exactly
// like a guest — watermarked 2048px export, no saving, no PRO tools. An
// allowlisted admin is trusted by construction and counts as verified.
const pro = !!user?.verified;
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// Bumped by RESET while CREATE is open: the form above the button listens for
// it and drops back to its defaults.
@@ -351,11 +362,30 @@ export function Workspace() {
// null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in
// tab (sign-in is the default; ?auth=signup asks for the other one); the
// landing page runs the same dialog in place instead of linking here.
const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => {
// `verify` is the third face of the same dialog: the account exists but its
// address is unproven, so the only thing left to do is open the letter.
const [authMode, setAuthMode] = useState<'login' | 'signup' | 'verify' | null>(() => {
const asked = new URLSearchParams(window.location.search).get('auth');
return asked ? (asked === 'signup' ? 'signup' : 'login') : null;
});
// What every PRO-only entry point calls instead of returning silently: a
// guest is sent to the dialog for an account, an unverified account to the
// same dialog for its letter.
const promptPro = useCallback(() => setAuthMode(user ? 'verify' : 'login'), [user]);
// The banner's own button. The address already has one live link, so this
// replaces it — the API caps how often, and answers either way.
const [resent, setResent] = useState(false);
const resendVerification = useCallback(async () => {
try {
await api.resendVerification();
setResent(true);
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
}, [t]);
const renderToken = useRef(0);
// Which previewMax the bytes in previewBytes were made at, so the upgrade
// effect does not redo the load-time 1600px copy.
@@ -395,14 +425,16 @@ export function Workspace() {
}, []);
useEffect(() => {
if (!user) {
// PRO, not merely signed in: those two listings are the API's own, and it
// answers an unverified account with a 403 (see requirePro there).
if (!pro) {
setSaved([]);
setMyPhotos([]);
return;
}
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
}, [user]);
}, [pro]);
// --- preview render -------------------------------------------------------
useEffect(() => {
@@ -702,12 +734,12 @@ export function Workspace() {
);
const saveRecipe = useCallback(() => {
if (!user) {
setAuthMode('login');
if (!pro) {
promptPro();
return;
}
setNaming('recipe');
}, [user]);
}, [pro, promptPro]);
// A recipe name is only free while this account has not spent it. Checked on
// the field rather than at the store: the list is already here, whole.
@@ -734,8 +766,8 @@ export function Workspace() {
// asked to sign in first, like every other write in the studio.
const favoriteRecipe = useCallback(
async (entry: Recipe) => {
if (!user) {
setAuthMode('login');
if (!pro) {
promptPro();
return;
}
try {
@@ -746,7 +778,7 @@ export function Workspace() {
setError(t('err.generic', { msg: String(err) }));
}
},
[user, t]
[pro, promptPro, t]
);
// The star reads the id the dragged chip carried back to its recipe.
@@ -772,14 +804,14 @@ export function Workspace() {
}, [favoriteRecipe, recipe]);
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
// signed-in account also stores it; a guest's copy stays in `created`, which
// is exactly what "forgotten when you leave" means.
// PRO account also stores it; a guest's — or an unverified account's — copy
// stays in `created`, which is exactly what "forgotten when you leave" means.
const addCreatedRecipe = useCallback(
async (draft: RecipeDraft) => {
const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true };
setCreated((list) => [entry, ...list]);
applyRecipe(entry);
if (!user) return;
if (!pro) return;
try {
const stored = await api.createRecipe(entry.name, entry);
setSaved((list) => [stored.recipe, ...list]);
@@ -787,7 +819,7 @@ export function Workspace() {
setError(t('err.generic', { msg: String(err) }));
}
},
[user, applyRecipe, t]
[pro, applyRecipe, t]
);
// CREATE edits the photo live: each knob lands on the render as it is typed so
@@ -798,11 +830,14 @@ export function Workspace() {
}, []);
// The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the
// app's own file, byte-identical). Signed in only — a guest's recipe is never
// written to disk.
// app's own file, byte-identical). PRO only — a guest's recipe, or an
// unverified account's, is never written to disk.
const exportRecipeFile = useCallback(
(draft: RecipeDraft) => {
if (!user) return;
if (!pro) {
promptPro();
return;
}
const xml = exportRecipeXml({ ...draft, id: 'share' });
download(
new TextEncoder().encode(xml),
@@ -810,7 +845,7 @@ export function Workspace() {
'application/xml'
);
},
[user]
[pro, promptPro]
);
// IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved
@@ -868,12 +903,28 @@ export function Workspace() {
[recipe, frameId, cropEditing, useGeotag]
);
// The look the stage is holding, judged against the PRO list. The chips keep
// a guest from picking one, but a look can arrive without them: an imported
// .recipe, or a photo saved before the gate existed. The FILE is where the
// gate bites — the preview is free, exactly as it is on the phone.
const proLookInUse = useCallback(
() =>
PRO_FRAMES.includes(frameId) ||
useGeotag ||
(recipe.adjustments.hdf ?? 0) > 0,
[frameId, useGeotag, recipe.adjustments.hdf]
);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
if (!pro && proLookInUse()) {
promptPro();
return;
}
setExporting(true);
setError(null);
try {
const tier = TIERS[user ? 'user' : 'guest'];
const tier = TIERS[pro ? 'user' : 'guest'];
const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes;
const out = await renderPhoto({
source: src,
@@ -924,15 +975,15 @@ export function Workspace() {
} finally {
setExporting(false);
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, user, t, strip, photoSettings]);
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
// own `.lp-shot` frame around it; the labels are stored alongside instead.
// The 12-photo cap is the API's, mirrored here so the button can say no.
const savePhotoToFolder = useCallback(async (photoName?: string) => {
if (!user) {
setAuthMode('login');
if (!pro) {
promptPro();
return;
}
if (!engine || !source) {
@@ -1008,7 +1059,7 @@ export function Workspace() {
} finally {
setSavingPhoto(false);
}
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
}, [pro, promptPro, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
// Reopen one of this account's saved frames: the bytes come back as a File
// through the same door an upload uses, then the stored look goes on top.
@@ -1134,15 +1185,18 @@ export function Workspace() {
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
defs.map((def) => {
const value = def.get(recipe.adjustments);
const locked = !!def.pro && !pro;
return {
key: def.key,
label: def.label,
value: def.display(value),
active: openParam === def.key,
amberValue: openParam !== def.key && value !== def.defaultValue,
// The web build is the demo: HDF EFFECT is part of what it shows off,
// so no knob is held back here. The PRO gate stays a phone-build rule.
onClick: () => toggleParam(def.key),
// The phone greys a PRO knob out; the web keeps it clickable and says
// why instead, because the fix here is an action (prove the address)
// rather than a purchase.
pro: !!def.pro,
onClick: locked ? promptPro : () => toggleParam(def.key),
};
});
@@ -1417,8 +1471,13 @@ export function Workspace() {
key: 'gps',
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
active: useGeotag,
pro: true,
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
onClick: () => {
if (!pro) {
promptPro();
return;
}
remember();
setUseGeotag(!useGeotag);
},
@@ -1469,6 +1528,16 @@ export function Workspace() {
},
});
// Shown where an unverified account would otherwise just see an empty list.
// The list is not empty because the account has nothing — it is shut until
// the address is proven, and saying so is the whole point of the chip.
const verifyChip = (): ChipDef => ({
key: 'verify-now',
label: t('pro.verifyChip'),
pro: true,
onClick: promptPro,
});
const framesRow = (): ChipDef[] => [
// NO FRAME is not a chip of its own: clicking the frame already on the
// photo takes it off again, so the row carries one entry per frame.
@@ -1476,7 +1545,12 @@ export function Workspace() {
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
pro: PRO_FRAMES.includes(frame.id),
onClick: () => {
if (PRO_FRAMES.includes(frame.id) && !pro) {
promptPro();
return;
}
setOpenParam(null);
setOpenGroup(null);
remember();
@@ -1545,6 +1619,7 @@ export function Workspace() {
// IMPORT is a chip inside the RECIPES strip.
];
case 'favorited':
if (user && !pro) return [verifyChip()];
return saved.length === 0
? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }]
: saved.map((entry) => ({
@@ -1564,6 +1639,7 @@ export function Workspace() {
// the landing strip — the owner takes it off again with that switch, or
// the curator with the admin slot.
const rows: ChipDef[] = [];
if (user && !pro) return [verifyChip()];
if (myPhotos.length === 0) {
rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} });
} else {
@@ -1747,6 +1823,24 @@ export function Workspace() {
}}
/>
{/* A signed-in account whose address is unproven is served as a guest, so
the studio says so once, at the top, instead of letting each locked
chip explain it on its own. */}
{user && !pro ? (
<div className="verify-bar" role="status">
<span>{t('pro.unverifiedBar', { email: user.email })}</span>
<button
type="button"
className="btn"
data-key="verify-resend"
disabled={resent}
onClick={() => void resendVerification()}
>
{resent ? t('pro.resent') : t('pro.resend')}
</button>
</div>
) : null}
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
@@ -1757,7 +1851,7 @@ export function Workspace() {
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
signedIn={pro}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
@@ -2041,6 +2135,7 @@ export function Workspace() {
{authMode ? (
<AuthModal
initialMode={authMode}
email={user?.email}
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
+38 -4
View File
@@ -180,7 +180,9 @@ export function Landing() {
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
const [con, setCon] = useState(100);
const [toast, setToast] = useState<string | null>(null);
// `lead` is the short bold tag in front of the message; the marketing lines
// share "Coming soon —", the verification ones carry their own.
const [toast, setToast] = useState<{ lead: string; text: string } | null>(null);
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
@@ -197,8 +199,13 @@ export function Landing() {
const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0];
const showToast = useCallback((text: Txt | string) => {
setToast(typeof text === 'string' ? text : c(text));
const toastLead: Txt = { en: 'Coming soon —', vi: 'Sắp có —' };
const showToast = useCallback((text: Txt | string, lead?: Txt) => {
setToast({
lead: c(lead ?? toastLead),
text: typeof text === 'string' ? text : c(text),
});
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(null), 3400);
}, [c]);
@@ -219,6 +226,32 @@ export function Landing() {
reload();
}, [reload]);
// The verification link in the letter redirects here with `?verified=1` (or
// `0` for a stale or unknown token). Say which it was, re-read the session so
// a just-proven account comes back PRO, and drop the flag so a reload does
// not repeat the toast.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const flag = params.get('verified');
if (flag === null) return;
params.delete('verified');
const rest = params.toString();
window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`);
if (flag === '1') {
showToast(
{ en: 'Your email is confirmed — the studio is unlocked.', vi: 'Đã xác thực email — studio đã mở khoá.' },
{ en: 'Verified —', vi: 'Đã xác thực —' },
);
reload();
} else {
showToast(
{ en: 'That verification link is invalid or has expired.', vi: 'Liên kết xác thực không hợp lệ hoặc đã hết hạn.' },
{ en: 'Not verified —', vi: 'Chưa xác thực —' },
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One vote. The visitor may change their mind: the API upserts under the same
// address hash, so a second click replaces the first rather than stacking. A
// vote that never lands is silently dropped — it is not worth a toast.
@@ -949,6 +982,7 @@ export function Landing() {
{auth ? (
<AuthModal
initialMode={auth}
email={user?.email}
onClose={() => setAuth(null)}
onDone={() => {
setAuth(null);
@@ -959,7 +993,7 @@ export function Landing() {
/>
) : null}
{toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
{toast ? <div className="lp-toast" role="status"><b>{toast.lead}</b> {toast.text}</div> : null}
</div>
);
}
+6
View File
@@ -16,6 +16,9 @@ export interface User {
// True when the account is on the API's ADMIN_EMAILS allowlist. The server
// re-checks it on every admin route; this only drives what the UI offers.
admin?: boolean;
// Signed-in accounts only unlock the PRO tier once their address is proven;
// an unverified one is served exactly like a guest. Admins count as verified.
verified?: boolean;
// A ready-to-use picture URL (`/api/users/<id>/avatar?v=<file>`), or null.
// The version segment is the file's own name, so a replacement is never
// served from cache.
@@ -197,6 +200,9 @@ export const api = {
login: (email: string, password: string) =>
call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => call<void>('/auth/logout', { method: 'POST' }),
// Mail the verification link to the signed-in address again. Works while
// unverified (that is the whole point); 429 once the hourly cap is spent.
resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }),
listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'),
createRecipe: (name: string, recipe: Recipe) =>
+13
View File
@@ -109,6 +109,19 @@ export const en: Dict = {
'auth.continueDemo': 'Keep using the demo',
'auth.busy': 'Working…',
'auth.loggedInAs': 'Signed in as {email}',
'auth.verifyTitle': 'Verify your email',
'auth.verifyBody': 'We sent a verification link to {email}. Open it to unlock the PRO features.',
'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.',
'auth.verifySent': 'Verification email sent again.',
'auth.verifiedDone': 'I have verified',
'auth.signupVerifyHint': 'After signing up we email you a verification link — open it to unlock the PRO features.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'VERIFY EMAIL TO USE PRO',
'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.',
'pro.resend': 'RESEND VERIFICATION EMAIL',
'pro.resent': 'SENT',
'save.title': 'Save recipe',
'save.name': 'Recipe name',
+13
View File
@@ -117,6 +117,19 @@ export const vi = {
'auth.continueDemo': 'Tiếp tục dùng thử',
'auth.busy': 'Đang xử lý…',
'auth.loggedInAs': 'Đã đăng nhập: {email}',
'auth.verifyTitle': 'Xác thực email',
'auth.verifyBody': 'Đã gửi một liên kết xác thực tới {email}. Mở liên kết đó để dùng được các tính năng PRO.',
'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.',
'auth.verifySent': 'Đã gửi lại email xác thực.',
'auth.verifiedDone': 'Tôi đã xác thực xong',
'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một email xác thực — mở liên kết trong đó để bật các tính năng PRO.',
// The PRO gate. Signed in but unproven is served as a guest, so the studio
// needs one place that says why and one way to ask for the letter again.
'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO',
'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.',
'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC',
'pro.resent': 'ĐÃ GỬI',
'save.title': 'Lưu công thức',
'save.name': 'Tên công thức',
+17
View File
@@ -195,6 +195,23 @@
}
input[type="range"] { width: 100%; accent-color: var(--accent); }
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
.hint.ok { color: var(--accent); }
/* --- PRO gate bar ------------------------------------------------------- */
/* One line between the header and the workspace, shown only while a signed-in
account has not proven its address — it is served as a guest until it does. */
.verify-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 6px 12px;
background: var(--accent-soft);
border-bottom: 1px solid var(--accent-line);
font-size: 12px;
}
.verify-bar .btn { padding: 3px 10px; font-size: 11px; }
/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */
.create-form {
+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')}