base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
@@ -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())}