From 0f21629ba516118790c5d7f65df18f485dfaeafe Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 30 Jul 2026 00:31:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D1=87=D0=B5=D0=BA=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=20=D0=BE=D1=84=D0=B5=D1=80=D1=82=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый пользователь попадал в кабинет молча — никаких документов ему не показывали. Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой. Набор документов и сам факт включения гейта приходят с бэка (GET /cabinet/info/legal-consent): там же учитывается, заполнены ли документы и видны ли они в вебе. Фронт знает только ключи и адреса публичных страниц /offer и /privacy — тех же, что уже линкует футер логина. Два пути входа устроены по-разному, поэтому и UI разный: - регистрация по email — чекбоксы прямо в форме, кнопка заблокирована до галочек; - Telegram-вход происходит сам собой, спрашивать заранее не у чего. Поэтому ловим 428 от бэка и показываем отдельный экран согласия, а замыкание помнит, какой именно вход повторить после простановки галочек. Тот же приём страхует и email-форму: конфиг мог протухнуть, если админ включил гейт между загрузкой страницы и отправкой. Неизвестный ключ документа всё равно рисуем (без ссылки): бэк его требует, и молча спрятанный чекбокс превратился бы в «кнопка не работает и непонятно почему». Локали ru/en/zh/fa. --- src/api/auth.ts | 7 ++ src/api/info.ts | 10 +- src/components/LegalConsent.tsx | 82 ++++++++++++++++ src/locales/en.json | 4 + src/locales/fa.json | 4 + src/locales/ru.json | 4 + src/locales/zh.json | 4 + src/pages/Login.tsx | 166 +++++++++++++++++++++++++++++++- src/store/auth.ts | 46 +++++++-- src/types/index.ts | 8 ++ 10 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 src/components/LegalConsent.tsx diff --git a/src/api/auth.ts b/src/api/auth.ts index 86e4f42..df0c816 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -18,12 +18,14 @@ export const authApi = { initData: string, campaignSlug?: string | null, referralCode?: string | null, + acceptedLegalDocuments?: string[], ): Promise => { const response = await apiClient.post('/cabinet/auth/telegram', { init_data: initData, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, yandex_cid: getYandexCid() || undefined, + accepted_legal_documents: acceptedLegalDocuments, }); return response.data; }, @@ -40,12 +42,14 @@ export const authApi = { }, campaignSlug?: string | null, referralCode?: string | null, + acceptedLegalDocuments?: string[], ): Promise => { const response = await apiClient.post('/cabinet/auth/telegram/widget', { ...data, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, yandex_cid: getYandexCid() || undefined, + accepted_legal_documents: acceptedLegalDocuments, }); return response.data; }, @@ -54,12 +58,14 @@ export const authApi = { idToken: string, campaignSlug?: string | null, referralCode?: string | null, + acceptedLegalDocuments?: string[], ): Promise => { const response = await apiClient.post('/cabinet/auth/telegram/oidc', { id_token: idToken, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, yandex_cid: getYandexCid() || undefined, + accepted_legal_documents: acceptedLegalDocuments, }); return response.data; }, @@ -115,6 +121,7 @@ export const authApi = { language?: string; referral_code?: string; campaign_slug?: string; + accepted_legal_documents?: string[]; }): Promise => { const response = await apiClient.post( '/cabinet/auth/email/register/standalone', diff --git a/src/api/info.ts b/src/api/info.ts index d67162e..9071104 100644 --- a/src/api/info.ts +++ b/src/api/info.ts @@ -1,5 +1,5 @@ import apiClient from './client'; -import type { SupportConfig } from '../types'; +import type { LegalConsentConfig, SupportConfig } from '../types'; export interface FaqPage { id: number; @@ -125,4 +125,12 @@ export const infoApi = { const response = await apiClient.get('/cabinet/info/visibility'); return response.data; }, + + // Публичный: экран логина запрашивает это ДО авторизации. + getLegalConsentConfig: async (language?: string): Promise => { + const response = await apiClient.get('/cabinet/info/legal-consent', { + params: language ? { language } : undefined, + }); + return response.data; + }, }; diff --git a/src/components/LegalConsent.tsx b/src/components/LegalConsent.tsx new file mode 100644 index 0000000..9869e1c --- /dev/null +++ b/src/components/LegalConsent.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from 'react-i18next'; + +// Галочки «ознакомлен» для НОВОГО пользователя. Набор документов приходит с бэка +// (GET /cabinet/info/legal-consent) — там же решается, включён ли гейт вообще и +// какие документы реально заполнены. Здесь только отрисовка и ссылки: адреса +// публичных страниц знает фронт, бэк отдаёт лишь ключи. + +export const LEGAL_DOCUMENT_LINKS: Record = { + public_offer: '/offer', + privacy_policy: '/privacy', +}; + +const LEGAL_DOCUMENT_LABELS: Record = { + public_offer: { key: 'footer.offer', fallback: 'Публичная оферта' }, + privacy_policy: { key: 'footer.privacy', fallback: 'Политика конфиденциальности' }, +}; + +interface LegalConsentProps { + documents: string[]; + accepted: Record; + onChange: (document: string, value: boolean) => void; + disabled?: boolean; + className?: string; +} + +export default function LegalConsent({ + documents, + accepted, + onChange, + disabled = false, + className = '', +}: LegalConsentProps) { + const { t } = useTranslation(); + + if (documents.length === 0) return null; + + return ( +
+ {documents.map((document) => { + // Неизвестный ключ всё равно показываем: он обязателен на бэке, и молча + // спрятанный чекбокс превратился бы в «кнопка не работает без объяснений». + const label = LEGAL_DOCUMENT_LABELS[document]; + const href = LEGAL_DOCUMENT_LINKS[document]; + const title = label ? t(label.key, label.fallback) : document; + const inputId = `legal-consent-${document}`; + + return ( + + ); + })} +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index c5af0e4..d29d33f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -225,6 +225,10 @@ "invalidCredentials": "Invalid email or password", "tooManyAttempts": "Too many attempts. Please try again later", "verificationEmailNotice": "After registration, a verification email will be sent to your address", + "legalConsentPrefix": "I have read the", + "legalConsentTitle": "One more step", + "legalConsentSubtitle": "To create an account, confirm that you have read the documents.", + "legalConsentContinue": "Continue", "checkEmail": "Check your email", "verificationSent": "We sent a verification link to:", "clickLinkToVerify": "Click the link in the email to verify your account and log in.", diff --git a/src/locales/fa.json b/src/locales/fa.json index 6e805a5..eebca41 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -228,6 +228,10 @@ "sendResetLink": "ارسال لینک بازنشانی", "tooManyAttempts": "تلاش‌های زیاد. لطفاً بعداً امتحان کنید", "verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد", + "legalConsentPrefix": "من این سند را مطالعه کرده‌ام:", + "legalConsentTitle": "یک قدم دیگر", + "legalConsentSubtitle": "برای ساخت حساب، تأیید کنید که اسناد را مطالعه کرده‌اید.", + "legalConsentContinue": "ادامه", "continueWithGoogle": "ادامه با Google", "continueWithYandex": "ادامه با Yandex", "continueWithDiscord": "ادامه با Discord", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9dd3f21..38614c1 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -231,6 +231,10 @@ "sendResetLink": "Отправить ссылку", "tooManyAttempts": "Слишком много попыток. Попробуйте позже", "verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения", + "legalConsentPrefix": "Я ознакомлен(а) с документом", + "legalConsentTitle": "Ещё один шаг", + "legalConsentSubtitle": "Чтобы создать аккаунт, подтвердите, что ознакомились с документами.", + "legalConsentContinue": "Продолжить", "checkEmail": "Проверьте почту", "verificationSent": "Мы отправили ссылку для подтверждения на:", "clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1e85997..09f525e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -228,6 +228,10 @@ "sendResetLink": "发送重置链接", "tooManyAttempts": "尝试次数过多,请稍后再试", "verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱", + "legalConsentPrefix": "我已阅读", + "legalConsentTitle": "还差一步", + "legalConsentSubtitle": "创建账户前,请确认您已阅读以下文件。", + "legalConsentContinue": "继续", "continueWithGoogle": "通过 Google 继续", "continueWithYandex": "通过 Yandex 继续", "continueWithDiscord": "通过 Discord 继续", diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index b3afb12..b49b45e 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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({ + 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>({}); + // Telegram-вход происходит сам собой, поэтому чекбоксы показываем только когда + // бэк ответил 428: пользователь новый и без согласия аккаунт не создастся. + // Замыкание помнит, какой именно вход повторить после простановки галочек. + const [pendingConsentRetry, setPendingConsentRetry] = useState< + ((accepted: string[]) => Promise) | 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): 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() { )} - {/* Check Email Screen */} - {registeredEmail ? ( + {/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */} + {pendingConsentRetry ? ( +
+

+ {t('auth.legalConsentTitle', 'Ещё один шаг')} +

+

+ {t( + 'auth.legalConsentSubtitle', + 'Чтобы создать аккаунт, подтвердите, что ознакомились с документами.', + )} +

+ + + + {error && ( +

+ {error} +

+ )} + + +
+ ) : /* Check Email Screen */ + registeredEmail ? (
@@ -696,9 +840,21 @@ export default function Login() {
)} + {authMode === 'register' && ( + + )} +