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

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

@@ -115,14 +115,29 @@ export class ProfileController {
@Post('self-delete') @Post('self-delete')
@ApiOperation({ @ApiOperation({
summary: 'Удалить свой профиль', summary: 'Запросить удаление своего профиля',
description: 'Мягко удаляет профиль: помечает аккаунт как удалённый, освобождает логин и контакты для повторной регистрации, сбрасывает роли и завершает все сессии.' description:
'Планирует удаление аккаунта через период ожидания ACCOUNT_DELETE_GRACE_DAYS. До истечения срока пользователь может отменить удаление.'
}) })
@ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Профиль удалён' }) @ApiResponse({ status: 201, description: 'Удаление запланировано' })
async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) { async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId); await this.assertSelfAccess(authorization, userId);
return this.core.profile.SoftDeleteProfile({ userId }); return this.core.profile.RequestAccountDeletion({ userId });
}
@Post('self-delete/cancel')
@ApiOperation({ summary: 'Отменить запланированное удаление профиля' })
async cancelSelfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.CancelAccountDeletion({ userId });
}
@Get('self-delete/status')
@ApiOperation({ summary: 'Статус запланированного удаления профиля' })
async selfDeleteStatus(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await this.assertSelfAccess(authorization, userId);
return this.core.profile.GetAccountDeletionStatus({ userId });
} }
} }

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

@@ -46,7 +46,26 @@ export const apiReference: ApiTagGroup[] = [
{ method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true }, { method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true },
{ method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true }, { method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true }, { method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/self-delete', summary: 'Удалить свой профиль', auth: true } {
method: 'POST',
path: '/profile/users/{userId}/self-delete',
summary: 'Запланировать удаление профиля',
description: 'Не удаляет аккаунт сразу. Запускает период ожидания ACCOUNT_DELETE_GRACE_DAYS (по умолчанию 30 дней).',
auth: true
},
{
method: 'POST',
path: '/profile/users/{userId}/self-delete/cancel',
summary: 'Отменить запланированное удаление профиля',
auth: true
},
{
method: 'GET',
path: '/profile/users/{userId}/self-delete/status',
summary: 'Статус запланированного удаления профиля',
description: 'Возвращает pending, deletionRequestedAt, effectiveAt и graceDays.',
auth: true
}
] ]
}, },
{ {
@@ -92,7 +111,28 @@ export const apiReference: ApiTagGroup[] = [
endpoints: [ endpoints: [
{ method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true }, { method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true },
{ method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true }, { method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true },
{ method: 'POST', path: '/family/groups/{groupId}/invites', summary: ригласить участника', auth: true } { method: 'GET', path: '/family/groups/{groupId}', summary: олучить семейную группу', auth: true },
{ method: 'PATCH', path: '/family/groups/{groupId}', summary: 'Обновить семейную группу (название)', auth: true },
{
method: 'DELETE',
path: '/family/groups/{groupId}',
summary: 'Удалить семейную группу',
description: 'Только создатель семьи. Удаляет всех участников, приглашения, чаты, сообщения и медиа семьи.',
auth: true
},
{ method: 'POST', path: '/family/groups/{groupId}/members', summary: 'Добавить участника', auth: true },
{
method: 'DELETE',
path: '/family/members/{memberId}',
summary: 'Исключить участника или выйти из семьи',
description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.',
auth: true
},
{ method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true },
{ method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей для приглашения', auth: true },
{ method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true },
{ method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true },
{ method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true }
] ]
}, },
{ {

View File

@@ -838,8 +838,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\
}, },
{ {
slug: 'sessions', slug: 'sessions',
title: 'Сессии и PIN', title: 'Сессии, PIN и удаление аккаунта',
description: 'Управление устройствами, PIN-блокировка и отзыв сессий.', description: 'Управление устройствами, PIN-блокировка, отзыв сессий и отложенное удаление профиля.',
sections: [ sections: [
{ {
id: 'totp', id: 'totp',
@@ -866,6 +866,46 @@ curl -X POST http://localhost:3000/auth/otp/verify \\
{ {
type: 'paragraph', type: 'paragraph',
text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.' text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.'
},
{
type: 'list',
items: [
'PIN_DELETE_GRACE_MINUTES — задержка перед окончательным удалением PIN после запроса',
'PIN_REQUIRE_ON_DELETE — требовать текущий PIN при запросе удаления защиты'
]
}
]
},
{
id: 'account-deletion',
title: 'Удаление аккаунта',
blocks: [
{
type: 'paragraph',
text: 'Пользователь может запланировать удаление профиля в личном кабинете: раздел «Данные» (/data) → «Удалить профиль». Аккаунт не удаляется мгновенно — начинается период ожидания, настраиваемый администратором.'
},
{
type: 'table',
headers: ['Ключ SystemSetting', 'Описание', 'По умолчанию'],
rows: [
['ACCOUNT_DELETE_GRACE_DAYS', 'Через сколько дней после запроса окончательно удалить аккаунт', '30']
]
},
{
type: 'list',
items: [
'POST /profile/users/{userId}/self-delete — запланировать удаление (только свой профиль)',
'GET /profile/users/{userId}/self-delete/status — дата окончательного удаления и статус pending',
'POST /profile/users/{userId}/self-delete/cancel — отменить запрос до истечения срока',
'До истечения срока пользователь может входить и пользоваться сервисом как обычно',
'На странице /data отображается предупреждение с датой удаления и кнопкой отмены'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Что происходит при финализации',
text: 'Фоновый планировщик sso-core (каждые 5 минут) находит аккаунты с истёкшим сроком ожидания и выполняет soft-delete: анонимизация email/телефона/username, отзыв сессий, снятие ролей. Семьи, где пользователь — создатель, удаляются полностью (участники, чаты, медиа). Из остальных семей пользователь исключается.'
} }
] ]
}, },
@@ -888,7 +928,7 @@ curl -X POST http://localhost:3000/auth/otp/verify \\
{ {
slug: 'family-chat', slug: 'family-chat',
title: 'Семья и чат', title: 'Семья и чат',
description: 'Семейные группы, приглашения, чат и realtime через WebSocket.', description: 'Семейные группы, приглашения, чат, удаление семьи и realtime через WebSocket.',
sections: [ sections: [
{ {
id: 'family', id: 'family',
@@ -896,7 +936,61 @@ curl -X POST http://localhost:3000/auth/otp/verify \\
blocks: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину. Лимиты (max family members) берутся из SystemSetting.' text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину или через поиск в интерфейсе. Лимит участников задаётся в SystemSetting MAX_FAMILY_MEMBERS (по умолчанию 6). При создании семьи автоматически создаётся общий чат «Общий чат».'
},
{
type: 'list',
items: [
'POST /family/groups — создать семью',
'GET /family/users/{userId}/groups — список семей пользователя',
'PATCH /family/groups/{groupId} — переименовать (только участники, название — создатель)',
'POST /family/groups/{groupId}/invites — отправить приглашение',
'GET /family/groups/{groupId}/invite-search?q=... — поиск пользователей для приглашения'
]
}
]
},
{
id: 'family-leave',
title: 'Выход и исключение участников',
blocks: [
{
type: 'paragraph',
text: 'Участник может выйти из семьи самостоятельно; создатель семьи может исключить любого участника, кроме себя. При выходе или исключении пользователь удаляется из всех чатов этой семьи.'
},
{
type: 'list',
items: [
'DELETE /family/members/{memberId} — выход (если memberId свой) или исключение (если запрос от создателя семьи)',
'Создателя семьи (role: owner) удалить через этот endpoint нельзя — только удалить всю семью целиком'
]
}
]
},
{
id: 'family-delete',
title: 'Удаление семьи',
blocks: [
{
type: 'paragraph',
text: 'Только создатель семьи может полностью удалить группу. В интерфейсе: страница семьи → «Участники семьи» → «Удалить семью». Действие необратимо.'
},
{
type: 'list',
items: [
'DELETE /family/groups/{groupId} — удалить семью (только ownerId === текущий пользователь)',
'Все участники исключаются из семьи',
'Все приглашения (FamilyInvite) удаляются',
'Все чаты семьи (ChatRoom), сообщения, опросы и голоса удаляются каскадом',
'Медиа в MinIO (аватар семьи, аватары чатов, вложения сообщений) удаляются best-effort',
'Остальным участникам отправляется уведомление family_group_deleted и событие WebSocket'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Без передачи владения',
text: 'Перед удалением семьи нельзя «передать» роль создателя другому участнику — только полное удаление группы или выход участников по отдельности.'
} }
] ]
}, },
@@ -909,7 +1003,8 @@ curl -X POST http://localhost:3000/auth/otp/verify \\
items: [ items: [
'REST: /chat/groups/{groupId}/rooms, /chat/rooms/{roomId}/messages', 'REST: /chat/groups/{groupId}/rooms, /chat/rooms/{roomId}/messages',
'WebSocket: ws://localhost:8085/ws с JWT в query или заголовке', 'WebSocket: ws://localhost:8085/ws с JWT в query или заголовке',
'Медиа чата: presigned upload + защищённый stream с Authorization' 'Медиа чата: presigned upload + защищённый stream с Authorization',
'События realtime: chat_message, chat_message_updated, chat_message_deleted, family_group_deleted'
] ]
} }
] ]

View File

@@ -11,7 +11,7 @@ export const docNavigation: DocNavItem[] = [
{ slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' }, { slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' },
{ slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' }, { slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' },
{ slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' }, { slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' },
{ slug: 'sessions', title: 'Сессии и PIN', group: 'Безопасность' }, { slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' },
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' }, { slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' } { slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
]; ];

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

@@ -21,7 +21,17 @@ import {
indexDocumentsByType, indexDocumentsByType,
type DocumentTypeCode type DocumentTypeCode
} from '@/lib/document-catalog'; } 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({ function Row({
icon: Icon, icon: Icon,
@@ -66,7 +76,7 @@ function Row({
export default function DataPage() { export default function DataPage() {
const router = useRouter(); const router = useRouter();
const { user, token, refreshProfile, logout } = useAuth(); const { user, token, refreshProfile } = useAuth();
const { isReady, isPinLocked } = useRequireAuth(); const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
@@ -84,6 +94,8 @@ export default function DataPage() {
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null); const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
const [cancellingDeletion, setCancellingDeletion] = useState(false);
const loadDocuments = useCallback(async () => { const loadDocuments = useCallback(async () => {
if (!user || !token || isPinLocked) return; if (!user || !token || isPinLocked) return;
@@ -124,6 +136,20 @@ export default function DataPage() {
if (isReady && user && !isPinLocked) void loadDocuments(); if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]); }, [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]); const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) { function openDocument(type: DocumentTypeCode) {
@@ -174,18 +200,37 @@ export default function DataPage() {
if (!user || !token) return; if (!user || !token) return;
setIsDeleting(true); setIsDeleting(true);
try { try {
await softDeleteProfile(user.id, token); const response = await requestAccountDeletion(user.id, token);
setDeletionStatus(response);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
showToast('Профиль удалён'); showToast(
logout(); response.effectiveAt
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
: 'Удаление аккаунта запланировано'
);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить профиль'); const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
if (message) showToast(message); if (message) showToast(message);
} finally { } finally {
setIsDeleting(false); 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 ( return (
<IdShell active="/data"> <IdShell active="/data">
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4"> <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"> <section className="mt-10">
<h2 className="text-2xl font-medium">Управление данными</h2> <h2 className="text-2xl font-medium">Управление данными</h2>
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40"> {deletionStatus?.pending && deletionStatus.effectiveAt ? (
<Row <div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
icon={Trash2} <p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
title="Удалить профиль" <p className="mt-2 text-sm leading-relaxed text-amber-800">
text="Аккаунт будет деактивирован, вход станет невозможен" Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
onClick={() => setDeleteDialogOpen(true)} можете пользоваться сервисом или отменить удаление.
destructive </p>
/> <Button
</div> 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> </section>
{activeDocumentType && user ? ( {activeDocumentType && user ? (
@@ -313,9 +383,10 @@ export default function DataPage() {
<DialogTitle>Удалить профиль?</DialogTitle> <DialogTitle>Удалить профиль?</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]"> <p className="text-sm leading-relaxed text-[#667085]">
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней срок
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты. задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
Запись в базе сохранится в архивном виде. логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
можно отменить на этой странице.
</p> </p>
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}> <Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
@@ -328,7 +399,7 @@ export default function DataPage() {
Удаляем... Удаляем...
</> </>
) : ( ) : (
'Удалить профиль' 'Запланировать удаление'
)} )}
</Button> </Button>
</div> </div>

View File

@@ -45,6 +45,7 @@ import {
ChatRoom, ChatRoom,
createChatRoom, createChatRoom,
deleteChatMessage, deleteChatMessage,
deleteFamilyGroup,
editChatMessage, editChatMessage,
FamilyGroup, FamilyGroup,
FamilyInviteCandidate, FamilyInviteCandidate,
@@ -221,6 +222,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null); const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState(''); const [editDraft, setEditDraft] = useState('');
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]); const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [deletingFamily, setDeletingFamily] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -350,9 +353,15 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
if (event.type === 'chat_read_receipt' && activeRoomId) { if (event.type === 'chat_read_receipt' && activeRoomId) {
if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current); if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current);
readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400); readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400);
return;
}
if (event.type === 'family_group_deleted' && payload?.groupId === groupId) {
showToast('Семья была удалена');
router.push('/family');
} }
}); });
}, [activeRoomId, appendMessage, loadGroup, loadMessages, subscribe]); }, [activeRoomId, appendMessage, groupId, loadGroup, loadMessages, router, showToast, subscribe]);
const loadChatMedia = useCallback( const loadChatMedia = useCallback(
async (message: ChatMessage, force = false) => { async (message: ChatMessage, force = false) => {
@@ -508,6 +517,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
if (!token) return; if (!token) return;
try { try {
await removeFamilyMember(memberId, token); await removeFamilyMember(memberId, token);
const removedMember = group?.members?.find((item) => item.id === memberId);
if (removedMember?.userId === user?.id) {
showToast('Вы вышли из семьи');
router.push('/family');
return;
}
await loadGroup(); await loadGroup();
setMembersOpen(false); setMembersOpen(false);
setFamilyMembersOpen(false); setFamilyMembersOpen(false);
@@ -517,6 +532,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
} }
} }
async function confirmDeleteFamily() {
if (!token) return;
setDeletingFamily(true);
try {
await deleteFamilyGroup(groupId, token);
setDeleteFamilyDialogOpen(false);
setFamilyMembersOpen(false);
showToast('Семья удалена');
router.push('/family');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить семью') ?? 'Ошибка');
} finally {
setDeletingFamily(false);
}
}
async function saveRename() { async function saveRename() {
if (!token || !renameValue.trim()) return; if (!token || !renameValue.trim()) return;
try { try {
@@ -1268,6 +1299,45 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Пригласить в семью Пригласить в семью
</Button> </Button>
{isFamilyOwner ? (
<Button
className="mt-2 w-full rounded-xl bg-red-600 hover:bg-red-700"
onClick={() => {
setFamilyMembersOpen(false);
setDeleteFamilyDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить семью
</Button>
) : null}
</DialogContent>
</Dialog>
<Dialog open={deleteFamilyDialogOpen} onOpenChange={setDeleteFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Удалить семью «{group?.name}»?</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Все участники будут исключены из семьи, все чаты и сообщения будут удалены без возможности
восстановления. Это действие нельзя отменить.
</p>
<div className="mt-6 flex gap-3">
<Button variant="secondary" className="flex-1" disabled={deletingFamily} onClick={() => setDeleteFamilyDialogOpen(false)}>
Отмена
</Button>
<Button className="flex-1 bg-red-600 hover:bg-red-700" disabled={deletingFamily} onClick={() => void confirmDeleteFamily()}>
{deletingFamily ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Удаляем...
</>
) : (
'Удалить семью'
)}
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -562,8 +562,36 @@ export async function fetchDocumentPhotoUrl(userId: string, storageKey: string,
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token); return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
} }
export interface AccountDeletionStatus {
pending: boolean;
deletionRequestedAt?: string;
effectiveAt?: string;
graceDays: number;
}
export async function requestAccountDeletion(userId: string, token?: string | null) {
return apiFetch<AccountDeletionStatus & { userId: string }>(
`/profile/users/${userId}/self-delete`,
{ method: 'POST' },
token
);
}
export async function cancelAccountDeletion(userId: string, token?: string | null) {
return apiFetch<{ userId: string; cancelled: boolean }>(
`/profile/users/${userId}/self-delete/cancel`,
{ method: 'POST' },
token
);
}
export async function fetchAccountDeletionStatus(userId: string, token?: string | null) {
return apiFetch<AccountDeletionStatus>(`/profile/users/${userId}/self-delete/status`, {}, token);
}
/** @deprecated Используйте requestAccountDeletion — удаление теперь отложенное */
export async function softDeleteProfile(userId: string, token?: string | null) { export async function softDeleteProfile(userId: string, token?: string | null) {
return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token); return requestAccountDeletion(userId, token);
} }
export interface UserAddress { export interface UserAddress {
@@ -726,6 +754,10 @@ export async function updateFamilyGroup(groupId: string, name: string, token?: s
return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token); return apiFetch<FamilyGroup>(`/family/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ name }) }, token);
} }
export async function deleteFamilyGroup(groupId: string, token?: string | null) {
return apiFetch<{ count: number }>(`/family/groups/${groupId}`, { method: 'DELETE' }, token);
}
export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) { export async function sendFamilyInvite(groupId: string, payload: { inviteeUserId: string; target?: string }, token?: string | null) {
return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token); return apiFetch<FamilyInvite>(`/family/groups/${groupId}/invites`, { method: 'POST', body: JSON.stringify(payload) }, token);
} }

View File

@@ -32,6 +32,14 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
{ key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' }, { key: 'PIN_MAX_LENGTH', label: 'Макс. длина PIN', group: 'pin', type: 'number', unit: 'цифр' },
{ key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' }, { key: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
{ key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' }, { key: 'OTP_MAX_ATTEMPTS', label: 'Попыток ввода OTP', group: 'auth', type: 'number' },
{
key: 'ACCOUNT_DELETE_GRACE_DAYS',
label: 'Отложенное удаление аккаунта',
group: 'auth',
type: 'number',
unit: 'дн',
hint: 'Срок ожидания перед окончательным удалением профиля после запроса пользователя'
},
{ key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' }, { key: 'SESSION_REFRESH_DAYS', label: 'Срок refresh-токена', group: 'auth', type: 'number', unit: 'дн' },
{ key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' }, { key: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' },
{ key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' }, { key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' },

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app

View File

@@ -53,6 +53,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
deletionRequestedAt DateTime?
pinCode PinCode? pinCode PinCode?
sessions Session[] sessions Session[]
devices Device[] devices Device[]

View File

@@ -33,6 +33,7 @@ import { LdapClientService } from './infra/ldap-client.service';
import { MessagingService } from './infra/messaging.service'; import { MessagingService } from './infra/messaging.service';
import { SmsService } from './infra/sms.service'; import { SmsService } from './infra/sms.service';
import { TotpService } from './domain/totp.service'; import { TotpService } from './domain/totp.service';
import { MaintenanceSchedulerService } from './domain/maintenance-scheduler.service';
@Module({ @Module({
imports: [ imports: [
@@ -70,7 +71,8 @@ import { TotpService } from './domain/totp.service';
MinioService, MinioService,
LdapClientService, LdapClientService,
MessagingService, MessagingService,
SmsService SmsService,
MaintenanceSchedulerService
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@@ -547,6 +547,21 @@ export class AuthGrpcController {
return this.profile.softDeleteProfile(command.userId); return this.profile.softDeleteProfile(command.userId);
} }
@GrpcMethod('ProfileService', 'RequestAccountDeletion')
requestAccountDeletion(command: { userId: string }) {
return this.profile.requestAccountDeletion(command.userId);
}
@GrpcMethod('ProfileService', 'CancelAccountDeletion')
cancelAccountDeletion(command: { userId: string }) {
return this.profile.cancelAccountDeletion(command.userId);
}
@GrpcMethod('ProfileService', 'GetAccountDeletionStatus')
getAccountDeletionStatus(command: { userId: string }) {
return this.profile.getAccountDeletionStatus(command.userId);
}
@GrpcMethod('DocumentsService', 'CreateDocument') @GrpcMethod('DocumentsService', 'CreateDocument')
createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) { createDocument(command: { userId: string; type: string; number: string; issuedAt?: string; expiresAt?: string; metadataJson?: string }) {
return this.documents.create(command); return this.documents.create(command);

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../infra/prisma.service';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { MinioService } from '../infra/minio.service';
@@ -22,7 +23,9 @@ export class FamilyService {
private readonly settings: SettingsService, private readonly settings: SettingsService,
private readonly notifications: NotificationsService private readonly notifications: NotificationsService,
private readonly minio: MinioService
) {} ) {}
@@ -158,12 +161,140 @@ export class FamilyService {
this.assertOwner(group, requesterId); this.assertOwner(group, requesterId);
await this.prisma.familyGroup.delete({ where: { id: groupId } }); await this.destroyGroupWithCleanup(group);
return { count: 1 }; return { count: 1 };
} }
async destroyOwnedGroupsForUser(userId: string) {
const groups = await this.prisma.familyGroup.findMany({
where: { ownerId: userId },
include: { members: { include: { user: true } } }
});
for (const group of groups) {
await this.destroyGroupWithCleanup(group);
}
}
async leaveAllMembershipsForUser(userId: string) {
const memberships = await this.prisma.familyMember.findMany({
where: { userId, role: { not: 'owner' } }
});
for (const member of memberships) {
await this.removeMember(userId, member.id);
}
}
private async destroyGroupWithCleanup(group: {
id: string;
ownerId: string;
name: string;
avatarStorageKey: string | null;
members: Array<{ userId: string }>;
}) {
const rooms = await this.prisma.chatRoom.findMany({
where: { groupId: group.id },
include: { messages: { select: { storageKey: true } } }
});
const storageKeys: string[] = [];
if (group.avatarStorageKey) {
storageKeys.push(group.avatarStorageKey);
}
for (const room of rooms) {
if (room.avatarStorageKey) {
storageKeys.push(room.avatarStorageKey);
}
for (const message of room.messages) {
if (message.storageKey) {
storageKeys.push(message.storageKey);
}
}
}
await this.minio.deleteObjects(storageKeys);
await this.prisma.familyGroup.delete({ where: { id: group.id } });
for (const member of group.members) {
await this.notifications.publishRealtime(
member.userId,
'family_group_deleted',
'Семья удалена',
`Семья «${group.name}» была удалена`,
{ groupId: group.id, groupName: group.name }
);
if (member.userId === group.ownerId) {
continue;
}
await this.notifications.create(
member.userId,
'family_group_deleted',
'Семья удалена',
`Семья «${group.name}» была удалена создателем`,
{ groupId: group.id, groupName: group.name }
);
}
}
async addMember(groupId: string, userId: string, role: string) { async addMember(groupId: string, userId: string, role: string) {

View File

@@ -0,0 +1,53 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PinService } from './pin.service';
import { ProfileService } from './profile.service';
const INTERVAL_MS = 5 * 60 * 1000;
@Injectable()
export class MaintenanceSchedulerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MaintenanceSchedulerService.name);
private timer?: NodeJS.Timeout;
constructor(
private readonly pin: PinService,
private readonly profile: ProfileService
) {}
onModuleInit() {
this.timer = setInterval(() => {
void this.runMaintenance();
}, INTERVAL_MS);
void this.runMaintenance();
}
onModuleDestroy() {
if (this.timer) {
clearInterval(this.timer);
}
}
private async runMaintenance() {
try {
const pinResult = await this.pin.finalizeDuePinDeletions();
if (pinResult.count > 0) {
this.logger.log(`Окончательно удалено PIN-кодов: ${pinResult.count}`);
}
} catch (error) {
this.logger.error(
`Ошибка финализации удаления PIN: ${error instanceof Error ? error.message : error}`
);
}
try {
const accountResult = await this.profile.finalizeDueAccountDeletions();
if (accountResult.count > 0) {
this.logger.log(`Окончательно удалено аккаунтов: ${accountResult.count}`);
}
} catch (error) {
this.logger.error(
`Ошибка финализации удаления аккаунтов: ${error instanceof Error ? error.message : error}`
);
}
}
}

View File

@@ -7,6 +7,8 @@ import { SessionStatus, UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service'; import { PrismaService } from '../infra/prisma.service';
import { OtpService } from './otp.service'; import { OtpService } from './otp.service';
import { TotpService } from './totp.service'; import { TotpService } from './totp.service';
import { SettingsService } from './settings.service';
import { FamilyService } from './family.service';
@@ -74,7 +76,9 @@ export class ProfileService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly otp: OtpService, private readonly otp: OtpService,
private readonly totp: TotpService private readonly totp: TotpService,
private readonly settings: SettingsService,
private readonly family: FamilyService
) {} ) {}
@@ -414,6 +418,96 @@ export class ProfileService {
async requestAccountDeletion(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('Пользователь не найден');
}
if (user.status === UserStatus.DELETED || user.deletedAt) {
throw new BadRequestException('Профиль уже удалён');
}
if (user.deletionRequestedAt) {
throw new BadRequestException('Удаление аккаунта уже запланировано');
}
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
const deletionRequestedAt = new Date();
const effectiveAt = new Date(deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000);
await this.prisma.user.update({
where: { id: userId },
data: { deletionRequestedAt }
});
return {
userId,
deletionRequestedAt: deletionRequestedAt.toISOString(),
effectiveAt: effectiveAt.toISOString(),
graceDays
};
}
async cancelAccountDeletion(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('Пользователь не найден');
}
if (!user.deletionRequestedAt) {
throw new BadRequestException('Запрос на удаление аккаунта не найден');
}
await this.prisma.user.update({
where: { id: userId },
data: { deletionRequestedAt: null }
});
return { userId, cancelled: true };
}
async getAccountDeletionStatus(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('Пользователь не найден');
}
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
if (!user.deletionRequestedAt) {
return { pending: false, graceDays };
}
const effectiveAt = new Date(user.deletionRequestedAt.getTime() + graceDays * 24 * 60 * 60 * 1000);
return {
pending: true,
deletionRequestedAt: user.deletionRequestedAt.toISOString(),
effectiveAt: effectiveAt.toISOString(),
graceDays
};
}
async finalizeDueAccountDeletions() {
const graceDays = await this.settings.getNumber('ACCOUNT_DELETE_GRACE_DAYS', 30);
const threshold = new Date(Date.now() - graceDays * 24 * 60 * 60 * 1000);
const due = await this.prisma.user.findMany({
where: {
status: UserStatus.ACTIVE,
deletionRequestedAt: { not: null, lte: threshold }
},
select: { id: true }
});
for (const user of due) {
await this.finalizeAccountDeletion(user.id);
}
return { count: due.length };
}
async finalizeAccountDeletion(userId: string) {
await this.family.destroyOwnedGroupsForUser(userId);
await this.family.leaveAllMembershipsForUser(userId);
await this.softDeleteProfile(userId);
}
async softDeleteProfile(userId: string) { async softDeleteProfile(userId: string) {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
@@ -486,7 +580,9 @@ export class ProfileService {
status: UserStatus.DELETED, status: UserStatus.DELETED,
deletedAt: new Date() deletedAt: new Date(),
deletionRequestedAt: null
} }
@@ -586,7 +682,9 @@ export class ProfileService {
birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null, birthDate: user.birthDate?.toISOString().slice(0, 10) ?? null,
hasPassword: Boolean(user.passwordHash) hasPassword: Boolean(user.passwordHash),
deletionRequestedAt: (user as { deletionRequestedAt?: Date | null }).deletionRequestedAt?.toISOString()
}; };

View File

@@ -13,6 +13,11 @@ export const DEFAULT_SYSTEM_SETTINGS = [
{ key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' }, { key: 'MAX_FAMILY_MEMBERS', value: '6', description: 'Максимальное количество участников в семейной группе' },
{ key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' }, { key: 'OTP_EXPIRY_MINUTES', value: '10', description: 'Время жизни OTP-кода для входа (минуты)' },
{ key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' }, { key: 'OTP_MAX_ATTEMPTS', value: '5', description: 'Максимальное количество попыток ввода OTP' },
{
key: 'ACCOUNT_DELETE_GRACE_DAYS',
value: '30',
description: 'Через сколько дней после запроса окончательно удалить аккаунт пользователя'
},
{ key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' }, { key: 'SESSION_REFRESH_DAYS', value: '30', description: 'Срок жизни refresh-токена (дни)' },
{ key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' }, { key: 'REGISTRATION_ENABLED', value: 'true', description: 'Разрешить регистрацию новых пользователей' },
{ key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' }, { key: 'AVATAR_URL_TTL_MINUTES', value: '15', description: 'Время жизни подписанной ссылки на аватар (минуты)' },

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { import {
CreateBucketCommand, CreateBucketCommand,
DeleteObjectsCommand,
HeadBucketCommand, HeadBucketCommand,
PutBucketCorsCommand, PutBucketCorsCommand,
PutObjectCommand, PutObjectCommand,
@@ -163,4 +164,31 @@ export class MinioService implements OnModuleInit {
getBucket() { getBucket() {
return this.bucket; return this.bucket;
} }
async deleteObjects(keys: string[]) {
const uniqueKeys = [...new Set(keys.filter(Boolean))];
if (!uniqueKeys.length) {
return;
}
const chunkSize = 1000;
for (let index = 0; index < uniqueKeys.length; index += chunkSize) {
const chunk = uniqueKeys.slice(index, index + chunkSize);
try {
await this.internalClient.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: chunk.map((Key) => ({ Key })),
Quiet: true
}
})
);
} catch (error) {
this.logger.warn(
`Не удалось удалить объекты MinIO (${chunk.length} шт.): ${error instanceof Error ? error.message : error}`
);
}
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@ service ProfileService {
rpc ChangePassword (ChangePasswordRequest) returns (SetPasswordResponse); rpc ChangePassword (ChangePasswordRequest) returns (SetPasswordResponse);
rpc RemovePassword (RemovePasswordRequest) returns (SetPasswordResponse); rpc RemovePassword (RemovePasswordRequest) returns (SetPasswordResponse);
rpc SoftDeleteProfile (UserProfileRequest) returns (SoftDeleteProfileResponse); rpc SoftDeleteProfile (UserProfileRequest) returns (SoftDeleteProfileResponse);
rpc RequestAccountDeletion (UserProfileRequest) returns (AccountDeletionResponse);
rpc CancelAccountDeletion (UserProfileRequest) returns (AccountDeletionCancelResponse);
rpc GetAccountDeletionStatus (UserProfileRequest) returns (AccountDeletionStatusResponse);
} }
message SetPasswordRequest { message SetPasswordRequest {
@@ -95,6 +98,26 @@ message ProfileResponse {
optional string backupPhone = 13; optional string backupPhone = 13;
optional string birthDate = 15; optional string birthDate = 15;
bool hasPassword = 16; bool hasPassword = 16;
optional string deletionRequestedAt = 17;
}
message AccountDeletionResponse {
string userId = 1;
string deletionRequestedAt = 2;
string effectiveAt = 3;
int32 graceDays = 4;
}
message AccountDeletionCancelResponse {
string userId = 1;
bool cancelled = 2;
}
message AccountDeletionStatusResponse {
bool pending = 1;
optional string deletionRequestedAt = 2;
optional string effectiveAt = 3;
int32 graceDays = 4;
} }
message SoftDeleteProfileResponse { message SoftDeleteProfileResponse {