update
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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: 'Справочник' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{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">
|
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||||
<Row
|
<Row
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
title="Удалить профиль"
|
title="Удалить профиль"
|
||||||
text="Аккаунт будет деактивирован, вход станет невозможен"
|
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
destructive
|
destructive
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
53
apps/sso-core/src/domain/maintenance-scheduler.service.ts
Normal file
53
apps/sso-core/src/domain/maintenance-scheduler.service.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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: 'Время жизни подписанной ссылки на аватар (минуты)' },
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user