more fix and update

This commit is contained in:
lendry
2026-06-24 20:15:19 +03:00
parent dcab6557d3
commit 9727cf3f35
53 changed files with 3479 additions and 494 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, 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';
@@ -78,6 +78,23 @@ export default function AdminUsersPage() {
}
}
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);
@@ -212,6 +229,9 @@ export default function AdminUsersPage() {
<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 ? (

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,15 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { PhoneInput, parseE164Phone, phoneCountries, toE164 } from '@/components/ui/phone-input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
getDocumentType,
indexDocumentsByType,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api';
function Row({
icon: Icon,
@@ -72,6 +74,11 @@ export default function DataPage() {
const [backupEmail, setBackupEmail] = useState('');
const [phone, setPhone] = useState('');
const [backupPhone, setBackupPhone] = useState('');
const [birthDate, setBirthDate] = useState<string | undefined>();
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
const [phoneDigits, setPhoneDigits] = useState('');
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
const [documents, setDocuments] = useState<UserDocument[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
@@ -94,10 +101,25 @@ export default function DataPage() {
setDisplayName(user.displayName);
setEmail(user.email ?? '');
setBackupEmail(user.backupEmail ?? '');
setPhone(user.phone ?? '');
setBackupPhone(user.backupPhone ?? '');
const parsedPhone = parseE164Phone(user.phone ?? '');
setPhoneCountry(parsedPhone.country);
setPhoneDigits(parsedPhone.digits);
setPhone(parsedPhone.digits ? toE164(parsedPhone.country, parsedPhone.digits) : '');
const parsedBackupPhone = parseE164Phone(user.backupPhone ?? '');
setBackupPhoneCountry(parsedBackupPhone.country);
setBackupPhoneDigits(parsedBackupPhone.digits);
setBackupPhone(parsedBackupPhone.digits ? toE164(parsedBackupPhone.country, parsedBackupPhone.digits) : '');
}, [user]);
useEffect(() => {
if (!user || !token || isPinLocked) return;
void apiFetch<UserProfileResponse>(`/profile/users/${user.id}`, {}, token)
.then((profile) => {
if (profile.birthDate) setBirthDate(profile.birthDate);
})
.catch(() => undefined);
}, [isPinLocked, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]);
@@ -116,14 +138,20 @@ export default function DataPage() {
try {
await apiFetch(`/profile/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
body: JSON.stringify({
firstName: firstName || undefined,
lastName: rest.join(' ') || undefined,
birthDate: birthDate || undefined
})
}, token);
const contacts: Record<string, string> = {};
const nextPhone = phoneDigits ? toE164(phoneCountry, phoneDigits) : '';
const nextBackupPhone = backupPhoneDigits ? toE164(backupPhoneCountry, backupPhoneDigits) : '';
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
if (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
if (Object.keys(contacts).length > 0) {
await apiFetch(`/profile/users/${user.id}/contacts`, {
@@ -177,11 +205,31 @@ export default function DataPage() {
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
<Input placeholder="Дата рождения" />
<DatePicker value={birthDate} onChange={setBirthDate} />
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
<PhoneInput
variant="light"
country={phoneCountry}
value={phoneDigits}
onCountryChange={setPhoneCountry}
onValueChange={(digits) => {
setPhoneDigits(digits);
setPhone(digits ? toE164(phoneCountry, digits) : '');
}}
required={false}
/>
<PhoneInput
variant="light"
country={backupPhoneCountry}
value={backupPhoneDigits}
onCountryChange={setBackupPhoneCountry}
onValueChange={(digits) => {
setBackupPhoneDigits(digits);
setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : '');
}}
required={false}
/>
</div>
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "leaflet/dist/leaflet.css";
@import "react-day-picker/style.css";
:root {
--background: #ffffff;
@@ -23,6 +24,11 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
.rdp-root {
--rdp-accent-color: #111827;
--rdp-accent-background-color: #f4f5f8;
}
@layer base {
a {
color: inherit;

View File

@@ -1,7 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ChevronRight,
Clock3,
@@ -24,7 +23,9 @@ 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 { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth';
function formatDate(value: string) {
@@ -46,9 +47,9 @@ const deviceIcons: Record<string, LucideIcon> = {
OTHER: Laptop
};
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null;
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { title: string; placeholder: string; type: string }> = {
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
@@ -56,8 +57,7 @@ const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placehold
};
export default function SecurityPage() {
const router = useRouter();
const { user, token, logout } = useAuth();
const { user, token } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]);
@@ -67,17 +67,22 @@ export default function SecurityPage() {
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [dialogValue, setDialogValue] = useState('');
const [isDialogSaving, setIsDialogSaving] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
const [totpCode, setTotpCode] = useState('');
const loadSecurity = useCallback(async () => {
if (!user || !token) return;
setIsSecurityLoading(true);
try {
const [devicesResponse, historyResponse] = await Promise.all([
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
]);
setDevices(devicesResponse.devices ?? []);
setHistory(historyResponse.events ?? []);
setTotpEnabled(Boolean(totpResponse.isEnabled));
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
} finally {
@@ -106,8 +111,8 @@ export default function SecurityPage() {
setIsRevoking(true);
try {
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
showToast('Все сессии завершены');
logout();
showToast('Выход выполнен на всех остальных устройствах');
await loadSecurity();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
} finally {
@@ -115,6 +120,64 @@ export default function SecurityPage() {
}
}
async function openTotpDialog() {
if (!user || !token) return;
if (totpEnabled) {
setTotpCode('');
setDialogMode('totpDisable');
return;
}
if (totpSetup) {
setTotpCode('');
setDialogMode('totpSetup');
return;
}
setIsDialogSaving(true);
try {
const setup = await apiFetch<TotpSetupResponse>(`/security/users/${user.id}/totp/setup`, { method: 'POST' }, token);
setTotpSetup(setup);
setTotpCode('');
setDialogMode('totpSetup');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось начать настройку аутентификатора');
} finally {
setIsDialogSaving(false);
}
}
async function submitTotpSetup(code: string) {
if (!user || !token) return;
setIsDialogSaving(true);
try {
await apiFetch(`/security/users/${user.id}/totp/enable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
setTotpEnabled(true);
setDialogMode(null);
setTotpSetup(null);
setTotpCode('');
showToast('Приложение-аутентификатор подключено');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось подтвердить код');
} finally {
setIsDialogSaving(false);
}
}
async function submitTotpDisable(code: string) {
if (!user || !token) return;
setIsDialogSaving(true);
try {
await apiFetch(`/security/users/${user.id}/totp/disable`, { method: 'POST', body: JSON.stringify({ code }) }, token);
setTotpEnabled(false);
setDialogMode(null);
setTotpCode('');
showToast('Приложение-аутентификатор отключено');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отключить аутентификатор');
} finally {
setIsDialogSaving(false);
}
}
async function submitDialog() {
if (!user || !token || !dialogMode) return;
setIsDialogSaving(true);
@@ -183,8 +246,25 @@ export default function SecurityPage() {
<LogOut className="h-6 w-6" />
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
</button>
<p className="text-sm text-[#667085]">Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства через меню профиля.</p>
</div>
<section className="mt-10">
<h2 className="text-2xl font-medium">Дополнительная защита</h2>
<div className="mt-4 space-y-2">
<button type="button" onClick={() => void openTotpDialog()} disabled={isDialogSaving} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6] disabled:opacity-60">
<ShieldCheck className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Приложение-аутентификатор</p>
<p className="text-sm text-[#667085]">
{totpEnabled ? 'Google Authenticator и аналоги подключены' : 'Подключите OTP-коды через Google Authenticator'}
</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
</div>
</section>
<section className="mt-10">
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
<div className="mt-4 space-y-2">
@@ -248,9 +328,45 @@ export default function SecurityPage() {
</div>
</section>
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
<Dialog open={dialogMode !== null} onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
if (dialogMode !== 'totpSetup' || totpEnabled) {
setTotpSetup(null);
}
setTotpCode('');
}
}}>
<DialogContent>
{dialogMode ? (
{dialogMode === 'totpSetup' && totpSetup ? (
<>
<DialogHeader>
<DialogTitle>Подключить аутентификатор</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.</p>
<div className="flex justify-center rounded-2xl bg-white p-4">
<QRCodeSVG value={totpSetup.otpauthUrl} size={180} />
</div>
<p className="break-all rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm text-[#667085]">
Ключ: <span className="font-mono text-[#111827]">{totpSetup.secret}</span>
</p>
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpSetup(code)} disabled={isDialogSaving} />
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
</>
) : null}
{dialogMode === 'totpDisable' ? (
<>
<DialogHeader>
<DialogTitle>Отключить аутентификатор</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">Введите код из приложения, чтобы отключить двухфакторную аутентификацию.</p>
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpDisable(code)} disabled={isDialogSaving} />
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
</>
) : null}
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
<>
<DialogHeader>
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>