mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix(auth): make login footer legal links open real pages, not the login tab
The login-page footer linked to /offer, /privacy and /recurrent-payments with target=_blank, but none of those routes existed, so opening them hit the SPA catch-all, which redirects unauthenticated users to /login — every footer link just opened another login tab. - Add a public PublicLegal page that fetches the offer / privacy document from the existing public /cabinet/info endpoints and renders sanitized HTML (DOMPurify via the shared formatContent helper). - Register public /offer and /privacy routes. - Extract sanitizeHtml/formatContent into src/utils/legalContent.ts and reuse from Info.tsx (was duplicated). - Drop the /recurrent-payments footer link: there is no recurring-payments legal document or endpoint in the backend, so it had no real destination. Re-add once such a document exists. type-check, eslint, prettier and build all pass.
This commit is contained in:
@@ -42,6 +42,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
|
|||||||
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
import PublicLegal from './pages/PublicLegal';
|
||||||
import OAuthCallback from './pages/OAuthCallback';
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
|
|
||||||
// Dashboard - load eagerly (default route, LCP-critical)
|
// Dashboard - load eagerly (default route, LCP-critical)
|
||||||
@@ -266,6 +267,8 @@ function App() {
|
|||||||
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
|
<Route path="/offer" element={<PublicLegal doc="offer" />} />
|
||||||
|
<Route path="/privacy" element={<PublicLegal doc="privacy" />} />
|
||||||
<Route
|
<Route
|
||||||
path="/merge/:mergeToken"
|
path="/merge/:mergeToken"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ interface LegalLink {
|
|||||||
fallback: string;
|
fallback: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurring-payments has no dedicated legal document in the backend yet, so it is
|
||||||
|
// intentionally omitted rather than pointed at unrelated content. Re-add once a
|
||||||
|
// `recurrent-payments` document + public endpoint exist.
|
||||||
const LINKS: LegalLink[] = [
|
const LINKS: LegalLink[] = [
|
||||||
{ href: '/offer', labelKey: 'footer.offer', fallback: 'Публичная оферта' },
|
{ href: '/offer', labelKey: 'footer.offer', fallback: 'Публичная оферта' },
|
||||||
{ href: '/privacy', labelKey: 'footer.privacy', fallback: 'Политика конфиденциальности' },
|
{ href: '/privacy', labelKey: 'footer.privacy', fallback: 'Политика конфиденциальности' },
|
||||||
{ href: '/recurrent-payments', labelKey: 'footer.recurrent', fallback: 'Рекуррентные платежи' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface LegalFooterProps {
|
interface LegalFooterProps {
|
||||||
|
|||||||
@@ -4779,6 +4779,7 @@
|
|||||||
"loyalty": "Loyalty",
|
"loyalty": "Loyalty",
|
||||||
"noFaq": "No FAQ available",
|
"noFaq": "No FAQ available",
|
||||||
"noContent": "No content available",
|
"noContent": "No content available",
|
||||||
|
"documentUnavailable": "This document is not available yet.",
|
||||||
"updatedAt": "Updated",
|
"updatedAt": "Updated",
|
||||||
"noLoyaltyTiers": "Loyalty program is not available yet",
|
"noLoyaltyTiers": "Loyalty program is not available yet",
|
||||||
"yourProgress": "Your Progress",
|
"yourProgress": "Your Progress",
|
||||||
|
|||||||
@@ -5333,6 +5333,7 @@
|
|||||||
"loyalty": "Статусы",
|
"loyalty": "Статусы",
|
||||||
"noFaq": "Нет вопросов и ответов",
|
"noFaq": "Нет вопросов и ответов",
|
||||||
"noContent": "Нет контента",
|
"noContent": "Нет контента",
|
||||||
|
"documentUnavailable": "Документ пока недоступен.",
|
||||||
"updatedAt": "Обновлено",
|
"updatedAt": "Обновлено",
|
||||||
"noLoyaltyTiers": "Программа лояльности пока недоступна",
|
"noLoyaltyTiers": "Программа лояльности пока недоступна",
|
||||||
"yourProgress": "Ваш прогресс",
|
"yourProgress": "Ваш прогресс",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { PiCaretDown } from 'react-icons/pi';
|
import { PiCaretDown } from 'react-icons/pi';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { infoApi, FaqPage, InfoVisibility } from '../api/info';
|
import { infoApi, FaqPage, InfoVisibility } from '../api/info';
|
||||||
|
import { formatContent } from '../utils/legalContent';
|
||||||
import { infoPagesApi } from '../api/infoPages';
|
import { infoPagesApi } from '../api/infoPages';
|
||||||
import { promoApi, LoyaltyTierInfo } from '../api/promo';
|
import { promoApi, LoyaltyTierInfo } from '../api/promo';
|
||||||
import type { FaqItem, ReplacesTab } from '../api/infoPages';
|
import type { FaqItem, ReplacesTab } from '../api/infoPages';
|
||||||
@@ -15,42 +16,6 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
|
|
||||||
const BUILTIN_TABS = new Set<string>(['faq', 'rules', 'privacy', 'offer', 'loyalty']);
|
const BUILTIN_TABS = new Set<string>(['faq', 'rules', 'privacy', 'offer', 'loyalty']);
|
||||||
|
|
||||||
// Sanitize HTML content to prevent XSS
|
|
||||||
const sanitizeHtml = (html: string): string => {
|
|
||||||
return DOMPurify.sanitize(html, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
'p',
|
|
||||||
'br',
|
|
||||||
'b',
|
|
||||||
'i',
|
|
||||||
'u',
|
|
||||||
'strong',
|
|
||||||
'em',
|
|
||||||
'a',
|
|
||||||
'ul',
|
|
||||||
'ol',
|
|
||||||
'li',
|
|
||||||
'h1',
|
|
||||||
'h2',
|
|
||||||
'h3',
|
|
||||||
'h4',
|
|
||||||
'h5',
|
|
||||||
'h6',
|
|
||||||
'blockquote',
|
|
||||||
'code',
|
|
||||||
'pre',
|
|
||||||
's',
|
|
||||||
'del',
|
|
||||||
'ins',
|
|
||||||
'span',
|
|
||||||
'div',
|
|
||||||
'tg-spoiler',
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
|
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rich sanitizer for custom InfoPage content (TipTap editor output with media)
|
// Rich sanitizer for custom InfoPage content (TipTap editor output with media)
|
||||||
const ALLOWED_IFRAME_HOSTS = new Set([
|
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||||
'www.youtube.com',
|
'www.youtube.com',
|
||||||
@@ -181,55 +146,6 @@ const sanitizeRichHtml = (html: string): string => {
|
|||||||
return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG);
|
return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert content to formatted HTML (handles Telegram HTML + plain text)
|
|
||||||
const formatContent = (content: string): string => {
|
|
||||||
if (!content) return '';
|
|
||||||
|
|
||||||
// Check if content has block-level HTML (full HTML document)
|
|
||||||
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
|
|
||||||
|
|
||||||
if (hasBlockHtml) {
|
|
||||||
return sanitizeHtml(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
|
|
||||||
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
|
|
||||||
const result = content
|
|
||||||
.split(/\n\n+/)
|
|
||||||
.map((paragraph) => {
|
|
||||||
const trimmed = paragraph.trim();
|
|
||||||
if (!trimmed) return '';
|
|
||||||
|
|
||||||
// Check if it's a markdown header
|
|
||||||
if (/^#{1,4}\s/.test(trimmed)) {
|
|
||||||
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
|
|
||||||
const text = trimmed.replace(/^#{1,4}\s*/, '');
|
|
||||||
return `<h${level}>${text}</h${level}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for list items
|
|
||||||
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
|
|
||||||
const lines = trimmed.split('\n');
|
|
||||||
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
|
|
||||||
const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
|
|
||||||
const listItems = lines
|
|
||||||
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
|
|
||||||
.filter((line) => line.trim())
|
|
||||||
.map((line) => `<li>${line}</li>`)
|
|
||||||
.join('');
|
|
||||||
return isOrdered ? `<ol start="${startNum}">${listItems}</ol>` : `<ul>${listItems}</ul>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular paragraph — single newlines become <br/>
|
|
||||||
const formatted = trimmed.split('\n').join('<br/>');
|
|
||||||
return `<p>${formatted}</p>`;
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
return sanitizeHtml(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- FAQ Accordion for tab replacements ---
|
// --- FAQ Accordion for tab replacements ---
|
||||||
|
|
||||||
function ReplacementFaqItem({
|
function ReplacementFaqItem({
|
||||||
|
|||||||
88
src/pages/PublicLegal.tsx
Normal file
88
src/pages/PublicLegal.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { infoApi } from '../api/info';
|
||||||
|
import { formatContent } from '../utils/legalContent';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
|
export type PublicLegalDoc = 'offer' | 'privacy';
|
||||||
|
|
||||||
|
interface PublicLegalProps {
|
||||||
|
doc: PublicLegalDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_CONFIG: Record<
|
||||||
|
PublicLegalDoc,
|
||||||
|
{
|
||||||
|
queryKey: string;
|
||||||
|
titleKey: string;
|
||||||
|
titleFallback: string;
|
||||||
|
fetch: () => Promise<{ content: string; updated_at: string | null }>;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
offer: {
|
||||||
|
queryKey: 'public-offer',
|
||||||
|
titleKey: 'footer.offer',
|
||||||
|
titleFallback: 'Публичная оферта',
|
||||||
|
fetch: infoApi.getPublicOffer,
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
queryKey: 'privacy-policy',
|
||||||
|
titleKey: 'footer.privacy',
|
||||||
|
titleFallback: 'Политика конфиденциальности',
|
||||||
|
fetch: infoApi.getPrivacyPolicy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public, unauthenticated viewer for the legal documents linked from the login
|
||||||
|
// footer. Reads the same public /cabinet/info endpoints the authenticated Info page
|
||||||
|
// uses, so the pages are reachable before login instead of bouncing to /login.
|
||||||
|
export default function PublicLegal({ doc }: PublicLegalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const config = DOC_CONFIG[doc];
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['public-legal', config.queryKey],
|
||||||
|
queryFn: config.fetch,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = t(config.titleKey, config.titleFallback);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-viewport bg-dark-950 px-4 py-8 sm:py-12">
|
||||||
|
<div className="fixed right-4 top-4 z-50">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
|
<h1 className="mb-6 text-2xl font-semibold text-dark-100">{title}</h1>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : isError || !data?.content ? (
|
||||||
|
<div className="bento-card text-dark-400">
|
||||||
|
{t('info.documentUnavailable', 'Документ пока недоступен.')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: formatContent(data.content) }} />
|
||||||
|
{data.updated_at && (
|
||||||
|
<p className="mt-4 text-xs text-dark-500">
|
||||||
|
{t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link to="/login" className="btn-secondary">
|
||||||
|
{t('auth.backToLogin', 'Вернуться ко входу')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/utils/legalContent.ts
Normal file
88
src/utils/legalContent.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
// Sanitize HTML content to prevent XSS. Shared by the authenticated Info page and
|
||||||
|
// the public legal pages (offer / privacy) reachable from the login footer.
|
||||||
|
export const sanitizeHtml = (html: string): string => {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'br',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'a',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'pre',
|
||||||
|
's',
|
||||||
|
'del',
|
||||||
|
'ins',
|
||||||
|
'span',
|
||||||
|
'div',
|
||||||
|
'tg-spoiler',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render legal/document content that may be either full block-level HTML or plain
|
||||||
|
// text with Telegram-style inline tags and newline structure.
|
||||||
|
export const formatContent = (content: string): string => {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Check if content has block-level HTML (full HTML document)
|
||||||
|
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
|
||||||
|
|
||||||
|
if (hasBlockHtml) {
|
||||||
|
return sanitizeHtml(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
|
||||||
|
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
|
||||||
|
const result = content
|
||||||
|
.split(/\n\n+/)
|
||||||
|
.map((paragraph) => {
|
||||||
|
const trimmed = paragraph.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
// Check if it's a markdown header
|
||||||
|
if (/^#{1,4}\s/.test(trimmed)) {
|
||||||
|
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
|
||||||
|
const text = trimmed.replace(/^#{1,4}\s*/, '');
|
||||||
|
return `<h${level}>${text}</h${level}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for list items
|
||||||
|
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
|
||||||
|
const lines = trimmed.split('\n');
|
||||||
|
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
|
||||||
|
const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
|
||||||
|
const listItems = lines
|
||||||
|
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => `<li>${line}</li>`)
|
||||||
|
.join('');
|
||||||
|
return isOrdered ? `<ol start="${startNum}">${listItems}</ol>` : `<ul>${listItems}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraph — single newlines become <br/>
|
||||||
|
const formatted = trimmed.split('\n').join('<br/>');
|
||||||
|
return `<p>${formatted}</p>`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return sanitizeHtml(result);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user