base commit
This commit is contained in:
@@ -20,17 +20,17 @@ import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
|
||||
import type { AdminUser } from '@/entities/user/model/types'
|
||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||
|
||||
type TokenFormState = { token: string }
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
|
||||
type UserFormState = {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' })
|
||||
const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
|
||||
|
||||
function formatDt(v: string) {
|
||||
try {
|
||||
@@ -44,28 +44,17 @@ function formatDt(v: string) {
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<AdminUser>()
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [rowsPerPage, setRowsPerPage] = useState(20)
|
||||
|
||||
const tokenForm = useForm<TokenFormState>({
|
||||
defaultValues: { token: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const userForm = useForm<UserFormState>({
|
||||
defaultValues: emptyUserForm(),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
tokenForm.reset({ token: '' })
|
||||
}, [token, tokenForm])
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
@@ -74,81 +63,64 @@ export function AdminUsersPage() {
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const saveToken = () => {
|
||||
const t = tokenForm.getValues('token').trim()
|
||||
if (!t) {
|
||||
clearAdminToken()
|
||||
setTokenState(null)
|
||||
return
|
||||
}
|
||||
setAdminToken(t)
|
||||
setTokenState(t)
|
||||
}
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin', 'users', token, { q, page, rowsPerPage }],
|
||||
queryKey: ['admin', 'users', { q, page, rowsPerPage }],
|
||||
queryFn: () =>
|
||||
fetchAdminUsers(token!, {
|
||||
fetchAdminUsers({
|
||||
q: q || undefined,
|
||||
page: page + 1,
|
||||
pageSize: rowsPerPage,
|
||||
}),
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = userForm.getValues()
|
||||
await createAdminUser(token!, {
|
||||
await createAdminUser({
|
||||
email: v.email.trim(),
|
||||
name: v.name.trim() || null,
|
||||
password: v.password.trim() || undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
setDialogOpen(false)
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = userForm.getValues()
|
||||
await updateAdminUser(token!, editing!.id, {
|
||||
await updateAdminUser(editing!.id, {
|
||||
email: v.email.trim(),
|
||||
name: v.name.trim() || null,
|
||||
...(v.password.trim() ? { password: v.password.trim() } : {}),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
setDialogOpen(false)
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: string) => deleteAdminUser(token!, id),
|
||||
mutationFn: async (id: string) => deleteAdminUser(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
userForm.reset(emptyUserForm())
|
||||
setDialogOpen(true)
|
||||
openCreateDialog()
|
||||
}
|
||||
|
||||
const openEdit = (u: AdminUser) => {
|
||||
setEditing(u)
|
||||
openEditDialog(u)
|
||||
userForm.reset({
|
||||
email: u.email,
|
||||
name: u.name ?? '',
|
||||
password: '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const emailValue = userForm.watch('email')
|
||||
@@ -171,124 +143,84 @@ export function AdminUsersPage() {
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из{' '}
|
||||
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
|
||||
.env
|
||||
</Typography>{' '}
|
||||
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
|
||||
Управление пользователями доступно пользователю с правами администратора.
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
|
||||
<Controller
|
||||
control={tokenForm.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Токен (Bearer)"
|
||||
type="password"
|
||||
fullWidth
|
||||
{...field}
|
||||
placeholder={token ? '••••••••' : ''}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||
Сохранить
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Новый пользователь
|
||||
</Button>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск (email/имя)"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Новый пользователь
|
||||
</Button>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск (email/имя)"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{usersQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Ошибка загрузки. Проверьте токен и что сервер запущен.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(mutationError as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Почта</TableCell>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>Пароль</TableCell>
|
||||
<TableCell>Создан</TableCell>
|
||||
<TableCell>Обновлён</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name ?? '—'}</TableCell>
|
||||
<TableCell>{u.hasPassword ? 'задан' : 'нет'}</TableCell>
|
||||
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||||
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => openEdit(u)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!confirm(`Удалить пользователя ${u.email}?`)) return
|
||||
deleteMut.mutate(u.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && !usersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setRowsPerPage(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
{usersQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
|
||||
{mutationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{getErrorMessage(mutationError)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Почта</TableCell>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>Создан</TableCell>
|
||||
<TableCell>Обновлён</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name ?? '—'}</TableCell>
|
||||
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||||
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<EntityRowActions
|
||||
onEdit={() => openEdit(u)}
|
||||
onDelete={() => deleteMut.mutate(u.id)}
|
||||
deleteDisabled={deleteMut.isPending}
|
||||
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && !usersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setRowsPerPage(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
@@ -302,23 +234,10 @@ export function AdminUsersPage() {
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
|
||||
type="password"
|
||||
fullWidth
|
||||
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
|
||||
Reference in New Issue
Block a user