Files
IdP/apps/frontend/app/admin/users/page.tsx
2026-06-27 00:15:36 +03:00

591 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { VerificationBadge } from '@/components/id/verification-badge';
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 { AdminPermission, AdminRole, AdminUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus } from '@/lib/api';
import { getAdminLandingPath } from '@/lib/admin-access';
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
const statusLabels: Record<string, string> = {
ACTIVE: 'Активен',
SUSPENDED: 'Заблокирован',
DELETED: 'Удалён'
};
const roleLabels: Record<string, string> = {
user: 'Пользователь',
bot: 'Бот',
admin: 'Администратор',
moderator: 'Модератор',
manager: 'Менеджер',
'super-admin': 'Супер-админ'
};
const DEFAULT_USER_ROLE = 'user';
export default function AdminUsersPage() {
const router = useRouter();
const { token, user: currentUser } = useAuth();
const { showToast } = useToast();
const [users, setUsers] = useState<AdminUser[]>([]);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
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' | 'verification' | null>(null);
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
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(() => {
if (!currentUser) return;
if (!currentUser.canViewUsers && !currentUser.canManageUsers) {
router.replace(getAdminLandingPath(currentUser));
}
}, [currentUser, router]);
useEffect(() => {
if (!currentUser?.canViewUsers && !currentUser?.canManageUsers) return;
void loadUsers();
}, [currentUser?.canManageUsers, currentUser?.canViewUsers, loadUsers]);
useEffect(() => {
if (!token || !currentUser?.canManageRoles) return;
Promise.all([
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token),
apiFetch<{ permissions: AdminPermission[] }>('/admin/rbac/permissions', {}, token)
])
.then(([rolesResponse, permissionsResponse]) => {
setRoles(rolesResponse.roles ?? []);
setPermissions(permissionsResponse.permissions ?? []);
})
.catch(() => undefined);
}, [currentUser?.canManageRoles, token]);
const assignableRoles = useMemo(
() => roles.filter((role) => role.slug !== 'super-admin' && !role.isDefault),
[roles]
);
const permissionLabelBySlug = useMemo(
() => Object.fromEntries(permissions.map((permission) => [permission.slug, permission.name])),
[permissions]
);
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 handleUnsuspend(user: AdminUser) {
if (!token) return;
setActionLoading(true);
try {
await apiFetch(`/admin/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify({ status: 'ACTIVE' })
}, 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);
}
}
async function handleAssignPermission(permissionSlug: string) {
if (!token || !selectedUser) return;
setActionLoading(true);
try {
const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions`, {
method: 'POST',
body: JSON.stringify({ permissionSlug })
}, token);
setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] });
showToast('Право назначено');
await loadUsers();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось назначить право');
} finally {
setActionLoading(false);
}
}
async function handleRemovePermission(permissionSlug: string) {
if (!token || !selectedUser) return;
setActionLoading(true);
try {
const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions/${permissionSlug}`, {
method: 'DELETE'
}, token);
setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] });
showToast('Право снято');
await loadUsers();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось снять право');
} finally {
setActionLoading(false);
}
}
async function handleSaveVerification() {
if (!token || !selectedUser) return;
setActionLoading(true);
try {
const updated = await apiFetch<AdminUser>(`/admin/users/${selectedUser.id}/verification`, {
method: 'PATCH',
body: JSON.stringify({
isVerified: true,
verificationIcon
})
}, token);
showToast('Пользователь верифицирован');
setSelectedUser(updated);
setDialog(null);
await loadUsers();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось верифицировать пользователя');
} finally {
setActionLoading(false);
}
}
async function handleRemoveVerification() {
if (!token || !selectedUser) return;
setActionLoading(true);
try {
await apiFetch<AdminUser>(`/admin/users/${selectedUser.id}/verification`, {
method: 'PATCH',
body: JSON.stringify({ isVerified: false })
}, token);
showToast('Верификация снята');
setSelectedUser({ ...selectedUser, isVerified: false, verificationIcon: undefined });
setDialog(null);
await loadUsers();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось снять верификацию');
} finally {
setActionLoading(false);
}
}
function openVerificationDialog(user: AdminUser) {
setSelectedUser(user);
setVerificationIcon(user.verificationIcon ?? DEFAULT_VERIFICATION_ICON);
setDialog('verification');
}
async function handleAdminDisableTotp(user: AdminUser) {
if (!token || !currentUser?.isSuperAdmin) return;
setActionLoading(true);
try {
const status = await fetchUserTotpStatus(user.id, token);
if (!status.isEnabled) {
showToast('У пользователя не включена двухфакторная аутентификация');
return;
}
const confirmed = window.confirm(`Отключить 2FA для ${user.displayName}? Пользователю не потребуется код из приложения-аутентификатора.`);
if (!confirmed) return;
await adminDisableUserTotp(user.id, token);
showToast('Двухфакторная аутентификация отключена');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отключить 2FA');
} finally {
setActionLoading(false);
}
}
function renderRoles(user: AdminUser) {
if (user.isBot) {
return roleLabels.bot;
}
const userRoles = user.roles ?? [];
const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE && role !== 'bot');
const direct = user.directPermissions ?? [];
const labels = user.isSuperAdmin
? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)]
: extraRoles.map((role) => roleLabels[role] ?? role);
const directLabels = direct.map((slug) => permissionLabelBySlug[slug] ?? slug);
const parts = [...labels, ...directLabels.map((label) => `+ ${label}`)];
if (!parts.length) return roleLabels.user;
return parts.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-x-auto 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="flex items-center gap-2">
<span className="font-medium">{user.displayName}</span>
{user.isVerified ? <VerificationBadge verificationIcon={user.verificationIcon} size="xs" /> : null}
</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>
<Button variant="secondary" size="icon" aria-label="Разблокировать" disabled={actionLoading || user.status !== 'SUSPENDED'} onClick={() => void handleUnsuspend(user)}>
<UserCheck className="h-4 w-4" />
</Button>
</>
) : null}
{currentUser?.canViewUserDocuments && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Документы пользователя"
disabled={actionLoading}
onClick={() => setDocumentsUser(user)}
>
<FileText className="h-4 w-4" />
</Button>
) : null}
{currentUser?.isSuperAdmin && !user.isBot ? (
<Button
variant="secondary"
size="icon"
aria-label="Отключить 2FA"
disabled={actionLoading}
onClick={() => void handleAdminDisableTotp(user)}
>
<ShieldOff className="h-4 w-4" />
</Button>
) : null}
{currentUser?.canVerifyUsers ? (
<Button
variant={user.isVerified ? 'default' : 'secondary'}
size="icon"
aria-label="Верификация"
disabled={actionLoading}
onClick={() => openVerificationDialog(user)}
>
<BadgeCheck 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}{role === DEFAULT_USER_ROLE ? ' (стандартная)' : ''}</span>
{role === DEFAULT_USER_ROLE ? (
<span className="text-xs text-[#667085]">Нельзя снять</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>
<div className="mt-6 space-y-2 border-t border-[#eceef4] pt-4">
<p className="text-sm font-medium">Прямые права</p>
<p className="text-xs text-[#667085]">Дополнительные permissions без смены роли. Например, oauth.manage для одного пользователя.</p>
{(selectedUser?.directPermissions ?? []).map((permissionSlug) => (
<div key={permissionSlug} className="flex items-center justify-between rounded-xl bg-[#eef4ff] px-3 py-2">
<span className="text-sm">{permissionLabelBySlug[permissionSlug] ?? permissionSlug}</span>
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemovePermission(permissionSlug)}>
Снять
</Button>
</div>
))}
{!(selectedUser?.directPermissions ?? []).length ? (
<p className="text-sm text-[#667085]">Прямые права не назначены</p>
) : null}
<div className="max-h-48 space-y-1 overflow-y-auto pt-2">
{permissions
.filter((permission) => !(selectedUser?.directPermissions ?? []).includes(permission.slug))
.map((permission) => (
<Button
key={permission.id}
variant="secondary"
size="sm"
className="w-full justify-start"
disabled={actionLoading}
onClick={() => void handleAssignPermission(permission.slug)}
>
+ {permission.name}
</Button>
))}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={dialog === 'verification'} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Верификация пользователя</DialogTitle>
</DialogHeader>
<p className="mb-4 text-sm text-[#667085]">{selectedUser?.displayName}</p>
{selectedUser?.isVerified ? (
<div className="mb-4 flex items-center gap-2 rounded-xl bg-[#eef4ff] px-3 py-2 text-sm">
<VerificationBadge verificationIcon={selectedUser.verificationIcon} size="sm" />
<span>Пользователь уже верифицирован</span>
</div>
) : null}
<div className="space-y-3">
<p className="text-sm font-medium">Выберите значок</p>
<div className="grid grid-cols-5 gap-2">
{VERIFICATION_ICON_OPTIONS.map((option) => (
<button
key={option.slug}
type="button"
className={`flex flex-col items-center gap-1 rounded-xl border px-2 py-3 text-xs transition ${
verificationIcon === option.slug ? 'border-[#3390ec] bg-[#eef4ff]' : 'border-[#eceef4] bg-[#fafbfd] hover:bg-[#f4f5f8]'
}`}
onClick={() => setVerificationIcon(option.slug)}
>
<option.Icon className="h-5 w-5 text-[#3390ec]" />
<span className="text-center leading-tight">{option.name}</span>
</button>
))}
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<Button className="w-full" disabled={actionLoading} onClick={() => void handleSaveVerification()}>
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : selectedUser?.isVerified ? 'Обновить значок' : 'Верифицировать'}
</Button>
{selectedUser?.isVerified ? (
<Button variant="secondary" className="w-full text-red-600" disabled={actionLoading} onClick={() => void handleRemoveVerification()}>
Снять верификацию
</Button>
) : null}
</div>
</DialogContent>
</Dialog>
{documentsUser ? (
<UserDocumentsDialog
open={Boolean(documentsUser)}
onOpenChange={(open) => {
if (!open) setDocumentsUser(null);
}}
targetUserId={documentsUser.id}
targetUserName={documentsUser.displayName}
token={token}
/>
) : null}
</AdminShell>
);
}