fix and update
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
|
||||
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';
|
||||
@@ -10,10 +10,16 @@ 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, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api';
|
||||
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';
|
||||
|
||||
@@ -34,6 +40,113 @@ const roleLabels: Record<string, string> = {
|
||||
|
||||
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();
|
||||
@@ -367,98 +480,28 @@ export default function AdminUsersPage() {
|
||||
<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" onClick={(event) => event.stopPropagation()}>
|
||||
{(currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Журнал и чаты"
|
||||
onClick={() => setInspectorUser(user)}
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{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 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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||
import { ActionTile } from '@/components/id/action-tile';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
@@ -20,6 +20,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import {
|
||||
getDocumentType,
|
||||
indexDocumentsByType,
|
||||
parseAttachmentMeta,
|
||||
parseDocumentPhotos,
|
||||
parseMetadata,
|
||||
type DocumentTypeCode
|
||||
@@ -324,6 +325,7 @@ export default function DataPage() {
|
||||
documents.map((document) => {
|
||||
const config = getDocumentType(document.type);
|
||||
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||
return (
|
||||
<div key={document.id}>
|
||||
<Row
|
||||
@@ -334,7 +336,7 @@ export default function DataPage() {
|
||||
/>
|
||||
{photoKeys.length > 0 && userId && token ? (
|
||||
<div className="border-b border-[#eceef4] px-1 pb-4">
|
||||
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -401,7 +403,7 @@ export default function DataPage() {
|
||||
</section>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DOCUMENT_CATEGORIES,
|
||||
DOCUMENT_TYPES,
|
||||
getDocumentType,
|
||||
parseAttachmentMeta,
|
||||
parseDocumentPhotos,
|
||||
parseMetadata,
|
||||
QUICK_DOCUMENT_TYPES,
|
||||
@@ -97,6 +98,7 @@ export default function DocumentsPage() {
|
||||
const Icon = item.icon;
|
||||
const existing = documentsByType.get(item.type);
|
||||
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
|
||||
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
|
||||
return (
|
||||
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
||||
<button
|
||||
@@ -115,7 +117,7 @@ export default function DocumentsPage() {
|
||||
</button>
|
||||
{existing && photoKeys.length > 0 && user && token ? (
|
||||
<div className="px-4 pb-4">
|
||||
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} />
|
||||
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export default function DocumentsPage() {
|
||||
))}
|
||||
|
||||
{activeType ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveType(null);
|
||||
|
||||
@@ -15,6 +15,29 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(168 173 188 / 55%) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgb(168 173 188 / 45%);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(102 112 133 / 65%);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -29,6 +52,10 @@ html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rdp-root {
|
||||
--rdp-accent-color: #111827;
|
||||
--rdp-accent-background-color: #f4f5f8;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { BadgeCheck, Bell, Car, FileText, Fingerprint, Heart, KeyRound, PawPrint, ShieldCheck, Smartphone, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
|
||||
import { AdminBadge } from '@/components/id/admin-badge';
|
||||
import { AvatarDisplay } from '@/components/id/avatar-upload';
|
||||
@@ -124,7 +124,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
<DocumentFormDialog
|
||||
<DocumentDialog
|
||||
open={Boolean(activeDocumentType)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setActiveDocumentType(null);
|
||||
|
||||
Reference in New Issue
Block a user