Files
planet/frontend/src/admin/pages/Users.tsx
linkong 5bdb55f3f1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.0
2026-06-30 13:52:52 +08:00

316 lines
11 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import type { ColumnDef } from '@tanstack/react-table'
import axios from 'axios'
import { Edit, Plus, Search, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { describeApiError } from '../../i18n/api-errors'
import { useAuthStore } from '../../stores/auth'
import { DataTable } from '../components/data-table/DataTable'
import { AdminLayout } from '../components/layout/AdminLayout'
import { Badge } from '../components/ui/badge'
import { Button } from '../components/ui/button'
import { ConfirmDialog, Dialog } from '../components/ui/dialog'
import { Input } from '../components/ui/input'
import { Select } from '../components/ui/select'
import { useToast } from '../components/ui/toast'
interface User {
id: number
username: string
email: string
role: string
gatekeeper_groups: string[]
is_active: boolean
created_at: string
}
interface UserFormValues {
username: string
email: string
password?: string
role: string
gatekeeper_groups?: string[]
}
const roleValues = ['super_admin', 'admin', 'operator', 'viewer'] as const
const gatekeeperValues = ['docs_user', 'docs_developer', 'docs_admin'] as const
function roleTone(role: string) {
if (role === 'super_admin') return 'red'
if (role === 'admin') return 'amber'
if (role === 'operator') return 'blue'
if (role === 'viewer') return 'green'
return 'default'
}
export default function Users() {
const { t } = useTranslation()
const { user: currentUser } = useAuthStore()
const { toast } = useToast()
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const [searchText, setSearchText] = useState('')
const isSuperAdmin = currentUser?.role === 'super_admin'
const userSchema = useMemo(() => z.object({
username: z.string().min(1, t('auth.username')),
email: z.string().email(t('auth.email')),
password: z.string().optional(),
role: z.string().min(1, t('users.role')),
gatekeeper_groups: z.array(z.string()).optional(),
}), [t])
const roleOptions = useMemo(() => roleValues.map((value) => ({
value,
label: t(`users.roles.${value}`),
})), [t])
const gatekeeperOptions = useMemo(() => gatekeeperValues.map((value) => ({
value,
label: t(`users.gatekeeper.${value}`),
})), [t])
const roleLabel = (role: string) => roleOptions.find((option) => option.value === role)?.label || role
const gatekeeperLabel = (group: string) => gatekeeperOptions.find((option) => option.value === group)?.label || group
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues: {
username: '',
email: '',
password: '',
role: 'viewer',
gatekeeper_groups: [],
},
})
const fetchUsers = async () => {
setLoading(true)
try {
const res = await axios.get('/api/v1/users')
setUsers(res.data.data || [])
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchUsers()
}, [])
const handleAdd = () => {
setEditingUser(null)
form.reset({ username: '', email: '', password: '', role: 'viewer', gatekeeper_groups: [] })
setModalVisible(true)
}
const handleEdit = (target: User) => {
setEditingUser(target)
form.reset({
username: target.username,
email: target.email,
password: '',
role: target.role,
gatekeeper_groups: target.gatekeeper_groups || [],
})
setModalVisible(true)
}
const handleSubmit = async (values: UserFormValues) => {
try {
const payload: Record<string, unknown> = { ...values, gatekeeper_groups: values.gatekeeper_groups || [] }
if (editingUser && !values.password) delete payload.password
if (!isSuperAdmin) delete payload.gatekeeper_groups
if (editingUser) {
await axios.put(`/api/v1/users/${editingUser.id}`, payload)
toast({ tone: 'success', title: t('users.updateSuccess') })
} else {
const createPayload = { ...payload, password: values.password || '' }
await axios.post('/api/v1/users', createPayload)
toast({ tone: 'success', title: t('users.createSuccess') })
}
setModalVisible(false)
void fetchUsers()
} catch (error: unknown) {
toast({ tone: 'error', title: t('common.operationFailed'), description: describeApiError(error, t('users.retryLater')) })
}
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
await axios.delete(`/api/v1/users/${deleteTarget.id}`)
toast({ tone: 'success', title: t('users.deleteSuccess') })
setDeleteTarget(null)
void fetchUsers()
} catch (error: unknown) {
toast({ tone: 'error', title: t('users.deleteFailed'), description: describeApiError(error, t('users.retryLater')) })
}
}
const columns = useMemo<Array<ColumnDef<User>>>(() => [
{ accessorKey: 'id', header: 'ID', size: 80 },
{ accessorKey: 'username', header: t('auth.username'), size: 180 },
{ accessorKey: 'email', header: t('auth.email'), size: 260 },
{
accessorKey: 'role',
header: t('users.role'),
size: 140,
cell: ({ row }) => <Badge tone={roleTone(row.original.role)} title={row.original.role}>{roleLabel(row.original.role)}</Badge>,
},
{
accessorKey: 'gatekeeper_groups',
header: 'Gatekeeper',
size: 260,
enableSorting: false,
cell: ({ row }) => {
const groups = row.original.gatekeeper_groups || []
return (
<div className="an-inline-list">
{groups.length > 0 ? groups.map((group) => (
<Badge key={group} tone={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
{gatekeeperLabel(group)}
</Badge>
)) : <Badge tone="slate">{t('users.unconfigured')}</Badge>}
</div>
)
},
},
{
accessorKey: 'is_active',
header: t('users.status'),
size: 120,
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? t('users.active') : t('users.disabled')}</Badge>,
},
{
id: 'actions',
header: t('users.actions'),
size: 148,
enableSorting: false,
cell: ({ row }) => (
<div className="an-row-actions">
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} />{t('users.edit')}</Button>
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} />{t('common.delete')}</Button>
</div>
),
},
], [gatekeeperOptions, roleOptions, t])
const filteredUsers = useMemo(() => {
const keyword = searchText.trim().toLowerCase()
if (!keyword) return users
return users.filter((target) => [
target.username,
target.email,
target.role,
...(target.gatekeeper_groups || []),
].some((value) => String(value).toLowerCase().includes(keyword)))
}, [searchText, users])
const selectedGroups = form.watch('gatekeeper_groups') || []
return (
<AdminLayout>
<div className="an-page">
<div className="an-page__header">
<div>
<h1>{t('admin.routes.users')}</h1>
<p>{t('users.description')}</p>
</div>
<div className="an-toolbar">
<div className="an-search-box">
<Search size={15} />
<Input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
placeholder={t('users.searchPlaceholder')}
/>
{searchText ? (
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label={t('users.clearSearch')} title={t('users.clearSearch')}>
<X size={14} />
</Button>
) : null}
</div>
<Button variant="primary" onClick={handleAdd}><Plus size={16} />{t('users.addUser')}</Button>
</div>
</div>
<div className="an-page__body">
<DataTable columns={columns} data={filteredUsers} getRowId={(row) => String(row.id)} loading={loading} />
</div>
</div>
<Dialog
title={editingUser ? t('users.editUser') : t('users.addUser')}
open={modalVisible}
onOpenChange={setModalVisible}
footer={(
<>
<Button variant="subtle" onClick={() => setModalVisible(false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}>{t('users.submit')}</Button>
</>
)}
>
<form className="an-form" onSubmit={form.handleSubmit(handleSubmit)}>
<label className="an-field">
<span>{t('auth.username')}</span>
<Input {...form.register('username')} />
{form.formState.errors.username ? <em>{form.formState.errors.username.message}</em> : null}
</label>
<label className="an-field">
<span>{t('auth.email')}</span>
<Input {...form.register('email')} />
{form.formState.errors.email ? <em>{form.formState.errors.email.message}</em> : null}
</label>
{!editingUser ? (
<label className="an-field">
<span>{t('auth.password')}</span>
<Input type="password" {...form.register('password', { required: true, minLength: 8 })} />
{form.formState.errors.password ? <em>{t('auth.passwordHint')}</em> : null}
</label>
) : null}
<label className="an-field">
<span>{t('users.role')}</span>
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)} options={roleOptions} />
</label>
<div className="an-field">
<span>{t('users.gatekeeperGroups')}</span>
<div className="an-checkbox-list" aria-disabled={!isSuperAdmin}>
{gatekeeperOptions.map((option) => (
<label key={option.value}>
<input
type="checkbox"
disabled={!isSuperAdmin}
checked={selectedGroups.includes(option.value)}
onChange={(event) => {
const next = event.target.checked
? [...selectedGroups, option.value]
: selectedGroups.filter((group) => group !== option.value)
form.setValue('gatekeeper_groups', next)
}}
/>
{option.label}
</label>
))}
</div>
</div>
</form>
</Dialog>
<ConfirmDialog
title={t('users.confirmDelete')}
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
description={t('users.confirmDeleteDescription', { username: deleteTarget?.username || '' })}
danger
confirmLabel={t('common.delete')}
onConfirm={() => void handleDelete()}
/>
</AdminLayout>
)
}