quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
/**
|
|
* Admin users directory — thin wiring over AdminUsersGrid.
|
|
* Preview: https://reui.io/preview/base/solution-users-1
|
|
*/
|
|
import { useCallback, useState } from 'react'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
|
|
import { AdminUsersGrid } from '@/components/reui-kit/admin-users-grid'
|
|
import { CreateUserSheet } from '@/components/reui-kit/create-user-sheet'
|
|
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
|
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
|
import { api, ApiError } from '@/lib/api-client'
|
|
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
|
import { adminResetPasskeys } from '@/queries/webauthn'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
|
|
export const Route = createFileRoute('/_auth/admin/')({
|
|
component: AdminUsersPage,
|
|
})
|
|
|
|
function AdminUsersPage() {
|
|
const queryClient = useQueryClient()
|
|
const { data: users = [], isLoading, error, refetch } = useQuery(
|
|
usersQueryOptions,
|
|
)
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [accessUserId, setAccessUserId] = useState<string | null>(null)
|
|
const [auditUser, setAuditUser] = useState<AdminUser | null>(null)
|
|
|
|
const invalidateUsers = useCallback(async () => {
|
|
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
|
|
}, [queryClient])
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: CreateUserRequest) =>
|
|
api.post<AdminUser>('/api/v1/admin/users', body),
|
|
onSuccess: async (user) => {
|
|
await invalidateUsers()
|
|
setCreateOpen(false)
|
|
setAccessUserId(user.id)
|
|
toast.success('Пользователь создан')
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Не удалось создать пользователя',
|
|
)
|
|
},
|
|
})
|
|
|
|
const patchMutation = useMutation({
|
|
mutationFn: ({
|
|
id,
|
|
...body
|
|
}: {
|
|
id: string
|
|
is_admin?: boolean
|
|
disabled?: boolean
|
|
}) => api.patch<AdminUser>(`/api/v1/admin/users/${id}`, body),
|
|
onSuccess: async () => {
|
|
await invalidateUsers()
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Не удалось обновить пользователя',
|
|
)
|
|
},
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => api.delete(`/api/v1/admin/users/${id}`),
|
|
onSuccess: async () => {
|
|
await invalidateUsers()
|
|
toast.success('Пользователь удалён')
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Не удалось удалить пользователя',
|
|
)
|
|
},
|
|
})
|
|
|
|
const handleDeactivate = useCallback(
|
|
(user: AdminUser) => {
|
|
patchMutation.mutate(
|
|
{ id: user.id, disabled: true },
|
|
{
|
|
onSuccess: () => {
|
|
toast.message('Пользователь отключён', {
|
|
description: `${user.email} больше не может войти.`,
|
|
})
|
|
},
|
|
},
|
|
)
|
|
},
|
|
[patchMutation],
|
|
)
|
|
|
|
const handleDelete = useCallback(
|
|
(user: AdminUser) => {
|
|
deleteMutation.mutate(user.id)
|
|
},
|
|
[deleteMutation],
|
|
)
|
|
|
|
const resetPasskeysMutation = useMutation({
|
|
mutationFn: (id: string) => adminResetPasskeys(id),
|
|
onSuccess: async (_data, id) => {
|
|
await invalidateUsers()
|
|
const user = users.find((u) => u.id === id)
|
|
toast.success('Passkeys сброшены', {
|
|
description: user?.email,
|
|
})
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys',
|
|
)
|
|
},
|
|
})
|
|
|
|
const handleResetPasskeys = useCallback(
|
|
(user: AdminUser) => {
|
|
resetPasskeysMutation.mutate(user.id)
|
|
},
|
|
[resetPasskeysMutation],
|
|
)
|
|
|
|
const handleBulkSetRole = useCallback(
|
|
(userIds: string[], isAdmin: boolean) => {
|
|
Promise.all(
|
|
userIds.map((id) =>
|
|
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, {
|
|
is_admin: isAdmin,
|
|
}),
|
|
),
|
|
)
|
|
.then(async () => {
|
|
await invalidateUsers()
|
|
toast.success(
|
|
`Роль обновлена для ${userIds.length} пользовател${userIds.length === 1 ? 'я' : 'ей'}`,
|
|
)
|
|
})
|
|
.catch((err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Ошибка массового обновления',
|
|
)
|
|
})
|
|
},
|
|
[invalidateUsers],
|
|
)
|
|
|
|
const handleBulkDeactivate = useCallback(
|
|
(userIds: string[]) => {
|
|
Promise.all(
|
|
userIds.map((id) =>
|
|
api.patch<AdminUser>(`/api/v1/admin/users/${id}`, { disabled: true }),
|
|
),
|
|
)
|
|
.then(async () => {
|
|
await invalidateUsers()
|
|
toast.message('Пользователи отключены', {
|
|
description: `${userIds.length} учётных записей.`,
|
|
})
|
|
})
|
|
.catch((err) => {
|
|
toast.error(
|
|
err instanceof ApiError ? err.message : 'Ошибка массового отключения',
|
|
)
|
|
})
|
|
},
|
|
[invalidateUsers],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
{error ? (
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-destructive text-sm">
|
|
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
|
|
</p>
|
|
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
|
|
Повторить
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<AdminUsersGrid
|
|
users={users}
|
|
isLoading={isLoading}
|
|
onCreateClick={() => setCreateOpen(true)}
|
|
onOpenAudit={setAuditUser}
|
|
onOpenAccess={(user) => setAccessUserId(user.id)}
|
|
onDeactivate={handleDeactivate}
|
|
onDelete={handleDelete}
|
|
onResetPasskeys={handleResetPasskeys}
|
|
onBulkSetRole={handleBulkSetRole}
|
|
onBulkDeactivate={handleBulkDeactivate}
|
|
/>
|
|
)}
|
|
|
|
<CreateUserSheet
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
pending={createMutation.isPending}
|
|
error={
|
|
createMutation.error instanceof ApiError
|
|
? createMutation.error.message
|
|
: createMutation.error
|
|
? 'Не удалось создать'
|
|
: null
|
|
}
|
|
onSubmit={(values) => createMutation.mutate(values)}
|
|
/>
|
|
|
|
<UserAccessSheet
|
|
userId={accessUserId}
|
|
open={Boolean(accessUserId)}
|
|
onOpenChange={(next) => {
|
|
if (!next) setAccessUserId(null)
|
|
}}
|
|
/>
|
|
|
|
<UserAuditSheet
|
|
user={auditUser}
|
|
open={Boolean(auditUser)}
|
|
onOpenChange={(next) => {
|
|
if (!next) setAuditUser(null)
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|