Files
edgeguard-native/management-ui/src/pages/Users/index.tsx

248 lines
8.9 KiB
TypeScript

import { useState } from 'react'
import {
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
const { Text } = Typography
interface User {
id: number
email: string
role: string
active: boolean
last_login_at: string | null
created_at: string
}
interface CreateValues { email: string; password: string; role: string; active: boolean }
interface EditValues { email: string; role: string; active: boolean }
interface PwValues { password: string }
async function listUsers(): Promise<User[]> {
const r = await apiClient.get('/users')
if (!isEnvelope(r.data)) return []
return (r.data.data as { users?: User[] }).users ?? []
}
const ROLE_COLORS: Record<string, string> = { admin: 'blue', viewer: 'default' }
export default function UsersPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const me = useAuthStore((s) => s.user)
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [pwTarget, setPwTarget] = useState<User | null>(null)
const [createForm] = Form.useForm<CreateValues>()
const [editForm] = Form.useForm<EditValues>()
const [pwForm] = Form.useForm<PwValues>()
const invalidate = () => void qc.invalidateQueries({ queryKey: ['users'] })
const createMut = useMutation({
mutationFn: (v: CreateValues) => apiClient.post('/users', v),
onSuccess: () => { message.success(t('common.save')); setCreating(false); createForm.resetFields(); invalidate() },
onError: (e: Error) => message.error(e.message),
})
const updateMut = useMutation({
mutationFn: ({ id, v }: { id: number; v: EditValues }) => apiClient.put(`/users/${id}`, v),
onSuccess: () => { message.success(t('common.save')); setEditing(null); editForm.resetFields(); invalidate() },
onError: (e: Error) => message.error(e.message),
})
const pwMut = useMutation({
mutationFn: ({ id, v }: { id: number; v: PwValues }) => apiClient.post(`/users/${id}/password`, v),
onSuccess: () => { message.success(t('common.save')); setPwTarget(null); pwForm.resetFields() },
onError: (e: Error) => message.error(e.message),
})
const delMut = useMutation({
mutationFn: (id: number) => apiClient.delete(`/users/${id}`),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const roleOptions = [
{ value: 'admin', label: t('users.roleAdmin') },
{ value: 'viewer', label: t('users.roleViewer') },
]
const columns: ColumnsType<User> = [
{
title: t('users.email'), dataIndex: 'email', key: 'email',
render: (s: string) => (
<Space size={6}>
<span>{s}</span>
{me?.actor === s && <Tag style={{ fontSize: 11, padding: '0 4px' }}>You</Tag>}
</Space>
),
},
{
title: t('users.role'), dataIndex: 'role', key: 'role',
render: (r: string) => (
<Tag color={ROLE_COLORS[r] ?? 'default'}>
{r === 'admin' ? t('users.roleAdmin') : t('users.roleViewer')}
</Tag>
),
},
{
title: t('users.active'), dataIndex: 'active', key: 'active', width: 70,
render: (v: boolean) => <StatusDot active={v} />,
},
{
title: t('users.lastLogin'), key: 'lastLogin', width: 160,
render: (_, row) => row.last_login_at
? <Tooltip title={new Date(row.last_login_at).toLocaleString()}>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(row.last_login_at).toLocaleDateString()}
</Text>
</Tooltip>
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
},
{
title: t('common.actions'), key: 'actions', width: 120,
render: (_, row) => (
<Space size={4}>
<Tooltip title={t('users.setPassword')}>
<Button type="text" size="small" icon={<KeyOutlined />}
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
</Tooltip>
<ActionButtons
onEdit={() => {
setEditing(row)
editForm.setFieldsValue({ email: row.email, role: row.role, active: row.active })
}}
onDelete={() => delMut.mutate(row.id)}
deleteConfirm={t('users.deleteConfirm', { email: row.email })}
/>
</Space>
),
},
]
return (
<div>
<PageHeader
icon={<TeamOutlined />}
title={t('users.title')}
subtitle={t('users.intro')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={users ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true)
createForm.resetFields()
createForm.setFieldsValue({ role: 'admin', active: true })
}}>
{t('users.addUser')}
</Button>
}
emptyContent={
<EmptyState
icon={<TeamOutlined />}
title={t('users.emptyTitle')}
description={t('users.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true)
createForm.resetFields()
createForm.setFieldsValue({ role: 'admin', active: true })
}}>
{t('users.addUser')}
</Button>
}
/>
}
/>
{/* Create modal */}
<Modal
title={t('users.addUser')}
open={creating}
onCancel={() => { setCreating(false); createForm.resetFields() }}
onOk={() => void createForm.submit()}
confirmLoading={createMut.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical"
onFinish={(v) => createMut.mutate(v)}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoFocus autoComplete="off" />
</Form.Item>
<Form.Item label={t('users.newPassword')} name="password"
extra={t('users.newPasswordHint')}
rules={[{ required: true, min: 12, message: t('users.newPasswordHint') }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item label={t('users.role')} name="role" rules={[{ required: true }]}>
<Select options={roleOptions} />
</Form.Item>
<Form.Item label={t('users.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
{/* Edit modal */}
<Modal
title={t('users.editUser')}
open={editing !== null}
onCancel={() => { setEditing(null); editForm.resetFields() }}
onOk={() => void editForm.submit()}
confirmLoading={updateMut.isPending}
destroyOnHidden
>
<Form form={editForm} layout="vertical"
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label={t('users.role')} name="role" rules={[{ required: true }]}>
<Select options={roleOptions} />
</Form.Item>
<Form.Item label={t('users.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
{/* Set password modal */}
<Modal
title={`${t('users.setPasswordTitle')}${pwTarget?.email ?? ''}`}
open={pwTarget !== null}
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
onOk={() => void pwForm.submit()}
confirmLoading={pwMut.isPending}
destroyOnHidden
>
<Form form={pwForm} layout="vertical"
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
<Form.Item label={t('users.newPassword')} name="password"
extra={t('users.newPasswordHint')}
rules={[{ required: true, min: 12, message: t('users.newPasswordHint') }]}>
<Input.Password autoFocus autoComplete="new-password" />
</Form.Item>
</Form>
</Modal>
</div>
)
}