Add MFA onboarding workflow to panel (#370)

This commit is contained in:
shenlan 2025-10-02 15:32:37 +08:00 committed by GitHub
parent f204180354
commit 3031be586e
9 changed files with 498 additions and 29 deletions

View File

@ -13,6 +13,7 @@ type AccountUser = {
username?: string
email: string
mfaEnabled?: boolean
mfaPending?: boolean
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
@ -60,11 +61,39 @@ export async function GET(request: NextRequest) {
? rawUser.id.trim()
: undefined
const rawMfa = rawUser.mfa ?? {}
const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled)
const derivedMfaPendingSource =
typeof rawUser.mfaPending === 'boolean'
? rawUser.mfaPending
: typeof rawMfa.totpPending === 'boolean'
? rawMfa.totpPending
: false
const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
const normalizedMfa = Object.keys(rawMfa).length
? {
...rawMfa,
totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
}
: {
totpEnabled: derivedMfaEnabled,
totpPending: derivedMfaPending,
}
const normalizedUser = identifier
? { ...rawUser, id: identifier, uuid: identifier }
: rawUser
return NextResponse.json({ user: normalizedUser })
return NextResponse.json({
user: {
...normalizedUser,
mfaEnabled: derivedMfaEnabled,
mfaPending: derivedMfaPending,
mfa: normalizedMfa,
},
})
}
export async function DELETE(request: NextRequest) {

View File

@ -41,6 +41,7 @@ export default function LoginContent({ children }: LoginContentProps) {
const errorParam = searchParams.get('error')
const registeredParam = searchParams.get('registered')
const setupMfaParam = searchParams.get('setupMfa')
const normalize = useCallback(
(value: string) =>
@ -59,8 +60,16 @@ export default function LoginContent({ children }: LoginContentProps) {
const socialButtonsDisabled = true
const initialAlert = useMemo(() => {
const successMessages: string[] = []
if (registeredParam === '1') {
return { type: 'success', message: alerts.registered } as const
successMessages.push(alerts.registered)
}
if (setupMfaParam === '1') {
successMessages.push(alerts.mfa.setupRequired)
}
if (successMessages.length > 0) {
return { type: 'success', message: successMessages.join(' ') } as const
}
if (!errorParam) {
@ -79,7 +88,7 @@ export default function LoginContent({ children }: LoginContentProps) {
}
const message = errorMap[normalizedError] ?? alerts.genericError
return { type: 'error', message } as const
}, [alerts, errorParam, normalize, registeredParam])
}, [alerts, errorParam, normalize, registeredParam, setupMfaParam])
const [alert, setAlert] = useState(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)

View File

@ -23,6 +23,7 @@ type ProvisionResponse = {
issuer?: string
account?: string
mfaToken?: string
qr?: string
user?: { mfa?: TotpStatus }
}
@ -49,12 +50,13 @@ export default function MfaSetupPanel() {
const copy = translations[language].userCenter.mfa
const router = useRouter()
const searchParams = useSearchParams()
const { user, refresh } = useUser()
const { user, refresh, logout } = useUser()
const [status, setStatus] = useState<TotpStatus | null>(null)
const [mfaToken, setMfaToken] = useState('')
const [secret, setSecret] = useState('')
const [uri, setUri] = useState('')
const [qrImage, setQrImage] = useState('')
const [code, setCode] = useState('')
const [isProvisioning, setIsProvisioning] = useState(false)
const [isVerifying, setIsVerifying] = useState(false)
@ -62,6 +64,7 @@ export default function MfaSetupPanel() {
const hasPendingMfa = Boolean(status?.totpPending && !status?.totpEnabled)
const setupRequested = searchParams.get('setupMfa') === '1'
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const ensureTokenPersisted = useCallback((token: string) => {
if (!token) {
@ -80,6 +83,20 @@ export default function MfaSetupPanel() {
}
}, [])
const generateQrImage = useCallback((value: string) => {
if (!value) {
return ''
}
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 fetchStatus = useCallback(async () => {
try {
const response = await fetch('/api/auth/mfa/status', { cache: 'no-store' })
@ -122,7 +139,10 @@ export default function MfaSetupPanel() {
return
}
setSecret(payload.secret)
setUri(payload?.uri ?? '')
const nextUri = payload?.uri ?? ''
setUri(nextUri)
const nextQr = payload?.qr ?? (nextUri ? generateQrImage(nextUri) : '')
setQrImage(nextQr)
ensureTokenPersisted(payload?.mfaToken ?? mfaToken)
setStatus(payload?.user?.mfa ?? status)
} catch (err) {
@ -131,7 +151,7 @@ export default function MfaSetupPanel() {
} finally {
setIsProvisioning(false)
}
}, [copy.error, ensureTokenPersisted, mfaToken, status])
}, [copy.error, ensureTokenPersisted, generateQrImage, mfaToken, status])
const handleVerify = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
@ -157,6 +177,7 @@ export default function MfaSetupPanel() {
clearToken()
setSecret('')
setUri('')
setQrImage('')
setCode('')
await refresh()
router.replace('/panel/account')
@ -182,6 +203,18 @@ export default function MfaSetupPanel() {
}
}, [handleProvision, hasPendingMfa, secret, setupRequested, showProvisionButton])
useEffect(() => {
if (!secret && user?.mfa?.totpEnabled) {
setQrImage('')
}
}, [secret, user?.mfa?.totpEnabled])
const handleLogoutClick = useCallback(async () => {
await logout()
router.replace('/login')
router.refresh()
}, [logout, router])
if (!user) {
return (
<Card>
@ -201,6 +234,17 @@ export default function MfaSetupPanel() {
</p>
</div>
{requiresSetup ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-800">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.steps.intro}</p>
<ol className="mt-2 list-decimal space-y-1 pl-5">
<li>{copy.steps.provision}</li>
<li>{copy.steps.verify}</li>
</ol>
</div>
) : null}
{displayStatus?.totpEnabled ? (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
<p className="font-medium">{copy.successTitle}</p>
@ -234,6 +278,18 @@ export default function MfaSetupPanel() {
{secret ? (
<div className="space-y-3 rounded-lg border border-gray-200 bg-white p-4">
{qrImage ? (
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.qrLabel}</p>
<div className="mt-2 flex justify-center">
<img
src={qrImage}
alt="Authenticator QR code"
className="h-40 w-40 rounded-lg border border-purple-100 bg-white p-2 shadow-sm"
/>
</div>
</div>
) : null}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.secretLabel}</p>
<code className="mt-1 block break-all rounded bg-purple-50 px-3 py-2 text-sm text-purple-700">{secret}</code>
@ -284,6 +340,28 @@ export default function MfaSetupPanel() {
)}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs text-gray-600">
<p className="font-semibold text-gray-700">{copy.actions.help}</p>
<p className="mt-1 text-gray-600">{copy.actions.description}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={handleLogoutClick}
className="inline-flex items-center justify-center rounded-md border border-purple-200 px-3 py-2 text-xs font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
{copy.actions.logout}
</button>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-transparent bg-purple-600 px-3 py-2 text-xs font-medium text-white transition hover:bg-purple-500"
>
{copy.actions.docs}
</a>
</div>
</div>
</div>
</Card>
)

View File

@ -4,6 +4,10 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Home, Server, Code, CreditCard, User, Shield, type LucideIcon } from 'lucide-react'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUser } from '@lib/userStore'
export interface SidebarProps {
className?: string
onNavigate?: () => void
@ -90,6 +94,10 @@ function isActive(pathname: string, href: string) {
export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const { user } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
return (
<aside
@ -101,9 +109,35 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
<p className="text-sm text-gray-500"></p>
</div>
{requiresSetup ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1">{copy.lockedMessage}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Link
href="/panel/account?setupMfa=1"
onClick={onNavigate}
className="inline-flex items-center justify-center rounded-md bg-purple-600 px-3 py-1.5 text-xs font-medium text-white shadow transition hover:bg-purple-500"
>
{copy.actions.setup}
</Link>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-purple-200 px-3 py-1.5 text-xs font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
{copy.actions.docs}
</a>
</div>
</div>
) : null}
<nav className="flex flex-1 flex-col gap-6 overflow-y-auto">
{navSections.map((section) => {
const sectionDisabled = section.items.every((item) => item.disabled)
const sectionDisabled = section.items.every(
(item) => item.disabled || (requiresSetup && item.href !== '/panel/account'),
)
return (
<div key={section.title} className="space-y-3">
@ -118,7 +152,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
{section.items.map((item) => {
const active = isActive(pathname, item.href)
const Icon = item.icon
const disabled = item.disabled
const disabled = item.disabled || (requiresSetup && item.href !== '/panel/account')
const content = (
<div

View File

@ -1,9 +1,13 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Copy } from 'lucide-react'
import Card from './Card'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUser } from '@lib/userStore'
function resolveDisplayName(
@ -29,13 +33,30 @@ function resolveDisplayName(
}
export default function UserOverview() {
const { user, isLoading } = useUser()
const router = useRouter()
const { language } = useLanguage()
const copy = translations[language].userCenter.overview
const mfaCopy = translations[language].userCenter.mfa
const { user, isLoading, logout } = useUser()
const [copied, setCopied] = useState(false)
const displayName = useMemo(() => resolveDisplayName(user), [user])
const uuid = user?.uuid ?? user?.id ?? '—'
const username = user?.username ?? '—'
const email = user?.email ?? '—'
const docsUrl = mfaCopy.actions.docsUrl
const mfaStatusLabel = useMemo(() => {
if (user?.mfaEnabled) {
return mfaCopy.state.enabled
}
if (user?.mfaPending) {
return mfaCopy.state.pending
}
return mfaCopy.state.disabled
}, [mfaCopy.state.disabled, mfaCopy.state.enabled, mfaCopy.state.pending, user?.mfaEnabled, user?.mfaPending])
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
const handleCopy = useCallback(async () => {
const identifier = user?.uuid ?? user?.id
@ -64,23 +85,66 @@ export default function UserOverview() {
}
}, [user?.id, user?.uuid])
const handleGoToSetup = useCallback(() => {
router.push('/panel/account?setupMfa=1')
}, [router])
const handleLogout = useCallback(async () => {
await logout()
router.replace('/login')
router.refresh()
}, [logout, router])
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<h1 className="text-3xl font-bold text-gray-900">{copy.heading}</h1>
<p className="mt-2 text-sm text-gray-600">
{isLoading ? '正在加载你的专属空间…' : user ? `欢迎回来,${displayName}` : '请登录后解锁属于你的用户中心。'}
</p>
<p className="mt-1 text-xs text-gray-500">
UUID XControl
{isLoading
? copy.loading
: user
? copy.welcome.replace('{name}', displayName)
: copy.guest}
</p>
<p className="mt-1 text-xs text-gray-500">{copy.uuidNote}</p>
</div>
{requiresSetup ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<p className="text-base font-semibold">{copy.lockBanner.title}</p>
<p className="mt-1 text-sm">{copy.lockBanner.body}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={handleGoToSetup}
className="inline-flex items-center justify-center rounded-md bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-purple-500"
>
{copy.lockBanner.action}
</button>
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-purple-200 px-4 py-2 text-sm font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
{copy.lockBanner.docs}
</a>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
{copy.lockBanner.logout}
</button>
</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">UUID</p>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.cards.uuid.label}</p>
<p className="mt-1 break-all text-base font-medium text-gray-900">{uuid}</p>
</div>
<button
@ -88,27 +152,41 @@ export default function UserOverview() {
onClick={handleCopy}
disabled={!user?.id}
className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600 disabled:cursor-not-allowed disabled:border-gray-200 disabled:text-gray-400"
aria-label="复制 UUID"
aria-label={copy.cards.uuid.copy}
>
<Copy className="h-3.5 w-3.5" />
{copied ? '已复制' : '复制'}
{copied ? copy.cards.uuid.copied : copy.cards.uuid.copy}
</button>
</div>
<p className="mt-3 text-xs text-gray-500">
</p>
<p className="mt-3 text-xs text-gray-500">{copy.cards.uuid.description}</p>
</Card>
<Card>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">UserName</p>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.cards.username.label}</p>
<p className="mt-1 text-base font-medium text-gray-900">{username}</p>
<p className="mt-3 text-xs text-gray-500"></p>
<p className="mt-3 text-xs text-gray-500">{copy.cards.username.description}</p>
</Card>
<Card>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">UserEmail</p>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.cards.email.label}</p>
<p className="mt-1 break-all text-base font-medium text-gray-900">{email}</p>
<p className="mt-3 text-xs text-gray-500"> UUID </p>
<p className="mt-3 text-xs text-gray-500">{copy.cards.email.description}</p>
</Card>
<Card>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600">{copy.cards.mfa.label}</p>
<p className="mt-1 text-base font-medium text-gray-900">{mfaStatusLabel}</p>
<p className="mt-3 text-xs text-gray-500">{copy.cards.mfa.description}</p>
</div>
<Link
href="/panel/account?setupMfa=1"
className="inline-flex items-center justify-center rounded-full border border-purple-200 px-3 py-1 text-xs font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
{copy.cards.mfa.action}
</Link>
</div>
</Card>
</div>
</div>

View File

@ -1,12 +1,37 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUser } from '@lib/userStore'
export default function PanelLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const router = useRouter()
const pathname = usePathname()
const { language } = useLanguage()
const copy = translations[language].userCenter.mfa
const { user, isLoading, logout } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
useEffect(() => {
if (!requiresSetup || pathname.startsWith('/panel/account')) {
return
}
router.replace('/panel/account?setupMfa=1')
}, [pathname, requiresSetup, router])
const handleLogout = async () => {
await logout()
router.replace('/login')
router.refresh()
}
return (
<div className="relative flex min-h-screen bg-gradient-to-br from-gray-100 via-purple-50 to-blue-50 text-gray-900">
@ -27,6 +52,41 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
<div className="flex min-h-screen flex-1 flex-col md:pl-64">
<Header onMenu={() => setOpen((prev) => !prev)} />
<main className="flex-1 space-y-6 bg-transparent px-3 py-6 md:px-6">
{requiresSetup ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<p className="font-semibold">{copy.pendingHint}</p>
<p className="mt-1 text-sm">{copy.lockedMessage}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
onClick={() => router.replace('/panel/account?setupMfa=1')}
className="inline-flex items-center justify-center rounded-md bg-purple-600 px-3 py-1.5 text-sm font-medium text-white shadow transition hover:bg-purple-500"
>
{copy.actions.setup}
</button>
<a
href={copy.actions.docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md border border-purple-200 px-3 py-1.5 text-sm font-medium text-purple-600 transition hover:border-purple-300 hover:bg-purple-50"
>
{copy.actions.docs}
</a>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center justify-center rounded-md border border-transparent px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
{copy.actions.logout}
</button>
{isLoading ? (
<span className="inline-flex items-center rounded-md border border-amber-100 bg-amber-100 px-3 py-1.5 text-xs text-amber-700">
</span>
) : null}
</div>
</div>
) : null}
<div className="flex w-full flex-col gap-6">{children}</div>
</main>
</div>

View File

@ -173,7 +173,7 @@ export default function RegisterContent() {
setAlert({ type: 'success', message: alerts.success })
setIsSubmitting(false)
router.push('/login?registered=1')
router.push('/login?registered=1&setupMfa=1')
} catch (error) {
console.error('Failed to register user', error)
setAlert({ type: 'error', message: alerts.genericError })

View File

@ -188,6 +188,42 @@ type AuthTranslation = {
login: AuthLoginTranslation
}
type UserCenterOverviewTranslation = {
heading: string
loading: string
welcome: string
guest: string
uuidNote: string
lockBanner: {
title: string
body: string
action: string
docs: string
logout: string
}
cards: {
uuid: {
label: string
description: string
copy: string
copied: string
}
username: {
label: string
description: string
}
email: {
label: string
description: string
}
mfa: {
label: string
description: string
action: string
}
}
}
type UserCenterMfaTranslation = {
title: string
subtitle: string
@ -208,10 +244,31 @@ type UserCenterMfaTranslation = {
issuedAt: string
confirmedAt: string
}
state: {
enabled: string
pending: string
disabled: string
}
qrLabel: string
lockedMessage: string
steps: {
intro: string
provision: string
verify: string
}
actions: {
help: string
description: string
logout: string
docs: string
docsUrl: string
setup: string
}
error: string
}
type UserCenterTranslation = {
overview: UserCenterOverviewTranslation
mfa: UserCenterMfaTranslation
}
@ -544,6 +601,41 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
},
userCenter: {
overview: {
heading: 'User Center',
loading: 'Loading your personalized space…',
welcome: 'Welcome back, {name}.',
guest: 'Sign in to unlock your user center.',
uuidNote: 'Your UUID uniquely identifies you across XControl services.',
lockBanner: {
title: 'Finish MFA setup',
body: 'Complete multi-factor authentication to unlock every panel section.',
action: 'Set up MFA',
docs: 'View setup guide',
logout: 'Sign out',
},
cards: {
uuid: {
label: 'UUID',
description: 'This fingerprint ties every service action back to your account.',
copy: 'Copy',
copied: 'Copied',
},
username: {
label: 'Username',
description: 'System-facing credential for automation and teammates.',
},
email: {
label: 'Email',
description: 'Receive notifications and maintain a trusted identity chain.',
},
mfa: {
label: 'Multi-factor authentication',
description: 'Secure the console by pairing an authenticator app.',
action: 'Manage MFA',
},
},
},
mfa: {
title: 'Multi-factor authentication',
subtitle: 'Bind Google Authenticator to finish securing your account.',
@ -564,6 +656,26 @@ export const translations: Record<'en' | 'zh', Translation> = {
issuedAt: 'Key generated at',
confirmedAt: 'Enabled at',
},
state: {
enabled: 'Enabled',
pending: 'Pending setup',
disabled: 'Not enabled',
},
qrLabel: 'Authenticator QR code',
lockedMessage: 'Finish the binding flow before exploring other sections.',
steps: {
intro: 'Complete these two steps to secure your account:',
provision: '1. Generate a secret and scan the QR code with Google Authenticator.',
verify: '2. Enter the 6-digit verification code to enable MFA.',
},
actions: {
help: 'Need help staying secure?',
description: 'If you run into issues, sign out or review the setup documentation.',
logout: 'Sign out',
docs: 'View setup guide',
docsUrl: '/docs/account-service-configuration/latest',
setup: 'Resume setup',
},
error: 'We could not complete the request. Please try again.',
},
},
@ -819,6 +931,41 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
},
userCenter: {
overview: {
heading: '用户中心',
loading: '正在加载你的专属空间…',
welcome: '欢迎回来,{name}。',
guest: '请登录后解锁属于你的用户中心。',
uuidNote: 'UUID 是你在 XControl 中的唯一身份凭证,后续的所有服务都与它关联在一起。',
lockBanner: {
title: '完成多因素认证',
body: '完成 MFA 绑定后即可访问所有控制台板块。',
action: '立即设置',
docs: '查看操作指引',
logout: '退出登录',
},
cards: {
uuid: {
label: 'UUID',
description: '这串指纹标识让平台中的每项服务都能准确识别你。',
copy: '复制',
copied: '已复制',
},
username: {
label: '用户名',
description: '面向系统与团队成员的登录凭据。',
},
email: {
label: '邮箱',
description: '用于接收通知、验证操作,并保持可信链路。',
},
mfa: {
label: '多因素认证',
description: '绑定认证器即可保护控制台访问。',
action: '前往设置',
},
},
},
mfa: {
title: '多因素认证',
subtitle: '绑定 Google Authenticator完成账号安全校验。',
@ -839,6 +986,26 @@ export const translations: Record<'en' | 'zh', Translation> = {
issuedAt: '密钥生成时间',
confirmedAt: '启用时间',
},
state: {
enabled: '已启用',
pending: '待验证',
disabled: '未开启',
},
qrLabel: '认证二维码',
lockedMessage: '请先完成绑定流程,再访问其他板块。',
steps: {
intro: '按照以下两步完成账号安全加固:',
provision: '1. 生成密钥并在认证器中扫描二维码。',
verify: '2. 输入认证器中的 6 位验证码完成启用。',
},
actions: {
help: '需要帮助?',
description: '遇到问题时可以退出重新登录,或查看绑定指引。',
logout: '退出登录',
docs: '查看操作指引',
docsUrl: '/docs/account-service-configuration/latest',
setup: '继续设置',
},
error: '操作失败,请稍后再试。',
},
},

View File

@ -17,6 +17,7 @@ type User = {
name?: string
username: string
mfaEnabled: boolean
mfaPending: boolean
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
@ -69,6 +70,7 @@ async function fetchSessionUser(): Promise<User | null> {
name?: string
username?: string
mfaEnabled?: boolean
mfaPending?: boolean
mfa?: {
totpEnabled?: boolean
totpPending?: boolean
@ -83,7 +85,7 @@ async function fetchSessionUser(): Promise<User | null> {
return null
}
const { id, uuid, email, name, username, mfaEnabled, mfa } = sessionUser
const { id, uuid, email, name, username, mfaEnabled, mfa, mfaPending } = sessionUser
const identifier =
typeof uuid === 'string' && uuid.trim().length > 0
? uuid.trim()
@ -98,14 +100,26 @@ async function fetchSessionUser(): Promise<User | null> {
const normalizedUsername =
typeof username === 'string' && username.trim().length > 0 ? username.trim() : normalizedName
const normalizedMfa = mfa
? {
...mfa,
totpEnabled: Boolean(mfa.totpEnabled ?? mfaEnabled),
totpPending: Boolean(mfa.totpPending ?? mfaPending) && !Boolean(mfa.totpEnabled ?? mfaEnabled),
}
: {
totpEnabled: Boolean(mfaEnabled),
totpPending: Boolean(mfaPending) && !Boolean(mfaEnabled),
}
return {
id: identifier,
uuid: identifier,
email,
name: normalizedName,
username: normalizedUsername ?? email,
mfaEnabled: Boolean(mfaEnabled),
mfa,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa,
}
} catch (error) {
console.warn('Failed to resolve user session', error)