mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-25 10:10:02 +08:00
Compare commits
1 Commits
dependabot
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed13194de1 |
50
web/src/components/admin/AdminDataSection.tsx
Normal file
50
web/src/components/admin/AdminDataSection.tsx
Normal file
@@ -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
web/src/components/admin/AdminRoleSelect.tsx
Normal file
34
web/src/components/admin/AdminRoleSelect.tsx
Normal file
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconCopy,
|
||||
IconBook,
|
||||
IconUser,
|
||||
IconCommand,
|
||||
IconNotification,
|
||||
IconSettings,
|
||||
IconMenuFold,
|
||||
@@ -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'
|
||||
@@ -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 /> },
|
||||
]
|
||||
|
||||
60
web/src/pages/admin/AdminLayout.test.tsx
Normal file
60
web/src/pages/admin/AdminLayout.test.tsx
Normal file
@@ -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
web/src/pages/admin/AdminLayout.tsx
Normal file
55
web/src/pages/admin/AdminLayout.tsx
Normal file
@@ -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
web/src/pages/admin/ApiKeysPage.test.ts
Normal file
31
web/src/pages/admin/ApiKeysPage.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +1,70 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
InputNumber,
|
||||
Message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AdminDataSection } from '../../components/admin/AdminDataSection'
|
||||
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
|
||||
import { IconCopy, IconDelete, IconList, IconPlus, IconRefresh, IconSearch } from '../../components/icons'
|
||||
import {
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
toggleApiKey,
|
||||
type ApiKeyCreateInput,
|
||||
type ApiKeySummary,
|
||||
} from '../../services/api-keys'
|
||||
import type { UserRole } from '../../services/users'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { roleLabel } from '../../utils/permissions'
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '管理员 (admin)', value: 'admin' },
|
||||
{ label: '运维 (operator)', value: 'operator' },
|
||||
{ label: '只读 (viewer)', value: 'viewer' },
|
||||
]
|
||||
type ApiKeyStatus = 'active' | 'disabled' | 'expired'
|
||||
type ApiKeyStatusFilter = ApiKeyStatus | 'all'
|
||||
|
||||
export function resolveApiKeyStatus(item: ApiKeySummary, now: number): ApiKeyStatus {
|
||||
if (item.expiresAt && new Date(item.expiresAt).getTime() <= now) {
|
||||
return 'expired'
|
||||
}
|
||||
return item.disabled ? 'disabled' : 'active'
|
||||
}
|
||||
|
||||
// ApiKeysPage API Key 管理(admin 专属)。
|
||||
// 新创建的 Key 明文只返回一次,需要用户立即保存。
|
||||
export function ApiKeysPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const navigate = useNavigate()
|
||||
const [items, setItems] = useState<ApiKeySummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<ApiKeyStatusFilter>('all')
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [plainKey, setPlainKey] = useState<string>('')
|
||||
const [rowAction, setRowAction] = useState('')
|
||||
const [plainKey, setPlainKey] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setItems(await listApiKeys())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载 API Key 失败'))
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载 API Key 失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -41,47 +74,81 @@ export function ApiKeysPage() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const now = useMemo(() => Date.now(), [items])
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
return items.filter((item) => {
|
||||
const matchesQuery = !keyword || [item.name, item.prefix, item.createdBy]
|
||||
.some((value) => value?.toLowerCase().includes(keyword))
|
||||
const matchesRole = roleFilter === 'all' || item.role === roleFilter
|
||||
const matchesStatus = statusFilter === 'all' || resolveApiKeyStatus(item, now) === statusFilter
|
||||
return matchesQuery && matchesRole && matchesStatus
|
||||
})
|
||||
}, [items, now, query, roleFilter, statusFilter])
|
||||
|
||||
const activeCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'active').length
|
||||
const disabledCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'disabled').length
|
||||
const expiredCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'expired').length
|
||||
const usedCount = items.filter((item) => Boolean(item.lastUsedAt)).length
|
||||
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
|
||||
|
||||
function openCreate() {
|
||||
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
|
||||
setPlainKey('')
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalVisible(false)
|
||||
setPlainKey('')
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.name.trim()) {
|
||||
const payload = { ...draft, name: draft.name.trim(), ttlHours: Number(draft.ttlHours ?? 0) }
|
||||
if (!payload.name) {
|
||||
Message.error('名称不能为空')
|
||||
return
|
||||
}
|
||||
if (payload.ttlHours < 0 || payload.ttlHours > 87600) {
|
||||
Message.error('有效期需要在 0 到 87600 小时之间')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createApiKey(draft)
|
||||
const result = await createApiKey(payload)
|
||||
setPlainKey(result.plainKey)
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '创建失败'))
|
||||
} catch (submitError) {
|
||||
Message.error(resolveErrorMessage(submitError, '创建失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(item: ApiKeySummary) {
|
||||
setRowAction(`toggle:${item.id}`)
|
||||
try {
|
||||
await toggleApiKey(item.id, !item.disabled)
|
||||
Message.success(item.disabled ? '已启用' : '已停用')
|
||||
Message.success(item.disabled ? 'API Key 已启用' : 'API Key 已停用')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '操作失败'))
|
||||
} catch (toggleError) {
|
||||
Message.error(resolveErrorMessage(toggleError, '操作失败'))
|
||||
} finally {
|
||||
setRowAction('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(item: ApiKeySummary) {
|
||||
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
|
||||
setRowAction(`revoke:${item.id}`)
|
||||
try {
|
||||
await revokeApiKey(item.id)
|
||||
Message.success('已撤销')
|
||||
Message.success('API Key 已撤销')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '撤销失败'))
|
||||
} catch (revokeError) {
|
||||
Message.error(resolveErrorMessage(revokeError, '撤销失败'))
|
||||
} finally {
|
||||
setRowAction('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,83 +162,221 @@ export function ApiKeysPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin)" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>API Key</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
签发 API Key 供 CI/CD、监控脚本等非交互式场景访问 BackupX。在请求头加 <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> 或 <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text> 即可。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>生成 API Key</Button>
|
||||
</Space>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={items}
|
||||
pagination={false}
|
||||
stripe
|
||||
noDataElement={<Empty description="暂无 API Key" />}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
|
||||
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}…</Typography.Text> },
|
||||
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
|
||||
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
|
||||
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
|
||||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||||
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}>撤销</Button>
|
||||
</Space>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<AdminDataSection
|
||||
title="API Key"
|
||||
description="为 CI/CD、监控和自动化任务签发独立凭据,并集中管理权限、有效期、使用状态与撤销操作。"
|
||||
metrics={[
|
||||
{ label: '凭据总数', value: items.length, detail: `${usedCount} 个凭据已有调用记录` },
|
||||
{ label: '当前可用', value: activeCount, detail: '未停用且未超过有效期' },
|
||||
{ label: '已停用', value: disabledCount, detail: '保留记录,可再次启用' },
|
||||
{ label: '已过期', value: expiredCount, detail: '到期后无法继续认证' },
|
||||
]}
|
||||
actions={(
|
||||
<Space>
|
||||
<Button icon={<IconList />} onClick={() => navigate('/audit?category=api_key')}>密钥审计</Button>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={openCreate}>生成 API Key</Button>
|
||||
</Space>
|
||||
)}
|
||||
toolbar={(
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar__filters">
|
||||
<Input
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
prefix={<IconSearch />}
|
||||
value={query}
|
||||
aria-label="搜索 API Key"
|
||||
placeholder="搜索名称、前缀或创建者"
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={roleFilter}
|
||||
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
|
||||
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '当前可用', value: 'active' },
|
||||
{ label: '已停用', value: 'disabled' },
|
||||
{ label: '已过期', value: 'expired' },
|
||||
]}
|
||||
onChange={(value) => setStatusFilter(value as ApiKeyStatusFilter)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
disabled={!filtersActive}
|
||||
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-toolbar__status">
|
||||
<Typography.Text type="secondary">显示 {filteredItems.length} / {items.length}</Typography.Text>
|
||||
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}>刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<div className="admin-data-panel__alert">
|
||||
<Alert type="error" content={error} />
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={filteredItems}
|
||||
stripe
|
||||
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
|
||||
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的 API Key' : '暂无 API Key'} />}
|
||||
columns={[
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 190,
|
||||
render: (value: string, row: ApiKeySummary) => (
|
||||
<div className="admin-identity">
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
<span className="admin-identity__secondary" title={`由 ${row.createdBy || '-'} 创建于 ${formatDateTime(row.createdAt)}`}>
|
||||
由 {row.createdBy || '-'} 创建 · {formatDateTime(row.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 90,
|
||||
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Key 前缀',
|
||||
dataIndex: 'prefix',
|
||||
width: 135,
|
||||
render: (value: string) => <span className="admin-key-prefix">{value}…</span>,
|
||||
},
|
||||
{
|
||||
title: '最近使用',
|
||||
dataIndex: 'lastUsedAt',
|
||||
width: 160,
|
||||
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '从未使用',
|
||||
},
|
||||
{
|
||||
title: '有效期',
|
||||
dataIndex: 'expiresAt',
|
||||
width: 160,
|
||||
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '永不过期',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'disabled',
|
||||
width: 90,
|
||||
render: (_: boolean, row: ApiKeySummary) => {
|
||||
const status = resolveApiKeyStatus(row, now)
|
||||
if (status === 'expired') return <Tag color="orange" bordered>已过期</Tag>
|
||||
if (status === 'disabled') return <Tag color="red" bordered>已停用</Tag>
|
||||
return <Tag color="green" bordered>当前可用</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_: unknown, row: ApiKeySummary) => {
|
||||
const expired = resolveApiKeyStatus(row, now) === 'expired'
|
||||
return (
|
||||
<Space>
|
||||
{expired ? (
|
||||
<Tooltip content="已过期凭据不能重新启用,请生成新凭据">
|
||||
<span><Button size="small" type="text" disabled>启用</Button></span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
loading={rowAction === `toggle:${row.id}`}
|
||||
onClick={() => void handleToggle(row)}
|
||||
>
|
||||
{row.disabled ? '启用' : '停用'}
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={`确定撤销 API Key「${row.name}」?`}
|
||||
content="撤销后无法恢复,使用该凭据的自动化任务将立即失效。"
|
||||
onOk={() => handleRevoke(row)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
status="danger"
|
||||
icon={<IconDelete />}
|
||||
loading={rowAction === `revoke:${row.id}`}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
title="生成 API Key"
|
||||
onCancel={() => { setModalVisible(false); setPlainKey('') }}
|
||||
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
|
||||
okText={plainKey ? '完成' : '生成'}
|
||||
title={plainKey ? '保存 API Key' : '生成 API Key'}
|
||||
style={{ width: 640 }}
|
||||
onCancel={closeModal}
|
||||
onOk={plainKey ? closeModal : handleSubmit}
|
||||
okText={plainKey ? '我已保存' : '生成'}
|
||||
confirmLoading={submitting}
|
||||
unmountOnExit
|
||||
>
|
||||
{plainKey ? (
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert type="warning" content="明文 Key 只会显示一次,请立即妥善保存。" />
|
||||
<Input.TextArea value={plainKey} autoSize readOnly />
|
||||
<Button type="outline" onClick={() => void copyPlainKey()}>复制到剪贴板</Button>
|
||||
</Space>
|
||||
<div className="admin-key-result">
|
||||
<Alert type="warning" content="明文 Key 只显示一次。关闭窗口前,请将它保存到安全的密钥管理系统。" />
|
||||
<Input.TextArea value={plainKey} autoSize={{ minRows: 2, maxRows: 3 }} readOnly />
|
||||
<Button type="outline" icon={<IconCopy />} onClick={() => void copyPlainKey()}>复制到剪贴板</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="名称" required>
|
||||
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如:ci-deploy-script" />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" required>
|
||||
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="有效期(小时,0=永不过期)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
|
||||
<Input
|
||||
value={draft.name}
|
||||
maxLength={128}
|
||||
showWordLimit
|
||||
placeholder="例如:ci-deploy-script"
|
||||
onChange={(value) => setDraft({ ...draft, name: value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Grid.Row gutter={16}>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="角色" required>
|
||||
<AdminRoleSelect value={draft.role} onChange={(role) => setDraft({ ...draft, role })} />
|
||||
<span className="admin-form-note">{adminRoleDescriptions[draft.role]}</span>
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="有效期">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={87600}
|
||||
suffix="小时"
|
||||
value={draft.ttlHours ?? 0}
|
||||
onChange={(value) => setDraft({ ...draft, ttlHours: Number(value ?? 0) })}
|
||||
/>
|
||||
<span className="admin-form-note">0 表示永不过期;自动化凭据建议设置明确有效期。</span>
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
</AdminDataSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用告警
|
||||
void Switch
|
||||
|
||||
@@ -1,40 +1,71 @@
|
||||
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Empty,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@arco-design/web-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AdminDataSection } from '../../components/admin/AdminDataSection'
|
||||
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
|
||||
import { IconDelete, IconEdit, IconList, IconPlus, IconRefresh, IconSafe, IconSearch } from '../../components/icons'
|
||||
import { clearTrustedDeviceToken } from '../../services/auth'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
resetUserTwoFactor,
|
||||
updateUser,
|
||||
type UserRole,
|
||||
type UserSummary,
|
||||
type UserUpsertPayload,
|
||||
} from '../../services/users'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { isAdmin, roleLabel } from '../../utils/permissions'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { roleLabel } from '../../utils/permissions'
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '管理员 (admin)', value: 'admin' },
|
||||
{ label: '运维 (operator)', value: 'operator' },
|
||||
{ label: '只读 (viewer)', value: 'viewer' },
|
||||
]
|
||||
type UserStatusFilter = 'all' | 'enabled' | 'disabled'
|
||||
|
||||
function createEmpty(): UserUpsertPayload {
|
||||
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
|
||||
}
|
||||
|
||||
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
|
||||
export function UsersPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [items, setItems] = useState<UserSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<UserStatusFilter>('all')
|
||||
const [editing, setEditing] = useState<UserSummary | null>(null)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [draft, setDraft] = useState<UserUpsertPayload>(createEmpty())
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [rowAction, setRowAction] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setItems(await listUsers())
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载用户失败'))
|
||||
} catch (loadError) {
|
||||
setError(resolveErrorMessage(loadError, '加载用户失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,6 +75,24 @@ export function UsersPage() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
return items.filter((item) => {
|
||||
const matchesQuery = !keyword || [item.username, item.displayName, item.email, item.phone]
|
||||
.some((value) => value?.toLowerCase().includes(keyword))
|
||||
const matchesRole = roleFilter === 'all' || item.role === roleFilter
|
||||
const matchesStatus = statusFilter === 'all'
|
||||
|| (statusFilter === 'enabled' && !item.disabled)
|
||||
|| (statusFilter === 'disabled' && item.disabled)
|
||||
return matchesQuery && matchesRole && matchesStatus
|
||||
})
|
||||
}, [items, query, roleFilter, statusFilter])
|
||||
|
||||
const enabledCount = items.filter((item) => !item.disabled).length
|
||||
const adminCount = items.filter((item) => item.role === 'admin').length
|
||||
const mfaCount = items.filter((item) => !item.disabled && item.mfaEnabled).length
|
||||
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setDraft(createEmpty())
|
||||
@@ -65,51 +114,65 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!draft.username.trim() || !draft.displayName.trim()) {
|
||||
Message.error('用户名与显示名称不能为空')
|
||||
const payload: UserUpsertPayload = {
|
||||
...draft,
|
||||
username: draft.username.trim(),
|
||||
displayName: draft.displayName.trim(),
|
||||
email: draft.email?.trim(),
|
||||
phone: draft.phone?.trim(),
|
||||
}
|
||||
if (payload.username.length < 3) {
|
||||
Message.error('用户名至少需要 3 个字符')
|
||||
return
|
||||
}
|
||||
if (!editing && !draft.password?.trim()) {
|
||||
Message.error('创建用户必须设置初始密码')
|
||||
if (!payload.displayName) {
|
||||
Message.error('显示名称不能为空')
|
||||
return
|
||||
}
|
||||
if ((!editing || payload.password?.trim()) && (payload.password?.length ?? 0) < 8) {
|
||||
Message.error(editing ? '新密码至少需要 8 个字符' : '初始密码至少需要 8 个字符')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateUser(editing.id, draft)
|
||||
const updated = await updateUser(editing.id, payload)
|
||||
if (updated.id === user?.id) {
|
||||
if (draft.password?.trim()) {
|
||||
if (payload.password?.trim()) {
|
||||
clearTrustedDeviceToken(updated.username)
|
||||
}
|
||||
setUser(updated)
|
||||
}
|
||||
Message.success('用户已更新')
|
||||
} else {
|
||||
await createUser(draft)
|
||||
await createUser(payload)
|
||||
Message.success('用户已创建')
|
||||
}
|
||||
setModalVisible(false)
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '保存失败'))
|
||||
} catch (submitError) {
|
||||
Message.error(resolveErrorMessage(submitError, '保存失败'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: UserSummary) {
|
||||
if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return
|
||||
setRowAction(`delete:${item.id}`)
|
||||
try {
|
||||
await deleteUser(item.id)
|
||||
Message.success('已删除')
|
||||
Message.success('用户已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '删除失败'))
|
||||
} catch (deleteError) {
|
||||
Message.error(resolveErrorMessage(deleteError, '删除失败'))
|
||||
} finally {
|
||||
setRowAction('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetTwoFactor(item: UserSummary) {
|
||||
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
|
||||
setRowAction(`mfa:${item.id}`)
|
||||
try {
|
||||
const updated = await resetUserTwoFactor(item.id)
|
||||
if (updated.id === user?.id) {
|
||||
@@ -118,104 +181,274 @@ export function UsersPage() {
|
||||
}
|
||||
Message.success('MFA 已重置')
|
||||
await load()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
|
||||
} catch (resetError) {
|
||||
Message.error(resolveErrorMessage(resetError, '重置 MFA 失败'))
|
||||
} finally {
|
||||
setRowAction('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin(user)) {
|
||||
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin)" />
|
||||
}
|
||||
const editingSelf = editing?.id === user?.id
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title heading={4}>用户管理</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">管理系统账号。角色分为管理员(全权)、运维(日常运维)、只读(仪表盘)。</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>新建用户</Button>
|
||||
</Space>
|
||||
|
||||
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={items}
|
||||
pagination={false}
|
||||
stripe
|
||||
noDataElement={<Empty description="暂无用户" />}
|
||||
columns={[
|
||||
{ title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text bold>{value}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.displayName}</Typography.Text>
|
||||
</Space>
|
||||
) },
|
||||
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
|
||||
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{row.email || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
|
||||
</Space>
|
||||
) },
|
||||
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered>已停用</Tag> : <Tag color="green" bordered>启用</Tag> },
|
||||
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
|
||||
<AdminDataSection
|
||||
title="用户账号"
|
||||
description="维护登录账号、角色、联系方式和多因素认证状态。当前账号与最后一个管理员受到界面级保护。"
|
||||
metrics={[
|
||||
{ label: '账号总数', value: items.length, detail: '系统中的全部登录账号' },
|
||||
{ label: '启用账号', value: enabledCount, detail: `${items.length - enabledCount} 个账号已停用` },
|
||||
{ label: '管理员', value: adminCount, detail: '拥有访问管理权限' },
|
||||
{ label: 'MFA 覆盖', value: `${mfaCount}/${enabledCount}`, detail: '已启用账号中的 MFA 使用情况' },
|
||||
]}
|
||||
actions={(
|
||||
<Space>
|
||||
<Button icon={<IconList />} onClick={() => navigate('/audit?category=user')}>用户审计</Button>
|
||||
<Button type="primary" icon={<IconPlus />} onClick={openCreate}>新建用户</Button>
|
||||
</Space>
|
||||
)}
|
||||
toolbar={(
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar__filters">
|
||||
<Input
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
prefix={<IconSearch />}
|
||||
value={query}
|
||||
aria-label="搜索用户"
|
||||
placeholder="搜索用户名、名称或联系方式"
|
||||
onChange={setQuery}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={roleFilter}
|
||||
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
|
||||
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={statusFilter}
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '已启用', value: 'enabled' },
|
||||
{ label: '已停用', value: 'disabled' },
|
||||
]}
|
||||
onChange={(value) => setStatusFilter(value as UserStatusFilter)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
disabled={!filtersActive}
|
||||
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-toolbar__status">
|
||||
<Typography.Text type="secondary">显示 {filteredItems.length} / {items.length}</Typography.Text>
|
||||
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}>刷新</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<div className="admin-data-panel__alert">
|
||||
<Alert type="error" content={error} />
|
||||
</div>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={filteredItems}
|
||||
stripe
|
||||
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
|
||||
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的用户' : '暂无用户'} />}
|
||||
columns={[
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
width: 170,
|
||||
render: (value: string, row: UserSummary) => (
|
||||
<div className="admin-identity">
|
||||
<Space size={6}>
|
||||
<Typography.Text>{value}</Typography.Text>
|
||||
{row.id === user?.id ? <Tag bordered>当前账号</Tag> : null}
|
||||
</Space>
|
||||
<span className="admin-identity__secondary">{row.displayName}</span>
|
||||
<span className="admin-identity__secondary" title={formatDateTime(row.createdAt)}>
|
||||
创建于 {formatDateTime(row.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 80,
|
||||
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '联系方式',
|
||||
dataIndex: 'email',
|
||||
width: 190,
|
||||
render: (_: string, row: UserSummary) => (
|
||||
<div className="admin-contact">
|
||||
<Typography.Text>{row.email || '未配置邮箱'}</Typography.Text>
|
||||
<span className="admin-contact__secondary">{row.phone || '未配置手机号'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'disabled',
|
||||
width: 80,
|
||||
render: (disabled: boolean) => disabled
|
||||
? <Tag color="red" bordered>已停用</Tag>
|
||||
: <Tag color="green" bordered>已启用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '多因素认证',
|
||||
dataIndex: 'mfaEnabled',
|
||||
width: 210,
|
||||
render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
|
||||
<Space wrap size={4}>
|
||||
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
|
||||
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
|
||||
{row.emailOtpEnabled ? <Tag color="purple" bordered>邮件</Tag> : null}
|
||||
{row.smsOtpEnabled ? <Tag color="orange" bordered>短信</Tag> : null}
|
||||
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}>恢复码 {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
|
||||
{row.trustedDeviceCount > 0 ? <Tag bordered>可信设备 {row.trustedDeviceCount}</Tag> : null}
|
||||
{row.twoFactorEnabled ? <Typography.Text type="secondary">恢复码 {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
|
||||
</Space>
|
||||
) : <Tag bordered>未启用</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt' },
|
||||
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
|
||||
<Space>
|
||||
<Button size="small" type="text" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}>重置 MFA</Button>}
|
||||
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}>删除</Button>
|
||||
</Space>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : <Tag bordered>未启用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 270,
|
||||
render: (_: unknown, row: UserSummary) => {
|
||||
const deleteDisabled = row.id === user?.id || (row.role === 'admin' && adminCount <= 1)
|
||||
const deleteReason = row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员'
|
||||
return (
|
||||
<Space wrap>
|
||||
<Button size="small" type="text" icon={<IconEdit />} onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.mfaEnabled ? (
|
||||
<Popconfirm
|
||||
title={`确定重置用户「${row.username}」的全部 MFA 配置?`}
|
||||
content="重置后,该用户可仅凭密码登录。"
|
||||
onOk={() => handleResetTwoFactor(row)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<IconSafe />}
|
||||
loading={rowAction === `mfa:${row.id}`}
|
||||
>
|
||||
重置 MFA
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
{deleteDisabled ? (
|
||||
<Tooltip content={deleteReason}>
|
||||
<span>
|
||||
<Button size="small" type="text" status="danger" icon={<IconDelete />} disabled>删除</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={`确定删除用户「${row.username}」?`}
|
||||
content="删除后无法恢复。"
|
||||
onOk={() => handleDelete(row)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
status="danger"
|
||||
icon={<IconDelete />}
|
||||
loading={rowAction === `delete:${row.id}`}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
title={editing ? '编辑用户' : '新建用户'}
|
||||
style={{ width: 680 }}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitting}
|
||||
unmountOnExit
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="用户名" required>
|
||||
<Input value={draft.username} onChange={(v) => setDraft({ ...draft, username: v })} disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item label="显示名称" required>
|
||||
<Input value={draft.displayName} onChange={(v) => setDraft({ ...draft, displayName: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="邮箱">
|
||||
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="手机号">
|
||||
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
|
||||
</Form.Item>
|
||||
<Grid.Row gutter={16}>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="用户名" required>
|
||||
<Input
|
||||
value={draft.username}
|
||||
placeholder="至少 3 个字符"
|
||||
disabled={Boolean(editing)}
|
||||
onChange={(value) => setDraft({ ...draft, username: value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="显示名称" required>
|
||||
<Input value={draft.displayName} onChange={(value) => setDraft({ ...draft, displayName: value })} />
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
<Grid.Row gutter={16}>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="邮箱">
|
||||
<Input value={draft.email ?? ''} onChange={(value) => setDraft({ ...draft, email: value })} />
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="手机号">
|
||||
<Input value={draft.phone ?? ''} onChange={(value) => setDraft({ ...draft, phone: value })} />
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
|
||||
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色" required>
|
||||
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="停用账号">
|
||||
<Switch checked={draft.disabled} onChange={(v) => setDraft({ ...draft, disabled: v })} />
|
||||
<Input.Password
|
||||
value={draft.password}
|
||||
placeholder="至少 8 个字符"
|
||||
onChange={(value) => setDraft({ ...draft, password: value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Grid.Row gutter={16}>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="角色" required>
|
||||
<AdminRoleSelect
|
||||
value={draft.role}
|
||||
disabled={editingSelf}
|
||||
onChange={(role) => setDraft({ ...draft, role })}
|
||||
/>
|
||||
<span className="admin-form-note">
|
||||
{editingSelf ? '当前登录账号不能在此修改自身角色。' : adminRoleDescriptions[draft.role]}
|
||||
</span>
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<Form.Item label="账号状态">
|
||||
<div className="admin-switch-field">
|
||||
<Switch
|
||||
checked={!draft.disabled}
|
||||
disabled={editingSelf}
|
||||
onChange={(enabled) => setDraft({ ...draft, disabled: !enabled })}
|
||||
/>
|
||||
<Typography.Text>{draft.disabled ? '已停用' : '已启用'}</Typography.Text>
|
||||
</div>
|
||||
{editingSelf ? <span className="admin-form-note">当前登录账号不能停用自身。</span> : null}
|
||||
</Form.Item>
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
</AdminDataSection>
|
||||
)
|
||||
}
|
||||
|
||||
178
web/src/pages/admin/admin.css
Normal file
178
web/src/pages/admin/admin.css
Normal file
@@ -0,0 +1,178 @@
|
||||
.admin-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-page__header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-page__nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.admin-section__title {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.admin-section__description {
|
||||
max-width: 760px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.admin-summary__item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-summary__value {
|
||||
color: var(--color-text-1);
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.admin-summary__detail {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-data-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.admin-data-panel__alert {
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
.admin-toolbar__filters,
|
||||
.admin-toolbar__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-identity__secondary,
|
||||
.admin-contact__secondary {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-identity > .arco-typography {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-contact > .arco-typography {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-date,
|
||||
.admin-key-prefix {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-form-note {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.admin-switch-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-key-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.admin-summary {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-toolbar__status {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
|
||||
import { fetchSettings, updateSettings } from '../../services/system'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
@@ -16,6 +17,8 @@ const categoryOptions = [
|
||||
{ label: '备份任务', value: 'backup_task' },
|
||||
{ label: '备份记录', value: 'backup_record' },
|
||||
{ label: '系统设置', value: 'settings' },
|
||||
{ label: '用户账号', value: 'user' },
|
||||
{ label: 'API Key', value: 'api_key' },
|
||||
]
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -24,6 +27,8 @@ const categoryLabels: Record<string, string> = {
|
||||
backup_task: '备份任务',
|
||||
backup_record: '备份记录',
|
||||
settings: '系统设置',
|
||||
user: '用户账号',
|
||||
api_key: 'API Key',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
@@ -53,6 +58,7 @@ const actionLabels: Record<string, string> = {
|
||||
delete: '删除',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
revoke: '撤销',
|
||||
run: '执行',
|
||||
restore: '恢复',
|
||||
}
|
||||
@@ -105,11 +111,15 @@ const columns: ColumnProps<AuditLog>[] = [
|
||||
]
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const requestedCategory = searchParams.get('category') ?? ''
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [category, setCategory] = useState(
|
||||
categoryOptions.some((option) => option.value === requestedCategory) ? requestedCategory : '',
|
||||
)
|
||||
const [username, setUsername] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [dateRange, setDateRange] = useState<string[] | null>(null)
|
||||
@@ -190,6 +200,7 @@ export function AuditLogsPage() {
|
||||
|
||||
function handleReset() {
|
||||
setCategory('')
|
||||
setSearchParams({}, { replace: true })
|
||||
setUsername('')
|
||||
setKeyword('')
|
||||
setDateRange(null)
|
||||
@@ -230,7 +241,11 @@ export function AuditLogsPage() {
|
||||
style={{ width: 160 }}
|
||||
value={category}
|
||||
options={categoryOptions}
|
||||
onChange={(v) => { setCategory(v); setPage(1) }}
|
||||
onChange={(v) => {
|
||||
setCategory(v)
|
||||
setSearchParams(v ? { category: v } : {}, { replace: true })
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="分类"
|
||||
/>
|
||||
<Input
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ReplicationRecordsPage } from '../pages/replication-records/Replication
|
||||
import { TaskTemplatesPage } from '../pages/task-templates/TaskTemplatesPage'
|
||||
import { UsersPage } from '../pages/admin/UsersPage'
|
||||
import { ApiKeysPage } from '../pages/admin/ApiKeysPage'
|
||||
import { AdminLayout } from '../pages/admin/AdminLayout'
|
||||
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
|
||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||
@@ -40,8 +41,11 @@ export function RouterView() {
|
||||
<Route path="verify/records" element={<VerificationRecordsPage />} />
|
||||
<Route path="replication/records" element={<ReplicationRecordsPage />} />
|
||||
<Route path="task-templates" element={<TaskTemplatesPage />} />
|
||||
<Route path="admin/users" element={<UsersPage />} />
|
||||
<Route path="admin/api-keys" element={<ApiKeysPage />} />
|
||||
<Route path="admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="users" replace />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||
</Route>
|
||||
<Route path="storage-targets" element={<StorageTargetsPage />} />
|
||||
<Route path="storage-targets/google-drive/callback" element={<GoogleDriveCallbackPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
Reference in New Issue
Block a user