first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
'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>
);
}