From 37a53e40ea37cdba0882f98455d8b00bc9dde2d2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 9 Jul 2026 18:14:49 +0300 Subject: [PATCH] fix(auth): make login footer legal links open real pages, not the login tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 3 ++ src/components/LegalFooter.tsx | 4 +- src/locales/en.json | 1 + src/locales/ru.json | 1 + src/pages/Info.tsx | 86 +-------------------------------- src/pages/PublicLegal.tsx | 88 ++++++++++++++++++++++++++++++++++ src/utils/legalContent.ts | 88 ++++++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 86 deletions(-) create mode 100644 src/pages/PublicLegal.tsx create mode 100644 src/utils/legalContent.ts diff --git a/src/App.tsx b/src/App.tsx index ce45aa3..bfab85e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import TelegramRedirect from './pages/TelegramRedirect'; import DeepLinkRedirect from './pages/DeepLinkRedirect'; import VerifyEmail from './pages/VerifyEmail'; import ResetPassword from './pages/ResetPassword'; +import PublicLegal from './pages/PublicLegal'; import OAuthCallback from './pages/OAuthCallback'; // Dashboard - load eagerly (default route, LCP-critical) @@ -266,6 +267,8 @@ function App() { } /> } /> } /> + } /> + } /> ( const BUILTIN_TABS = new Set(['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) const ALLOWED_IFRAME_HOSTS = new Set([ 'www.youtube.com', @@ -181,55 +146,6 @@ const sanitizeRichHtml = (html: string): string => { 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 (, , , , ) 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 `${text}`; - } - - // 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) => `
  • ${line}
  • `) - .join(''); - return isOrdered ? `
      ${listItems}
    ` : `
      ${listItems}
    `; - } - - // Regular paragraph — single newlines become
    - const formatted = trimmed.split('\n').join('
    '); - return `

    ${formatted}

    `; - }) - .filter(Boolean) - .join(''); - - return sanitizeHtml(result); -}; - // --- FAQ Accordion for tab replacements --- function ReplacementFaqItem({ diff --git a/src/pages/PublicLegal.tsx b/src/pages/PublicLegal.tsx new file mode 100644 index 0000000..024b17b --- /dev/null +++ b/src/pages/PublicLegal.tsx @@ -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 ( +
    +
    + +
    + +
    +

    {title}

    + + {isLoading ? ( +
    +
    +
    + ) : isError || !data?.content ? ( +
    + {t('info.documentUnavailable', 'Документ пока недоступен.')} +
    + ) : ( +
    +
    + {data.updated_at && ( +

    + {t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()} +

    + )} +
    + )} + +
    + + {t('auth.backToLogin', 'Вернуться ко входу')} + +
    +
    +
    + ); +} diff --git a/src/utils/legalContent.ts b/src/utils/legalContent.ts new file mode 100644 index 0000000..b9a767d --- /dev/null +++ b/src/utils/legalContent.ts @@ -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 (, , , ,
    ) 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 `${text}`; + } + + // 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) => `
  • ${line}
  • `) + .join(''); + return isOrdered ? `
      ${listItems}
    ` : `
      ${listItems}
    `; + } + + // Regular paragraph — single newlines become
    + const formatted = trimmed.split('\n').join('
    '); + return `

    ${formatted}

    `; + }) + .filter(Boolean) + .join(''); + + return sanitizeHtml(result); +};