@@ -177,13 +139,12 @@ 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 || (requiresSetup && item.href !== '/panel/account')
+ const { Icon } = item
const baseClasses = [
'group flex items-center gap-3 rounded-[var(--radius-xl)] border px-3 py-3 text-sm transition-colors',
]
- if (disabled) {
+ if (item.disabled) {
baseClasses.push(
'cursor-not-allowed border-dashed border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] opacity-60',
)
@@ -201,7 +162,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const iconClasses = ['flex h-8 w-8 items-center justify-center rounded-xl transition-colors']
if (active) {
iconClasses.push('bg-[var(--color-primary)] text-[var(--color-primary-foreground)]')
- } else if (disabled) {
+ } else if (item.disabled) {
iconClasses.push('bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-60')
} else {
iconClasses.push(
@@ -211,7 +172,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
const descriptionClasses = [
'text-xs transition-colors',
- disabled
+ item.disabled
? 'text-[var(--color-text-subtle)] opacity-60'
: 'text-[var(--color-text-subtle)] group-hover:text-[var(--color-primary)]',
]
@@ -228,7 +189,7 @@ export default function Sidebar({ className = '', onNavigate }: SidebarProps) {
)
- if (disabled) {
+ if (item.disabled) {
return (
{content}
diff --git a/dashboard/app/panel/layout.tsx b/dashboard/app/panel/layout.tsx
index f6d82cc..06e7158 100644
--- a/dashboard/app/panel/layout.tsx
+++ b/dashboard/app/panel/layout.tsx
@@ -1,43 +1,29 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
+import { getExtensionRegistry } from '@extensions/loader'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { resolveAccess, type AccessRule } from '@lib/accessControl'
import { useUser } from '@lib/userStore'
+const registry = getExtensionRegistry()
+
type RouteGuard = {
- test: (pathname: string) => boolean
- redirect: {
- unauthenticated: string
+ path: string
+ match: 'exact' | 'startsWith'
+ redirect?: {
+ unauthenticated?: string
forbidden?: string
}
rule: AccessRule
}
-const routeGuards: RouteGuard[] = [
- {
- test: (pathname) => pathname.startsWith('/panel/management'),
- redirect: {
- unauthenticated: '/login',
- forbidden: '/panel',
- },
- rule: { requireLogin: true, roles: ['admin', 'operator'] },
- },
- {
- test: (pathname) => pathname.startsWith('/panel'),
- redirect: {
- unauthenticated: '/login',
- },
- rule: { requireLogin: true },
- },
-]
-
export default function PanelLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
const router = useRouter()
@@ -46,28 +32,43 @@ export default function PanelLayout({ children }: { children: React.ReactNode })
const copy = translations[language].userCenter.mfa
const { user, isLoading, logout } = useUser()
const requiresSetup = Boolean(user && (!user.mfaEnabled || user.mfaPending))
-
+
+ const routeGuards = useMemo
(() => {
+ return registry.routes
+ .filter((route) => route.guard)
+ .map((route) => ({
+ path: route.path,
+ match: route.match ?? 'exact',
+ redirect: route.redirect,
+ rule: route.guard!,
+ }))
+ .sort((a, b) => b.path.length - a.path.length)
+ }, [])
+
useEffect(() => {
if (isLoading) {
return
}
- const guard = routeGuards.find((entry) => entry.test(pathname))
+ const guard = routeGuards.find((entry) =>
+ entry.match === 'startsWith' ? pathname.startsWith(entry.path) : pathname === entry.path,
+ )
if (!guard) {
return
}
const decision = resolveAccess(user, guard.rule)
if (!decision.allowed) {
+ const redirect = guard.redirect ?? {}
const destination =
decision.reason === 'unauthenticated'
- ? guard.redirect.unauthenticated
- : guard.redirect.forbidden ?? guard.redirect.unauthenticated
+ ? redirect.unauthenticated ?? '/login'
+ : redirect.forbidden ?? redirect.unauthenticated ?? '/login'
if (destination && destination !== pathname) {
router.replace(destination)
}
}
- }, [isLoading, pathname, router, user])
+ }, [isLoading, pathname, routeGuards, router, user])
useEffect(() => {
if (!requiresSetup || pathname.startsWith('/panel/account')) {
diff --git a/dashboard/app/panel/ldp/page.tsx b/dashboard/app/panel/ldp/page.tsx
index dd172be..e7ae391 100644
--- a/dashboard/app/panel/ldp/page.tsx
+++ b/dashboard/app/panel/ldp/page.tsx
@@ -1,35 +1,17 @@
-import Link from 'next/link'
-
export const dynamic = 'error'
-import Card from '../components/Card'
+import { redirect } from 'next/navigation'
-export default function LdpPage() {
- const links = [
- { href: '/panel/ldp/users', label: 'Users' },
- { href: '/panel/ldp/services', label: 'Services' },
- { href: '/panel/ldp/config', label: 'Configuration' },
- { href: '/panel/ldp/status', label: 'Status' },
- { href: '/panel/ldp/consent', label: 'Login & Consent' },
- ]
+import { resolveExtensionRouteComponent } from '@extensions/loader'
- return (
-
- LDP Management
- Explore low-latency directory plane modules.
-
- {links.map((link) => (
- -
-
- {link.label}
- Coming soon
-
-
- ))}
-
-
- )
+export default async function LdpPage() {
+ try {
+ const Component = await resolveExtensionRouteComponent('/panel/ldp')
+ return
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('disabled')) {
+ redirect('/panel')
+ }
+ throw error
+ }
}
diff --git a/dashboard/app/panel/management/page.tsx b/dashboard/app/panel/management/page.tsx
index a342d99..26f4476 100644
--- a/dashboard/app/panel/management/page.tsx
+++ b/dashboard/app/panel/management/page.tsx
@@ -1,282 +1,17 @@
-'use client'
+export const dynamic = 'error'
-import { useCallback, useEffect, useMemo, useState } from 'react'
-import useSWR from 'swr'
+import { redirect } from 'next/navigation'
-import TrendChart, { type MetricsSeries } from './components/TrendChart'
-import OverviewCards, { type MetricsOverview } from './components/OverviewCards'
-import PermissionMatrixEditor, {
- type PermissionMatrix,
-} from './components/PermissionMatrixEditor'
-import UserGroupManagement, { type ManagedUser } from './components/UserGroupManagement'
-import Card from '../components/Card'
-import { resolveAccess } from '@lib/accessControl'
-import { useUser } from '@lib/userStore'
+import { resolveExtensionRouteComponent } from '@extensions/loader'
-type UserMetricsResponse = {
- overview: MetricsOverview
- series: MetricsSeries
-}
-
-type AdminSettingsResponse = {
- version: number
- matrix: PermissionMatrix
-}
-
-type ApiError = {
- error?: string
- message?: string
- matrix?: PermissionMatrix
- version?: number
-}
-
-async function jsonFetcher(input: RequestInfo, init?: RequestInit): Promise {
- const response = await fetch(input, {
- ...init,
- credentials: 'include',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- ...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
- },
- cache: 'no-store',
- })
-
- if (!response.ok) {
- let payload: ApiError | undefined
- try {
- payload = (await response.json()) as ApiError
- } catch (error) {
- // Ignore JSON parse errors; fall back to status text below.
+export default async function ManagementPage() {
+ try {
+ const Component = await resolveExtensionRouteComponent('/panel/management')
+ return
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('disabled')) {
+ redirect('/panel')
}
- const message = payload?.error ?? payload?.message ?? response.statusText
- throw new Error(message || '请求失败')
+ throw error
}
-
- return (await response.json()) as T
-}
-
-export default function ManagementPage() {
- const { user, isLoading: isUserLoading } = useUser()
- const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
- const canAccess = accessDecision.allowed
- const canEditPermissions = Boolean(user?.isAdmin)
- const canEditRoles = Boolean(user?.isAdmin)
-
- const [matrixDraft, setMatrixDraft] = useState({})
- const [matrixVersion, setMatrixVersion] = useState(0)
- const [matrixDirty, setMatrixDirty] = useState(false)
- const [matrixSaving, setMatrixSaving] = useState(false)
- const [matrixStatus, setMatrixStatus] = useState()
- const [matrixError, setMatrixError] = useState()
- const [roleUpdateMessage, setRoleUpdateMessage] = useState()
- const [pendingRoleUpdates, setPendingRoleUpdates] = useState>(new Set())
-
- const metricsSWR = useSWR(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
- revalidateOnFocus: false,
- })
- const settingsSWR = useSWR(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
- revalidateOnFocus: false,
- })
- const usersSWR = useSWR(canAccess ? '/api/users' : null, jsonFetcher, {
- revalidateOnFocus: false,
- })
-
- useEffect(() => {
- if (settingsSWR.data?.matrix) {
- setMatrixDraft(settingsSWR.data.matrix)
- setMatrixVersion(settingsSWR.data.version)
- setMatrixDirty(false)
- setMatrixError(undefined)
- }
- }, [settingsSWR.data])
-
- const lastUpdatedLabel = useMemo(() => {
- if (!metricsSWR.data) {
- return undefined
- }
- const now = new Date()
- return `更新于 ${now.toLocaleString('zh-CN', {
- hour: '2-digit',
- minute: '2-digit',
- })}`
- }, [metricsSWR.data])
-
- const handleTogglePermission = useCallback(
- (moduleKey: string, role: string, nextValue: boolean) => {
- setMatrixDraft((prev) => {
- const next: PermissionMatrix = { ...prev }
- const normalizedModuleKey = moduleKey.trim()
- const normalizedRole = role.trim()
- const currentRoleMap = next[normalizedModuleKey] ?? {}
- next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
- return next
- })
- setMatrixDirty(true)
- setMatrixStatus(undefined)
- setMatrixError(undefined)
- },
- [],
- )
-
- const handleSaveMatrix = useCallback(async () => {
- if (!canEditPermissions || !matrixDirty) {
- return
- }
- setMatrixSaving(true)
- setMatrixStatus(undefined)
- setMatrixError(undefined)
- try {
- const response = await fetch('/api/admin/settings', {
- method: 'POST',
- credentials: 'include',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- version: matrixVersion,
- matrix: matrixDraft,
- }),
- })
-
- if (response.ok) {
- const payload = (await response.json()) as AdminSettingsResponse
- setMatrixDraft(payload.matrix)
- setMatrixVersion(payload.version)
- setMatrixDirty(false)
- setMatrixStatus('已保存')
- settingsSWR.mutate(payload, { revalidate: false })
- return
- }
-
- let payload: ApiError | undefined
- try {
- payload = (await response.json()) as ApiError
- } catch (error) {
- // ignore parsing error
- }
-
- if (response.status === 409 && payload?.matrix) {
- setMatrixDraft(payload.matrix)
- if (typeof payload.version === 'number') {
- setMatrixVersion(payload.version)
- }
- setMatrixDirty(false)
- setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
- return
- }
-
- const message = payload?.error ?? payload?.message ?? '保存失败'
- throw new Error(message)
- } catch (error) {
- setMatrixError(error instanceof Error ? error.message : '保存失败')
- } finally {
- setMatrixSaving(false)
- }
- }, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
-
- const markRolePending = useCallback((userId: string, pending: boolean) => {
- setPendingRoleUpdates((prev) => {
- const next = new Set(prev)
- if (pending) {
- next.add(userId)
- } else {
- next.delete(userId)
- }
- return next
- })
- }, [])
-
- const handleRoleChange = useCallback(
- async (userId: string, role: string) => {
- if (!canEditRoles) {
- return
- }
- setRoleUpdateMessage(undefined)
- markRolePending(userId, true)
- try {
- await jsonFetcher(`/api/admin/users/${userId}/role`, {
- method: 'POST',
- body: JSON.stringify({ role }),
- })
- setRoleUpdateMessage('角色已更新')
- usersSWR.mutate()
- } catch (error) {
- setRoleUpdateMessage(error instanceof Error ? error.message : '角色更新失败')
- } finally {
- markRolePending(userId, false)
- }
- },
- [canEditRoles, markRolePending, usersSWR],
- )
-
- if (isUserLoading) {
- return (
-
- )
- }
-
- if (!canAccess) {
- return (
-
-
-
权限不足
-
- {accessDecision.reason === 'unauthenticated'
- ? '请先登录后再访问该页面。'
- : '该页面仅向管理员与运营角色开放。如果你认为这是一个错误,请联系管理员。'}
-
-
-
- )
- }
-
- return (
-
-
-
-
-
-
-
-
setRoleUpdateMessage('邀请入口尚未接入,可在后台触发工单流程。')}
- onImport={() => setRoleUpdateMessage('导入入口尚未接入,请联系管理员执行批量导入。')}
- />
-
- {roleUpdateMessage ? (
-
- {roleUpdateMessage}
-
- ) : null}
-
- )
}
diff --git a/dashboard/app/panel/page.tsx b/dashboard/app/panel/page.tsx
index cc144a4..86f4a61 100644
--- a/dashboard/app/panel/page.tsx
+++ b/dashboard/app/panel/page.tsx
@@ -1,7 +1,17 @@
export const dynamic = 'error'
-import UserOverview from './components/UserOverview'
+import { redirect } from 'next/navigation'
-export default function PanelHome() {
- return
+import { resolveExtensionRouteComponent } from '@extensions/loader'
+
+export default async function PanelHome() {
+ try {
+ const Component = await resolveExtensionRouteComponent('/panel')
+ return
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('disabled')) {
+ redirect('/panel')
+ }
+ throw error
+ }
}
diff --git a/dashboard/app/panel/subscription/page.tsx b/dashboard/app/panel/subscription/page.tsx
index d5a107c..3e33396 100644
--- a/dashboard/app/panel/subscription/page.tsx
+++ b/dashboard/app/panel/subscription/page.tsx
@@ -1,12 +1,17 @@
export const dynamic = 'error'
-import Card from '../components/Card'
+import { redirect } from 'next/navigation'
-export default function SubscriptionPage() {
- return (
-
- Subscription
- Manage subscriptions and invoicing rules.
-
- )
+import { resolveExtensionRouteComponent } from '@extensions/loader'
+
+export default async function SubscriptionPage() {
+ try {
+ const Component = await resolveExtensionRouteComponent('/panel/subscription')
+ return
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('disabled')) {
+ redirect('/panel')
+ }
+ throw error
+ }
}
diff --git a/dashboard/src/extensions/__tests__/loader.test.ts b/dashboard/src/extensions/__tests__/loader.test.ts
new file mode 100644
index 0000000..12acb57
--- /dev/null
+++ b/dashboard/src/extensions/__tests__/loader.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+ getExtensionRegistry,
+ resetExtensionRegistryCache,
+ resolveExtensionRouteComponent,
+} from '@extensions/loader'
+
+const AGENT_FLAG = 'NEXT_PUBLIC_FEATURE_AGENT_MODULE'
+
+afterEach(() => {
+ delete process.env[AGENT_FLAG]
+ resetExtensionRegistryCache()
+})
+
+describe('extension loader', () => {
+ it('disables agent module when feature flag is off', async () => {
+ process.env[AGENT_FLAG] = '0'
+ resetExtensionRegistryCache()
+
+ const registry = getExtensionRegistry()
+ const agentRoute = registry.getRoute('/panel/agent')
+
+ expect(agentRoute?.enabled).toBe(false)
+ await expect(resolveExtensionRouteComponent('/panel/agent')).rejects.toThrow('disabled')
+
+ const panelRoute = registry.getRoute('/panel')
+ expect(panelRoute?.enabled).toBe(true)
+ const PanelComponent = await resolveExtensionRouteComponent('/panel')
+ expect(typeof PanelComponent).toBe('function')
+ })
+
+ it('enables agent module when feature flag is on', async () => {
+ process.env[AGENT_FLAG] = '1'
+ resetExtensionRegistryCache()
+
+ const registry = getExtensionRegistry()
+ const agentRoute = registry.getRoute('/panel/agent')
+
+ expect(agentRoute?.enabled).toBe(true)
+
+ const AgentComponent = await resolveExtensionRouteComponent('/panel/agent')
+ expect(typeof AgentComponent).toBe('function')
+ })
+})
diff --git a/dashboard/src/extensions/builtin/index.ts b/dashboard/src/extensions/builtin/index.ts
new file mode 100644
index 0000000..bd4488c
--- /dev/null
+++ b/dashboard/src/extensions/builtin/index.ts
@@ -0,0 +1,5 @@
+import type { DashboardExtension } from '../types'
+
+import { userCenterExtension } from './user-center'
+
+export const builtinExtensions: DashboardExtension[] = [userCenterExtension]
diff --git a/dashboard/app/panel/account/MfaSetupPanel.tsx b/dashboard/src/extensions/builtin/user-center/account/MfaSetupPanel.tsx
similarity index 100%
rename from dashboard/app/panel/account/MfaSetupPanel.tsx
rename to dashboard/src/extensions/builtin/user-center/account/MfaSetupPanel.tsx
diff --git a/dashboard/app/panel/account/ThemePreferenceCard.tsx b/dashboard/src/extensions/builtin/user-center/account/ThemePreferenceCard.tsx
similarity index 100%
rename from dashboard/app/panel/account/ThemePreferenceCard.tsx
rename to dashboard/src/extensions/builtin/user-center/account/ThemePreferenceCard.tsx
diff --git a/dashboard/app/panel/components/Card.tsx b/dashboard/src/extensions/builtin/user-center/components/Card.tsx
similarity index 100%
rename from dashboard/app/panel/components/Card.tsx
rename to dashboard/src/extensions/builtin/user-center/components/Card.tsx
diff --git a/dashboard/app/panel/components/UserOverview.tsx b/dashboard/src/extensions/builtin/user-center/components/UserOverview.tsx
similarity index 100%
rename from dashboard/app/panel/components/UserOverview.tsx
rename to dashboard/src/extensions/builtin/user-center/components/UserOverview.tsx
diff --git a/dashboard/src/extensions/builtin/user-center/index.ts b/dashboard/src/extensions/builtin/user-center/index.ts
new file mode 100644
index 0000000..e40b56d
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/index.ts
@@ -0,0 +1,116 @@
+import { Code, CreditCard, Home, Server, Settings, Shield, User } from 'lucide-react'
+
+import type { DashboardExtension } from '../../types'
+
+export const userCenterExtension: DashboardExtension = {
+ id: 'builtin.user-center',
+ meta: {
+ title: '用户中心',
+ description: '核心控制台能力,包括账户、管理与观测功能。',
+ version: '1.0.0',
+ author: 'XControl',
+ keywords: ['dashboard', 'accounts', 'management'],
+ },
+ routes: [
+ {
+ path: '/panel',
+ label: 'Dashboard',
+ description: '专属于你的信息总览',
+ icon: Home,
+ loader: () => import('./routes/home'),
+ match: 'startsWith',
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '用户中心', order: 0 },
+ },
+ {
+ path: '/panel/account',
+ label: 'Accounts',
+ description: '目录与多因素设置',
+ icon: User,
+ loader: () => import('./routes/account'),
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '权限设置', order: 0 },
+ },
+ {
+ path: '/panel/agent',
+ label: 'Agents',
+ description: '管理运行时节点',
+ icon: Server,
+ loader: () => import('./routes/agent'),
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '功能特性', order: 0 },
+ featureFlag: {
+ id: 'user-center.agent',
+ title: 'Agent 管理',
+ description: '启用代理节点管理页面。',
+ envVar: 'NEXT_PUBLIC_FEATURE_AGENT_MODULE',
+ defaultEnabled: false,
+ },
+ },
+ {
+ path: '/panel/api',
+ label: 'APIs',
+ description: '洞察后端服务',
+ icon: Code,
+ loader: () => import('./routes/api'),
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '功能特性', order: 1 },
+ featureFlag: {
+ id: 'user-center.api',
+ title: 'API 监控',
+ description: '启用 API 状态与特性开关页面。',
+ envVar: 'NEXT_PUBLIC_FEATURE_API_MODULE',
+ defaultEnabled: false,
+ },
+ },
+ {
+ path: '/panel/subscription',
+ label: 'Subscription',
+ description: '订阅方案与计费规则',
+ icon: CreditCard,
+ loader: () => import('./routes/subscription'),
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '功能特性', order: 2 },
+ featureFlag: {
+ id: 'user-center.subscription',
+ title: '订阅与计费',
+ description: '启用订阅与计费配置页面。',
+ envVar: 'NEXT_PUBLIC_FEATURE_SUBSCRIPTION_MODULE',
+ defaultEnabled: false,
+ },
+ },
+ {
+ path: '/panel/ldp',
+ label: 'LDP',
+ description: '低时延身份平面',
+ icon: Shield,
+ loader: () => import('./routes/ldp'),
+ guard: { requireLogin: true },
+ redirect: { unauthenticated: '/login' },
+ sidebar: { section: '权限设置', order: 1 },
+ featureFlag: {
+ id: 'user-center.ldp',
+ title: 'LDP 管理',
+ description: '启用低时延身份平面管理模块。',
+ envVar: 'NEXT_PUBLIC_FEATURE_LDP_MODULE',
+ defaultEnabled: false,
+ },
+ },
+ {
+ path: '/panel/management',
+ label: 'Management',
+ description: '集中化的权限矩阵与用户编排',
+ icon: Settings,
+ loader: () => import('./routes/management'),
+ guard: { requireLogin: true, roles: ['admin', 'operator'] },
+ match: 'startsWith',
+ redirect: { unauthenticated: '/login', forbidden: '/panel' },
+ sidebar: { section: '管理页面', order: 0 },
+ },
+ ],
+}
diff --git a/dashboard/app/panel/lib/api.ts b/dashboard/src/extensions/builtin/user-center/lib/api.ts
similarity index 100%
rename from dashboard/app/panel/lib/api.ts
rename to dashboard/src/extensions/builtin/user-center/lib/api.ts
diff --git a/dashboard/app/panel/management/__tests__/ManagementComponents.test.tsx b/dashboard/src/extensions/builtin/user-center/management/__tests__/ManagementComponents.test.tsx
similarity index 100%
rename from dashboard/app/panel/management/__tests__/ManagementComponents.test.tsx
rename to dashboard/src/extensions/builtin/user-center/management/__tests__/ManagementComponents.test.tsx
diff --git a/dashboard/app/panel/management/components/OverviewCards.tsx b/dashboard/src/extensions/builtin/user-center/management/components/OverviewCards.tsx
similarity index 100%
rename from dashboard/app/panel/management/components/OverviewCards.tsx
rename to dashboard/src/extensions/builtin/user-center/management/components/OverviewCards.tsx
diff --git a/dashboard/app/panel/management/components/PermissionMatrixEditor.tsx b/dashboard/src/extensions/builtin/user-center/management/components/PermissionMatrixEditor.tsx
similarity index 100%
rename from dashboard/app/panel/management/components/PermissionMatrixEditor.tsx
rename to dashboard/src/extensions/builtin/user-center/management/components/PermissionMatrixEditor.tsx
diff --git a/dashboard/app/panel/management/components/TrendChart.tsx b/dashboard/src/extensions/builtin/user-center/management/components/TrendChart.tsx
similarity index 100%
rename from dashboard/app/panel/management/components/TrendChart.tsx
rename to dashboard/src/extensions/builtin/user-center/management/components/TrendChart.tsx
diff --git a/dashboard/app/panel/management/components/UserGroupManagement.tsx b/dashboard/src/extensions/builtin/user-center/management/components/UserGroupManagement.tsx
similarity index 100%
rename from dashboard/app/panel/management/components/UserGroupManagement.tsx
rename to dashboard/src/extensions/builtin/user-center/management/components/UserGroupManagement.tsx
diff --git a/dashboard/src/extensions/builtin/user-center/routes/account.tsx b/dashboard/src/extensions/builtin/user-center/routes/account.tsx
new file mode 100644
index 0000000..f81dc57
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/account.tsx
@@ -0,0 +1,13 @@
+import MfaSetupPanel from '../account/MfaSetupPanel'
+import ThemePreferenceCard from '../account/ThemePreferenceCard'
+import UserOverview from '../components/UserOverview'
+
+export default function UserCenterAccountRoute() {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/agent.tsx b/dashboard/src/extensions/builtin/user-center/routes/agent.tsx
new file mode 100644
index 0000000..53621b6
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/agent.tsx
@@ -0,0 +1,12 @@
+import Card from '../components/Card'
+
+export default function UserCenterAgentRoute() {
+ return (
+
+ Agent Management
+
+ Manage node agents and rollout updates from a unified workspace.
+
+
+ )
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/api.tsx b/dashboard/src/extensions/builtin/user-center/routes/api.tsx
new file mode 100644
index 0000000..efb2069
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/api.tsx
@@ -0,0 +1,10 @@
+import Card from '../components/Card'
+
+export default function UserCenterApiRoute() {
+ return (
+
+ API Status
+ View backend API health and toggle feature matrices.
+
+ )
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/home.tsx b/dashboard/src/extensions/builtin/user-center/routes/home.tsx
new file mode 100644
index 0000000..e74bbd8
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/home.tsx
@@ -0,0 +1,5 @@
+import UserOverview from '../components/UserOverview'
+
+export default function UserCenterHomeRoute() {
+ return
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/ldp.tsx b/dashboard/src/extensions/builtin/user-center/routes/ldp.tsx
new file mode 100644
index 0000000..380937c
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/ldp.tsx
@@ -0,0 +1,33 @@
+import Link from 'next/link'
+
+import Card from '../components/Card'
+
+export default function UserCenterLdpRoute() {
+ const links = [
+ { href: '/panel/ldp/users', label: 'Users' },
+ { href: '/panel/ldp/services', label: 'Services' },
+ { href: '/panel/ldp/config', label: 'Configuration' },
+ { href: '/panel/ldp/status', label: 'Status' },
+ { href: '/panel/ldp/consent', label: 'Login & Consent' },
+ ]
+
+ return (
+
+ LDP Management
+ Explore low-latency directory plane modules.
+
+ {links.map((link) => (
+ -
+
+ {link.label}
+ Coming soon
+
+
+ ))}
+
+
+ )
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/management.tsx b/dashboard/src/extensions/builtin/user-center/routes/management.tsx
new file mode 100644
index 0000000..c076a96
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/management.tsx
@@ -0,0 +1,286 @@
+'use client'
+
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import useSWR from 'swr'
+
+import Card from '../components/Card'
+import TrendChart, { type MetricsSeries } from '../management/components/TrendChart'
+import OverviewCards, { type MetricsOverview } from '../management/components/OverviewCards'
+import PermissionMatrixEditor, {
+ type PermissionMatrix,
+} from '../management/components/PermissionMatrixEditor'
+import UserGroupManagement, { type ManagedUser } from '../management/components/UserGroupManagement'
+import { resolveAccess } from '@lib/accessControl'
+import { useUser } from '@lib/userStore'
+
+type UserMetricsResponse = {
+ overview: MetricsOverview
+ series: MetricsSeries
+}
+
+type AdminSettingsResponse = {
+ version: number
+ matrix: PermissionMatrix
+}
+
+type ApiError = {
+ error?: string
+ message?: string
+ matrix?: PermissionMatrix
+ version?: number
+}
+
+async function jsonFetcher(input: RequestInfo, init?: RequestInit): Promise {
+ const response = await fetch(input, {
+ ...init,
+ credentials: 'include',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ ...(init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init?.headers),
+ },
+ cache: 'no-store',
+ })
+
+ if (!response.ok) {
+ let payload: ApiError | undefined
+ try {
+ payload = (await response.json()) as ApiError
+ } catch (error) {
+ // Ignore JSON parse errors; fall back to status text below.
+ }
+ const message = payload?.error ?? payload?.message ?? response.statusText
+ throw new Error(message || '请求失败')
+ }
+
+ return (await response.json()) as T
+}
+
+export default function UserCenterManagementRoute() {
+ const { user, isLoading: isUserLoading } = useUser()
+ const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
+ const canAccess = accessDecision.allowed
+ const canEditPermissions = Boolean(user?.isAdmin)
+ const canEditRoles = Boolean(user?.isAdmin)
+
+ const [matrixDraft, setMatrixDraft] = useState({})
+ const [matrixVersion, setMatrixVersion] = useState(0)
+ const [matrixDirty, setMatrixDirty] = useState(false)
+ const [matrixSaving, setMatrixSaving] = useState(false)
+ const [matrixStatus, setMatrixStatus] = useState()
+ const [matrixError, setMatrixError] = useState()
+ const [roleUpdateMessage, setRoleUpdateMessage] = useState()
+ const [pendingRoleUpdates, setPendingRoleUpdates] = useState>(new Set())
+
+ const metricsSWR = useSWR(canAccess ? '/api/admin/users/metrics' : null, jsonFetcher, {
+ revalidateOnFocus: false,
+ })
+ const settingsSWR = useSWR(canAccess ? '/api/admin/settings' : null, jsonFetcher, {
+ revalidateOnFocus: false,
+ })
+ const usersSWR = useSWR(canAccess ? '/api/users' : null, jsonFetcher, {
+ revalidateOnFocus: false,
+ })
+
+ useEffect(() => {
+ if (settingsSWR.data?.matrix) {
+ setMatrixDraft(settingsSWR.data.matrix)
+ setMatrixVersion(settingsSWR.data.version)
+ setMatrixDirty(false)
+ setMatrixError(undefined)
+ }
+ }, [settingsSWR.data])
+
+ const lastUpdatedLabel = useMemo(() => {
+ if (!metricsSWR.data) {
+ return undefined
+ }
+ const now = new Date()
+ return `更新于 ${now.toLocaleString('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}`
+ }, [metricsSWR.data])
+
+ const handleTogglePermission = useCallback(
+ (moduleKey: string, role: string, nextValue: boolean) => {
+ setMatrixDraft((prev) => {
+ const next: PermissionMatrix = { ...prev }
+ const normalizedModuleKey = moduleKey.trim()
+ const normalizedRole = role.trim()
+ const currentRoleMap = next[normalizedModuleKey] ?? {}
+ next[normalizedModuleKey] = { ...currentRoleMap, [normalizedRole]: nextValue }
+ return next
+ })
+ setMatrixDirty(true)
+ setMatrixStatus(undefined)
+ setMatrixError(undefined)
+ },
+ [],
+ )
+
+ const handleSaveMatrix = useCallback(async () => {
+ if (!canEditPermissions || !matrixDirty) {
+ return
+ }
+ setMatrixSaving(true)
+ setMatrixStatus(undefined)
+ setMatrixError(undefined)
+ try {
+ const response = await fetch('/api/admin/settings', {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ version: matrixVersion,
+ matrix: matrixDraft,
+ }),
+ })
+
+ if (response.ok) {
+ const payload = (await response.json()) as AdminSettingsResponse
+ setMatrixDraft(payload.matrix)
+ setMatrixVersion(payload.version)
+ setMatrixDirty(false)
+ setMatrixStatus('已保存')
+ settingsSWR.mutate(payload, { revalidate: false })
+ return
+ }
+
+ let payload: ApiError | undefined
+ try {
+ payload = (await response.json()) as ApiError
+ } catch (error) {
+ // ignore parsing error
+ }
+
+ if (response.status === 409 && payload?.matrix) {
+ setMatrixDraft(payload.matrix)
+ if (typeof payload.version === 'number') {
+ setMatrixVersion(payload.version)
+ }
+ setMatrixDirty(false)
+ setMatrixError(payload.message ?? '配置已被其他人更新,已同步最新版本')
+ return
+ }
+
+ const message = payload?.error ?? payload?.message ?? '保存失败'
+ throw new Error(message)
+ } catch (error) {
+ setMatrixError(error instanceof Error ? error.message : '保存失败')
+ } finally {
+ setMatrixSaving(false)
+ }
+ }, [canEditPermissions, matrixDirty, matrixDraft, matrixVersion, settingsSWR])
+
+ const markRolePending = useCallback((userId: string, pending: boolean) => {
+ setPendingRoleUpdates((prev) => {
+ const next = new Set(prev)
+ if (pending) {
+ next.add(userId)
+ } else {
+ next.delete(userId)
+ }
+ return next
+ })
+ }, [])
+
+ const handleRoleChange = useCallback(
+ async (userId: string, role: string) => {
+ if (!canEditRoles) {
+ return
+ }
+ setRoleUpdateMessage(undefined)
+ markRolePending(userId, true)
+ try {
+ await jsonFetcher(`/api/admin/users/${userId}/role`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ role }),
+ })
+ setRoleUpdateMessage('角色已更新')
+ usersSWR.mutate()
+ } catch (error) {
+ setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
+ } finally {
+ markRolePending(userId, false)
+ }
+ },
+ [canEditRoles, markRolePending, usersSWR],
+ )
+
+ const handleRoleReset = useCallback(
+ async (userId: string) => {
+ if (!canEditRoles) {
+ return
+ }
+ setRoleUpdateMessage(undefined)
+ markRolePending(userId, true)
+ try {
+ await jsonFetcher(`/api/admin/users/${userId}/role`, {
+ method: 'DELETE',
+ credentials: 'include',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
+ setRoleUpdateMessage('角色已重置')
+ usersSWR.mutate()
+ } catch (error) {
+ setRoleUpdateMessage(error instanceof Error ? error.message : '更新失败')
+ } finally {
+ markRolePending(userId, false)
+ }
+ },
+ [canEditRoles, markRolePending, usersSWR],
+ )
+
+ const matrixPending = matrixSaving || isUserLoading
+ const metricsLoading = metricsSWR.isLoading
+ const settingsLoading = settingsSWR.isLoading
+ const usersLoading = usersSWR.isLoading
+
+ if (!canAccess) {
+ return (
+
+ 权限不足
+ 需要管理员或运维角色才能访问此页面。
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/dashboard/src/extensions/builtin/user-center/routes/subscription.tsx b/dashboard/src/extensions/builtin/user-center/routes/subscription.tsx
new file mode 100644
index 0000000..aef3d7d
--- /dev/null
+++ b/dashboard/src/extensions/builtin/user-center/routes/subscription.tsx
@@ -0,0 +1,10 @@
+import Card from '../components/Card'
+
+export default function UserCenterSubscriptionRoute() {
+ return (
+
+ Subscription
+ Manage subscriptions and invoicing rules.
+
+ )
+}
diff --git a/dashboard/src/extensions/loader.ts b/dashboard/src/extensions/loader.ts
new file mode 100644
index 0000000..1082529
--- /dev/null
+++ b/dashboard/src/extensions/loader.ts
@@ -0,0 +1,137 @@
+import { createFeatureFlag } from '@lib/featureFlags'
+
+import { builtinExtensions } from './builtin'
+import type {
+ DashboardExtension,
+ ExtensionRegistry,
+ RegisteredExtension,
+ RegisteredRoute,
+ SidebarItem,
+ SidebarSection,
+} from './types'
+
+let registryCache: ExtensionRegistry | undefined
+
+function instantiateExtension(definition: DashboardExtension): RegisteredExtension {
+ const extensionFlag = definition.featureFlag ? createFeatureFlag(definition.featureFlag) : undefined
+ const extensionEnabled = extensionFlag ? extensionFlag.enabled : true
+
+ const registered: RegisteredExtension = {
+ ...definition,
+ featureFlag: extensionFlag,
+ enabled: extensionEnabled,
+ routes: [],
+ }
+
+ registered.routes = definition.routes.map((route) => {
+ const routeFlag = route.featureFlag ? createFeatureFlag(route.featureFlag) : undefined
+ const routeEnabled = extensionEnabled && (routeFlag ? routeFlag.enabled : true)
+
+ const registeredRoute: RegisteredRoute = {
+ ...route,
+ extensionId: definition.id,
+ extension: registered,
+ enabled: routeEnabled,
+ featureFlag: routeFlag,
+ }
+
+ return registeredRoute
+ })
+
+ return registered
+}
+
+function buildSidebar(routes: RegisteredRoute[]): SidebarSection[] {
+ const sectionMap = new Map()
+
+ routes.forEach((route) => {
+ if (!route.sidebar || route.sidebar.hidden) {
+ return
+ }
+
+ const { section, order } = route.sidebar
+ const sectionId = section
+ let entry = sectionMap.get(sectionId)
+ if (!entry) {
+ entry = { id: sectionId, title: section, order, items: [] }
+ sectionMap.set(sectionId, entry)
+ }
+
+ const item: SidebarItem = {
+ route,
+ disabled: !route.enabled,
+ }
+
+ entry.items.push(item)
+ })
+
+ const sortedSections = Array.from(sectionMap.values()).sort((a, b) => {
+ const orderA = a.order ?? Number.MAX_SAFE_INTEGER
+ const orderB = b.order ?? Number.MAX_SAFE_INTEGER
+ if (orderA !== orderB) {
+ return orderA - orderB
+ }
+ return a.title.localeCompare(b.title, 'zh-CN')
+ })
+
+ sortedSections.forEach((section) => {
+ section.items.sort((a, b) => {
+ const orderA = a.route.sidebar?.order ?? Number.MAX_SAFE_INTEGER
+ const orderB = b.route.sidebar?.order ?? Number.MAX_SAFE_INTEGER
+ if (orderA !== orderB) {
+ return orderA - orderB
+ }
+ return a.route.label.localeCompare(b.route.label, 'zh-CN')
+ })
+ })
+
+ return sortedSections
+}
+
+function createRegistry(): ExtensionRegistry {
+ const extensions = builtinExtensions.map(instantiateExtension)
+ const routes = extensions.flatMap((extension) => extension.routes)
+ const routeMap = new Map()
+
+ routes.forEach((route) => {
+ if (!routeMap.has(route.path)) {
+ routeMap.set(route.path, route)
+ }
+ })
+
+ const sidebar = buildSidebar(routes)
+
+ return {
+ extensions,
+ routes,
+ sidebar,
+ getRoute: (path: string) => routeMap.get(path),
+ resolveComponent: async (path: string) => {
+ const route = routeMap.get(path)
+ if (!route) {
+ throw new Error(`No extension route registered for path: ${path}`)
+ }
+ if (!route.enabled) {
+ throw new Error(`Extension route is disabled: ${path}`)
+ }
+ const module = await route.loader()
+ return module.default
+ },
+ }
+}
+
+export function getExtensionRegistry(): ExtensionRegistry {
+ if (!registryCache) {
+ registryCache = createRegistry()
+ }
+ return registryCache
+}
+
+export async function resolveExtensionRouteComponent(path: string) {
+ const registry = getExtensionRegistry()
+ return registry.resolveComponent(path)
+}
+
+export function resetExtensionRegistryCache() {
+ registryCache = undefined
+}
diff --git a/dashboard/src/extensions/types.ts b/dashboard/src/extensions/types.ts
new file mode 100644
index 0000000..05c5859
--- /dev/null
+++ b/dashboard/src/extensions/types.ts
@@ -0,0 +1,84 @@
+import type { ComponentType } from 'react'
+import type { LucideIcon } from 'lucide-react'
+
+import type { AccessRule } from '@lib/accessControl'
+import type { FeatureFlag, FeatureFlagDefinition } from '@lib/featureFlags'
+
+export type RouteMatchStrategy = 'exact' | 'startsWith'
+
+export interface ExtensionMeta {
+ title: string
+ description?: string
+ version?: string
+ author?: string
+ keywords?: string[]
+}
+
+export interface ExtensionStoreRegistration {
+ id: string
+ register: () => Promise | void
+}
+
+export interface ExtensionMenuItem {
+ section: string
+ order?: number
+ badge?: string
+ hidden?: boolean
+}
+
+export interface ExtensionRoute {
+ path: string
+ label: string
+ description?: string
+ icon?: LucideIcon
+ loader: () => Promise<{ default: ComponentType }>
+ match?: RouteMatchStrategy
+ guard?: AccessRule
+ sidebar?: ExtensionMenuItem
+ featureFlag?: FeatureFlagDefinition
+ redirect?: {
+ unauthenticated?: string
+ forbidden?: string
+ }
+}
+
+export interface DashboardExtension {
+ id: string
+ meta: ExtensionMeta
+ routes: ExtensionRoute[]
+ stores?: ExtensionStoreRegistration[]
+ featureFlag?: FeatureFlagDefinition
+}
+
+export interface RegisteredRoute extends ExtensionRoute {
+ extensionId: string
+ extension: RegisteredExtension
+ enabled: boolean
+ featureFlag?: FeatureFlag
+}
+
+export interface RegisteredExtension extends DashboardExtension {
+ enabled: boolean
+ featureFlag?: FeatureFlag
+ routes: RegisteredRoute[]
+}
+
+export interface SidebarItem {
+ route: RegisteredRoute
+ disabled: boolean
+}
+
+export interface SidebarSection {
+ id: string
+ title: string
+ order?: number
+ items: SidebarItem[]
+}
+
+export interface ExtensionRegistry {
+ extensions: RegisteredExtension[]
+ routes: RegisteredRoute[]
+ sidebar: SidebarSection[]
+ getRoute: (path: string) => RegisteredRoute | undefined
+ resolveComponent: (path: string) => Promise>
+}
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 806c2d4..650d4d2 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -23,6 +23,7 @@
"@lib/*": ["lib/*"],
"@types/*": ["types/*"],
"@server/*": ["server/*"],
+ "@extensions/*": ["src/extensions/*"],
"@theme/*": ["src/theme/*"],
"@templates/*": ["src/templates/*"],
"@src/*": ["src/*"]
diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts
index 1312501..ef9a06a 100644
--- a/dashboard/vitest.config.ts
+++ b/dashboard/vitest.config.ts
@@ -26,6 +26,7 @@ export default defineConfig({
'@i18n': path.resolve(__dirname, 'i18n'),
'@lib': path.resolve(__dirname, 'lib'),
'@types': path.resolve(__dirname, 'types'),
+ '@extensions': path.resolve(__dirname, 'src/extensions'),
'@templates': path.resolve(__dirname, 'src/templates'),
'@src': path.resolve(__dirname, 'src'),
},