update
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
indexDocumentsByType,
|
||||
type DocumentTypeCode
|
||||
} from '@/lib/document-catalog';
|
||||
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument, UserProfileResponse } from '@/lib/api';
|
||||
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
|
||||
|
||||
function formatDeletionDate(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon: Icon,
|
||||
@@ -66,7 +76,7 @@ function Row({
|
||||
|
||||
export default function DataPage() {
|
||||
const router = useRouter();
|
||||
const { user, token, refreshProfile, logout } = useAuth();
|
||||
const { user, token, refreshProfile } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
@@ -84,6 +94,8 @@ export default function DataPage() {
|
||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
|
||||
const [cancellingDeletion, setCancellingDeletion] = useState(false);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
@@ -124,6 +136,20 @@ export default function DataPage() {
|
||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||||
|
||||
const loadDeletionStatus = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const status = await fetchAccountDeletionStatus(user.id, token);
|
||||
setDeletionStatus(status);
|
||||
} catch {
|
||||
setDeletionStatus(null);
|
||||
}
|
||||
}, [isPinLocked, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadDeletionStatus();
|
||||
}, [isPinLocked, isReady, loadDeletionStatus, user]);
|
||||
|
||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||
|
||||
function openDocument(type: DocumentTypeCode) {
|
||||
@@ -174,18 +200,37 @@ export default function DataPage() {
|
||||
if (!user || !token) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await softDeleteProfile(user.id, token);
|
||||
const response = await requestAccountDeletion(user.id, token);
|
||||
setDeletionStatus(response);
|
||||
setDeleteDialogOpen(false);
|
||||
showToast('Профиль удалён');
|
||||
logout();
|
||||
showToast(
|
||||
response.effectiveAt
|
||||
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
|
||||
: 'Удаление аккаунта запланировано'
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
|
||||
const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelDeletion() {
|
||||
if (!user || !token) return;
|
||||
setCancellingDeletion(true);
|
||||
try {
|
||||
await cancelAccountDeletion(user.id, token);
|
||||
setDeletionStatus({ pending: false, graceDays: deletionStatus?.graceDays ?? 30 });
|
||||
showToast('Удаление аккаунта отменено');
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось отменить удаление');
|
||||
if (message) showToast(message);
|
||||
} finally {
|
||||
setCancellingDeletion(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/data">
|
||||
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
||||
@@ -282,15 +327,40 @@ export default function DataPage() {
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||
<Row
|
||||
icon={Trash2}
|
||||
title="Удалить профиль"
|
||||
text="Аккаунт будет деактивирован, вход станет невозможен"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
|
||||
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-amber-800">
|
||||
Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
|
||||
можете пользоваться сервисом или отменить удаление.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="secondary"
|
||||
disabled={cancellingDeletion}
|
||||
onClick={() => void handleCancelDeletion()}
|
||||
>
|
||||
{cancellingDeletion ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Отменяем...
|
||||
</>
|
||||
) : (
|
||||
'Отменить удаление'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||
<Row
|
||||
icon={Trash2}
|
||||
title="Удалить профиль"
|
||||
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activeDocumentType && user ? (
|
||||
@@ -313,9 +383,10 @@ export default function DataPage() {
|
||||
<DialogTitle>Удалить профиль?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm leading-relaxed text-[#667085]">
|
||||
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
|
||||
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
|
||||
Запись в базе сохранится в архивном виде.
|
||||
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней — срок
|
||||
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
|
||||
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
|
||||
можно отменить на этой странице.
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
@@ -328,7 +399,7 @@ export default function DataPage() {
|
||||
Удаляем...
|
||||
</>
|
||||
) : (
|
||||
'Удалить профиль'
|
||||
'Запланировать удаление'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ChatRoom,
|
||||
createChatRoom,
|
||||
deleteChatMessage,
|
||||
deleteFamilyGroup,
|
||||
editChatMessage,
|
||||
FamilyGroup,
|
||||
FamilyInviteCandidate,
|
||||
@@ -221,6 +222,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editDraft, setEditDraft] = useState('');
|
||||
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
||||
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
|
||||
const [deletingFamily, setDeletingFamily] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current);
|
||||
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(
|
||||
async (message: ChatMessage, force = false) => {
|
||||
@@ -508,6 +517,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
if (!token) return;
|
||||
try {
|
||||
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();
|
||||
setMembersOpen(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() {
|
||||
if (!token || !renameValue.trim()) return;
|
||||
try {
|
||||
@@ -1268,6 +1299,45 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Пригласить в семью
|
||||
</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>
|
||||
</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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return apiFetch<{ success: boolean }>(`/profile/users/${userId}/self-delete`, { method: 'POST' }, token);
|
||||
return requestAccountDeletion(userId, token);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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: 'OTP_EXPIRY_MINUTES', label: 'Срок жизни OTP', group: 'auth', type: 'number', unit: 'мин' },
|
||||
{ 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: 'PASSWORD_MIN_LENGTH', label: 'Мин. длина пароля', group: 'auth', type: 'number', unit: 'симв' },
|
||||
{ key: 'REGISTRATION_ENABLED', label: 'Регистрация включена', group: 'auth', type: 'boolean' },
|
||||
|
||||
Reference in New Issue
Block a user