Files
IdP/apps/frontend/app/security/page.tsx
2026-07-07 09:01:49 +03:00

761 lines
34 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 {
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>
);
}