'use client'; import { useCallback, useEffect, useState } from 'react'; 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 { PinInput } from '@/components/ui/pin-input'; import { isPinInputComplete } from '@/lib/pin-input'; import { OtpInput } from '@/components/ui/otp-input'; import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api'; import { markPinIdleWatch } from '@/lib/pin-idle-storage'; import { formatUserAgentLabel } from '@/lib/device-client'; import { cn } from '@/lib/utils'; import { QRCodeSVG } from 'qrcode.react'; 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 = { WEB: Monitor, DESKTOP: Monitor, IOS: Smartphone, ANDROID: Smartphone, TV: Tv, SMART_SPEAKER: Speaker, OTHER: Laptop }; type DialogMode = 'pin' | 'password' | 'backupEmail' | 'backupPhone' | 'totpSetup' | 'totpDisable' | null; const dialogConfig: Record, { title: string; placeholder: string; type?: string }> = { pin: { title: 'Установить PIN-код', placeholder: 'PIN из 4-6 цифр' }, password: { title: 'Установить пароль', placeholder: 'Новый пароль (мин. 8 символов)', type: 'password' }, backupEmail: { title: 'Резервная почта', placeholder: 'backup@example.com', type: 'email' }, backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' } }; const passwordChangeHint = 'Чтобы сменить или удалить пароль, подтвердите личность одним из доступных способов ниже.'; type PasswordVerifyMethod = 'current' | 'sms' | 'email' | 'totp'; export default function SecurityPage() { const { user, token, refreshProfile } = useAuth(); const { isReady } = useRequireAuth(); const { showToast } = useToast(); const userId = user?.id; const [devices, setDevices] = useState([]); const [sessions, setSessions] = useState([]); const [history, setHistory] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [isSecurityLoading, setIsSecurityLoading] = useState(true); const [isRevoking, setIsRevoking] = useState(false); const [dialogMode, setDialogMode] = useState(null); const [dialogValue, setDialogValue] = useState(''); const [isDialogSaving, setIsDialogSaving] = useState(false); const [totpEnabled, setTotpEnabled] = useState(false); const [totpSetup, setTotpSetup] = useState(null); const [totpCode, setTotpCode] = useState(''); const [passwordVerifyMethod, setPasswordVerifyMethod] = useState('current'); const [currentPassword, setCurrentPassword] = useState(''); const [passwordOtpCode, setPasswordOtpCode] = useState(''); const [passwordOtpSent, setPasswordOtpSent] = useState(false); const [passwordOtpMasked, setPasswordOtpMasked] = useState(''); const [passwordTotpCode, setPasswordTotpCode] = useState(''); const [newPassword, setNewPassword] = useState(''); const [passwordOtpSending, setPasswordOtpSending] = useState(false); const hasSmsContact = Boolean(user?.phone || user?.backupPhone); const hasEmailContact = Boolean(user?.email || user?.backupEmail); const loadSecurity = useCallback(async () => { if (!userId || !token) return; setIsSecurityLoading(true); try { const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([ apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${userId}/devices`, {}, token), apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${userId}/sessions`, {}, token), apiFetch<{ events: SignInEvent[] }>(`/security/users/${userId}/sign-in-history`, {}, token), apiFetch(`/security/users/${userId}/totp/status`, {}, token) ]); setDevices(devicesResponse.devices ?? []); setSessions(sessionsResponse.sessions ?? []); setHistory(historyResponse.events ?? []); setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY)); setTotpEnabled(Boolean(totpResponse.isEnabled)); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось загрузить безопасность'); if (message) showToast(message); } finally { setIsSecurityLoading(false); } }, [showToast, token, userId]); useEffect(() => { if (!isReady || !userId) return; void loadSecurity(); }, [isReady, loadSecurity, userId]); async function revokeSession(sessionId: string) { if (!user || !token) return; try { await apiFetch(`/security/users/${user.id}/sessions/${sessionId}/revoke`, { method: 'POST' }, token); setSessions((current) => current.filter((session) => session.id !== sessionId)); showToast('Сессия завершена'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию'); } } function sessionDeviceLabel(session: ActiveSession) { return session.deviceName ?? formatUserAgentLabel(session.userAgent) ?? 'Неизвестное устройство'; } function sessionMetaLine(session: ActiveSession) { const parts = [session.ipAddress ?? 'IP не сохранён']; const agentLabel = formatUserAgentLabel(session.userAgent); if (agentLabel && agentLabel !== session.deviceName) { parts.push(agentLabel); } parts.push(formatDate(session.updatedAt)); return parts.join(' · '); } 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('Выход выполнен на всех остальных устройствах'); await loadSecurity(); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось выйти везде'); } finally { setIsRevoking(false); } } async function openTotpDialog() { if (!user || !token) return; if (totpEnabled) { setTotpCode(''); setDialogMode('totpDisable'); return; } if (totpSetup) { setTotpCode(''); setDialogMode('totpSetup'); return; } setIsDialogSaving(true); try { const setup = await apiFetch(`/security/users/${user.id}/totp/setup`, { method: 'POST' }, token); setTotpSetup(setup); setTotpCode(''); setDialogMode('totpSetup'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось начать настройку аутентификатора'); } finally { setIsDialogSaving(false); } } async function submitTotpSetup(code: string) { if (!user || !token) return; setIsDialogSaving(true); try { await apiFetch(`/security/users/${user.id}/totp/enable`, { method: 'POST', body: JSON.stringify({ code }) }, token); setTotpEnabled(true); setDialogMode(null); setTotpSetup(null); setTotpCode(''); showToast('Приложение-аутентификатор подключено'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось подтвердить код'); } finally { setIsDialogSaving(false); } } async function submitTotpDisable(code: string) { if (!user || !token) return; setIsDialogSaving(true); try { await apiFetch(`/security/users/${user.id}/totp/disable`, { method: 'POST', body: JSON.stringify({ code }) }, token); setTotpEnabled(false); setDialogMode(null); setTotpCode(''); showToast('Приложение-аутентификатор отключено'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось отключить аутентификатор'); } finally { setIsDialogSaving(false); } } function buildPasswordVerification() { if (passwordVerifyMethod === 'current') { return { currentPassword: currentPassword.trim() }; } if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') { return { otpCode: passwordOtpCode.trim(), otpChannel: passwordVerifyMethod }; } return { totpCode: passwordTotpCode.trim() }; } function isPasswordVerificationReady() { if (passwordVerifyMethod === 'current') return currentPassword.trim().length > 0; if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') { return passwordOtpSent && passwordOtpCode.trim().length === 6; } return passwordTotpCode.trim().length === 6; } async function sendPasswordVerificationOtp() { if (!user || !token || (passwordVerifyMethod !== 'sms' && passwordVerifyMethod !== 'email')) return; setPasswordOtpSending(true); try { const response = await apiFetch<{ maskedTarget?: string }>( `/profile/users/${user.id}/password/otp`, { method: 'POST', body: JSON.stringify({ channel: passwordVerifyMethod }) }, token ); setPasswordOtpSent(true); setPasswordOtpMasked(response.maskedTarget ?? ''); setPasswordOtpCode(''); showToast(`Код отправлен${response.maskedTarget ? ` на ${response.maskedTarget}` : ''}`); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить код') ?? 'Ошибка'); } finally { setPasswordOtpSending(false); } } async function submitPasswordChange() { if (!user || !token || !newPassword.trim()) { showToast('Укажите новый пароль'); return; } if (!isPasswordVerificationReady()) { showToast('Подтвердите личность перед сменой пароля'); return; } setIsDialogSaving(true); try { await apiFetch( `/profile/users/${user.id}/password`, { method: 'PATCH', body: JSON.stringify({ newPassword: newPassword.trim(), ...buildPasswordVerification() }) }, token ); showToast('Пароль изменён'); resetPasswordDialog(); setDialogMode(null); await refreshProfile(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось сменить пароль') ?? 'Ошибка'); } finally { setIsDialogSaving(false); } } async function submitPasswordRemove() { if (!user || !token) return; if (!isPasswordVerificationReady()) { showToast('Подтвердите личность перед удалением пароля'); return; } setIsDialogSaving(true); try { await apiFetch( `/profile/users/${user.id}/password`, { method: 'DELETE', body: JSON.stringify(buildPasswordVerification()) }, token ); showToast('Пароль удалён'); resetPasswordDialog(); setDialogMode(null); await refreshProfile(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить пароль') ?? 'Ошибка'); } finally { setIsDialogSaving(false); } } function resetPasswordDialog() { setPasswordVerifyMethod('current'); setCurrentPassword(''); setPasswordOtpCode(''); setPasswordOtpSent(false); setPasswordOtpMasked(''); setPasswordTotpCode(''); setNewPassword(''); } 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); markPinIdleWatch(user.id); showToast('PIN-код установлен'); } else if (dialogMode === 'password') { await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token); showToast('Пароль установлен'); await refreshProfile(); } 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) { setDialogValue(''); if (mode === 'password') { resetPasswordDialog(); } setDialogMode(mode); } return (

Безопасность

Устройства и сессии

На них вы вошли в ID и получаете уведомления безопасности от сервисов

Активные сессии

{isSecurityLoading ? (
Загружаем сессии...
) : sessions.length ? ( sessions.map((session) => { const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop; const isCurrent = session.id === currentSessionId; return (
{sessionDeviceLabel(session)} {isCurrent ? ( Текущая ) : null}
{sessionMetaLine(session)}
{!isCurrent ? ( ) : null}
); }) ) : (
Активных сессий пока нет
)}

Другие устройства

{isSecurityLoading ? (
Загружаем устройства...
) : devices.length ? ( devices.map((device) => { const Icon = deviceIcons[device.type] ?? Laptop; return (
{device.name} {device.lastIp ?? 'IP не сохранён'} · {formatDate(device.lastSeenAt)}
); }) ) : (
Активных устройств пока нет
)}

Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства — через меню профиля.

Дополнительная защита

Способ входа в профиль

Способы восстановления

Активность

{isSecurityLoading ? (
Загружаем историю входов...
) : history.length ? ( history.map((item) => (

{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}

{[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)] .filter(Boolean) .join(' · ')}

{formatDate(item.createdAt)}
)) ) : (
История входов пока пуста
)}
{ if (!open) { setDialogMode(null); if (dialogMode !== 'totpSetup' || totpEnabled) { setTotpSetup(null); } setTotpCode(''); resetPasswordDialog(); } }}> {dialogMode === 'totpSetup' && totpSetup ? ( <> Подключить аутентификатор

Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.

Ключ: {totpSetup.secret}

void submitTotpSetup(code)} disabled={isDialogSaving} /> {isDialogSaving ?

Проверяем код...

: null} ) : null} {dialogMode === 'totpDisable' ? ( <> Отключить аутентификатор

Введите код из приложения, чтобы отключить двухфакторную аутентификацию.

void submitTotpDisable(code)} disabled={isDialogSaving} /> {isDialogSaving ?

Проверяем код...

: null} ) : null} {dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? ( <> {dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title} {dialogMode === 'password' && user?.hasPassword ? (

{passwordChangeHint}

{[ { id: 'current' as const, label: 'Текущий пароль' }, ...(hasSmsContact ? [{ id: 'sms' as const, label: 'SMS' }] : []), ...(hasEmailContact ? [{ id: 'email' as const, label: 'Почта' }] : []), ...(totpEnabled ? [{ id: 'totp' as const, label: 'Аутентификатор' }] : []) ].map((method) => ( ))}
{passwordVerifyMethod === 'current' ? (
setCurrentPassword(event.target.value)} />
) : null} {passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? (
{passwordOtpSent ? (

Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''}

) : null}
) : null} {passwordVerifyMethod === 'totp' ? (

Введите код из приложения-аутентификатора

) : null}
setNewPassword(event.target.value)} />
) : ( <>
{dialogMode === 'pin' ? ( ) : ( setDialogValue(event.target.value)} /> )}
)} ) : null}
); }