From 4a5b05f421d0d56cb4af2fd1c9815441f2b1f7ee Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 19 Oct 2026 01:09:43 -0700 Subject: [PATCH] Logged-out home stays the product page (#120) ## Summary - Closes #118 and #119. - Logged-out `/` is the product page. Sign In is a link. After sign-in, `/` is still the chat dashboard. - A 401 from a public API call (for example a stale token on `/public/usage/`) no longer sends visitors from `/` to Sign In. Protected routes such as `/account/` still redirect. - Product, pricing, changelog, and privacy use the same floating-dot background as Sign In and the dashboard. Text follows the light and dark theme so it stays readable. ## Test plan - [x] `authRedirect` tests: public API calls and public paths do not redirect; `/account/` and `/document_storage` still do. - [x] Landing, pricing, privacy, changelog, HomeRoute, and Sign In tests pass. - [ ] Logged out, open `/` and confirm the product page, then Sign In, then `/` is the dashboard. - [ ] With an expired access token still in storage, open `/` and confirm you stay on the product page. - [ ] Check product, pricing, changelog, and privacy in light and dark mode. Dots visible, text readable. Reviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/120 --- llm-fe/src/axiosApi.js | 19 +++++++--- llm-fe/src/llm-fe/auth/authRedirect.test.ts | 15 ++++++++ llm-fe/src/llm-fe/auth/authRedirect.ts | 38 +++++++++++++++++++ .../components/PublicSite/GetStarted.tsx | 14 ++++--- .../PublicSite/PublicSiteLayout.tsx | 34 +++++++++++------ .../components/PublicSite/UsageCounts.tsx | 6 ++- .../components/PublicSite/publicColors.ts | 17 +++++++++ .../pages/Changelog/ChangelogPage.test.tsx | 2 + .../llm-fe/pages/Changelog/ChangelogPage.tsx | 8 ++-- .../llm-fe/pages/Landing/LandingPage.test.tsx | 2 + .../src/llm-fe/pages/Landing/LandingPage.tsx | 18 +++++---- .../llm-fe/pages/Pricing/PricingPage.test.tsx | 2 + .../src/llm-fe/pages/Pricing/PricingPage.tsx | 5 ++- .../llm-fe/pages/Privacy/PrivacyPage.test.tsx | 2 + .../src/llm-fe/pages/Privacy/PrivacyPage.tsx | 5 ++- 15 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 llm-fe/src/llm-fe/auth/authRedirect.test.ts create mode 100644 llm-fe/src/llm-fe/auth/authRedirect.ts create mode 100644 llm-fe/src/llm-fe/components/PublicSite/publicColors.ts diff --git a/llm-fe/src/axiosApi.js b/llm-fe/src/axiosApi.js index 0245b8a..0198221 100644 --- a/llm-fe/src/axiosApi.js +++ b/llm-fe/src/axiosApi.js @@ -11,6 +11,7 @@ import { isTokenNotValidError, } from "./llm-fe/auth/jwtHelpers"; import { redirectToAppPath } from "./llm-fe/platform/nativePlatform"; +import { shouldRedirectToSignIn } from "./llm-fe/auth/authRedirect"; const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL; @@ -97,8 +98,10 @@ axiosInstance.interceptors.response.use( if (error.response.status === 401 && isRefreshCall) { await clearTokens(); applyAccessToken(null); - // Hash-safe on Capacitor (#24); plain path on web BrowserRouter. - redirectToAppPath("/signin/"); + // Stay on the product page. Only bounce protected app routes. + if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) { + redirectToAppPath("/signin/"); + } return Promise.reject(error); } @@ -109,7 +112,9 @@ axiosInstance.interceptors.response.use( if (!refresh_token) { await clearTokens(); applyAccessToken(null); - redirectToAppPath("/signin/"); + if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) { + redirectToAppPath("/signin/"); + } return Promise.reject(error); } @@ -127,14 +132,18 @@ axiosInstance.interceptors.response.use( } catch (err) { await clearTokens(); applyAccessToken(null); - redirectToAppPath("/signin/"); + if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) { + redirectToAppPath("/signin/"); + } return Promise.reject(err); } } await clearTokens(); applyAccessToken(null); - redirectToAppPath("/signin/"); + if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) { + redirectToAppPath("/signin/"); + } } return Promise.reject(error); diff --git a/llm-fe/src/llm-fe/auth/authRedirect.test.ts b/llm-fe/src/llm-fe/auth/authRedirect.test.ts new file mode 100644 index 0000000..36a0de1 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/authRedirect.test.ts @@ -0,0 +1,15 @@ +import { shouldRedirectToSignIn } from './authRedirect'; + +describe('shouldRedirectToSignIn', () => { + it('keeps a logged-out visitor on the product page', () => { + expect(shouldRedirectToSignIn('/public/usage/', '/')).toBe(false); + expect(shouldRedirectToSignIn('/public/settings/', '/pricing/')).toBe(false); + expect(shouldRedirectToSignIn('/user/get/', '/')).toBe(false); + expect(shouldRedirectToSignIn('/user/get/', '/changelog/')).toBe(false); + }); + + it('still sends an expired session on the app to sign in', () => { + expect(shouldRedirectToSignIn('/user/get/', '/account/')).toBe(true); + expect(shouldRedirectToSignIn('/token/refresh/', '/document_storage')).toBe(true); + }); +}); diff --git a/llm-fe/src/llm-fe/auth/authRedirect.ts b/llm-fe/src/llm-fe/auth/authRedirect.ts new file mode 100644 index 0000000..b798243 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/authRedirect.ts @@ -0,0 +1,38 @@ +/** Public marketing routes. An auth failure here must not replace the page with Sign In. */ + +const PUBLIC_PATHS = new Set([ + "/", + "/pricing", + "/privacy", + "/changelog", + "/terms_of_service", + "/legal/terms-of-service", + "/account-deletion", + "/signin", + "/signup", + "/password_reset", + "/password_reset_confirmation", + "/set_password", +]); + +export function normalizeAppPath(pathname: string | null | undefined): string { + if (!pathname || pathname === "/") return "/"; + return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +export function isPublicApiRequest(requestUrl: string | null | undefined): boolean { + return String(requestUrl || "").includes("/public/"); +} + +export function isPublicAppPath(pathname: string | null | undefined): boolean { + return PUBLIC_PATHS.has(normalizeAppPath(pathname)); +} + +export function shouldRedirectToSignIn( + requestUrl: string | null | undefined, + pathname: string | null | undefined, +): boolean { + if (isPublicApiRequest(requestUrl)) return false; + if (isPublicAppPath(pathname)) return false; + return true; +} diff --git a/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx b/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx index 9305c01..159dbd7 100644 --- a/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx +++ b/llm-fe/src/llm-fe/components/PublicSite/GetStarted.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { axiosInstance } from '../../../axiosApi'; import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; +import { publicAccent, publicCard, publicLine, publicText } from './publicColors'; const Stack = styled.div` display: flex; @@ -34,12 +35,13 @@ const Primary = styled(Link)` `; const Secondary = styled(Link)` - color: #d6dcff; + color: ${publicAccent}; font-weight: 600; `; const Quiet = styled(Link)` - color: rgba(255, 255, 255, 0.72); + color: ${publicText}; + opacity: 0.75; font-size: 0.95rem; `; @@ -63,11 +65,11 @@ const Label = styled.label` const Input = styled.input` flex: 1 1 14rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.16); + background: ${publicCard}; + border: 1px solid ${publicLine}; border-radius: 0.5rem; padding: 0.9rem 1rem; - color: #fff; + color: ${publicText}; font-size: 1rem; `; @@ -89,7 +91,7 @@ const Submit = styled.button` const Note = styled.p` margin: 0; - color: rgba(255, 255, 255, 0.8); + color: ${publicText}; font-size: 0.95rem; `; diff --git a/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx b/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx index 861958f..28294c5 100644 --- a/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx +++ b/llm-fe/src/llm-fe/components/PublicSite/PublicSiteLayout.tsx @@ -1,12 +1,21 @@ import React from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; +import ParticleBackground from '../ParticleBackground/ParticleBackground'; import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav'; +const text = ({ theme }: { theme?: { colors?: { text?: string } } }) => + theme?.colors?.text ?? '#344767'; + +const line = ({ theme }: { theme?: { colors?: { cardBorder?: string } } }) => + theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)'; + const Page = styled.div` + position: relative; + z-index: 1; min-height: 100vh; - background: #07080d; - color: #fff; + background: transparent; + color: ${text}; font-family: Inter, sans-serif; display: flex; flex-direction: column; @@ -19,11 +28,11 @@ const Header = styled.header` justify-content: space-between; gap: 1rem; padding: 1.25rem 1.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid ${line}; `; const Brand = styled(Link)` - color: #fff; + color: ${text}; font-weight: 700; font-size: 1.15rem; text-decoration: none; @@ -38,19 +47,20 @@ const Nav = styled.nav` `; const NavLink = styled(Link)` - color: rgba(255, 255, 255, 0.82); + color: ${text}; text-decoration: none; font-size: 0.95rem; + opacity: 0.85; &:hover { - color: #fff; + opacity: 1; } `; const SignInLink = styled(Link)` - color: #fff; + color: ${text}; text-decoration: none; - border: 1px solid rgba(255, 255, 255, 0.28); + border: 1px solid ${line}; border-radius: 0.5rem; padding: 0.45rem 0.8rem; font-size: 0.95rem; @@ -64,10 +74,11 @@ const Main = styled.main` `; const FooterBar = styled.footer` - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid ${line}; padding: 1.5rem; - color: rgba(255, 255, 255, 0.7); + color: ${text}; font-size: 0.9rem; + opacity: 0.85; `; const FooterNav = styled.nav` @@ -78,7 +89,7 @@ const FooterNav = styled.nav` `; const FooterLink = styled(Link)` - color: rgba(255, 255, 255, 0.82); + color: ${text}; `; type PublicSiteLayoutProps = { @@ -88,6 +99,7 @@ type PublicSiteLayoutProps = { const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => { return ( +
Hesychia