Files
IdP/apps/frontend/app/admin/users/page.tsx
2026-07-01 23:27:48 +03:00

664 lines
28 KiB
TypeScript
Raw Permalink 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, MoreVertical, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog';
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AdminPermission, AdminRole, AdminUser, PublicUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } 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';
function UserActionsMenu({
user,
currentUser,
actionLoading,
onOpenInspector,
onResetPassword,
onSuspend,
onUnsuspend,
onOpenDocuments,
onDisableTotp,
onOpenVerification,
onOpenRoles,
onToggleSuperAdmin
}: {
user: AdminUser;
currentUser: PublicUser | null;
actionLoading: boolean;
onOpenInspector: () => void;
onResetPassword: () => void;
onSuspend: () => void;
onUnsuspend: () => void;
onOpenDocuments: () => void;
onDisableTotp: () => void;
onOpenVerification: () => void;
onOpenRoles: () => void;
onToggleSuperAdmin: () => void;
}) {
const canInspect = (currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot;
const canManage = Boolean(currentUser?.canManageUsers);
const canDocuments = Boolean(currentUser?.canViewUserDocuments && !user.isBot);
const canDisableTotp = Boolean(currentUser?.isSuperAdmin && !user.isBot);
const canVerify = Boolean(currentUser?.canVerifyUsers);
const canRoles = Boolean(currentUser?.canManageRoles);
if (!canInspect && !canManage && !canDocuments && !canDisableTotp && !canVerify && !canRoles) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" aria-label="Действия с пользователем" disabled={actionLoading}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 rounded-xl">
{canInspect ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenInspector}>
<ScrollText className="h-4 w-4" />
Журнал и чаты
</DropdownMenuItem>
) : null}
{canManage ? (
<>
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onResetPassword}>
<KeyRound className="h-4 w-4" />
Сбросить пароль
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status === 'SUSPENDED'} onClick={onSuspend}>
<Ban className="h-4 w-4" />
Заблокировать
</DropdownMenuItem>
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status !== 'SUSPENDED'} onClick={onUnsuspend}>
<UserCheck className="h-4 w-4" />
Разблокировать
</DropdownMenuItem>
</>
) : null}
{canDocuments ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenDocuments}>
<FileText className="h-4 w-4" />
Документы
</DropdownMenuItem>
) : null}
{canDisableTotp ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onDisableTotp}>
<ShieldOff className="h-4 w-4" />
Отключить 2FA
</DropdownMenuItem>
) : null}
{canVerify ? (
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenVerification}>
<BadgeCheck className="h-4 w-4" />
{user.isVerified ? 'Изменить верификацию' : 'Верифицировать'}
</DropdownMenuItem>
) : null}
{canRoles ? (
<>
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenRoles}>
<UserCog className="h-4 w-4" />
Роли и доступ
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 rounded-lg"
disabled={user.id === currentUser?.id}
onClick={onToggleSuperAdmin}
>
<Crown className="h-4 w-4" />
{user.isSuperAdmin ? 'Снять супер-админа' : 'Назначить супер-админом'}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 [inspectorUser, setInspectorUser] = 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) {
const message = getApiErrorMessage(error, 'Не удалось загрузить пользователей');
if (message) showToast(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>
<TableContainer className="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}
className={user.isBot ? undefined : 'cursor-pointer hover:bg-[#fafbff]'}
onClick={() => {
if (user.isBot) return;
setInspectorUser(user);
}}
>
<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" onClick={(event) => event.stopPropagation()}>
<UserActionsMenu
user={user}
currentUser={currentUser}
actionLoading={actionLoading}
onOpenInspector={() => setInspectorUser(user)}
onResetPassword={() => {
setSelectedUser(user);
setPassword('');
setDialog('password');
}}
onSuspend={() => void handleSuspend(user)}
onUnsuspend={() => void handleUnsuspend(user)}
onOpenDocuments={() => setDocumentsUser(user)}
onDisableTotp={() => void handleAdminDisableTotp(user)}
onOpenVerification={() => openVerificationDialog(user)}
onOpenRoles={() => {
setSelectedUser(user);
setDialog('roles');
}}
onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</TableContainer>
<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}
<UserInspectorDialog
user={inspectorUser}
token={token}
canModerateChats={Boolean(currentUser?.canModerateChats || currentUser?.isSuperAdmin)}
open={Boolean(inspectorUser)}
onOpenChange={(open) => {
if (!open) setInspectorUser(null);
}}
/>
</AdminShell>
);
}