This commit is contained in:
lendry
2026-06-25 08:31:36 +03:00
parent 71b270fcb3
commit 933f7fb9e1
22 changed files with 1871 additions and 620 deletions

View File

@@ -21,7 +21,17 @@ import {
indexDocumentsByType,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api';
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,
@@ -66,7 +76,7 @@ function Row({
export default function DataPage() {
const router = useRouter();
const { user, token, refreshProfile, logout } = useAuth();
const { user, token, refreshProfile } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const [displayName, setDisplayName] = useState('');
@@ -84,6 +94,8 @@ export default function DataPage() {
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 (!user || !token || isPinLocked) return;
@@ -124,6 +136,20 @@ export default function DataPage() {
if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]);
const loadDeletionStatus = useCallback(async () => {
if (!user || !token || isPinLocked) return;
try {
const status = await fetchAccountDeletionStatus(user.id, token);
setDeletionStatus(status);
} catch {
setDeletionStatus(null);
}
}, [isPinLocked, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadDeletionStatus();
}, [isPinLocked, isReady, loadDeletionStatus, user]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) {
@@ -174,18 +200,37 @@ export default function DataPage() {
if (!user || !token) return;
setIsDeleting(true);
try {
await softDeleteProfile(user.id, token);
const response = await requestAccountDeletion(user.id, token);
setDeletionStatus(response);
setDeleteDialogOpen(false);
showToast('Профиль удалён');
logout();
showToast(
response.effectiveAt
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
: 'Удаление аккаунта запланировано'
);
} catch (error) {
const message = getApiErrorMessage(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">
@@ -282,15 +327,40 @@ export default function DataPage() {
<section className="mt-10">
<h2 className="text-2xl font-medium">Управление данными</h2>
<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>
{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 ? (
@@ -313,9 +383,10 @@ export default function DataPage() {
<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}>
@@ -328,7 +399,7 @@ export default function DataPage() {
Удаляем...
</>
) : (
'Удалить профиль'
'Запланировать удаление'
)}
</Button>
</div>