761 lines
34 KiB
TypeScript
761 lines
34 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
ChevronRight,
|
||
Clock3,
|
||
Fingerprint,
|
||
KeyRound,
|
||
Laptop,
|
||
LogOut,
|
||
Mail,
|
||
Monitor,
|
||
type LucideIcon,
|
||
Phone,
|
||
QrCode,
|
||
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 { DeviceLinkQrDialog } from '@/components/id/device-link-qr-dialog';
|
||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||
import { cn } from '@/lib/utils';
|
||
import { QRCodeSVG } from 'qrcode.react';
|
||
|
||
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' | 'totpSetup' | 'totpDisable' | null;
|
||
|
||
const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable'>, { 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<ActiveDevice[]>([]);
|
||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||
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 [totpEnabled, setTotpEnabled] = useState(false);
|
||
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
|
||
const [totpCode, setTotpCode] = useState('');
|
||
const [passwordVerifyMethod, setPasswordVerifyMethod] = useState<PasswordVerifyMethod>('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 [deviceLinkOpen, setDeviceLinkOpen] = 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<TotpStatusResponse>(`/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<TotpSetupResponse>(`/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<DialogMode, null>) {
|
||
setDialogValue('');
|
||
if (mode === 'password') {
|
||
resetPasswordDialog();
|
||
}
|
||
setDialogMode(mode);
|
||
}
|
||
|
||
return (
|
||
<IdShell active="/security">
|
||
<p className="text-sm text-[#667085]">Безопасность</p>
|
||
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства и сессии</h1>
|
||
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
|
||
|
||
<div className="mt-8 space-y-2">
|
||
<h2 className="text-lg font-medium">Активные сессии</h2>
|
||
{isSecurityLoading ? (
|
||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем сессии...</div>
|
||
) : sessions.length ? (
|
||
sessions.map((session) => {
|
||
const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop;
|
||
const isCurrent = session.id === currentSessionId;
|
||
return (
|
||
<div key={session.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">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium">{sessionDeviceLabel(session)}</span>
|
||
{isCurrent ? (
|
||
<span className="rounded-full bg-[#e8f4ff] px-2 py-0.5 text-[11px] font-semibold text-[#3390ec]">
|
||
Текущая
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<span className="text-xs text-[#667085]">{sessionMetaLine(session)}</span>
|
||
</div>
|
||
{!isCurrent ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]"
|
||
onClick={() => void revokeSession(session.id)}
|
||
>
|
||
Выйти
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных сессий пока нет</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-8 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-lg font-medium">Другие устройства</h2>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="rounded-xl"
|
||
disabled={!user || !token || isSecurityLoading}
|
||
onClick={() => setDeviceLinkOpen(true)}
|
||
>
|
||
<QrCode className="mr-2 h-4 w-4" />
|
||
Подключить новое
|
||
</Button>
|
||
</div>
|
||
{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>
|
||
<p className="text-sm text-[#667085]">Завершит сессии на всех устройствах, кроме текущего. Выход с этого устройства — через меню профиля.</p>
|
||
</div>
|
||
|
||
<section className="mt-10">
|
||
<h2 className="text-2xl font-medium">Дополнительная защита</h2>
|
||
<div className="mt-4 space-y-2">
|
||
<button type="button" onClick={() => void openTotpDialog()} disabled={isDialogSaving} className="flex w-full items-center gap-4 rounded-[18px] bg-[#f8f9fb] p-4 text-left hover:bg-[#f1f2f6] disabled:opacity-60">
|
||
<ShieldCheck className="h-5 w-5" />
|
||
<div className="flex-1">
|
||
<p className="font-medium">Приложение-аутентификатор</p>
|
||
<p className="text-sm text-[#667085]">
|
||
{totpEnabled ? 'Google Authenticator и аналоги подключены' : 'Подключите OTP-коды через Google Authenticator'}
|
||
</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('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?.hasPassword
|
||
? 'Пароль установлен. Для смены или удаления потребуется подтверждение.'
|
||
: 'Добавить пароль как способ входа'}
|
||
</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-start gap-4 border-b border-[#eceef4] pb-3">
|
||
<Clock3 className="mt-0.5 h-5 w-5 shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p>{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</p>
|
||
<p className="mt-1 text-sm text-[#667085]">
|
||
{[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)]
|
||
.filter(Boolean)
|
||
.join(' · ')}
|
||
</p>
|
||
</div>
|
||
<span className="shrink-0 text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-[#667085]">История входов пока пуста</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<Dialog open={dialogMode !== null} onOpenChange={(open) => {
|
||
if (!open) {
|
||
setDialogMode(null);
|
||
if (dialogMode !== 'totpSetup' || totpEnabled) {
|
||
setTotpSetup(null);
|
||
}
|
||
setTotpCode('');
|
||
resetPasswordDialog();
|
||
}
|
||
}}>
|
||
<DialogContent>
|
||
{dialogMode === 'totpSetup' && totpSetup ? (
|
||
<>
|
||
<DialogHeader>
|
||
<DialogTitle>Подключить аутентификатор</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-sm text-[#667085]">Отсканируйте QR-код в Google Authenticator или аналоге, затем введите 6-значный код.</p>
|
||
<div className="flex justify-center rounded-2xl bg-white p-4">
|
||
<QRCodeSVG value={totpSetup.otpauthUrl} size={180} />
|
||
</div>
|
||
<p className="break-all rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm text-[#667085]">
|
||
Ключ: <span className="font-mono text-[#111827]">{totpSetup.secret}</span>
|
||
</p>
|
||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpSetup(code)} disabled={isDialogSaving} />
|
||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||
</>
|
||
) : null}
|
||
|
||
{dialogMode === 'totpDisable' ? (
|
||
<>
|
||
<DialogHeader>
|
||
<DialogTitle>Отключить аутентификатор</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-sm text-[#667085]">Введите код из приложения, чтобы отключить двухфакторную аутентификацию.</p>
|
||
<OtpInput variant="light" value={totpCode} onChange={setTotpCode} onComplete={(code) => void submitTotpDisable(code)} disabled={isDialogSaving} />
|
||
{isDialogSaving ? <p className="text-center text-sm text-[#667085]">Проверяем код...</p> : null}
|
||
</>
|
||
) : null}
|
||
|
||
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
|
||
<>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{dialogMode === 'password' && user?.hasPassword ? (
|
||
<div className="space-y-4">
|
||
<p className="text-sm leading-relaxed text-[#667085]">{passwordChangeHint}</p>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ 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) => (
|
||
<button
|
||
key={method.id}
|
||
type="button"
|
||
className={cn(
|
||
'rounded-full px-3 py-1.5 text-sm transition',
|
||
passwordVerifyMethod === method.id
|
||
? 'bg-[#111827] text-white'
|
||
: 'bg-[#f4f5f8] text-[#667085] hover:bg-[#eceef4]'
|
||
)}
|
||
onClick={() => {
|
||
setPasswordVerifyMethod(method.id);
|
||
setPasswordOtpSent(false);
|
||
setPasswordOtpCode('');
|
||
setPasswordTotpCode('');
|
||
setCurrentPassword('');
|
||
}}
|
||
>
|
||
{method.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{passwordVerifyMethod === 'current' ? (
|
||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||
<Input
|
||
className="border-0 bg-transparent focus-visible:ring-0"
|
||
type="password"
|
||
placeholder="Текущий пароль"
|
||
value={currentPassword}
|
||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? (
|
||
<div className="space-y-3">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
className="w-full rounded-xl"
|
||
disabled={passwordOtpSending}
|
||
onClick={() => void sendPasswordVerificationOtp()}
|
||
>
|
||
{passwordOtpSending ? 'Отправляем код...' : `Отправить код ${passwordVerifyMethod === 'sms' ? 'в SMS' : 'на почту'}`}
|
||
</Button>
|
||
{passwordOtpSent ? (
|
||
<p className="text-center text-sm text-[#667085]">
|
||
Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''}
|
||
</p>
|
||
) : null}
|
||
<OtpInput
|
||
variant="light"
|
||
value={passwordOtpCode}
|
||
onChange={setPasswordOtpCode}
|
||
disabled={!passwordOtpSent || isDialogSaving}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{passwordVerifyMethod === 'totp' ? (
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-[#667085]">Введите код из приложения-аутентификатора</p>
|
||
<OtpInput variant="light" value={passwordTotpCode} onChange={setPasswordTotpCode} disabled={isDialogSaving} />
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-2xl bg-[#f4f5f8] px-4">
|
||
<Input
|
||
className="border-0 bg-transparent focus-visible:ring-0"
|
||
type="password"
|
||
placeholder="Новый пароль (мин. 8 символов)"
|
||
value={newPassword}
|
||
onChange={(event) => setNewPassword(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 pt-1">
|
||
<Button
|
||
className="w-full rounded-xl"
|
||
disabled={isDialogSaving || newPassword.trim().length < 8 || !isPasswordVerificationReady()}
|
||
onClick={() => void submitPasswordChange()}
|
||
>
|
||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить новый пароль'}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="w-full rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
|
||
disabled={isDialogSaving || !isPasswordVerificationReady()}
|
||
onClick={() => void submitPasswordRemove()}
|
||
>
|
||
Удалить пароль
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
|
||
<ShieldCheck className="h-5 w-5 text-[#667085]" />
|
||
{dialogMode === 'pin' ? (
|
||
<PinInput
|
||
className="border-0 bg-transparent focus:ring-0"
|
||
placeholder={dialogConfig.pin.placeholder}
|
||
value={dialogValue}
|
||
onChange={setDialogValue}
|
||
maxLength={6}
|
||
/>
|
||
) : (
|
||
<Input
|
||
className="border-0 bg-transparent focus:ring-0"
|
||
type={dialogMode === 'password' ? 'password' : 'text'}
|
||
placeholder={dialogConfig[dialogMode].placeholder}
|
||
value={dialogValue}
|
||
onChange={(event) => setDialogValue(event.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
<Button
|
||
className="mt-5 w-full"
|
||
onClick={submitDialog}
|
||
disabled={isDialogSaving || (dialogMode === 'pin' ? !isPinInputComplete(dialogValue) : !dialogValue.trim())}
|
||
>
|
||
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{user && token ? (
|
||
<DeviceLinkQrDialog
|
||
open={deviceLinkOpen}
|
||
userId={user.id}
|
||
token={token}
|
||
onOpenChange={setDeviceLinkOpen}
|
||
onLinked={() => void loadSecurity()}
|
||
/>
|
||
) : null}
|
||
</IdShell>
|
||
);
|
||
}
|