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}
+
-
{copy.guide.step1Title}
@@ -392,17 +453,17 @@ export default function MfaSetupPanel() {
{copy.guide.step2Title}
{copy.guide.step2Description}
- {qrImage ? (
+ {uri ? (
) : 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"