Files
bedolaga-cabinet/src/pages/Login.tsx
Fringg 0f21629ba5 feat(auth): чекбоксы согласия с офертой и политикой при первой авторизации
Новый пользователь попадал в кабинет молча — никаких документов ему не показывали.
Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой.

Набор документов и сам факт включения гейта приходят с бэка
(GET /cabinet/info/legal-consent): там же учитывается, заполнены ли документы и
видны ли они в вебе. Фронт знает только ключи и адреса публичных страниц /offer
и /privacy — тех же, что уже линкует футер логина.

Два пути входа устроены по-разному, поэтому и UI разный:
- регистрация по email — чекбоксы прямо в форме, кнопка заблокирована до галочек;
- Telegram-вход происходит сам собой, спрашивать заранее не у чего. Поэтому ловим
  428 от бэка и показываем отдельный экран согласия, а замыкание помнит, какой
  именно вход повторить после простановки галочек. Тот же приём страхует и
  email-форму: конфиг мог протухнуть, если админ включил гейт между загрузкой
  страницы и отправкой.

Неизвестный ключ документа всё равно рисуем (без ссылки): бэк его требует, и молча
спрятанный чекбокс превратился бы в «кнопка не работает и непонятно почему».

Локали ru/en/zh/fa.
2026-07-30 00:31:47 +03:00

907 lines
38 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.

import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../store/auth';
import { useShallow } from 'zustand/shallow';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
type BrandingInfo,
type EmailAuthEnabled,
} from '../api/branding';
import { getAndClearReturnUrl, tokenStorage } from '../utils/token';
import { getApiErrorMessage } from '../utils/api-error';
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
import { closeMiniApp } from '@telegram-apps/sdk-react';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from '../utils/oauth';
import { getPendingReferralCode } from '../utils/referral';
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
import LegalFooter from '../components/LegalFooter';
import LegalConsent from '../components/LegalConsent';
import { infoApi } from '../api/info';
import type { LegalConsentConfig } from '../types';
export default function Login() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const {
isAuthenticated,
isLoading: isAuthInitializing,
loginWithTelegram,
loginWithEmail,
registerWithEmail,
} = useAuthStore(
useShallow((state) => ({
isAuthenticated: state.isAuthenticated,
isLoading: state.isLoading,
loginWithTelegram: state.loginWithTelegram,
loginWithEmail: state.loginWithEmail,
registerWithEmail: state.registerWithEmail,
})),
);
// Get referral code from localStorage (captured from ?ref= param at module level in auth store)
const referralCode = getPendingReferralCode() || '';
const [authMode, setAuthMode] = useState<'login' | 'register'>(() =>
referralCode ? 'register' : 'login',
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('');
const [forgotPasswordSent, setForgotPasswordSent] = useState(false);
const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false);
const [forgotPasswordError, setForgotPasswordError] = useState('');
const [showEmailForm, setShowEmailForm] = useState(true);
// Гейт согласия с офертой/политикой для НОВОГО пользователя. Конфиг публичный:
// нужен до авторизации, чтобы нарисовать чекбоксы ещё на экране входа.
const { data: legalConsent } = useQuery<LegalConsentConfig>({
queryKey: ['legal-consent-config', i18n.language],
queryFn: () => infoApi.getLegalConsentConfig(i18n.language),
staleTime: 5 * 60 * 1000,
retry: false,
});
const consentDocuments = useMemo(() => legalConsent?.documents ?? [], [legalConsent]);
const [acceptedDocuments, setAcceptedDocuments] = useState<Record<string, boolean>>({});
// Telegram-вход происходит сам собой, поэтому чекбоксы показываем только когда
// бэк ответил 428: пользователь новый и без согласия аккаунт не создастся.
// Замыкание помнит, какой именно вход повторить после простановки галочек.
const [pendingConsentRetry, setPendingConsentRetry] = useState<
((accepted: string[]) => Promise<void>) | null
>(null);
useEffect(() => {
if (!legalConsent?.prechecked || consentDocuments.length === 0) return;
setAcceptedDocuments((prev) => {
const next = { ...prev };
for (const document of consentDocuments) {
if (next[document] === undefined) next[document] = true;
}
return next;
});
}, [legalConsent?.prechecked, consentDocuments]);
const acceptedDocumentKeys = useMemo(
() => consentDocuments.filter((document) => acceptedDocuments[document]),
[consentDocuments, acceptedDocuments],
);
const allDocumentsAccepted =
consentDocuments.length === 0 || acceptedDocumentKeys.length === consentDocuments.length;
const toggleDocument = useCallback((document: string, value: boolean) => {
setAcceptedDocuments((prev) => ({ ...prev, [document]: value }));
}, []);
// 428 = бэк требует согласие. Запоминаем, что повторить, и рисуем чекбоксы.
const captureConsentRequirement = useCallback(
(err: unknown, retry: (accepted: string[]) => Promise<void>): boolean => {
const error = err as { response?: { status?: number; data?: { detail?: unknown } } };
if (error.response?.status !== 428) return false;
const detail = error.response?.data?.detail as
| { documents?: string[]; prechecked?: boolean }
| undefined;
if (detail?.documents?.length) {
const documents = detail.documents;
setAcceptedDocuments((prev) => {
const next = { ...prev };
for (const document of documents) {
if (next[document] === undefined) next[document] = Boolean(detail.prechecked);
}
return next;
});
}
setPendingConsentRetry(() => retry);
return true;
},
[],
);
// Telegram safe area insets
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
const safeBottom = Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom);
// Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => {
// Сначала проверяем state от React Router
const stateFrom = (location.state as { from?: string })?.from;
if (stateFrom && stateFrom !== '/login') {
return stateFrom;
}
// Затем проверяем сохранённый URL в sessionStorage (от safeRedirectToLogin)
const savedUrl = getAndClearReturnUrl();
if (savedUrl && savedUrl !== '/login') {
return savedUrl;
}
// По умолчанию на главную
return '/';
}, [location.state]);
// Fetch branding with unified cache
const cachedBranding = useMemo(() => getCachedBranding(), []);
const { data: branding } = useQuery<BrandingInfo>({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
await preloadLogo(data);
return data;
},
staleTime: 60000,
initialData: cachedBranding ?? undefined,
initialDataUpdatedAt: 0,
});
// Check if email auth is enabled
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled,
staleTime: 60000,
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
const { data: footerEnabled } = useQuery({
queryKey: ['footer-enabled'],
queryFn: brandingApi.getFooterEnabled,
staleTime: 60000,
});
// Fetch enabled OAuth providers
const { data: oauthData } = useQuery({
queryKey: ['oauth-providers'],
queryFn: authApi.getOAuthProviders,
staleTime: 60000,
});
const oauthProviders = Array.isArray(oauthData?.providers) ? oauthData.providers : [];
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const handleOAuthLogin = async (provider: string) => {
setError('');
setOauthLoading(provider);
try {
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
// Validate redirect URL — only allow HTTPS to prevent open redirect
let parsed: URL;
try {
parsed = new URL(authorize_url);
} catch {
throw new Error('Invalid OAuth redirect URL');
}
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect URL');
}
saveOAuthState(state, provider);
window.location.href = authorize_url;
} catch {
setError(t('auth.oauthError', 'Authorization was denied or failed'));
setOauthLoading(null);
}
};
const appName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN';
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
useEffect(() => {
if (isAuthenticated) {
navigate(getReturnUrl(), { replace: true });
}
}, [isAuthenticated, navigate, getReturnUrl]);
// Try Telegram WebApp authentication on mount (with auto-retry on 401)
// Wait for auth store initialization to complete to avoid race conditions
// with stale tokens triggering interceptor refresh/redirect loops
useEffect(() => {
// Don't attempt Telegram auth until store initialization is done
if (isAuthInitializing) return;
const tryTelegramAuth = async () => {
const initData = getTelegramInitData();
if (!isInTelegramWebApp() || !initData) return;
setIsTelegramWebApp(true);
setIsLoading(true);
const MAX_RETRIES = 1;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
await loginWithTelegram(initData);
navigate(getReturnUrl(), { replace: true });
return;
} catch (err) {
const error = err as { response?: { status?: number } };
const status = error.response?.status;
const detail = getApiErrorMessage(err, '');
if (import.meta.env.DEV)
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
// Не ошибка входа, а недостающее согласие: показываем чекбоксы.
const needsConsent = captureConsentRequirement(err, async (accepted) => {
await loginWithTelegram(initData, accepted);
navigate(getReturnUrl(), { replace: true });
});
if (needsConsent) {
setIsLoading(false);
return;
}
if (status === 401 && attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, 1500));
continue;
}
// Show backend error detail if available, otherwise generic message
setError(detail || t('auth.telegramRequired'));
}
}
setIsLoading(false);
};
tryTelegramAuth();
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
const handleRetryTelegramAuth = () => {
// Clear ALL cached auth state to prevent stale token/initData loops
tokenStorage.clearTokens();
sessionStorage.removeItem('tapps/launchParams');
sessionStorage.removeItem('telegram_init_data');
localStorage.removeItem('cabinet-auth');
localStorage.removeItem('tg_user_id');
try {
// Close miniapp — Telegram will provide fresh initData on reopen
closeMiniApp();
} catch {
// If closeMiniApp fails, force a clean page reload
window.location.reload();
}
};
const handleEmailSubmit = async (e: React.SyntheticEvent) => {
e.preventDefault();
setError('');
// Валидация email
if (!email.trim() || !isValidEmail(email.trim())) {
setError(t('auth.invalidEmail', 'Please enter a valid email address'));
return;
}
if (authMode === 'register') {
// Валидация для регистрации
if (password !== confirmPassword) {
setError(t('auth.passwordMismatch', 'Passwords do not match'));
return;
}
if (password.length < 8) {
setError(t('auth.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
}
setIsLoading(true);
try {
if (authMode === 'login') {
await loginWithEmail(email, password);
navigate(getReturnUrl(), { replace: true });
} else {
const result = await registerWithEmail(
email,
password,
firstName || undefined,
referralCode || undefined,
acceptedDocumentKeys,
);
// Show "check your email" screen
setRegisteredEmail(result.email);
}
} catch (err: unknown) {
const error = err as { response?: { status?: number } };
const status = error.response?.status;
const detail = getApiErrorMessage(err, '');
// Конфиг чекбоксов мог протухнуть (админ включил гейт между загрузкой страницы
// и отправкой формы) — показываем недостающие галочки вместо сырой ошибки.
const needsConsent = captureConsentRequirement(err, async (accepted) => {
const retried = await registerWithEmail(
email,
password,
firstName || undefined,
referralCode || undefined,
accepted,
);
setPendingConsentRetry(null);
setRegisteredEmail(retried.email);
});
if (needsConsent) {
setIsLoading(false);
return;
}
if (status === 400 && detail.includes('already registered')) {
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
} else if (status === 401 || status === 403) {
if (detail.includes('verify your email')) {
setError(t('auth.emailNotVerified', 'Please verify your email first'));
} else {
setError(t('auth.invalidCredentials', 'Invalid email or password'));
}
} else if (status === 429) {
setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later'));
} else {
setError(detail || t('common.error'));
}
} finally {
setIsLoading(false);
}
};
const handleForgotPassword = async (e: React.SyntheticEvent) => {
e.preventDefault();
setForgotPasswordError('');
if (!forgotPasswordEmail.trim() || !isValidEmail(forgotPasswordEmail.trim())) {
setForgotPasswordError(t('auth.invalidEmail', 'Please enter a valid email address'));
return;
}
setForgotPasswordLoading(true);
try {
await authApi.forgotPassword(forgotPasswordEmail.trim());
setForgotPasswordSent(true);
} catch (err: unknown) {
setForgotPasswordError(getApiErrorMessage(err, t('common.error')));
} finally {
setForgotPasswordLoading(false);
}
};
const closeForgotPasswordModal = () => {
setShowForgotPassword(false);
setForgotPasswordEmail('');
setForgotPasswordSent(false);
setForgotPasswordError('');
};
return (
<div
className="flex min-h-[100dvh] items-center justify-center px-4 sm:px-6 lg:px-8"
style={{
paddingTop:
safeTop > 0 ? `${safeTop + 16}px` : 'calc(1rem + env(safe-area-inset-top, 0px))',
paddingBottom:
safeBottom > 0 ? `${safeBottom + 16}px` : 'calc(1rem + env(safe-area-inset-bottom, 0px))',
}}
>
{/* Flat background — the previous two layered gradients (linear
+ accent radial halo) read as the airdrop / crypto aesthetic
PRODUCT.md explicitly anti-references. Body bg-dark-950 carries
the surface alone. */}
{/* Language switcher */}
<div
className="fixed right-3 z-50"
style={{
top: safeTop > 0 ? `${safeTop + 12}px` : 'calc(12px + env(safe-area-inset-top, 0px))',
}}
>
<LanguageSwitcher />
</div>
<div className="relative w-full max-w-md space-y-5">
{/* Logo & branding */}
<div className="text-center">
<div className="relative mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md">
{/* Letter fallback */}
<span
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 ${branding?.has_custom_logo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
>
{appLogo}
</span>
{/* Logo image */}
{branding?.has_custom_logo && logoUrl && (
<img
src={logoUrl}
alt={appName || 'Logo'}
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)}
/>
)}
</div>
{appName && <h1 className="text-2xl font-bold text-dark-50">{appName}</h1>}
{/* Referral Banner */}
{referralCode && isEmailAuthEnabled && (
<div className="mt-3 rounded-xl border border-accent-500/30 bg-accent-500/10 p-2.5">
<div className="flex items-center justify-center gap-2 text-accent-400">
<UsersIcon className="h-4 w-4 flex-shrink-0" />
<span className="text-xs font-medium">{t('auth.referralInvite')}</span>
</div>
</div>
)}
</div>
{/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */}
{pendingConsentRetry ? (
<div className="card">
<h2 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.legalConsentTitle', 'Ещё один шаг')}
</h2>
<p className="mb-4 text-sm text-dark-400">
{t(
'auth.legalConsentSubtitle',
'Чтобы создать аккаунт, подтвердите, что ознакомились с документами.',
)}
</p>
<LegalConsent
documents={consentDocuments}
accepted={acceptedDocuments}
onChange={toggleDocument}
disabled={isLoading}
/>
{error && (
<p className="mt-4 text-sm text-error-400" role="alert">
{error}
</p>
)}
<button
type="button"
className="btn-primary mt-5 w-full"
disabled={!allDocumentsAccepted || isLoading}
onClick={async () => {
setError('');
setIsLoading(true);
try {
await pendingConsentRetry(acceptedDocumentKeys);
setPendingConsentRetry(null);
} catch (err) {
setError(getApiErrorMessage(err, t('common.error')));
} finally {
setIsLoading(false);
}
}}
>
{isLoading
? t('common.loading', 'Загрузка...')
: t('auth.legalConsentContinue', 'Продолжить')}
</button>
</div>
) : /* Check Email Screen */
registeredEmail ? (
<div className="card text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
<EmailIcon className="h-7 w-7 text-success-400" />
</div>
<h2 className="mb-2 text-lg font-bold text-dark-50">
{t('auth.checkEmail', 'Check your email')}
</h2>
<p className="mb-3 text-sm text-dark-400">
{t('auth.verificationSent', 'We sent a verification link to:')}
</p>
<p className="mb-4 text-sm font-medium text-accent-400">{registeredEmail}</p>
<p className="mb-5 text-xs text-dark-500">
{t(
'auth.clickLinkToVerify',
'Click the link in the email to verify your account and log in.',
)}
</p>
<button
onClick={() => {
setRegisteredEmail(null);
setAuthMode('login');
}}
className="btn-secondary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
</div>
) : (
/* Main auth card */
<div className="card">
{error && (
<div
role="alert"
className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm text-error-400"
>
{error}
</div>
)}
{/* Telegram auth section */}
<div className="space-y-3">
{isLoading && isTelegramWebApp ? (
<div className="py-6 text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<p className="text-sm text-dark-400">{t('auth.authenticating')}</p>
</div>
) : isTelegramWebApp && error ? (
<div className="space-y-3 text-center">
<button
onClick={handleRetryTelegramAuth}
className="btn-primary mx-auto flex items-center gap-2 px-5 py-2.5"
>
<RefreshIcon className="h-4 w-4" />
{t('auth.tryAgain')}
</button>
<p className="text-xs text-dark-500">
{t(
'auth.telegramReopenHint',
'If the problem persists, close and reopen the app',
)}
</p>
</div>
) : (
<TelegramLoginButton referralCode={referralCode || undefined} />
)}
</div>
{/* OAuth providers - compact icon row */}
{oauthProviders.length > 0 && (
<>
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
<div className="h-px flex-1 bg-dark-700" />
</div>
<div className="flex items-stretch gap-2">
{oauthProviders.map((provider) => (
<button
key={provider.name}
type="button"
onClick={() => handleOAuthLogin(provider.name)}
disabled={oauthLoading !== null}
className="flex flex-1 flex-col items-center justify-center gap-1.5 rounded-xl border border-dark-700 bg-dark-800/80 py-2.5 transition-all hover:border-dark-600 hover:bg-dark-700 disabled:opacity-50"
title={provider.display_name}
>
{oauthLoading === provider.name ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
) : (
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
)}
<span className="text-[10px] leading-none text-dark-500">
{provider.display_name}
</span>
</button>
))}
</div>
</>
)}
{/* Email auth section - collapsible */}
{isEmailAuthEnabled && (
<>
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<button
type="button"
onClick={() => setShowEmailForm(!showEmailForm)}
className="flex items-center gap-1.5 rounded-full border border-dark-700 bg-dark-800/60 px-3.5 py-1.5 text-xs font-medium text-dark-300 transition-all hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
>
<EmailIcon className="h-3.5 w-3.5 text-dark-400" />
<span>{t('auth.loginWithEmail')}</span>
<ChevronDownIcon
className={`h-3 w-3 text-dark-400 transition-transform duration-300 ${showEmailForm ? 'rotate-180' : ''}`}
/>
</button>
<div className="h-px flex-1 bg-dark-700" />
</div>
{/* Collapsible email form */}
<div
className={`grid transition-[grid-template-rows] duration-300 ease-in-out ${
showEmailForm ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
}`}
style={{ transform: 'translateZ(0)' }}
>
<div className="overflow-hidden">
<div className="space-y-4 pb-1 pt-1">
{showForgotPassword ? (
/* Forgot password screen - replaces login/register */
forgotPasswordSent ? (
<div className="space-y-4 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-success-500/20">
<EmailIcon className="h-6 w-6 text-success-400" />
</div>
<p className="text-sm font-medium text-dark-100">
{t('auth.checkEmail', 'Check your email')}
</p>
<p className="text-xs text-dark-400">
{t(
'auth.passwordResetSent',
'If an account exists with this email, we sent password reset instructions.',
)}
</p>
<button
type="button"
onClick={closeForgotPasswordModal}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('common.back', 'Back')}
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-center text-sm text-dark-400">
{t(
'auth.forgotPasswordHint',
'Enter your email and we will send you instructions to reset your password.',
)}
</p>
<form onSubmit={handleForgotPassword} className="space-y-3">
<div>
<label htmlFor="forgotEmail" className="label">
Email
</label>
<input
id="forgotEmail"
type="email"
value={forgotPasswordEmail}
onChange={(e) => setForgotPasswordEmail(e.target.value)}
placeholder="you@example.com"
className="input"
autoFocus
/>
</div>
{forgotPasswordError && (
<p className="text-sm text-error-400">{forgotPasswordError}</p>
)}
<button
type="submit"
disabled={forgotPasswordLoading}
className="btn-primary w-full py-2.5"
>
{forgotPasswordLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('auth.sendResetLink', 'Send reset link')
)}
</button>
</form>
<div className="text-center">
<button
type="button"
onClick={closeForgotPasswordModal}
className="text-sm text-dark-400 transition-colors hover:text-dark-200"
>
{t('common.back', 'Back')}
</button>
</div>
</div>
)
) : (
/* Normal login / register */
<>
<div className="flex rounded-lg bg-dark-800 p-1">
<button
type="button"
className={`flex-1 rounded-md py-2 text-sm font-medium transition-all ${
authMode === 'login'
? 'bg-accent-500 text-on-accent'
: 'text-dark-400 hover:text-dark-200'
}`}
onClick={() => setAuthMode('login')}
>
{t('auth.login')}
</button>
<button
type="button"
className={`flex-1 rounded-md py-2 text-sm font-medium transition-all ${
authMode === 'register'
? 'bg-accent-500 text-on-accent'
: 'text-dark-400 hover:text-dark-200'
}`}
onClick={() => setAuthMode('register')}
>
{t('auth.register', 'Register')}
</button>
</div>
<form className="space-y-3" onSubmit={handleEmailSubmit}>
{authMode === 'register' && (
<div>
<label htmlFor="firstName" className="label">
{t('auth.firstName', 'First Name')}
</label>
<input
id="firstName"
name="firstName"
type="text"
autoComplete="given-name"
className="input"
placeholder={t(
'auth.firstNamePlaceholder',
'Your name (optional)',
)}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
</div>
)}
<div>
<label htmlFor="email" className="label">
{t('auth.email')}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="label">
{t('auth.password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete={
authMode === 'login' ? 'current-password' : 'new-password'
}
required
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{authMode === 'register' &&
password.length > 0 &&
password.length < 8 && (
<p className="mt-1.5 text-xs text-error-400">
{t(
'auth.passwordTooShort',
'Password must be at least 8 characters',
)}
</p>
)}
</div>
{authMode === 'register' && (
<div>
<label htmlFor="confirmPassword" className="label">
{t('auth.confirmPassword', 'Confirm Password')}
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className="input"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
)}
{authMode === 'register' && (
<LegalConsent
documents={consentDocuments}
accepted={acceptedDocuments}
onChange={toggleDocument}
disabled={isLoading}
className="pt-1"
/>
)}
<button
type="submit"
disabled={
isLoading || (authMode === 'register' && !allDocumentsAccepted)
}
className="btn-primary w-full py-2.5"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : authMode === 'login' ? (
t('auth.login')
) : (
t('auth.register', 'Register')
)}
</button>
</form>
{authMode === 'register' && (
<p className="text-center text-xs text-dark-500">
{t(
'auth.verificationEmailNotice',
'After registration, a verification email will be sent to your address',
)}
</p>
)}
{authMode === 'login' && (
<div className="text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
>
{t('auth.forgotPassword', 'Forgot password?')}
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
</>
)}
</div>
)}
{footerEnabled && <LegalFooter className="pt-1" />}
</div>
</div>
);
}