From 777920419fe2edd890940fa09d5700bdefcbb8ca Mon Sep 17 00:00:00 2001 From: shenlan Date: Tue, 7 Oct 2025 05:47:45 +0800 Subject: [PATCH] feat(ui): improve totp mfa provisioning and ux (#438) --- ui/homepage/app/api/auth/session/route.ts | 1 + .../app/panel/account/MfaSetupPanel.tsx | 172 +++++++++++++----- ui/homepage/i18n/translations.ts | 36 ++++ ui/homepage/lib/userStore.tsx | 2 + ui/homepage/package.json | 3 + ui/homepage/yarn.lock | 17 ++ 6 files changed, 183 insertions(+), 48 deletions(-) diff --git a/ui/homepage/app/api/auth/session/route.ts b/ui/homepage/app/api/auth/session/route.ts index 8cc6f65..3d4fffa 100644 --- a/ui/homepage/app/api/auth/session/route.ts +++ b/ui/homepage/app/api/auth/session/route.ts @@ -20,6 +20,7 @@ type AccountUser = { totpPending?: boolean totpSecretIssuedAt?: string totpConfirmedAt?: string + totpLockedUntil?: string } } diff --git a/ui/homepage/app/panel/account/MfaSetupPanel.tsx b/ui/homepage/app/panel/account/MfaSetupPanel.tsx index 0021da8..3c86b59 100644 --- a/ui/homepage/app/panel/account/MfaSetupPanel.tsx +++ b/ui/homepage/app/panel/account/MfaSetupPanel.tsx @@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import Image from 'next/image' +import QRCode from 'react-qr-code' import Card from '../components/Card' import { useLanguage } from '@i18n/LanguageProvider' @@ -14,6 +14,7 @@ type TotpStatus = { totpPending?: boolean totpSecretIssuedAt?: string totpConfirmedAt?: string + totpLockedUntil?: string } type ProvisionResponse = { @@ -21,7 +22,6 @@ type ProvisionResponse = { otpauth_url?: string issuer?: string account?: string - qr?: string mfa?: TotpStatus user?: { mfa?: TotpStatus } } @@ -47,7 +47,8 @@ export default function MfaSetupPanel() { const [status, setStatus] = useState(null) const [secret, setSecret] = useState('') const [uri, setUri] = useState('') - const [qrImage, setQrImage] = useState('') + const [issuer, setIssuer] = useState('') + const [accountLabel, setAccountLabel] = useState('') const [code, setCode] = useState('') const [isProvisioning, setIsProvisioning] = useState(false) const [isVerifying, setIsVerifying] = useState(false) @@ -59,19 +60,40 @@ export default function MfaSetupPanel() { const hasPendingMfa = Boolean(status?.totpPending && !status?.totpEnabled) const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending)) - const generateQrImage = useCallback((value: string) => { - if (!value) { - return '' - } + const resolveErrorMessage = useCallback( + (code?: string | null) => { + if (!code) { + return copy.error + } - try { - const encoded = encodeURIComponent(value) - return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encoded}` - } catch (err) { - console.warn('Failed to build MFA QR code URL', err) - return '' - } - }, []) + const normalized = code.toLowerCase() + const mapping: Record = { + 'mfa_token_required': copy.errors.sessionExpired, + 'session_token_required': copy.errors.sessionExpired, + 'session_required': copy.errors.sessionExpired, + 'invalid_session': copy.errors.sessionExpired, + 'invalid_mfa_token': copy.errors.sessionExpired, + 'mfa_setup_failed': copy.errors.provisioningFailed, + 'mfa_user_lookup_failed': copy.errors.provisioningFailed, + 'mfa_secret_generation_failed': copy.errors.provisioningFailed, + 'mfa_challenge_creation_failed': copy.errors.provisioningFailed, + 'mfa_status_failed': copy.errors.network, + 'account_service_unreachable': copy.errors.network, + 'mfa_disable_failed': copy.errors.disableFailed, + 'mfa_not_enabled': copy.errors.disableFailed, + 'mfa_code_required': copy.errors.missingCode, + 'missing_credentials': copy.errors.missingCode, + 'mfa_secret_missing': copy.errors.provisioningFailed, + 'invalid_mfa_code': copy.errors.invalidCode, + 'mfa_verification_failed': copy.errors.verificationFailed, + 'mfa_update_failed': copy.errors.verificationFailed, + 'mfa_challenge_locked': copy.errors.locked, + } + + return mapping[normalized] ?? copy.error + }, + [copy.error, copy.errors], + ) const fetchStatus = useCallback(async () => { try { @@ -116,29 +138,42 @@ export default function MfaSetupPanel() { data?: ProvisionResponse } if (!payload?.success || !payload?.data) { - setError(payload?.error ?? copy.error) + setError(resolveErrorMessage(payload?.error)) return } + const data = payload.data - setSecret(data?.secret ?? '') - const nextUri = data?.otpauth_url ?? '' + const nextSecret = typeof data?.secret === 'string' ? data.secret.trim() : '' + const nextUri = typeof data?.otpauth_url === 'string' ? data.otpauth_url.trim() : '' + const nextIssuer = typeof data?.issuer === 'string' ? data.issuer.trim() : '' + const nextAccount = typeof data?.account === 'string' ? data.account.trim() : '' + + setSecret(nextSecret) setUri(nextUri) - const nextQr = data?.qr ?? (nextUri ? generateQrImage(nextUri) : '') - setQrImage(nextQr) - setStatus(data?.mfa ?? data?.user?.mfa ?? status) + setIssuer(nextIssuer) + setAccountLabel(nextAccount) + setCode('') + + const nextStatus = data?.mfa ?? data?.user?.mfa ?? null + if (nextStatus) { + setStatus(nextStatus) + } else { + void fetchStatus() + } } catch (err) { console.warn('Provision TOTP failed', err) - setError(copy.error) + setError(resolveErrorMessage('account_service_unreachable')) } finally { setIsProvisioning(false) } - }, [copy.error, generateQrImage, status]) + }, [fetchStatus, resolveErrorMessage]) const handleVerify = useCallback( async (event: FormEvent) => { event.preventDefault() - if (!code.trim()) { - setError(copy.codePlaceholder) + const normalizedCode = code.replace(/\D/g, '').slice(0, 6) + if (!normalizedCode) { + setError(resolveErrorMessage('mfa_code_required')) return } setIsVerifying(true) @@ -148,7 +183,7 @@ export default function MfaSetupPanel() { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ code: code.trim() }), + body: JSON.stringify({ code: normalizedCode }), }) const payload = (await response.json().catch(() => ({}))) as { success?: boolean @@ -156,30 +191,41 @@ export default function MfaSetupPanel() { needMfa?: boolean } if (!payload?.success || !response.ok) { - setError(payload?.error ?? copy.error) + setError(resolveErrorMessage(payload?.error)) + void fetchStatus() return } - setStatus({ totpEnabled: true }) + setStatus({ totpEnabled: true, totpPending: false }) setSecret('') setUri('') - setQrImage('') + setIssuer('') + setAccountLabel('') setCode('') await refresh() + void fetchStatus() setIsDialogOpen(false) router.replace('/panel/account') router.refresh() } catch (err) { console.warn('Verify TOTP failed', err) - setError(copy.error) + setError(resolveErrorMessage('account_service_unreachable')) } finally { setIsVerifying(false) } }, - [code, copy.codePlaceholder, copy.error, refresh, router], + [code, fetchStatus, refresh, resolveErrorMessage, router], ) const displayStatus = useMemo(() => status ?? user?.mfa ?? null, [status, user?.mfa]) + const lockoutLabel = useMemo(() => { + if (!displayStatus?.totpLockedUntil || displayStatus?.totpEnabled) { + return '' + } + return formatTimestamp(displayStatus.totpLockedUntil) + }, [displayStatus?.totpLockedUntil, displayStatus?.totpEnabled]) + const lockoutActive = Boolean(lockoutLabel) + useEffect(() => { if ( (setupRequested || isDialogOpen) && @@ -201,10 +247,14 @@ export default function MfaSetupPanel() { ]) useEffect(() => { - if (!secret && user?.mfa?.totpEnabled) { - setQrImage('') + if (displayStatus?.totpEnabled) { + setSecret('') + setUri('') + setIssuer('') + setAccountLabel('') + setCode('') } - }, [secret, user?.mfa?.totpEnabled]) + }, [displayStatus?.totpEnabled]) const handleLogoutClick = useCallback(async () => { await logout() @@ -226,24 +276,26 @@ export default function MfaSetupPanel() { data?: { user?: { mfa?: TotpStatus } } } if (!response.ok || !payload?.success) { - setError(payload?.error ?? copy.error) + setError(resolveErrorMessage(payload?.error)) return } const nextStatus = payload?.data?.user?.mfa ?? null setStatus(nextStatus ?? { totpEnabled: false, totpPending: false }) setSecret('') setUri('') - setQrImage('') + setIssuer('') + setAccountLabel('') setCode('') await refresh() + void fetchStatus() router.refresh() } catch (err) { console.warn('Disable TOTP failed', err) - setError(copy.error) + setError(resolveErrorMessage('account_service_unreachable')) } finally { setIsDisabling(false) } - }, [copy.error, refresh, router]) + }, [fetchStatus, refresh, resolveErrorMessage, router]) const closeDialog = useCallback(() => { setIsDialogOpen(false) @@ -378,6 +430,15 @@ export default function MfaSetupPanel() { {hasPendingMfa ? copy.pendingHint : copy.subtitle}

+ {lockoutActive ? ( +

+ {copy.errors.locked} + {lockoutLabel ? ( + {lockoutLabel} + ) : null} +

+ ) : null} +
  1. {copy.guide.step1Title}

    @@ -392,17 +453,17 @@ export default function MfaSetupPanel() {

    {copy.guide.step2Title}

    {copy.guide.step2Description}

    - {qrImage ? ( + {uri ? (
    - {copy.qrLabel} +
    + +
    ) : null} @@ -411,6 +472,18 @@ export default function MfaSetupPanel() {

    {copy.secretLabel}

    {secret}
    + {issuer ? ( +
    +

    {copy.issuerLabel}

    +

    {issuer}

    +
    + ) : null} + {accountLabel ? ( +
    +

    {copy.accountLabel}

    +

    {accountLabel}

    +
    + ) : null} {uri ? (

    {copy.uriLabel}

    @@ -457,7 +530,10 @@ export default function MfaSetupPanel() { maxLength={6} autoComplete="one-time-code" value={code} - onChange={(event) => setCode(event.target.value)} + onChange={(event) => { + const digitsOnly = event.target.value.replace(/\D/g, '').slice(0, 6) + setCode(digitsOnly) + }} placeholder={copy.codePlaceholder} className="mt-2 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-[0.6em] text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-200" /> diff --git a/ui/homepage/i18n/translations.ts b/ui/homepage/i18n/translations.ts index ee3f966..5cd3043 100644 --- a/ui/homepage/i18n/translations.ts +++ b/ui/homepage/i18n/translations.ts @@ -242,6 +242,8 @@ type UserCenterMfaTranslation = { generate: string regenerate: string secretLabel: string + issuerLabel: string + accountLabel: string uriLabel: string manualHint: string codeLabel: string @@ -294,6 +296,16 @@ type UserCenterMfaTranslation = { action: string confirming: string } + errors: { + provisioningFailed: string + verificationFailed: string + missingCode: string + invalidCode: string + locked: string + sessionExpired: string + network: string + disableFailed: string + } error: string } @@ -685,6 +697,8 @@ export const translations: Record<'en' | 'zh', Translation> = { generate: 'Generate setup key', regenerate: 'Regenerate key', secretLabel: 'Secret key', + issuerLabel: 'Issuer', + accountLabel: 'Account label', uriLabel: 'Authenticator link', manualHint: 'Scan the link with Google Authenticator or enter the key manually.', codeLabel: 'Verification code', @@ -741,6 +755,16 @@ export const translations: Record<'en' | 'zh', Translation> = { action: 'Unbind MFA', confirming: 'Unbinding…', }, + errors: { + provisioningFailed: 'We could not generate a new secret. Please try again.', + verificationFailed: 'The verification failed. Enter a fresh authenticator code.', + missingCode: 'Enter the 6-digit code from your authenticator.', + invalidCode: 'The code did not match. Try again with a new one.', + locked: 'Too many invalid attempts. Wait for the cooldown before retrying.', + sessionExpired: 'Your authentication session has expired. Sign in again to continue.', + network: 'The account service is not reachable right now. Try again in a moment.', + disableFailed: 'We could not reset MFA. Please try again later.', + }, error: 'We could not complete the request. Please try again.', }, }, @@ -1049,6 +1073,8 @@ export const translations: Record<'en' | 'zh', Translation> = { generate: '生成绑定密钥', regenerate: '重新生成密钥', secretLabel: '密钥', + issuerLabel: '签发方', + accountLabel: '账户标识', uriLabel: '认证链接', manualHint: '使用 Google Authenticator 扫描链接或手动输入密钥。', codeLabel: '动态验证码', @@ -1101,6 +1127,16 @@ export const translations: Record<'en' | 'zh', Translation> = { action: '立即解绑', confirming: '解绑中…', }, + errors: { + provisioningFailed: '暂时无法生成新的密钥,请稍后重试。', + verificationFailed: '验证码校验失败,请重新输入新的动态验证码。', + missingCode: '请输入 6 位动态验证码以继续。', + invalidCode: '验证码不正确,请重试或等待新的验证码。', + locked: '错误次数过多,请等待冷却时间后再试。', + sessionExpired: '登录状态已失效,请重新登录后再继续操作。', + network: '账户服务暂时不可用,请稍后再试。', + disableFailed: '无法重置多因素认证,请稍后再试。', + }, error: '操作失败,请稍后再试。', }, }, diff --git a/ui/homepage/lib/userStore.tsx b/ui/homepage/lib/userStore.tsx index 8dea788..18e942b 100644 --- a/ui/homepage/lib/userStore.tsx +++ b/ui/homepage/lib/userStore.tsx @@ -23,6 +23,7 @@ type User = { totpPending?: boolean totpSecretIssuedAt?: string totpConfirmedAt?: string + totpLockedUntil?: string } } @@ -76,6 +77,7 @@ async function fetchSessionUser(): Promise { totpPending?: boolean totpSecretIssuedAt?: string totpConfirmedAt?: string + totpLockedUntil?: string } } | null } diff --git a/ui/homepage/package.json b/ui/homepage/package.json index 3809c1a..edb3281 100644 --- a/ui/homepage/package.json +++ b/ui/homepage/package.json @@ -20,10 +20,13 @@ "marked": "^16.1.2", "next": "14.2.32", "pdfjs-dist": "^4.2.67", + "prop-types": "^15.8.1", + "qr.js": "0.0.0", "react": "18.2.0", "react-dom": "18.2.0", "react-grid-layout": "^1.4.4", "react-pdf": "^9.1.0", + "react-qr-code": "^2.0.18", "react-resizable": "^3.0.4", "swr": "^2.3.0", "zustand": "^4.5.4" diff --git a/ui/homepage/yarn.lock b/ui/homepage/yarn.lock index 36332fb..7cf1d2d 100644 --- a/ui/homepage/yarn.lock +++ b/ui/homepage/yarn.lock @@ -4274,6 +4274,16 @@ __metadata: languageName: node linkType: hard +"react-qr-code@npm:^2.0.18": + version: 2.0.18 + resolution: "react-qr-code@npm:2.0.18" + dependencies: + prop-types: "npm:^15.8.1" + qr.js: "npm:0.0.0" + checksum: 10/bf526acfbbab2cc8643ba8e4809b816219eabd76a0cdc7a0dea25459acae08afdce8b4c8a27a566f0c51d5fd403c6778c6b5bf41c404a2f36b0288db533eb94e + languageName: node + linkType: hard + "react-resizable@npm:^3.0.4, react-resizable@npm:^3.0.5": version: 3.0.5 resolution: "react-resizable@npm:3.0.5" @@ -5498,6 +5508,13 @@ __metadata: languageName: node linkType: hard +"qr.js@npm:0.0.0": + version: 0.0.0 + resolution: "qr.js@npm:0.0.0" + checksum: 10/7388989d66fe93613ebd8a518a61ea4aee7be5bd7fc0a9785c57891a9166ac9433e48f3cbfd698d9cce1eecd30dfab2b7c2335b17802ff1a68273e5d25fabe39 + languageName: node + linkType: hard + "yaml@npm:^2.3.4": version: 2.8.1 resolution: "yaml@npm:2.8.1"