Files
IdP/apps/frontend/app/security/page.tsx
2026-06-24 14:37:15 +03:00

278 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ChevronRight,
Clock3,
Fingerprint,
KeyRound,
Laptop,
LogOut,
Mail,
Monitor,
type LucideIcon,
Phone,
ShieldCheck,
Smartphone,
Speaker,
Tv
} from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { IdShell } from '@/components/id/shell';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ActiveDevice, apiFetch, SignInEvent } from '@/lib/api';
import { useRequireAuth } from '@/hooks/use-require-auth';
function formatDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
const deviceIcons: Record<string, LucideIcon> = {
WEB: Monitor,
DESKTOP: Monitor,
IOS: Smartphone,
ANDROID: Smartphone,
TV: Tv,
SMART_SPEAKER: Speaker,
OTHER: Laptop
};
type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | null;
const dialogConfig: Record<Exclude<DialogMode, null>, { title: string; placeholder: string; type: string }> = {
pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр', type: 'password' },
password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' },
backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' },
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
};
export default function SecurityPage() {
const router = useRouter();
const { user, token, logout } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]);
const [history, setHistory] = useState<SignInEvent[]>([]);
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
const [isRevoking, setIsRevoking] = useState(false);
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [dialogValue, setDialogValue] = useState('');
const [isDialogSaving, setIsDialogSaving] = useState(false);
const loadSecurity = useCallback(async () => {
if (!user || !token) return;
setIsSecurityLoading(true);
try {
const [devicesResponse, historyResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token)
]);
setDevices(devicesResponse.devices ?? []);
setHistory(historyResponse.events ?? []);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
} finally {
setIsSecurityLoading(false);
}
}, [showToast, token, user]);
useEffect(() => {
if (!isReady || !user) return;
void loadSecurity();
}, [isReady, loadSecurity, user]);
async function revokeDevice(deviceId: string) {
if (!user || !token) return;
try {
await apiFetch(`/security/users/${user.id}/devices/${deviceId}/revoke`, { method: 'POST' }, token);
setDevices((current) => current.filter((device) => device.id !== deviceId));
showToast('Сессии устройства завершены');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
}
}
async function revokeAllSessions() {
if (!user || !token) return;
setIsRevoking(true);
try {
await apiFetch(`/security/users/${user.id}/revoke-all-sessions`, { method: 'POST' }, token);
showToast('Все сессии завершены');
logout();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось выйти везде');
} finally {
setIsRevoking(false);
}
}
async function submitDialog() {
if (!user || !token || !dialogMode) return;
setIsDialogSaving(true);
try {
if (dialogMode === 'pin') {
await apiFetch(`/security/users/${user.id}/pin/setup`, { method: 'POST', body: JSON.stringify({ pin: dialogValue.trim() }) }, token);
showToast('PIN-код установлен');
} else if (dialogMode === 'password') {
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
showToast('Пароль установлен');
} else if (dialogMode === 'backupEmail') {
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
showToast('Резервная почта добавлена');
} else if (dialogMode === 'backupPhone') {
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupPhone: dialogValue.trim() }) }, token);
showToast('Резервный телефон добавлен');
}
setDialogMode(null);
setDialogValue('');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось сохранить');
} finally {
setIsDialogSaving(false);
}
}
function openDialog(mode: Exclude<DialogMode, null>) {
setDialogValue('');
setDialogMode(mode);
}
return (
<IdShell active="/security">
<p className="text-sm text-[#667085]">Безопасность</p>
<h1 className="text-4xl font-medium tracking-tight">Устройства</h1>
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
<div className="mt-8 space-y-2">
{isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
) : devices.length ? (
devices.map((device) => {
const Icon = deviceIcons[device.type] ?? Laptop;
return (
<div key={device.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<span className="block truncate font-medium">{device.name}</span>
<span className="text-xs text-[#667085]">{device.lastIp ?? 'IP не сохранён'} · {formatDate(device.lastSeenAt)}</span>
</div>
<Button variant="ghost" size="sm" className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]" onClick={() => revokeDevice(device.id)}>
Выйти
</Button>
</div>
);
})
) : (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных устройств пока нет</div>
)}
</div>
<div className="mt-6 space-y-1">
<button type="button" onClick={revokeAllSessions} disabled={isRevoking || !user} className="flex w-full items-center gap-4 border-t border-[#eceef4] py-4 text-left disabled:opacity-60">
<LogOut className="h-6 w-6" />
<span>{isRevoking ? 'Выходим...' : 'Выйти везде'}</span>
</button>
</div>
<section className="mt-10">
<h2 className="text-2xl font-medium">Способ входа в профиль</h2>
<div className="mt-4 space-y-2">
<button type="button" onClick={() => openDialog('pin')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
<Fingerprint className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">PIN-код</p>
<p className="text-sm text-[#667085]">Установить или изменить PIN для входа</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
<button type="button" onClick={() => openDialog('password')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
<KeyRound className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Пароль</p>
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
</div>
</section>
<section className="mt-10">
<h2 className="text-2xl font-medium">Способы восстановления</h2>
<div className="mt-4 space-y-2">
<button type="button" onClick={() => openDialog('backupEmail')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
<Mail className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Резервная почта</p>
<p className="text-sm text-[#667085]">{user?.backupEmail || 'Не добавлена'}</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
<button type="button" onClick={() => openDialog('backupPhone')} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6]">
<Phone className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Резервный телефон</p>
<p className="text-sm text-[#667085]">{user?.backupPhone || 'Не добавлен'}</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
</div>
</section>
<section className="mt-10">
<h2 className="text-2xl font-medium">Активность</h2>
<div className="mt-4 space-y-3">
{isSecurityLoading ? (
<div className="text-[#667085]">Загружаем историю входов...</div>
) : history.length ? (
history.map((item) => (
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
<Clock3 className="h-5 w-5" />
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
</div>
))
) : (
<div className="text-[#667085]">История входов пока пуста</div>
)}
</div>
</section>
<Dialog open={dialogMode !== null} onOpenChange={(open) => (open ? null : setDialogMode(null))}>
<DialogContent>
{dialogMode ? (
<>
<DialogHeader>
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
<ShieldCheck className="h-5 w-5 text-[#667085]" />
<Input
className="border-0 bg-transparent focus:ring-0"
type={dialogConfig[dialogMode].type}
placeholder={dialogConfig[dialogMode].placeholder}
value={dialogValue}
onChange={(event) => setDialogValue(event.target.value)}
/>
</div>
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
</Button>
</>
) : null}
</DialogContent>
</Dialog>
</IdShell>
);
}