feat(auth): чекбоксы согласия с офертой и политикой при первой авторизации

Новый пользователь попадал в кабинет молча — никаких документов ему не показывали.
Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой.

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

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

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

Локали ru/en/zh/fa.
This commit is contained in:
Fringg
2026-07-30 00:31:47 +03:00
parent b615927283
commit 0f21629ba5
10 changed files with 319 additions and 16 deletions

View File

@@ -26,9 +26,12 @@ 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 } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const {
@@ -69,6 +72,70 @@ export default function Login() {
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);
@@ -197,6 +264,16 @@ export default function Login() {
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;
@@ -211,7 +288,7 @@ export default function Login() {
};
tryTelegramAuth();
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
const handleRetryTelegramAuth = () => {
// Clear ALL cached auth state to prevent stale token/initData loops
@@ -264,6 +341,7 @@ export default function Login() {
password,
firstName || undefined,
referralCode || undefined,
acceptedDocumentKeys,
);
// Show "check your email" screen
setRegisteredEmail(result.email);
@@ -273,6 +351,24 @@ export default function Login() {
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) {
@@ -376,8 +472,56 @@ export default function Login() {
)}
</div>
{/* Check Email Screen */}
{registeredEmail ? (
{/* Экран согласия: бэк ответил 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" />
@@ -696,9 +840,21 @@ export default function Login() {
</div>
)}
{authMode === 'register' && (
<LegalConsent
documents={consentDocuments}
accepted={acceptedDocuments}
onChange={toggleDocument}
disabled={isLoading}
className="pt-1"
/>
)}
<button
type="submit"
disabled={isLoading}
disabled={
isLoading || (authMode === 'register' && !allDocumentsAccepted)
}
className="btn-primary w-full py-2.5"
>
{isLoading ? (