278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
'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>
|
||
);
|
||
}
|