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

450 lines
19 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 { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
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';
import { IdShell } from '@/components/id/shell';
import { useToast } from '@/components/id/toast-provider';
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,
parseAttachmentMeta,
parseDocumentPhotos,
parseMetadata,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
function formatDeletionDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
function Row({
icon: Icon,
title,
text,
onClick,
destructive
}: {
icon: typeof Mail;
title: string;
text?: string;
onClick?: () => void;
destructive?: boolean;
}) {
const content = (
<>
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${destructive ? 'bg-red-50' : 'bg-[#f4f5f8]'}`}>
<Icon className={`h-5 w-5 ${destructive ? 'text-red-500' : ''}`} />
</div>
<div className="min-w-0 flex-1">
<div className={`font-medium ${destructive ? 'text-red-600' : ''}`}>{title}</div>
{text ? <div className="truncate text-sm text-[#667085]">{text}</div> : null}
</div>
{onClick ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : null}
</>
);
if (!onClick) {
return <div className="flex items-center gap-4 border-b border-[#eceef4] py-4">{content}</div>;
}
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-4 border-b border-[#eceef4] py-4 text-left transition hover:bg-[#fafbfd]"
>
{content}
</button>
);
}
export default function DataPage() {
const router = useRouter();
const { user, token, applyUserPatch } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const userId = user?.id;
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
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);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
const [cancellingDeletion, setCancellingDeletion] = useState(false);
const loadDocuments = useCallback(async () => {
if (!userId || !token || isPinLocked) return;
try {
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token);
setDocuments(response.documents ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, userId]);
useEffect(() => {
if (!user) return;
setDisplayName(user.displayName);
setEmail(user.email ?? '');
setBackupEmail(user.backupEmail ?? '');
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 (!isReady || !userId || !token || isPinLocked) return;
void apiFetch<UserProfileResponse>(`/profile/users/${userId}`, {}, token)
.then((profile) => {
if (profile.birthDate) setBirthDate(profile.birthDate);
})
.catch(() => undefined);
}, [isPinLocked, isReady, token, userId]);
useEffect(() => {
if (isReady && userId && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, userId]);
const loadDeletionStatus = useCallback(async () => {
if (!userId || !token || isPinLocked) return;
try {
const status = await fetchAccountDeletionStatus(userId, token);
setDeletionStatus(status);
} catch {
// Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
}
}, [isPinLocked, token, userId]);
useEffect(() => {
if (isReady && userId && !isPinLocked) void loadDeletionStatus();
}, [isPinLocked, isReady, loadDeletionStatus, userId]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) {
setActiveDocumentType(type);
}
async function updateProfile() {
if (!user || !token) return;
setIsSaving(true);
const trimmedName = displayName.trim();
const [firstName, ...rest] = trimmedName.split(' ');
try {
await apiFetch(`/profile/users/${user.id}`, {
method: 'PATCH',
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 (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
if (Object.keys(contacts).length > 0) {
await apiFetch(`/profile/users/${user.id}/contacts`, {
method: 'PATCH',
body: JSON.stringify(contacts)
}, token);
}
applyUserPatch({
displayName: trimmedName || user.displayName,
email: contacts.email ?? user.email,
phone: contacts.phone ?? user.phone,
backupEmail: contacts.backupEmail ?? user.backupEmail,
backupPhone: contacts.backupPhone ?? user.backupPhone
});
showToast('Профиль обновлён');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
async function confirmDeleteProfile() {
if (!user || !token) return;
setIsDeleting(true);
try {
const response = await requestAccountDeletion(user.id, token);
setDeletionStatus(response);
setDeleteDialogOpen(false);
showToast(
response.effectiveAt
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
: 'Удаление аккаунта запланировано'
);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
if (message) showToast(message);
} finally {
setIsDeleting(false);
}
}
async function handleCancelDeletion() {
if (!user || !token) return;
setCancellingDeletion(true);
try {
await cancelAccountDeletion(user.id, token);
setDeletionStatus({ pending: false, graceDays: deletionStatus?.graceDays ?? 30 });
showToast('Удаление аккаунта отменено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось отменить удаление');
if (message) showToast(message);
} finally {
setCancellingDeletion(false);
}
}
return (
<IdShell active="/data">
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
{user ? (
<AvatarUpload
userId={user.id}
displayName={user.displayName}
hasAvatar={user.hasAvatar}
token={token}
isVerified={user.isVerified}
verificationIcon={user.verificationIcon}
className="h-14 w-14"
badgeSize="sm"
onUpdated={async () => {
applyUserPatch({ hasAvatar: true });
}}
/>
) : (
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
)}
<div className="flex-1">
<h1 className="text-lg font-semibold">{user?.displayName ?? 'Профиль'}</h1>
<p className="text-sm text-[#667085]">Логин: {user?.username ?? user?.email ?? user?.phone ?? 'не указан'}</p>
</div>
</div>
<section>
<h2 className="text-2xl font-medium">Личные данные</h2>
<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)} />
<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)} />
<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 ? 'Сохраняем...' : 'Обновить профиль'}
</Button>
</section>
<section className="mt-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-medium">Документы</h2>
<p className="mt-1 text-sm text-[#667085]">ID бережно хранит документы и подставляет их там, где вы разрешили.</p>
</div>
<Button variant="secondary" onClick={() => router.push('/documents')}>Все документы</Button>
</div>
<div className="mt-5 grid grid-cols-4 gap-3">
<ActionTile icon={FileText} label="Паспорт РФ" onClick={() => openDocument('PASSPORT_RF')} />
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
<ActionTile icon={FileText} label="ОМС" onClick={() => openDocument('OMS')} />
</div>
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
{documents.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[#667085]">Документы пока не добавлены</p>
) : (
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
icon={FileText}
title={config?.label ?? document.type}
text={document.number}
onClick={() => openDocument(document.type as DocumentTypeCode)}
/>
{photoKeys.length > 0 && userId && token ? (
<div className="border-b border-[#eceef4] px-1 pb-4">
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
</div>
) : null}
</div>
);
})
)}
</div>
</section>
<AddressQuickSection layout="data" isPinLocked={isPinLocked} isReady={isReady} />
<section className="mt-10">
<h2 className="text-2xl font-medium">Контакты</h2>
<Row icon={Mail} title="Email в ID" text={email || 'Не указан'} />
<Row icon={Phone} title="Основной телефон" text={phone || 'Не указан'} />
<Row icon={Mail} title="Запасная почта" text={backupEmail || 'Не указана'} />
<Row icon={UserRound} title="Добавить внешние аккаунты" text="Помогут быстрее входить и заполнить данные" />
</section>
<section className="mt-10">
<h2 className="text-2xl font-medium">Управление данными</h2>
<div className="mt-2 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
<Row
icon={FileKey2}
title="Доступы к данным"
text="Приложения, которым вы разрешили доступ к аккаунту"
onClick={() => router.push('/data/consents')}
/>
</div>
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
<p className="mt-2 text-sm leading-relaxed text-amber-800">
Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
можете пользоваться сервисом или отменить удаление.
</p>
<Button
className="mt-4"
variant="secondary"
disabled={cancellingDeletion}
onClick={() => void handleCancelDeletion()}
>
{cancellingDeletion ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отменяем...
</>
) : (
'Отменить удаление'
)}
</Button>
</div>
) : (
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
<Row
icon={Trash2}
title="Удалить профиль"
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
onClick={() => setDeleteDialogOpen(true)}
destructive
/>
</div>
)}
</section>
{activeDocumentType && user ? (
<DocumentDialog
open={Boolean(activeDocumentType)}
onOpenChange={(open) => {
if (!open) setActiveDocumentType(null);
}}
documentType={activeDocumentType}
userId={user.id}
token={token}
existing={documentsByType.get(activeDocumentType) ?? null}
onSaved={loadDocuments}
/>
) : null}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Удалить профиль?</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней срок
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
можно отменить на этой странице.
</p>
<div className="mt-6 flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
Отмена
</Button>
<Button className="flex-1 bg-red-600 hover:bg-red-700" onClick={() => void confirmDeleteProfile()} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Удаляем...
</>
) : (
'Запланировать удаление'
)}
</Button>
</div>
</DialogContent>
</Dialog>
</IdShell>
);
}