591 lines
26 KiB
TypeScript
591 lines
26 KiB
TypeScript
'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>
|
||
);
|
||
}
|