From e021f7d1ff7e240b9fa8a6543ad525bd8567d6b5 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 20:08:08 +0700 Subject: [PATCH] web: prove an address with the six digits the letter carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing up mailed a link and nothing else, so a visitor who signed up on one device and read the mail on another had to leave the page the studio was open on, or give up and stay a guest. The letter now carries six digits as well, and the verify dialog — the face a fresh signup already lands on — takes them. Backend, one row is both proofs. `createEmailVerification` mints the token as it did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token, code }`; `sendVerification` passes both to the mailer, which puts the code first and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes) and the link keeps the row's own 24-hour `expires_at`: two clocks over one row, so the code needs no expiry column of its own. That row's `code` is NULL for anything minted before this commit, a value no typed guess can match, so an in-flight link from the old mail still works and its owner simply has no code to type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather than a rebuild, and sets `attempts` to 0. `verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and the shape of the answer is the point. 'stale' is both "no live code" and "too old", so the caller learns nothing about which; 'locked' is the spent-attempts state, which only a fresh letter leaves. The attempt is counted BEFORE the comparison is trusted, so an interrupted request cannot hand back a guess nobody paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit secret from being walked through at a hundred requests a second. The comparison itself is `timingSafeEqual` behind a length check, the same pair the password path uses. On success the row is deleted and `email_verified` set, so the same row spends the link with the code — one proof, one use. The route is `POST /api/auth/verify-code`, a POST and not a GET like the link because a code in a query string lands in every proxy log on the way. It reads `auth`, not `requirePro`: the whole point of it is the account that has not passed the gate yet. Input must be exactly six digits before anything else happens, so the counter only ever counts real guesses; a wrong or stale code is 400, a locked one 429 with `retry-after: 900`, and an already-verified caller gets 200 without touching the row. No limiter of its own: the cap lives with the secret on the row, and a fresh code costs one of the three resends an hour, so five guesses per code is the budget either way. On the web side `api.verifyCode` posts the code, and the dialog's verify face swaps its resend button for a code box plus a smaller resend beside it: the box is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and strips non-digits as they are typed, so the number pad comes up on a phone and nothing can paste a password into it. The submit button is disabled until six digits are there. The two answers a visitor can actually act on get sentences of their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it comes. The link path is untouched and still works, and the dialog keeps its "Tôi đã xác thực xong" escape in no place at all — it verified nothing, so closing the dialog and asking again covers the same ground. The comment in `docker/.env.example` now says the letter carries both, since a deployment without a relay writes both to the api log. Verified: backend `npm test` — 180 passed, 0 failed. The new section in security.mjs drives the route end to end against the source: signup leaves a six-digit code beside the link, a wrong code verifies nothing and leaves the account unproven, a five-digit body is refused, a signed-out caller cannot type one, the mailed code verifies, spending it spends the link, five wrong guesses lock the code out and the right code then does not help, a resent letter hands out a fresh code that is not locked out by the old guesses, and a code aged past its quarter hour is refused. otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090 against the rebuilt app and api, no page errors: a fresh signup lands on the verify face with the box ready, a wrong code says so and the account stays a guest, the mailed code unlocks PRO, and a proven address is not asked again on the next login. Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27, award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean. ponytail: the code rides `created_at` rather than an `expires_at` of its own, so the link's 24 hours and the code's 15 minutes are one column read twice; the day the two need to drift apart independently, the column is the thing to split. The route carries no per-IP limiter, only the per-row cap — a stranger can burn one account's five guesses, which costs that owner a resend, and a limiter keyed on the address would be the next thing to add if that turns out to be cheap for an attacker. The code is not usable from another browser: it verifies the session that asked for it, which is the behaviour the request asked for and not a gap. --- docker/.env.example | 12 +++-- docker/backend/src/db.ts | 73 ++++++++++++++++++++++++---- docker/backend/src/mailer.ts | 24 ++++++--- docker/backend/src/server.ts | 36 +++++++++++--- docker/backend/test/security.mjs | 62 +++++++++++++++++++++++ docker/frontend/src/api.ts | 5 ++ docker/frontend/src/i18n/en.ts | 11 +++-- docker/frontend/src/i18n/vi.ts | 11 +++-- docker/frontend/src/ui/AuthModal.tsx | 66 ++++++++++++++++++++----- 9 files changed, 253 insertions(+), 47 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index 9df20f2..d40b736 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -6,12 +6,14 @@ WEB_PORT=8090 # Leave empty to make nobody an admin. ADMIN_EMAILS= -# The mail relay that sends the address-verification link. A new account is a -# guest until it follows that link, so a deployment without a relay can only -# ever hand out guest access. +# The mail relay that sends the address-verification mail — the 6-digit code +# plus the link. A new account is a guest until it proves the address with one +# of the two, so a deployment without a relay can only ever hand out guest +# access. # -# Leave SMTP_HOST empty and the link is written to the api container's log -# instead (`docker compose logs api`), which is enough for local work. +# Leave SMTP_HOST empty and both the code and the link are written to the api +# container's log instead (`docker compose logs api`), which is enough for local +# work. SMTP_HOST= # 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set # SMTP_SECURE=true to force the latter on an unusual port. diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index bd47fd8..eaa6dda 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; +import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto'; export const SESSION_COOKIE = 'rc_session'; export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days @@ -86,7 +86,12 @@ CREATE TABLE IF NOT EXISTS email_verifications ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + -- The six digits the same letter carries, so a visitor can prove the address + -- without leaving the page they signed up on. NULL for a row minted before + -- codes existed; attempts counts only the guesses at it. + code TEXT, + attempts INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS places ( key TEXT PRIMARY KEY, @@ -165,6 +170,19 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string => } } +// The mailed code, added once visitors were expected to prove an address +// without leaving the page. Rows minted before it keep working as links: their +// `code` is NULL, which no typed guess can match (see verifyEmailCode). +{ + const cols = db.prepare('PRAGMA table_info(email_verifications)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'code')) { + db.exec(`ALTER TABLE email_verifications ADD COLUMN code TEXT`); + } + if (!cols.some((c) => c.name === 'attempts')) { + db.exec(`ALTER TABLE email_verifications ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`); + } +} + // The strip's own labels, added after the first contributions were on disk: the // tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title // and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are @@ -277,18 +295,30 @@ export function findUserById(id: number): User | undefined { // mail is the only link that works and the table cannot grow past the user // count. 24 hours is long enough to find the mail in a spam folder. export const VERIFY_TTL_S = 24 * 60 * 60; +// The typed code is the same proof over a shorter clock: six digits are worth +// guessing for a quarter of an hour, not for a day. It rides the same row — +// the link's `expires_at` and the code's `created_at + CODE_TTL_S` are two +// clocks over one row, which is why the code needs no expiry column of its own. +export const CODE_TTL_S = 15 * 60; +// Five guesses at a million, then the row is spent: the count is what keeps a +// six-digit secret from being walked through a hundred requests a second. +export const MAX_CODE_ATTEMPTS = 5; -export function createEmailVerification(userId: number): string { +export interface Verification { + token: string; + code: string; +} + +export function createEmailVerification(userId: number): Verification { const token = randomBytes(32).toString('hex'); + // randomInt, not Math.random: the code is the one value here worth predicting. + const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); const at = now(); db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); - db.prepare('INSERT INTO email_verifications (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)').run( - token, - userId, - new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), - at, - ); - return token; + db.prepare( + 'INSERT INTO email_verifications (token, code, user_id, expires_at, created_at, attempts) VALUES (?, ?, ?, ?, ?, 0)', + ).run(token, code, userId, new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), at); + return { token, code }; } // The account the token proves, or null when it is unknown or expired — the @@ -305,6 +335,29 @@ export function verifyEmailToken(token: string): number | null { return row.userId; } +// What a typed code did. 'stale' covers both "no live code" and "too old", so +// the caller learns nothing about which — the two are one answer, "ask for +// another letter". 'locked' is the spent-attempts state, which the visitor can +// only leave by having a new code mailed. +export type CodeResult = 'ok' | 'bad' | 'stale' | 'locked'; + +export function verifyEmailCode(userId: number, code: string): CodeResult { + const row = db + .prepare('SELECT code, attempts, created_at AS createdAt FROM email_verifications WHERE user_id = ?') + .get(userId) as { code: string | null; attempts: number; createdAt: string } | undefined; + if (!row || !row.code) return 'stale'; + if (new Date(row.createdAt).getTime() + CODE_TTL_S * 1000 <= Date.now()) return 'stale'; + if (row.attempts >= MAX_CODE_ATTEMPTS) return 'locked'; + // The attempt is counted before the answer is given, so an interrupted + // request cannot hand back a guess nobody paid for. + db.prepare('UPDATE email_verifications SET attempts = attempts + 1 WHERE user_id = ?').run(userId); + if (code.length !== row.code.length || !timingSafeEqual(Buffer.from(code), Buffer.from(row.code))) return 'bad'; + // One row is both proofs, so the code being spent spends the link with it. + db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); + db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(userId); + return 'ok'; +} + export function deleteEmailVerifications(userId: number): void { db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); } diff --git a/docker/backend/src/mailer.ts b/docker/backend/src/mailer.ts index 0935373..cf5526a 100644 --- a/docker/backend/src/mailer.ts +++ b/docker/backend/src/mailer.ts @@ -33,26 +33,36 @@ const transporter: Transporter | null = mailConfigured : null; // Both languages, because the account's language is not known before it exists. -const body = (url: string) => +// The code comes first and the link second: the code is the one that works +// without leaving the page the visitor signed up on, so it is what most of them +// will use — but the link stays, for the phone that owns the address, and +// because a letter that only carries digits is a letter a spam filter can +// decide is a phishing attempt. +const body = (url: string, code: string) => [ 'RecipesCam — xác thực địa chỉ email / verify your email address', '', + `Mã xác thực của bạn / your verification code: ${code}`, + '', + 'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.', + 'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.', + '', + 'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):', url, '', - 'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.', - 'The link expires in 24 hours. If you did not sign up, ignore this mail.', + 'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.', ].join('\r\n'); // Fire and forget: the account already exists, so a relay that is slow, out of // quota or misconfigured may not fail the signup that asked for it. The owner // can ask for another link from the studio; the operator sees the error here. -export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void { +export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void { if (!transporter) { - log(`[verify] SMTP not configured — verification link for ${to}: ${url}`); + log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`); return; } transporter - .sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) }) - .then(() => log(`[verify] link sent to ${to}`)) + .sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) }) + .then(() => log(`[verify] code and link sent to ${to}`)) .catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`)); } diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index fa9200b..205eca0 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -53,6 +53,7 @@ import { updateRecipe, updateUserEmail, userAvatar, + verifyEmailCode, verifyEmailToken, verifyPassword, type PhotoMeta, @@ -267,13 +268,13 @@ function originOf(req: FastifyRequest): string { return host ? `${proto}://${host}` : ''; } -// Mints the single live token and hands the link to the mailer. The URL is the -// API's own route, so a click needs no page of its own (see the redirect -// there). A relay that cannot send is not an error here: the link is in the -// log, and the account can ask again. +// Mints the single live token and code and hands both to the mailer. The URL is +// the API's own route, so a click needs no page of its own (see the redirect +// there). A relay that cannot send is not an error here: the code and the link +// are in the log, and the account can ask again. function sendVerification(req: FastifyRequest, user: User): void { - const token = createEmailVerification(user.id); - sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg)); + const { token, code } = createEmailVerification(user.id); + sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, code, (msg) => req.log.info(msg)); } // ---- analytics ------------------------------------------------------------ @@ -475,6 +476,29 @@ app.get('/api/auth/verify', async (req, reply) => { return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303); }); +// The code from the same letter, typed into the page the visitor signed up on. +// A POST, unlike the link: it is a mutation the visitor submits on purpose, and +// a code in a GET query string would land in every proxy log on the way. +// +// No limiter of its own: the row carries the cap (MAX_CODE_ATTEMPTS) and a +// fresh code costs one of the three resends an hour, so five guesses per code +// is the budget an attacker has either way — and the cap lives with the secret. +app.post('/api/auth/verify-code', async (req, reply) => { + // `auth`, not `requirePro`: the whole point of the route is the account that + // has not passed the gate yet. + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true }); + const b = bodyOf(req); + const code = b && typeof b.code === 'string' ? b.code.trim() : ''; + // Six digits or nothing, so the counter only ever counts real guesses. + if (!/^\d{6}$/.test(code)) return reply.status(400).send({ error: 'invalid code' }); + const result = verifyEmailCode(user.id, code); + if (result === 'locked') return reply.header('retry-after', '900').status(429).send({ error: 'too_many_attempts' }); + if (result !== 'ok') return reply.status(400).send({ error: 'invalid code' }); + return reply.status(200).send({ ok: true, verified: true }); +}); + // The banner's own button. Capped like signup and keyed on the address, so the // route is not a way to mail a stranger repeatedly. const allowResend = limiter(3, 60 * 60_000); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index b2e2576..0d35f03 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -100,6 +100,25 @@ async function activeSignup(a, email) { await followVerifyLink(email); return res; } +// The same letter carries a code, and the suite reaches it the same way — the +// database is the only reader of the mail here. +function codeFor(email) { + const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true }); + const row = db + .prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)') + .get(email); + db.close(); + return row?.code; +} +// The code's own clock starts at `created_at`, so an old one is made here +// rather than waited for. +function ageCode(email, minutes) { + const db = new Database(join(DATA_DIR, 'recipescam.db')); + db.prepare( + 'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)', + ).run(new Date(Date.now() - minutes * 60_000).toISOString(), email); + db.close(); +} // Run the sources, not a possibly stale build: the point of this suite is to // test the code as written. @@ -225,6 +244,49 @@ try { check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location'))); check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true); + // ---- the code in the same letter ---------------------------------------- + // The link is not the only way to prove an address any more: the letter also + // carries six digits the visitor types into the dialog they signed up on. The + // code is the shorter-lived of the two proofs and the one worth guessing, so + // the checks below are mostly about what a wrong guess costs. + const typed = actor(); + const typedEmail = `typed${stamp}@test.local`; + await typed.signup(typedEmail); + const code = codeFor(typedEmail); + check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code)); + const wrongCode = code === '000000' ? '111111' : '000000'; + const type = (a, value) => + a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) }); + check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400); + check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false); + check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400); + check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401); + check('the mailed code verifies the account', (await type(typed, code)).status === 200); + check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true); + check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail))); + + const locked = actor(); + const lockedEmail = `locked${stamp}@test.local`; + await locked.signup(lockedEmail); + const lockedCode = codeFor(lockedEmail); + const otherCode = lockedCode === '000000' ? '111111' : '000000'; + for (let i = 0; i < 5; i++) await type(locked, otherCode); + const afterLock = await type(locked, lockedCode); + check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`); + check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false); + await locked.req('/auth/resend-verification', { method: 'POST' }); + const freshCode = codeFor(lockedEmail); + check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode))); + check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200); + + const stale = actor(); + const staleEmail = `stale${stamp}@test.local`; + await stale.signup(staleEmail); + const staleCode = codeFor(staleEmail); + ageCode(staleEmail, 16); // past CODE_TTL_S + check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400); + check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false); + // ---- rate limiting ------------------------------------------------------ const brute = actor(); const bruteEmail = `brute${stamp}@test.local`; diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 424fb61..d69fa9d 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -221,6 +221,11 @@ export const api = { // 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' }), + // The six digits from the same letter, typed instead of clicking the link. + // The route answers to the session the signup already granted, so the code + // proves the address to the browser that asked for it. + verifyCode: (code: string) => + call<{ ok: boolean; verified?: boolean }>('/auth/verify-code', { method: 'POST', body: JSON.stringify({ code }) }), listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'), createRecipe: (name: string, recipe: Recipe) => diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 13d2a97..afc7fe9 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -119,11 +119,14 @@ export const en: Dict = { '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.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — 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.', + 'auth.verifySent': 'A new code has been sent.', + 'auth.codePlaceholder': 'The six digits from the mail', + 'auth.verifyBtn': 'VERIFY', + 'auth.codeBad': 'That code is wrong or has expired (15 minutes).', + 'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.', + 'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in 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. diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index fd604b1..3895a7b 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -127,11 +127,14 @@ export const vi = { '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.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để 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.', + 'auth.verifySent': 'Đã gửi lại mã xác thực.', + 'auth.codePlaceholder': 'Mã 6 số trong email', + 'auth.verifyBtn': 'XÁC THỰC', + 'auth.codeBad': 'Mã không đúng hoặc đã hết hạn (15 phút).', + 'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.', + 'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để 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. diff --git a/docker/frontend/src/ui/AuthModal.tsx b/docker/frontend/src/ui/AuthModal.tsx index 4d2b08e..d259729 100644 --- a/docker/frontend/src/ui/AuthModal.tsx +++ b/docker/frontend/src/ui/AuthModal.tsx @@ -7,10 +7,11 @@ import { api } from '../api'; // 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. +// address it gave has not been confirmed, so until the visitor proves it — by +// typing the code from the letter, or by opening the link in it — it is served +// exactly like a guest. A fresh signup lands on that face by itself, with the +// code box already waiting: 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, @@ -28,18 +29,45 @@ export function AuthModal({ const [mode, setMode] = useState<'login' | 'signup' | 'verify'>(initialMode); const [address, setAddress] = useState(''); const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [sent, setSent] = useState(false); + // The API answers in codes; the two a visitor can actually reach are worth a + // sentence of their own, and the rest are shown as they come. + const complain = (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + setError( + msg === 'invalid code' + ? t('auth.codeBad') + : msg === 'too_many_attempts' + ? t('auth.codeLocked') + : msg, + ); + }; + + const resend = async () => { + setBusy(true); + setError(null); + try { + await api.resendVerification(); + setSent(true); + } catch (err) { + complain(err); + } finally { + setBusy(false); + } + }; + const submit = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { if (mode === 'verify') { - await api.resendVerification(); - setSent(true); + await api.verifyCode(code); + onDone(); return; } const { user } = await (mode === 'login' @@ -47,13 +75,13 @@ export function AuthModal({ : api.signup(address, password)); setAddress(user.email); if (!user.verified) { - if (mode === 'signup') setSent(true); // signup mails the first link itself + if (mode === 'signup') setSent(true); // signup mails the first code itself setMode('verify'); return; } onDone(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + complain(err); } finally { setBusy(false); } @@ -67,12 +95,28 @@ export function AuthModal({

{t('auth.verifyTitle')}

{t('auth.verifyBody', { email: who })}

{t('auth.verifyProHint')}

+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> {sent ?

{t('auth.verifySent')}

: null} {error ?

{error}

: null} - + -