Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions web/src/components/admin/AdminDataSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Typography } from '@arco-design/web-react'
import type { ReactNode } from 'react'

export interface AdminMetric {
label: string
value: ReactNode
detail: string
}

interface AdminDataSectionProps {
title: string
description: string
actions?: ReactNode
metrics: AdminMetric[]
toolbar: ReactNode
children: ReactNode
}

export function AdminDataSection({ title, description, actions, metrics, toolbar, children }: AdminDataSectionProps) {
return (
<section className="admin-section" aria-labelledby="admin-section-title">
<header className="admin-section__header">
<div>
<Typography.Title id="admin-section-title" heading={5} className="admin-section__title">
{title}
</Typography.Title>
<Typography.Paragraph type="secondary" className="admin-section__description">
{description}
</Typography.Paragraph>
</div>
{actions}
</header>

<div className="admin-summary" aria-label={`${title}概览`}>
{metrics.map((metric) => (
<div key={metric.label} className="admin-summary__item">
<Typography.Text type="secondary">{metric.label}</Typography.Text>
<span className="admin-summary__value">{metric.value}</span>
<span className="admin-summary__detail">{metric.detail}</span>
</div>
))}
</div>

<div className="admin-data-panel">
{toolbar}
{children}
</div>
</section>
)
}
34 changes: 34 additions & 0 deletions web/src/components/admin/AdminRoleSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Select } from '@arco-design/web-react'
import type { CSSProperties } from 'react'
import type { UserRole } from '../../services/users'

export const adminRoleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]

export const adminRoleDescriptions: Record<UserRole, string> = {
admin: '拥有系统配置、账号与访问凭据的完整管理权限。',
operator: '可执行日常备份、恢复和节点运维操作。',
viewer: '仅可查看仪表盘和允许读取的数据。',
}

interface AdminRoleSelectProps {
value: UserRole
onChange: (role: UserRole) => void
disabled?: boolean
style?: CSSProperties
}

export function AdminRoleSelect({ value, onChange, disabled, style }: AdminRoleSelectProps) {
return (
<Select
value={value}
options={adminRoleOptions}
disabled={disabled}
style={style}
onChange={(role) => onChange(role as UserRole)}
/>
)
}
11 changes: 3 additions & 8 deletions web/src/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
IconCopy,
IconBook,
IconUser,
IconCommand,
IconNotification,
IconSettings,
IconMenuFold,
Expand Down Expand Up @@ -86,11 +85,8 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/task-templates')) {
return '/task-templates'
}
if (pathname.startsWith('/admin/users')) {
return '/admin/users'
}
if (pathname.startsWith('/admin/api-keys')) {
return '/admin/api-keys'
if (pathname.startsWith('/admin')) {
return '/admin'
}
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
return '/settings'
Expand All @@ -117,8 +113,7 @@ const menuItems: MenuItemConfig[] = [
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
{ key: '/admin/users', label: '用户管理', icon: <IconUser />, adminOnly: true },
{ key: '/admin/api-keys', label: 'API Key', icon: <IconCommand />, adminOnly: true },
{ key: '/admin', label: '访问管理', icon: <IconUser />, adminOnly: true },
{ key: '/audit', label: '审计日志', icon: <IconList /> },
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
]
Expand Down
60 changes: 60 additions & 0 deletions web/src/pages/admin/AdminLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '../../stores/auth'
import { AdminLayout } from './AdminLayout'

describe('AdminLayout', () => {
beforeEach(() => {
useAuthStore.setState({
token: 'test-token',
user: { id: 1, username: 'admin', displayName: 'Admin', role: 'admin' },
status: 'authenticated',
bootstrapped: true,
})
})

it('keeps user and API key management in one navigable admin area', async () => {
const actor = userEvent.setup()
render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>user management content</div>} />
<Route path="api-keys" element={<div>api key management content</div>} />
</Route>
<Route path="/audit" element={<div>audit content</div>} />
</Routes>
</MemoryRouter>,
)

expect(screen.getByText('user management content')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '访问管理分区' })).toBeInTheDocument()

await actor.click(screen.getByRole('button', { name: 'API Key' }))
expect(screen.getByText('api key management content')).toBeInTheDocument()

await actor.click(screen.getByRole('button', { name: '访问审计' }))
expect(screen.getByText('audit content')).toBeInTheDocument()
})

it('blocks non-admin users before rendering management content', () => {
useAuthStore.setState({
user: { id: 2, username: 'viewer', displayName: 'Viewer', role: 'viewer' },
})

render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>restricted content</div>} />
</Route>
</Routes>
</MemoryRouter>,
)

expect(screen.getByText('当前账号无权进入访问管理(仅管理员)')).toBeInTheDocument()
expect(screen.queryByText('restricted content')).not.toBeInTheDocument()
})
})
55 changes: 55 additions & 0 deletions web/src/pages/admin/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Alert, Button, PageHeader } from '@arco-design/web-react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { IconCommand, IconList, IconUser } from '../../components/icons'
import { useAuthStore } from '../../stores/auth'
import { isAdmin } from '../../utils/permissions'
import './admin.css'

const sections = [
{ path: '/admin/users', label: '用户账号', icon: <IconUser /> },
{ path: '/admin/api-keys', label: 'API Key', icon: <IconCommand /> },
]

export function AdminLayout() {
const user = useAuthStore((state) => state.user)
const location = useLocation()
const navigate = useNavigate()

if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权进入访问管理(仅管理员)" />
}

return (
<div className="admin-page">
<PageHeader
className="admin-page__header"
title="访问管理"
subTitle="统一管理系统账号、角色权限、多因素认证与程序化访问凭据。"
extra={(
<Button icon={<IconList />} onClick={() => navigate('/audit')}>
访问审计
</Button>
)}
/>

<nav className="admin-page__nav" aria-label="访问管理分区">
{sections.map((section) => {
const selected = location.pathname.startsWith(section.path)
return (
<Button
key={section.path}
type={selected ? 'secondary' : 'text'}
icon={section.icon}
aria-current={selected ? 'page' : undefined}
onClick={() => navigate(section.path)}
>
{section.label}
</Button>
)
})}
</nav>

<Outlet />
</div>
)
}
31 changes: 31 additions & 0 deletions web/src/pages/admin/ApiKeysPage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { ApiKeySummary } from '../../services/api-keys'
import { resolveApiKeyStatus } from './ApiKeysPage'

const baseKey: ApiKeySummary = {
id: 1,
name: 'automation',
role: 'viewer',
prefix: 'bax_example',
createdBy: 'admin',
disabled: false,
createdAt: '2026-08-01T00:00:00Z',
}

describe('resolveApiKeyStatus', () => {
const now = new Date('2026-08-07T00:00:00Z').getTime()

it('derives active, disabled, and expired states from the credential lifecycle', () => {
expect(resolveApiKeyStatus(baseKey, now)).toBe('active')
expect(resolveApiKeyStatus({ ...baseKey, disabled: true }, now)).toBe('disabled')
expect(resolveApiKeyStatus({ ...baseKey, expiresAt: '2026-08-06T23:59:59Z' }, now)).toBe('expired')
})

it('keeps expiration authoritative when an expired key is also disabled', () => {
expect(resolveApiKeyStatus({
...baseKey,
disabled: true,
expiresAt: '2026-08-01T00:00:00Z',
}, now)).toBe('expired')
})
})
Loading
Loading