'use client'; import Link from 'next/link'; import { FormEvent, useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronLeft, Download, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo'; import { useAuth } from '@/components/id/auth-provider'; import { usePublicSettings } from '@/components/id/public-settings-provider'; import { useToast } from '@/components/id/toast-provider'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { OtpInput } from '@/components/ui/otp-input'; import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { AuthTokens, LoginMethod, OtpChannel, OtpSendResponse } from '@/lib/api'; import { createQrLoginSession, pollQrLoginSession } from '@/lib/api'; import { clearOtpResendAvailableAt, formatCooldownSeconds, readOtpResendAvailableAt, useOtpResendCooldown, writeOtpResendAvailableAt } from '@/hooks/use-otp-resend-cooldown'; type Step = 'identify' | 'otp' | 'otpChannel' | 'password' | 'pin' | 'ldap' | 'qr' | 'totp'; const channelIcon: Record = { email: Mail, backupEmail: Mail, phone: Phone, backupPhone: Phone, password: KeyRound }; const channelLabel: Record = { email: 'Почта', phone: 'Телефон', backupEmail: 'Резервная почта', backupPhone: 'Резервный телефон' }; export default function LoginPage() { const router = useRouter(); const finishLoginRedirect = useCallback(() => { if (typeof window !== 'undefined') { const fedcmLoginApi = (navigator as Navigator & { login?: { setStatus?: (status: string) => void } }).login; if (window.opener && typeof fedcmLoginApi?.setStatus === 'function') { return; } } const redirectAfterLogin = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null; if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) { router.replace(redirectAfterLogin); return; } router.replace('/'); }, [router]); const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth(); const { showToast } = useToast(); const { ldapEnabled, ldapUseLdaps } = usePublicSettings(); const [authTab, setAuthTab] = useState<'email' | 'phone'>('email'); const [email, setEmail] = useState(''); const [phoneNumber, setPhoneNumber] = useState(''); const [country, setCountry] = useState(phoneCountries[0]); const [recipient, setRecipient] = useState(''); const [maskedTarget, setMaskedTarget] = useState(''); const [otp, setOtp] = useState(''); const [totpCode, setTotpCode] = useState(''); const [totpChallengeToken, setTotpChallengeToken] = useState(null); const [password, setPassword] = useState(''); const [ldapUsername, setLdapUsername] = useState(''); const [ldapPassword, setLdapPassword] = useState(''); const [pin, setPin] = useState(''); const [pendingSessionId, setPendingSessionId] = useState(null); const [methods, setMethods] = useState([]); const [otpChannels, setOtpChannels] = useState([]); const [otpBackupChannels, setOtpBackupChannels] = useState([]); const [showMethods, setShowMethods] = useState(false); const [step, setStep] = useState('identify'); const [isSubmitting, setIsSubmitting] = useState(false); const [qrPayload, setQrPayload] = useState(null); const [qrSessionId, setQrSessionId] = useState(null); const [qrExpiresAt, setQrExpiresAt] = useState(null); const [qrLoading, setQrLoading] = useState(false); const [lastOtpChannel, setLastOtpChannel] = useState(); const [otpResendAvailableAt, setOtpResendAvailableAt] = useState(null); const otpResendSecondsLeft = useOtpResendCooldown(otpResendAvailableAt); const canResendOtp = otpResendSecondsLeft <= 0 && !isSubmitting; function getIdentifier() { return authTab === 'email' ? email.trim() : toE164(country, phoneNumber); } function beginTotpStep(challengeToken: string) { setTotpChallengeToken(challengeToken); setTotpCode(''); setStep('totp'); } function finishAuth(pinVerified: boolean, sessionId: string) { if (!pinVerified) { setPendingSessionId(sessionId); setStep('pin'); } else { finishLoginRedirect(); } } function finishQrAuth(auth: AuthTokens) { void (async () => { const resolved = await applyLoginAuth(auth); finishAuth(resolved.pinVerified, resolved.sessionId); })(); } function applyOtpSendResponse(response: OtpSendResponse, loginRecipient: string, channel?: string) { setMaskedTarget(response.maskedTarget); setLastOtpChannel(channel); setOtp(''); setShowMethods(false); const resendAt = response.resendAvailableAt ?? new Date(Date.now() + 60_000).toISOString(); setOtpResendAvailableAt(resendAt); writeOtpResendAvailableAt(loginRecipient, resendAt); setStep('otp'); } async function requestLoginOtp(channel?: string, recipientOverride?: string) { setIsSubmitting(true); const loginRecipient = recipientOverride ?? recipient; try { const response = await sendLoginOtp(loginRecipient, channel); applyOtpSendResponse(response, loginRecipient, channel); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось отправить код'); } finally { setIsSubmitting(false); } } async function handleResendOtp() { if (!canResendOtp) return; await requestLoginOtp(lastOtpChannel, recipient); } function openOtpDelivery() { if (otpChannels.length > 1) { setShowMethods(false); setStep('otpChannel'); return; } void requestLoginOtp(otpChannels[0]?.channel); } useEffect(() => { if (step !== 'otp' || !recipient) return; const saved = readOtpResendAvailableAt(recipient); if (saved && new Date(saved).getTime() > Date.now()) { setOtpResendAvailableAt(saved); } }, [recipient, step]); const startQrLogin = useCallback(async () => { setQrLoading(true); try { const session = await createQrLoginSession(); setQrSessionId(session.sessionId); setQrPayload(session.qrPayload ?? null); setQrExpiresAt(session.expiresAt); setStep('qr'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось создать QR-сессию'); } finally { setQrLoading(false); } }, [showToast]); useEffect(() => { if (step !== 'qr' || !qrSessionId) return; let cancelled = false; const poll = async () => { try { const session = await pollQrLoginSession(qrSessionId); if (cancelled) return; if (session.status === 'EXPIRED') { showToast('QR-код истёк, создайте новый'); setStep('identify'); return; } if (session.status === 'APPROVED' && session.auth?.user) { finishQrAuth({ accessToken: session.auth.accessToken, refreshToken: session.auth.refreshToken, sessionId: session.auth.sessionId, pinVerified: session.auth.pinVerified, expiresAt: session.auth.expiresAt, user: session.auth.user }); } } catch { if (!cancelled) { showToast('Не удалось проверить QR-сессию'); } } }; void poll(); const timer = window.setInterval(() => void poll(), 2000); return () => { cancelled = true; window.clearInterval(timer); }; }, [applyLoginAuth, qrSessionId, showToast, step]); async function handleIdentify(event: FormEvent) { event.preventDefault(); setIsSubmitting(true); try { const identifier = getIdentifier(); if (authTab === 'phone') { const digits = phoneNumber.replace(/\D/g, ''); if (digits.length < country.maxDigits) { showToast('Введите номер телефона полностью'); return; } } else if (!identifier) { showToast('Укажите почту'); return; } setRecipient(identifier); const result = await identifyLogin(identifier); setMethods(result.methods ?? []); setOtpChannels(result.otpChannels ?? []); setOtpBackupChannels(result.otpBackupChannels ?? []); if (result.isTotpEnabled) { const challengeToken = await beginTotpLogin(identifier); beginTotpStep(challengeToken); return; } const channels = result.otpChannels ?? []; const backupChannels = result.otpBackupChannels ?? []; if (channels.length > 1) { setStep('otpChannel'); return; } if (channels.length === 1) { await requestLoginOtp(channels[0].channel, identifier); return; } // Нет основных каналов, но есть резервные — даём выбрать резервную доставку. if (backupChannels.length > 1) { setStep('otpChannel'); return; } if (backupChannels.length === 1) { await requestLoginOtp(backupChannels[0].channel, identifier); return; } if (result.hasPassword) { setStep('password'); return; } showToast('Для этого аккаунта не настроены каналы для SMS или email-кода'); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось продолжить вход'); } finally { setIsSubmitting(false); } } async function verifyOtp(code: string) { setIsSubmitting(true); try { const response = await verifyLoginOtp(recipient, code); if (response.requiresTotp && response.totpChallengeToken) { beginTotpStep(response.totpChallengeToken); return; } if (response.auth) { finishAuth(response.auth.pinVerified, response.auth.sessionId); } else { showToast('Не удалось завершить вход'); } } catch (error) { setOtp(''); showToast(error instanceof Error ? error.message : 'Неверный код'); } finally { setIsSubmitting(false); } } async function verifyTotp(code: string) { if (!totpChallengeToken) return; setIsSubmitting(true); try { const auth = await verifyTotpLogin(totpChallengeToken, code); setTotpChallengeToken(null); finishAuth(auth.pinVerified, auth.sessionId); } catch (error) { setTotpCode(''); showToast(error instanceof Error ? error.message : 'Неверный код аутентификатора'); } finally { setIsSubmitting(false); } } async function handlePasswordSubmit(event: FormEvent) { event.preventDefault(); setIsSubmitting(true); try { const auth = await loginWithPassword(recipient, password); finishAuth(auth.pinVerified, auth.sessionId); } catch (error) { showToast(error instanceof Error ? error.message : 'Неверный пароль'); } finally { setIsSubmitting(false); } } async function chooseMethod(method: LoginMethod) { setShowMethods(false); if (method.kind === 'password') { setPassword(''); setStep('password'); return; } await requestLoginOtp(method.channel); } async function handleLdapSubmit(event: FormEvent) { event.preventDefault(); setIsSubmitting(true); try { const auth = await loginWithLdap(ldapUsername.trim(), ldapPassword); finishAuth(auth.pinVerified, auth.sessionId); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось выполнить LDAP-вход'); } finally { setIsSubmitting(false); } } async function handlePinSubmit(event: FormEvent) { event.preventDefault(); if (!pendingSessionId) return; setIsSubmitting(true); try { await completePin(pendingSessionId, pin); finishLoginRedirect(); } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код'); } finally { setIsSubmitting(false); } } function resetToIdentify() { setStep('identify'); setShowMethods(false); setOtp(''); setTotpCode(''); setTotpChallengeToken(null); setOtpChannels([]); setPassword(''); setLdapUsername(''); setLdapPassword(''); setQrPayload(null); setQrSessionId(null); setQrExpiresAt(null); clearOtpResendAvailableAt(recipient); setOtpResendAvailableAt(null); setLastOtpChannel(undefined); } function renderAlternativeMethods(includeOtpRequest: boolean) { const hasAlternatives = includeOtpRequest || methods.length > 0 || otpBackupChannels.length > 0; if (!hasAlternatives) return null; return (
{showMethods ? (
{includeOtpRequest && otpChannels.length ? ( ) : null} {methods.map((method) => { const Icon = channelIcon[method.channel] ?? KeyRound; return ( ); })} {otpBackupChannels.length ? ( <>

Резервные контакты

{otpBackupChannels.map((channel) => { const Icon = channelIcon[channel.channel] ?? Mail; return ( ); })} ) : null}
) : null}
); } return (

Войдите с ID

{step !== 'identify' ? ( ) : null} {step === 'identify' ? (
setAuthTab(value as 'email' | 'phone')} className="w-full"> Почта Телефон setEmail(event.target.value)} required={authTab === 'email'} /> {ldapEnabled ? ( ) : null}
) : null} {step === 'ldap' ? (

Корпоративный вход

{ldapUseLdaps ? 'LDAPS' : 'LDAP'}

setLdapUsername(event.target.value)} autoComplete="username" required /> setLdapPassword(event.target.value)} autoComplete="current-password" required />
) : null} {step === 'otpChannel' ? (

Куда отправить код

Выберите канал доставки

{otpChannels.map((channel) => { const Icon = channelIcon[channel.channel] ?? Mail; return ( ); })} {otpBackupChannels.length ? ( <>

Резервные контакты

{otpBackupChannels.map((channel) => { const Icon = channelIcon[channel.channel] ?? Mail; return ( ); })} ) : null}
{totpChallengeToken ? ( ) : null}
) : null} {step === 'otp' ? (

Код отправлен на

{maskedTarget || recipient}

{isSubmitting ?

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

: null}
{canResendOtp ? ( ) : (

Запросить новый код через {formatCooldownSeconds(otpResendSecondsLeft)}

)}
{renderAlternativeMethods(false)} {totpChallengeToken ? ( ) : null}
) : null} {step === 'totp' ? (

Вход в аккаунт

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

{isSubmitting ?

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

: null} {renderAlternativeMethods(true)}
) : null} {step === 'password' ? (

Вход в аккаунт

{recipient}

setPassword(event.target.value)} required /> {totpChallengeToken ? ( ) : null}
) : null} {step === 'qr' && qrPayload ? (

Откройте мобильное приложение ID, отсканируйте код и подтвердите вход на этом устройстве.

{qrExpiresAt ? (

Код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))}

) : null}
) : null} {step === 'pin' && pendingSessionId ? (
setPin(event.target.value)} required minLength={4} maxLength={6} />
) : null} {step === 'identify' ? (
) : null} Скачать приложение
); }