mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
feat(auth): чекбоксы согласия с офертой и политикой при первой авторизации
Новый пользователь попадал в кабинет молча — никаких документов ему не показывали. Теперь при создании аккаунта он подтверждает, что ознакомился с офертой и политикой. Набор документов и сам факт включения гейта приходят с бэка (GET /cabinet/info/legal-consent): там же учитывается, заполнены ли документы и видны ли они в вебе. Фронт знает только ключи и адреса публичных страниц /offer и /privacy — тех же, что уже линкует футер логина. Два пути входа устроены по-разному, поэтому и UI разный: - регистрация по email — чекбоксы прямо в форме, кнопка заблокирована до галочек; - Telegram-вход происходит сам собой, спрашивать заранее не у чего. Поэтому ловим 428 от бэка и показываем отдельный экран согласия, а замыкание помнит, какой именно вход повторить после простановки галочек. Тот же приём страхует и email-форму: конфиг мог протухнуть, если админ включил гейт между загрузкой страницы и отправкой. Неизвестный ключ документа всё равно рисуем (без ссылки): бэк его требует, и молча спрятанный чекбокс превратился бы в «кнопка не работает и непонятно почему». Локали ru/en/zh/fa.
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user