Add MFA onboarding workflow to panel (#370)
This commit is contained in:
parent
f204180354
commit
3031be586e
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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: '操作失败,请稍后再试。',
|
||||
},
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user