first commit
This commit is contained in:
323
apps/frontend/app/admin/users/page.tsx
Normal file
323
apps/frontend/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
|
||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { AdminRole, AdminUser, apiFetch } from '@/lib/api';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
ACTIVE: 'Активен',
|
||||
SUSPENDED: 'Заблокирован',
|
||||
DELETED: 'Удалён'
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: 'Администратор',
|
||||
moderator: 'Модератор',
|
||||
manager: 'Менеджер',
|
||||
'super-admin': 'Супер-админ'
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { token, user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [dialog, setDialog] = useState<'password' | 'roles' | null>(null);
|
||||
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
||||
setUsers(response.users ?? []);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token)
|
||||
.then((response) => setRoles(response.roles ?? []))
|
||||
.catch(() => undefined);
|
||||
}, [currentUser?.canManageRoles, token]);
|
||||
|
||||
const assignableRoles = useMemo(() => roles.filter((role) => role.slug !== 'super-admin'), [roles]);
|
||||
|
||||
async function handleSuspend(user: AdminUser) {
|
||||
if (!token) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/suspend`, { method: 'POST' }, token);
|
||||
showToast('Пользователь заблокирован');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось заблокировать пользователя');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!token || !selectedUser || password.length < 8) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${selectedUser.id}/reset-password`, { method: 'POST', body: JSON.stringify({ newPassword: password }) }, token);
|
||||
showToast('Пароль обновлён');
|
||||
setDialog(null);
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось сбросить пароль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSuperAdmin(user: AdminUser) {
|
||||
if (!token || !currentUser?.canManageRoles) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/users/${user.id}/super-admin`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isSuperAdmin: !user.isSuperAdmin })
|
||||
}, token);
|
||||
showToast(user.isSuperAdmin ? 'Права супер-админа сняты' : 'Пользователь назначен супер-админом');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось изменить права');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await apiFetch<{ roles: string[] }>(`/admin/rbac/users/${selectedUser.id}/roles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roleSlug })
|
||||
}, token);
|
||||
setSelectedUser({ ...selectedUser, roles: response.roles ?? [] });
|
||||
showToast('Роль назначена');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось назначить роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleSlug: string) {
|
||||
if (!token || !selectedUser) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await apiFetch(`/admin/rbac/users/${selectedUser.id}/roles/${roleSlug}`, { method: 'DELETE' }, token);
|
||||
setSelectedUser({ ...selectedUser, roles: (selectedUser.roles ?? []).filter((role) => role !== roleSlug) });
|
||||
showToast('Роль снята');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось снять роль');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoles(user: AdminUser) {
|
||||
const userRoles = user.roles ?? [];
|
||||
const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role);
|
||||
if (!labels.length) return 'Пользователь';
|
||||
return labels.join(', ');
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/users">
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-medium">Пользователи</h2>
|
||||
<p className="text-sm text-[#667085]">Поиск, блокировка, сброс пароля и управление доступом</p>
|
||||
</div>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Поиск по имени, почте или телефону" className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Телефон</TableHead>
|
||||
<TableHead>Роли</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-[#667085]">{user.email ?? user.username ?? '—'}</div>
|
||||
</TableCell>
|
||||
<TableCell>{user.phone ?? '—'}</TableCell>
|
||||
<TableCell>{renderRoles(user)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
{currentUser?.canManageUsers ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Сбросить пароль"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setPassword('');
|
||||
setDialog('password');
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{currentUser?.canViewUserDocuments ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Документы пользователя"
|
||||
disabled={actionLoading}
|
||||
onClick={() => setDocumentsUser(user)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{currentUser?.canManageRoles ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Управление ролями"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
setSelectedUser(user);
|
||||
setDialog('roles');
|
||||
}}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={user.isSuperAdmin ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
aria-label="Супер-админ"
|
||||
disabled={actionLoading || user.id === currentUser?.id}
|
||||
onClick={() => void handleToggleSuperAdmin(user)}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сброс пароля</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">Новый пароль для {selectedUser?.displayName}</p>
|
||||
<Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Минимум 8 символов" />
|
||||
<Button className="mt-4 w-full" disabled={actionLoading || password.length < 8} onClick={() => void handleResetPassword()}>
|
||||
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Сохранить пароль'}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialog === 'roles'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Роли и доступ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="mb-4 text-sm text-[#667085]">{selectedUser?.displayName}</p>
|
||||
<div className="space-y-2">
|
||||
{(selectedUser?.roles ?? []).map((role) => (
|
||||
<div key={role} className="flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2">
|
||||
<span>{roleLabels[role] ?? role}</span>
|
||||
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemoveRole(role)}>
|
||||
Снять
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Назначить роль</p>
|
||||
{assignableRoles.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
disabled={actionLoading || (selectedUser?.roles ?? []).includes(role.slug)}
|
||||
onClick={() => void handleAssignRole(role.slug)}
|
||||
>
|
||||
<ShieldPlus className="mr-2 h-4 w-4" />
|
||||
{role.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{documentsUser ? (
|
||||
<UserDocumentsDialog
|
||||
open={Boolean(documentsUser)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDocumentsUser(null);
|
||||
}}
|
||||
targetUserId={documentsUser.id}
|
||||
targetUserName={documentsUser.displayName}
|
||||
token={token}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user