Logged-out home stays the product page (#120)
Unit Tests / test (push) Successful in 12s
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / deploy-beta (push) Successful in 4m8s

## 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: #120
This commit was merged in pull request #120.
This commit is contained in:
westfarn committed 2026-10-19 01:09:43 -07:00
1 parent fa91c4969f
commit 4a5b05f421
15 files changed
+149 -38

No files matched your search

+14 -5
View File
@@ -11,6 +11,7 @@ import {
isTokenNotValidError, isTokenNotValidError,
} from "./llm-fe/auth/jwtHelpers"; } from "./llm-fe/auth/jwtHelpers";
import { redirectToAppPath } from "./llm-fe/platform/nativePlatform"; 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; 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) { if (error.response.status === 401 && isRefreshCall) {
await clearTokens(); await clearTokens();
applyAccessToken(null); applyAccessToken(null);
// Hash-safe on Capacitor (#24); plain path on web BrowserRouter. // Stay on the product page. Only bounce protected app routes.
redirectToAppPath("/signin/"); if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error); return Promise.reject(error);
} }
@@ -109,7 +112,9 @@ axiosInstance.interceptors.response.use(
if (!refresh_token) { if (!refresh_token) {
await clearTokens(); await clearTokens();
applyAccessToken(null); applyAccessToken(null);
redirectToAppPath("/signin/"); if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(error); return Promise.reject(error);
} }
@@ -127,14 +132,18 @@ axiosInstance.interceptors.response.use(
} catch (err) { } catch (err) {
await clearTokens(); await clearTokens();
applyAccessToken(null); applyAccessToken(null);
redirectToAppPath("/signin/"); if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
return Promise.reject(err); return Promise.reject(err);
} }
} }
await clearTokens(); await clearTokens();
applyAccessToken(null); applyAccessToken(null);
redirectToAppPath("/signin/"); if (shouldRedirectToSignIn(requestUrl, window.location.pathname)) {
redirectToAppPath("/signin/");
}
} }
return Promise.reject(error); return Promise.reject(error);
@@ -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);
});
});
+38
View File
@@ -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;
}
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi'; import { axiosInstance } from '../../../axiosApi';
import { AnalyticsEvents, trackEvent } from '../../utils/analytics'; import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
import { publicAccent, publicCard, publicLine, publicText } from './publicColors';
const Stack = styled.div` const Stack = styled.div`
display: flex; display: flex;
@@ -34,12 +35,13 @@ const Primary = styled(Link)`
`; `;
const Secondary = styled(Link)` const Secondary = styled(Link)`
color: #d6dcff; color: ${publicAccent};
font-weight: 600; font-weight: 600;
`; `;
const Quiet = styled(Link)` const Quiet = styled(Link)`
color: rgba(255, 255, 255, 0.72); color: ${publicText};
opacity: 0.75;
font-size: 0.95rem; font-size: 0.95rem;
`; `;
@@ -63,11 +65,11 @@ const Label = styled.label`
const Input = styled.input` const Input = styled.input`
flex: 1 1 14rem; flex: 1 1 14rem;
background: rgba(255, 255, 255, 0.06); background: ${publicCard};
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid ${publicLine};
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
color: #fff; color: ${publicText};
font-size: 1rem; font-size: 1rem;
`; `;
@@ -89,7 +91,7 @@ const Submit = styled.button`
const Note = styled.p` const Note = styled.p`
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.8); color: ${publicText};
font-size: 0.95rem; font-size: 0.95rem;
`; `;
@@ -1,12 +1,21 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ParticleBackground from '../ParticleBackground/ParticleBackground';
import { PUBLIC_FOOTER_LINKS, PUBLIC_HEADER_LINKS } from './publicNav'; 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` const Page = styled.div`
position: relative;
z-index: 1;
min-height: 100vh; min-height: 100vh;
background: #07080d; background: transparent;
color: #fff; color: ${text};
font-family: Inter, sans-serif; font-family: Inter, sans-serif;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -19,11 +28,11 @@ const Header = styled.header`
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid ${line};
`; `;
const Brand = styled(Link)` const Brand = styled(Link)`
color: #fff; color: ${text};
font-weight: 700; font-weight: 700;
font-size: 1.15rem; font-size: 1.15rem;
text-decoration: none; text-decoration: none;
@@ -38,19 +47,20 @@ const Nav = styled.nav`
`; `;
const NavLink = styled(Link)` const NavLink = styled(Link)`
color: rgba(255, 255, 255, 0.82); color: ${text};
text-decoration: none; text-decoration: none;
font-size: 0.95rem; font-size: 0.95rem;
opacity: 0.85;
&:hover { &:hover {
color: #fff; opacity: 1;
} }
`; `;
const SignInLink = styled(Link)` const SignInLink = styled(Link)`
color: #fff; color: ${text};
text-decoration: none; text-decoration: none;
border: 1px solid rgba(255, 255, 255, 0.28); border: 1px solid ${line};
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0.45rem 0.8rem; padding: 0.45rem 0.8rem;
font-size: 0.95rem; font-size: 0.95rem;
@@ -64,10 +74,11 @@ const Main = styled.main`
`; `;
const FooterBar = styled.footer` const FooterBar = styled.footer`
border-top: 1px solid rgba(255, 255, 255, 0.08); border-top: 1px solid ${line};
padding: 1.5rem; padding: 1.5rem;
color: rgba(255, 255, 255, 0.7); color: ${text};
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.85;
`; `;
const FooterNav = styled.nav` const FooterNav = styled.nav`
@@ -78,7 +89,7 @@ const FooterNav = styled.nav`
`; `;
const FooterLink = styled(Link)` const FooterLink = styled(Link)`
color: rgba(255, 255, 255, 0.82); color: ${text};
`; `;
type PublicSiteLayoutProps = { type PublicSiteLayoutProps = {
@@ -88,6 +99,7 @@ type PublicSiteLayoutProps = {
const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => { const PublicSiteLayout = ({ children }: PublicSiteLayoutProps): JSX.Element => {
return ( return (
<Page> <Page>
<ParticleBackground />
<Header> <Header>
<Brand to="/">Hesychia</Brand> <Brand to="/">Hesychia</Brand>
<Nav aria-label="Primary"> <Nav aria-label="Primary">
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi'; import { axiosInstance } from '../../../axiosApi';
import { publicText } from './publicColors';
const Section = styled.section` const Section = styled.section`
margin-top: 1.5rem; margin-top: 1.5rem;
color: rgba(255, 255, 255, 0.86); color: ${publicText};
font-size: 0.95rem; font-size: 0.95rem;
`; `;
@@ -15,7 +16,8 @@ const Figures = styled.p`
const AsOf = styled.p` const AsOf = styled.p`
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.62); color: ${publicText};
opacity: 0.7;
`; `;
type PublicUsage = { type PublicUsage = {
@@ -0,0 +1,17 @@
type PublicTheme = {
colors?: {
text?: string;
cardBackground?: string;
cardBorder?: string;
};
};
export const publicText = ({ theme }: { theme?: PublicTheme }) => theme?.colors?.text ?? '#344767';
export const publicCard = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBackground ?? 'rgba(255, 255, 255, 0.72)';
export const publicLine = ({ theme }: { theme?: PublicTheme }) =>
theme?.colors?.cardBorder ?? 'rgba(0, 0, 0, 0.08)';
export const publicAccent = '#667eea';
@@ -4,6 +4,8 @@ import { MemoryRouter } from 'react-router-dom';
import ChangelogPage from './ChangelogPage'; import ChangelogPage from './ChangelogPage';
import { CHANGELOG_ENTRIES } from './changelogEntries'; import { CHANGELOG_ENTRIES } from './changelogEntries';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('ChangelogPage', () => { describe('ChangelogPage', () => {
it('lists dated shipped notes and no founder', () => { it('lists dated shipped notes and no founder', () => {
render( render(
@@ -2,6 +2,7 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { CHANGELOG_ENTRIES } from './changelogEntries'; import { CHANGELOG_ENTRIES } from './changelogEntries';
import { publicLine, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1` const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem); font-size: clamp(2rem, 4vw, 2.75rem);
@@ -17,13 +18,14 @@ const List = styled.ol`
`; `;
const Item = styled.li` const Item = styled.li`
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid ${publicLine};
padding-top: 1rem; padding-top: 1rem;
`; `;
const When = styled.time` const When = styled.time`
display: block; display: block;
color: rgba(255, 255, 255, 0.6); color: ${publicText};
opacity: 0.65;
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
`; `;
@@ -35,7 +37,7 @@ const Heading = styled.h2`
const Note = styled.p` const Note = styled.p`
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.82); color: ${publicText};
line-height: 1.5; line-height: 1.5;
max-width: 40rem; max-width: 40rem;
`; `;
@@ -4,6 +4,8 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import LandingPage from './LandingPage'; import LandingPage from './LandingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockGet = jest.fn(); const mockGet = jest.fn();
const mockPost = jest.fn(); const mockPost = jest.fn();
@@ -5,6 +5,7 @@ import hesychiaMark from '../../assets/brand/hesychia-mark.png';
import GetStarted from '../../components/PublicSite/GetStarted'; import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import UsageCounts from '../../components/PublicSite/UsageCounts'; import UsageCounts from '../../components/PublicSite/UsageCounts';
import { publicAccent, publicCard, publicLine, publicText } from '../../components/PublicSite/publicColors';
const Hero = styled.header` const Hero = styled.header`
max-width: 40rem; max-width: 40rem;
@@ -27,14 +28,15 @@ const Lede = styled.p`
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.45; line-height: 1.45;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
color: rgba(255, 255, 255, 0.92); color: ${publicText};
`; `;
const Memory = styled.p` const Memory = styled.p`
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.5; line-height: 1.5;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: rgba(255, 255, 255, 0.82); color: ${publicText};
opacity: 0.85;
`; `;
const Privacy = styled.section` const Privacy = styled.section`
@@ -45,12 +47,13 @@ const Privacy = styled.section`
const PrivacyList = styled.ul` const PrivacyList = styled.ul`
margin: 0.5rem 0 0.75rem; margin: 0.5rem 0 0.75rem;
padding-left: 1.2rem; padding-left: 1.2rem;
color: rgba(255, 255, 255, 0.82); color: ${publicText};
opacity: 0.85;
line-height: 1.5; line-height: 1.5;
`; `;
const TextLink = styled(Link)` const TextLink = styled(Link)`
color: #c9d4ff; color: ${publicAccent};
`; `;
const Features = styled.section` const Features = styled.section`
@@ -60,10 +63,10 @@ const Features = styled.section`
`; `;
const Feature = styled.article` const Feature = styled.article`
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid ${publicLine};
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1.25rem 1.35rem; padding: 1.25rem 1.35rem;
background: rgba(255, 255, 255, 0.03); background: ${publicCard};
`; `;
const FeatureTitle = styled.h2` const FeatureTitle = styled.h2`
@@ -73,7 +76,8 @@ const FeatureTitle = styled.h2`
const FeatureBody = styled.p` const FeatureBody = styled.p`
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.8); color: ${publicText};
opacity: 0.85;
line-height: 1.5; line-height: 1.5;
`; `;
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import PricingPage from './PricingPage'; import PricingPage from './PricingPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
jest.mock('../../../axiosApi', () => ({ jest.mock('../../../axiosApi', () => ({
axiosInstance: { axiosInstance: {
get: () => Promise.resolve({ data: { enable_account_registration: false } }), get: () => Promise.resolve({ data: { enable_account_registration: false } }),
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import GetStarted from '../../components/PublicSite/GetStarted'; import GetStarted from '../../components/PublicSite/GetStarted';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1` const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem); font-size: clamp(2rem, 4vw, 2.75rem);
@@ -15,13 +16,13 @@ const Price = styled.p`
`; `;
const Body = styled.p` const Body = styled.p`
color: rgba(255, 255, 255, 0.82); color: ${publicText};
line-height: 1.5; line-height: 1.5;
max-width: 36rem; max-width: 36rem;
`; `;
const Back = styled(Link)` const Back = styled(Link)`
color: #c9d4ff; color: ${publicAccent};
`; `;
const PricingPage = (): JSX.Element => { const PricingPage = (): JSX.Element => {
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import PrivacyPage from './PrivacyPage'; import PrivacyPage from './PrivacyPage';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
describe('PrivacyPage', () => { describe('PrivacyPage', () => {
it('states storage, no ads, and that cancel does not delete documents', () => { it('states storage, no ads, and that cancel does not delete documents', () => {
render( render(
@@ -2,6 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout'; import PublicSiteLayout from '../../components/PublicSite/PublicSiteLayout';
import { publicAccent, publicText } from '../../components/PublicSite/publicColors';
const Title = styled.h1` const Title = styled.h1`
font-size: clamp(2rem, 4vw, 2.75rem); font-size: clamp(2rem, 4vw, 2.75rem);
@@ -20,12 +21,12 @@ const Heading = styled.h2`
const Body = styled.p` const Body = styled.p`
margin: 0; margin: 0;
color: rgba(255, 255, 255, 0.82); color: ${publicText};
line-height: 1.55; line-height: 1.55;
`; `;
const TextLink = styled(Link)` const TextLink = styled(Link)`
color: #c9d4ff; color: ${publicAccent};
`; `;
const PrivacyPage = (): JSX.Element => { const PrivacyPage = (): JSX.Element => {