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:
@@ -18,12 +18,14 @@ export const authApi = {
|
|||||||
initData: string,
|
initData: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||||
init_data: initData,
|
init_data: initData,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -40,12 +42,14 @@ export const authApi = {
|
|||||||
},
|
},
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
||||||
...data,
|
...data,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -54,12 +58,14 @@ export const authApi = {
|
|||||||
idToken: string,
|
idToken: string,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
||||||
id_token: idToken,
|
id_token: idToken,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
yandex_cid: getYandexCid() || undefined,
|
yandex_cid: getYandexCid() || undefined,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -115,6 +121,7 @@ export const authApi = {
|
|||||||
language?: string;
|
language?: string;
|
||||||
referral_code?: string;
|
referral_code?: string;
|
||||||
campaign_slug?: string;
|
campaign_slug?: string;
|
||||||
|
accepted_legal_documents?: string[];
|
||||||
}): Promise<RegisterResponse> => {
|
}): Promise<RegisterResponse> => {
|
||||||
const response = await apiClient.post<RegisterResponse>(
|
const response = await apiClient.post<RegisterResponse>(
|
||||||
'/cabinet/auth/email/register/standalone',
|
'/cabinet/auth/email/register/standalone',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { SupportConfig } from '../types';
|
import type { LegalConsentConfig, SupportConfig } from '../types';
|
||||||
|
|
||||||
export interface FaqPage {
|
export interface FaqPage {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -125,4 +125,12 @@ export const infoApi = {
|
|||||||
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Публичный: экран логина запрашивает это ДО авторизации.
|
||||||
|
getLegalConsentConfig: async (language?: string): Promise<LegalConsentConfig> => {
|
||||||
|
const response = await apiClient.get<LegalConsentConfig>('/cabinet/info/legal-consent', {
|
||||||
|
params: language ? { language } : undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
82
src/components/LegalConsent.tsx
Normal file
82
src/components/LegalConsent.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
// Галочки «ознакомлен» для НОВОГО пользователя. Набор документов приходит с бэка
|
||||||
|
// (GET /cabinet/info/legal-consent) — там же решается, включён ли гейт вообще и
|
||||||
|
// какие документы реально заполнены. Здесь только отрисовка и ссылки: адреса
|
||||||
|
// публичных страниц знает фронт, бэк отдаёт лишь ключи.
|
||||||
|
|
||||||
|
export const LEGAL_DOCUMENT_LINKS: Record<string, string> = {
|
||||||
|
public_offer: '/offer',
|
||||||
|
privacy_policy: '/privacy',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGAL_DOCUMENT_LABELS: Record<string, { key: string; fallback: string }> = {
|
||||||
|
public_offer: { key: 'footer.offer', fallback: 'Публичная оферта' },
|
||||||
|
privacy_policy: { key: 'footer.privacy', fallback: 'Политика конфиденциальности' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LegalConsentProps {
|
||||||
|
documents: string[];
|
||||||
|
accepted: Record<string, boolean>;
|
||||||
|
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 (
|
||||||
|
<div className={`space-y-2.5 ${className}`}>
|
||||||
|
{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 (
|
||||||
|
<label
|
||||||
|
key={document}
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="flex cursor-pointer items-start gap-2.5 text-xs leading-relaxed text-dark-400"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(accepted[document])}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(document, e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('auth.legalConsentPrefix', 'Я ознакомлен(а) с документом')}{' '}
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent-400 underline underline-offset-2 transition-colors hover:text-accent-300"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-dark-200">{title}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -225,6 +225,10 @@
|
|||||||
"invalidCredentials": "Invalid email or password",
|
"invalidCredentials": "Invalid email or password",
|
||||||
"tooManyAttempts": "Too many attempts. Please try again later",
|
"tooManyAttempts": "Too many attempts. Please try again later",
|
||||||
"verificationEmailNotice": "After registration, a verification email will be sent to your address",
|
"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",
|
"checkEmail": "Check your email",
|
||||||
"verificationSent": "We sent a verification link to:",
|
"verificationSent": "We sent a verification link to:",
|
||||||
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
|
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
|
||||||
|
|||||||
@@ -228,6 +228,10 @@
|
|||||||
"sendResetLink": "ارسال لینک بازنشانی",
|
"sendResetLink": "ارسال لینک بازنشانی",
|
||||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||||
|
"legalConsentPrefix": "من این سند را مطالعه کردهام:",
|
||||||
|
"legalConsentTitle": "یک قدم دیگر",
|
||||||
|
"legalConsentSubtitle": "برای ساخت حساب، تأیید کنید که اسناد را مطالعه کردهاید.",
|
||||||
|
"legalConsentContinue": "ادامه",
|
||||||
"continueWithGoogle": "ادامه با Google",
|
"continueWithGoogle": "ادامه با Google",
|
||||||
"continueWithYandex": "ادامه با Yandex",
|
"continueWithYandex": "ادامه با Yandex",
|
||||||
"continueWithDiscord": "ادامه با Discord",
|
"continueWithDiscord": "ادامه با Discord",
|
||||||
|
|||||||
@@ -231,6 +231,10 @@
|
|||||||
"sendResetLink": "Отправить ссылку",
|
"sendResetLink": "Отправить ссылку",
|
||||||
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
|
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
|
||||||
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
|
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
|
||||||
|
"legalConsentPrefix": "Я ознакомлен(а) с документом",
|
||||||
|
"legalConsentTitle": "Ещё один шаг",
|
||||||
|
"legalConsentSubtitle": "Чтобы создать аккаунт, подтвердите, что ознакомились с документами.",
|
||||||
|
"legalConsentContinue": "Продолжить",
|
||||||
"checkEmail": "Проверьте почту",
|
"checkEmail": "Проверьте почту",
|
||||||
"verificationSent": "Мы отправили ссылку для подтверждения на:",
|
"verificationSent": "Мы отправили ссылку для подтверждения на:",
|
||||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||||
|
|||||||
@@ -228,6 +228,10 @@
|
|||||||
"sendResetLink": "发送重置链接",
|
"sendResetLink": "发送重置链接",
|
||||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||||
|
"legalConsentPrefix": "我已阅读",
|
||||||
|
"legalConsentTitle": "还差一步",
|
||||||
|
"legalConsentSubtitle": "创建账户前,请确认您已阅读以下文件。",
|
||||||
|
"legalConsentContinue": "继续",
|
||||||
"continueWithGoogle": "通过 Google 继续",
|
"continueWithGoogle": "通过 Google 继续",
|
||||||
"continueWithYandex": "通过 Yandex 继续",
|
"continueWithYandex": "通过 Yandex 继续",
|
||||||
"continueWithDiscord": "通过 Discord 继续",
|
"continueWithDiscord": "通过 Discord 继续",
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ import { saveOAuthState } from '../utils/oauth';
|
|||||||
import { getPendingReferralCode } from '../utils/referral';
|
import { getPendingReferralCode } from '../utils/referral';
|
||||||
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
|
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
|
||||||
import LegalFooter from '../components/LegalFooter';
|
import LegalFooter from '../components/LegalFooter';
|
||||||
|
import LegalConsent from '../components/LegalConsent';
|
||||||
|
import { infoApi } from '../api/info';
|
||||||
|
import type { LegalConsentConfig } from '../types';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
@@ -69,6 +72,70 @@ export default function Login() {
|
|||||||
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
||||||
const [showEmailForm, setShowEmailForm] = useState(true);
|
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
|
// Telegram safe area insets
|
||||||
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||||
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
||||||
@@ -197,6 +264,16 @@ export default function Login() {
|
|||||||
if (import.meta.env.DEV)
|
if (import.meta.env.DEV)
|
||||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
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) {
|
if (status === 401 && attempt < MAX_RETRIES) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
continue;
|
continue;
|
||||||
@@ -211,7 +288,7 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tryTelegramAuth();
|
tryTelegramAuth();
|
||||||
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
|
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
|
||||||
|
|
||||||
const handleRetryTelegramAuth = () => {
|
const handleRetryTelegramAuth = () => {
|
||||||
// Clear ALL cached auth state to prevent stale token/initData loops
|
// Clear ALL cached auth state to prevent stale token/initData loops
|
||||||
@@ -264,6 +341,7 @@ export default function Login() {
|
|||||||
password,
|
password,
|
||||||
firstName || undefined,
|
firstName || undefined,
|
||||||
referralCode || undefined,
|
referralCode || undefined,
|
||||||
|
acceptedDocumentKeys,
|
||||||
);
|
);
|
||||||
// Show "check your email" screen
|
// Show "check your email" screen
|
||||||
setRegisteredEmail(result.email);
|
setRegisteredEmail(result.email);
|
||||||
@@ -273,6 +351,24 @@ export default function Login() {
|
|||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const detail = getApiErrorMessage(err, '');
|
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')) {
|
if (status === 400 && detail.includes('already registered')) {
|
||||||
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
||||||
} else if (status === 401 || status === 403) {
|
} else if (status === 401 || status === 403) {
|
||||||
@@ -376,8 +472,56 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Email Screen */}
|
{/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */}
|
||||||
{registeredEmail ? (
|
{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="card text-center">
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
|
<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" />
|
<EmailIcon className="h-7 w-7 text-success-400" />
|
||||||
@@ -696,9 +840,21 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{authMode === 'register' && (
|
||||||
|
<LegalConsent
|
||||||
|
documents={consentDocuments}
|
||||||
|
accepted={acceptedDocuments}
|
||||||
|
onChange={toggleDocument}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="pt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={
|
||||||
|
isLoading || (authMode === 'register' && !allDocumentsAccepted)
|
||||||
|
}
|
||||||
className="btn-primary w-full py-2.5"
|
className="btn-primary w-full py-2.5"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ interface AuthState {
|
|||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
checkAdminStatus: () => Promise<void>;
|
checkAdminStatus: () => Promise<void>;
|
||||||
loginWithTelegram: (initData: string) => Promise<void>;
|
loginWithTelegram: (initData: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
loginWithTelegramWidget: (
|
||||||
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
|
data: TelegramWidgetData,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
|
) => Promise<void>;
|
||||||
|
loginWithTelegramOIDC: (idToken: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||||
loginWithOAuth: (
|
loginWithOAuth: (
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -64,6 +67,7 @@ interface AuthState {
|
|||||||
password: string,
|
password: string,
|
||||||
firstName?: string,
|
firstName?: string,
|
||||||
referralCode?: string,
|
referralCode?: string,
|
||||||
|
acceptedLegalDocuments?: string[],
|
||||||
) => Promise<RegisterResponse>;
|
) => Promise<RegisterResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,10 +265,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
return initState.promise;
|
return initState.promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegram: async (initData) => {
|
loginWithTelegram: async (initData, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
const referralCode = getPendingReferralCode();
|
||||||
const response = await authApi.loginTelegram(initData, campaignSlug, referralCode);
|
const response = await authApi.loginTelegram(
|
||||||
|
initData,
|
||||||
|
campaignSlug,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
);
|
||||||
// Clear only after successful auth — retry keeps the slugs
|
// Clear only after successful auth — retry keeps the slugs
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
@@ -279,10 +288,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegramWidget: async (data) => {
|
loginWithTelegramWidget: async (data, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
const referralCode = getPendingReferralCode();
|
||||||
const response = await authApi.loginTelegramWidget(data, campaignSlug, referralCode);
|
const response = await authApi.loginTelegramWidget(
|
||||||
|
data,
|
||||||
|
campaignSlug,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
);
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
@@ -296,10 +310,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegramOIDC: async (idToken) => {
|
loginWithTelegramOIDC: async (idToken, acceptedLegalDocuments) => {
|
||||||
const campaignSlug = getPendingCampaignSlug();
|
const campaignSlug = getPendingCampaignSlug();
|
||||||
const referralCode = getPendingReferralCode();
|
const referralCode = getPendingReferralCode();
|
||||||
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
|
const response = await authApi.loginTelegramOIDC(
|
||||||
|
idToken,
|
||||||
|
campaignSlug,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
);
|
||||||
consumeCampaignSlug();
|
consumeCampaignSlug();
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||||
@@ -370,7 +389,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
registerWithEmail: async (
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
referralCode,
|
||||||
|
acceptedLegalDocuments,
|
||||||
|
) => {
|
||||||
const code = referralCode || getPendingReferralCode() || undefined;
|
const code = referralCode || getPendingReferralCode() || undefined;
|
||||||
const campaignSlug = getPendingCampaignSlug() || undefined;
|
const campaignSlug = getPendingCampaignSlug() || undefined;
|
||||||
const response = await authApi.registerEmailStandalone({
|
const response = await authApi.registerEmailStandalone({
|
||||||
@@ -380,6 +405,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
language: navigator.language.split('-')[0] || 'ru',
|
language: navigator.language.split('-')[0] || 'ru',
|
||||||
referral_code: code,
|
referral_code: code,
|
||||||
campaign_slug: campaignSlug,
|
campaign_slug: campaignSlug,
|
||||||
|
accepted_legal_documents: acceptedLegalDocuments,
|
||||||
});
|
});
|
||||||
consumeReferralCode();
|
consumeReferralCode();
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -529,6 +529,14 @@ export interface TicketDetail extends Omit<Ticket, 'messages_count' | 'last_mess
|
|||||||
messages: TicketMessage[];
|
messages: TicketMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гейт согласия с офертой/политикой на экране первой авторизации.
|
||||||
|
// documents — ключи документов, которые бэк реально требует отметить.
|
||||||
|
export interface LegalConsentConfig {
|
||||||
|
required: boolean;
|
||||||
|
prechecked: boolean;
|
||||||
|
documents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SupportConfig {
|
export interface SupportConfig {
|
||||||
tickets_enabled: boolean;
|
tickets_enabled: boolean;
|
||||||
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
||||||
|
|||||||
Reference in New Issue
Block a user