Files
redefined-designs/frontend/src/main.tsx
T
synAdminandClaude Opus 5 2c6ac4d2be
Linting / lint (pull_request) Successful in 3m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m35s
feat(auth): offer Google sign-in on the login form (#345)
The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both.

Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it.

Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds.

Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down.

The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects.

Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again.

That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages.

The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above.

docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare.

The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent.

Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration and end-to-end suites need a database this machine has no Docker for.

Closes #345

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:13:18 -05:00

279 lines
11 KiB
TypeScript
Executable File

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import ConfigProvider from 'antd/es/config-provider';
import antdTheme from 'antd/es/theme';
import 'antd/dist/reset.css';
import Button from 'antd/es/button';
import ModalDialog from 'antd/es/modal';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
import Admin from './admin/Admin';
import AuthRouteModal from './customer/AuthRouteModal';
import Welcome from './customer/Welcome';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Submit from './intake/Submit';
import VerifyEmail from './customer/VerifyEmail';
import ForgotPassword from './customer/ForgotPassword';
import ResetPassword from './customer/ResetPassword';
import Cart from './cart/Cart';
import Orders from './customer/Orders';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { CartProvider } from './cart/CartContext';
import { FavoritesProvider } from './customer/FavoritesContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css';
import { fetchConfig } from './api';
import BrevoTracking from './BrevoTracking';
// The brand accent is monochrome, so it inverts between themes rather than
// switching to a different hue.
const LIGHT_ACCENT = '#1a1a1a';
const DARK_ACCENT = '#f0f0f0';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// What /account renders over when it was entered directly — a bookmark, a link
// in an email, or a redirect after signing in. There is no page behind in that
// case, and a modal floating on nothing has nowhere to close back to.
const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash: '' };
// Routes presented as a modal over the page the customer was already on rather
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
// refreshable, and closed by the browser's Back button — while never being
// somewhere with no way out.
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password', '/welcome'];
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
// standing source of flake in end-to-end tests, which drive the app with this
// preference enabled.
function usePrefersReducedMotion(): boolean {
const [prefers, setPrefers] = useState(
() => typeof window !== 'undefined' && window.matchMedia(REDUCED_MOTION_QUERY).matches
);
useEffect(() => {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
const update = () => setPrefers(query.matches);
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
return prefers;
}
// /account is a route that renders as a modal over whatever the customer was
// looking at, rather than a page of its own. It stays a real, linkable URL —
// bookmarkable, refreshable, and closed by the browser's Back button — while
// never being a place with no way out of it.
// Both hoisted out of the components that declared them inline.
//
// S6478 flags a function-returning-JSX in a prop as "defining a component
// during render". These are render props — ErrorBoundary's `fallback` is typed
// `(error: Error) => React.ReactNode` and called as `this.props.fallback(...)`
// — so React only ever sees the returned elements, never a new component type,
// and the subtree destruction the rule warns about does not happen. The rule's
// own message offers `allowAsProps` for exactly this shape, which cannot be set
// from here.
//
// Hoisting rather than suppressing because it costs nothing: neither closes
// over anything local, so at module level each is one stable function instead
// of a new closure per render. See #181.
function modalErrorFallback(error: Error) {
return (
<ModalDialog
open
// No `title` here: ErrorFallback renders the same string as an <h3>, and
// antd would otherwise announce the dialog's accessible name and then the
// identical heading right after it.
footer={null}
onCancel={() => {
window.location.href = '/';
}}
>
<ErrorFallback
error={error}
title="Couldn't open that"
actions={
<Button
type="primary"
onClick={() => {
window.location.href = '/';
}}
>
Close
</Button>
}
/>
</ModalDialog>
);
}
function pageErrorFallback(error: Error) {
return (
<ErrorFallback
error={error}
title="Something went wrong"
fullPage
actions={[
<Button key="reload" type="primary" onClick={() => window.location.reload()}>
Reload
</Button>,
<Button
key="home"
onClick={() => {
window.location.href = '/';
}}
>
Back to the shop
</Button>
]}
/>
);
}
function AppRoutes() {
const location = useLocation();
const navigate = useNavigate();
const state = location.state as { background?: Location } | null;
const modalPath = MODAL_ROUTES.includes(location.pathname) ? location.pathname : null;
// In-app navigation names the page to render behind. Anything else — a
// bookmark, an email link, a link in a password reset message — falls back to
// the storefront, so closing always lands somewhere real.
const background = state?.background;
const backdrop = modalPath ? background ?? { ...location, ...STOREFRONT_BACKDROP } : location;
// Where a Google sign-in should land the customer: the page behind the modal,
// not the modal's own path. Built here because the backdrop is only known
// here, and validated again on the server (#345).
const returnTo = `${backdrop.pathname}${backdrop.search ?? ''}`;
function closeModal() {
// Back, when there is somewhere to go back to, so closing the modal and
// pressing Back do the same thing and neither leaves a dead entry behind.
if (background) navigate(-1);
else navigate('/', { replace: true });
}
// Steps within the auth flow replace rather than push, so the whole detour
// stays a single history entry and closing returns to where it started
// instead of walking back through every tab that was looked at.
function goWithinAuth(path: string) {
navigate(path, { state: background ? { background } : undefined, replace: true });
}
return (
<>
{/* Renders nothing. Mounted here because this is the innermost place
that has both the router and the auth context, which is what it
needs to report route changes for a consenting customer (#56). */}
<BrevoTracking />
{import.meta.env.DEV && <DevThrow scope="page" />}
<Routes location={backdrop}>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/cart" element={<Cart />} />
{/* A page rather than a modal route, deliberately: order history is a
list you read, like the cart, not a dialog you dismiss. Adding it to
MODAL_ROUTES would put it back in the 700px box it just left. */}
<Route path="/orders" element={<Orders />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
{/* Public and unauthenticated: the token is the whole access control. */}
<Route path="/submit/:token" element={<Submit />} />
<Route path="/verify-email" element={<VerifyEmail />} />
</Routes>
{/* Rendered outside the Routes above, which are showing the backdrop. */}
<ErrorBoundary
context="modal"
fallback={modalErrorFallback}
>
{/* Unconditional, so /?boom=modal fires this boundary with the
storefront rendered behind it — no session needed. */}
{import.meta.env.DEV && <DevThrow scope="modal" />}
{modalPath === '/account' && <Account onClose={closeModal} />}
{modalPath === '/login' && (
<AuthRouteModal mode="login" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
)}
{modalPath === '/register' && (
<AuthRouteModal mode="register" onClose={closeModal} onNavigate={goWithinAuth} returnTo={returnTo} />
)}
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{/* One-time, right after a Google sign-up (#342). A route rather than
a flag so it has an address and uses the same modal machinery as
every other auth screen. */}
{modalPath === '/welcome' && <Welcome onClose={closeModal} />}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}
onRequestNewLink={() => goWithinAuth('/forgot-password')}
onBackToSignIn={() => goWithinAuth('/login')}
/>
)}
</ErrorBoundary>
</>
);
}
function Root() {
const { mode } = useThemeMode();
const prefersReducedMotion = usePrefersReducedMotion();
return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
// The accent inverts with the theme rather than staying near-black.
// Held constant, it rendered the active tab label in #1a1a1a on a
// dark background — invisible.
colorPrimary: mode === 'dark' ? DARK_ACCENT : LIGHT_ACCENT,
// Text drawn *on* the accent (primary buttons, selected rows) has to
// invert with it too, or a near-white accent gets antd's default
// white label and disappears.
colorTextLightSolid: mode === 'dark' ? LIGHT_ACCENT : '#ffffff',
motion: !prefersReducedMotion
}
}}
>
<BrowserRouter>
<ErrorBoundary
context="page"
fallback={pageErrorFallback}
>
<AppRoutes />
</ErrorBoundary>
</BrowserRouter>
</ConfigProvider>
);
}
// Fired at entry purely for its side effect: it sets the origin uploaded
// images are fetched from (#103). Not awaited, because nothing should wait on
// it — anything that renders first gets a site-relative path, which the app's
// own origin still serves.
//
// Rejections are swallowed on purpose. A config that cannot be fetched is a
// broken deployment which every other request will report; failing here would
// only replace the app with an error page before it has drawn anything.
void fetchConfig().catch(() => {});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeModeProvider>
<CustomerAuthProvider>
<CartProvider>
<FavoritesProvider>
<Root />
</FavoritesProvider>
</CartProvider>
</CustomerAuthProvider>
</ThemeModeProvider>
</React.StrictMode>
);