316 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|