Files
IdP/apps/frontend/app/auth/login/page.tsx
2026-06-26 13:56:54 +03:00

1291 lines
28 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 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<string, typeof Mail> = {
email: Mail,
backupEmail: Mail,
phone: Phone,
backupPhone: Phone,
password: KeyRound
};
const channelLabel: Record<string, string> = {
email: 'Почта',
phone: 'Телефон',
backupEmail: 'Резервная почта',
backupPhone: 'Резервный телефон'
};
export default function LoginPage() {
const router = useRouter();
const finishLoginRedirect = useCallback(() => {
const redirectAfterLogin =
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('redirect') : null;
if (redirectAfterLogin && redirectAfterLogin.startsWith('/')) {
router.push(redirectAfterLogin);
return;
}
router.push('/');
}, [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<string | null>(null);
const [password, setPassword] = useState('');
const [ldapUsername, setLdapUsername] = useState('');
const [ldapPassword, setLdapPassword] = useState('');
const [pin, setPin] = useState('');
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [methods, setMethods] = useState<LoginMethod[]>([]);
const [otpChannels, setOtpChannels] = useState<OtpChannel[]>([]);
const [showMethods, setShowMethods] = useState(false);
const [step, setStep] = useState<Step>('identify');
const [isSubmitting, setIsSubmitting] = useState(false);
const [qrPayload, setQrPayload] = useState<string | null>(null);
const [qrSessionId, setQrSessionId] = useState<string | null>(null);
const [qrExpiresAt, setQrExpiresAt] = useState<string | null>(null);
const [qrLoading, setQrLoading] = useState(false);
const [lastOtpChannel, setLastOtpChannel] = useState<string | undefined>();
const [otpResendAvailableAt, setOtpResendAvailableAt] = useState<string | null>(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) {
const resolved = 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 deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser';
const session = await createQrLoginSession(deviceName);
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<HTMLFormElement>) {
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 ?? []);
if (result.isTotpEnabled) {
const challengeToken = await beginTotpLogin(identifier);
beginTotpStep(challengeToken);
return;
}
const channels = result.otpChannels ?? [];
if (channels.length > 1) {
setStep('otpChannel');
return;
}
if (channels.length === 1) {
await requestLoginOtp(channels[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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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;
if (!hasAlternatives) return null;
return (
<div className="mt-6">
<Button type="button" variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setShowMethods((prev) => !prev)}>
<ShieldQuestion className="h-5 w-5" />
Другой способ входа
</Button>
{showMethods ? (
<div className="mt-3 space-y-2">
{includeOtpRequest && otpChannels.length ? (
<button
type="button"
onClick={() => openOtpDelivery()}
disabled={isSubmitting}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<Mail className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">Получить код на {otpChannels.length > 1 ? 'почту или телефон' : otpChannels[0]?.masked}</span>
</button>
) : null}
{methods.map((method) => {
const Icon = channelIcon[method.channel] ?? KeyRound;
return (
<button
key={`${method.kind}-${method.channel}`}
type="button"
onClick={() => void chooseMethod(method)}
disabled={isSubmitting}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<Icon className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] px-5">
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 pb-8 pt-10 text-white shadow-2xl">
<div className="mb-8 flex flex-col items-center text-center">
<BrandLogo size="lg" variant="light" />
<h1 className="mt-8 text-xl font-bold">Войдите с ID</h1>
</div>
{step !== 'identify' ? (
<button type="button" onClick={resetToIdentify} className="mb-4 flex items-center gap-1 text-sm text-[#b9bdc9] hover:text-white">
<ChevronLeft className="h-4 w-4" />
Изменить аккаунт
</button>
) : null}
{step === 'identify' ? (
<form onSubmit={handleIdentify}>
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="email">Почта</TabsTrigger>
<TabsTrigger value="phone">Телефон</TabsTrigger>
</TabsList>
<TabsContent value="email">
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="name@example.com"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required={authTab === 'email'}
/>
</TabsContent>
<TabsContent value="phone">
<PhoneInput country={country} value={phoneNumber} onCountryChange={setCountry} onValueChange={setPhoneNumber} />
</TabsContent>
</Tabs>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Проверяем...' : 'Далее'}
</Button>
<Button
type="button"
variant="outline"
size="lg"
className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]"
disabled={qrLoading}
onClick={() => void startQrLogin()}
>
<QrCode className="h-5 w-5" />
{qrLoading ? 'Создаём QR...' : 'Войти по QR-коду'}
</Button>
{ldapEnabled ? (
<Button
type="button"
variant="outline"
size="lg"
className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]"
onClick={() => {
setLdapUsername('');
setLdapPassword('');
setStep('ldap');
}}
>
<Network className="h-5 w-5" />
{ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
</Button>
) : null}
</form>
) : null}
{step === 'ldap' ? (
<form onSubmit={handleLdapSubmit}>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Корпоративный вход</p>
<p className="truncate text-sm font-semibold">{ldapUseLdaps ? 'LDAPS' : 'LDAP'}</p>
</div>
</div>
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Логин LDAP"
value={ldapUsername}
onChange={(event) => setLdapUsername(event.target.value)}
autoComplete="username"
required
/>
<Input
className="mt-3 h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Пароль LDAP"
type="password"
value={ldapPassword}
onChange={(event) => setLdapPassword(event.target.value)}
autoComplete="current-password"
required
/>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Проверяем...' : ldapUseLdaps ? 'Войти через LDAPS' : 'Войти через LDAP'}
</Button>
</form>
) : null}
{step === 'otpChannel' ? (
<div>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<UserRound className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Куда отправить код</p>
<p className="truncate text-sm font-semibold">Выберите канал доставки</p>
</div>
</div>
<div className="space-y-2">
{otpChannels.map((channel) => {
const Icon = channelIcon[channel.channel] ?? Mail;
return (
<button
key={channel.channel}
type="button"
disabled={isSubmitting}
onClick={() => void requestLoginOtp(channel.channel)}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<Icon className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">
{channelLabel[channel.channel] ?? 'Код'} {channel.masked}
</span>
</button>
);
})}
</div>
{totpChallengeToken ? (
<Button type="button" variant="outline" size="lg" className="mt-6 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('totp')}>
Вернуться к аутентификатору
</Button>
) : null}
</div>
) : null}
{step === 'otp' ? (
<div>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<UserRound className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Код отправлен на</p>
<p className="truncate text-sm font-semibold">{maskedTarget || recipient}</p>
</div>
</div>
<OtpInput value={otp} onChange={setOtp} onComplete={verifyOtp} disabled={isSubmitting} />
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
<div className="mt-4 text-center">
{canResendOtp ? (
<button
type="button"
onClick={() => void handleResendOtp()}
className="text-sm text-[#b9bdc9] underline-offset-4 transition hover:text-white hover:underline"
>
Запросить новый код
</button>
) : (
<p className="text-sm text-[#b9bdc9]">
Запросить новый код через {formatCooldownSeconds(otpResendSecondsLeft)}
</p>
)}
</div>
{renderAlternativeMethods(false)}
{totpChallengeToken ? (
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('totp')}>
Войти через аутентификатор
</Button>
) : null}
</div>
) : null}
{step === 'totp' ? (
<div>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Вход в аккаунт</p>
<p className="truncate text-sm font-semibold">Код из приложения-аутентификатора</p>
</div>
</div>
<OtpInput value={totpCode} onChange={setTotpCode} onComplete={verifyTotp} disabled={isSubmitting} />
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
{renderAlternativeMethods(true)}
</div>
) : null}
{step === 'password' ? (
<form onSubmit={handlePasswordSubmit}>
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
<UserRound className="h-5 w-5" />
</div>
<div className="min-w-0 text-left">
<p className="text-sm text-[#b9bdc9]">Вход в аккаунт</p>
<p className="truncate text-sm font-semibold">{recipient}</p>
</div>
</div>
<Input
className="h-[58px] border-[#555762] bg-transparent text-lg text-white placeholder:text-[#8f92a0]"
placeholder="Пароль"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<Button variant="white" size="lg" className="mt-8 w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Входим...' : 'Войти'}
</Button>
{totpChallengeToken ? (
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('totp')}>
Войти через аутентификатор
</Button>
) : null}
</form>
) : null}
{step === 'qr' && qrPayload ? (
<div className="text-center">
<div className="mx-auto flex w-fit rounded-[24px] bg-white p-4">
<QRCodeSVG value={qrPayload} size={220} />
</div>
<p className="mt-5 text-sm leading-relaxed text-[#b9bdc9]">
Откройте мобильное приложение ID, отсканируйте код и подтвердите вход на этом устройстве.
</p>
{qrExpiresAt ? (
<p className="mt-2 text-xs text-[#8f92a0]">Код действует до {new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(qrExpiresAt))}</p>
) : null}
<Button type="button" variant="outline" size="lg" className="mt-6 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" disabled={qrLoading} onClick={() => void startQrLogin()}>
{qrLoading ? 'Обновляем QR...' : 'Обновить QR-код'}
</Button>
</div>
) : null}
{step === 'pin' && pendingSessionId ? (
<form onSubmit={handlePinSubmit} className="space-y-3">
<Input
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
placeholder="PIN"
value={pin}
onChange={(event) => setPin(event.target.value)}
required
minLength={4}
maxLength={6}
/>
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting}>
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
</Button>
</form>
) : null}
{step === 'identify' ? (
<div className="mt-3">
<Button asChild variant="outline" size="lg" className="w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]">
<Link href="/auth/register">Создать ID</Link>
</Button>
</div>
) : null}
<ProjectTagline className="mt-9 text-center text-sm font-semibold" />
<a
href="/download/android"
download
className="mx-auto mt-3 flex w-fit items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium text-[#b9bdc9] transition hover:bg-white/10 hover:text-white"
>
<Download className="h-3.5 w-3.5" />
Скачать приложение
</a>
</section>
</main>
);
}