diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 8ab9102..cab871c 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -44,6 +44,27 @@ jobs: # init (fail-open), so PR forks / unconfigured envs still build cleanly. VITE_NEWRELIC_LICENSE_KEY: ${{ secrets.VITE_NEWRELIC_LICENSE_KEY }} VITE_NEWRELIC_APP_ID: ${{ secrets.VITE_NEWRELIC_APP_ID }} + # ────────────────────────────────────────────────────────────── + # SCHEDULED-MAINTENANCE TOGGLE (src/components/MaintenanceNotice.tsx). + # + # '1' → the published Pages build renders the customer-facing + # maintenance banner (every route) + a one-time dismissible + # modal on /app* + /login*. This is ON because the prod + # cluster (api.instanode.dev) is intentionally paused, so the + # SPA loads but every API call fails — the banner explains the + # downtime is scheduled and the data is safe. + # + # Gated to PUBLISH events only (push to main / manual dispatch) so a + # PR's `build` check produces a byte-identical, banner-OFF artifact — + # it never publishes (the deploy job below is PR-gated) and runs no + # tests, so this has zero effect on the required CI checks. The + # separate build-and-test / playwright / coverage / lighthouse jobs + # never set this var, so they build with the notice OFF. + # + # ▶ TO TURN THE BANNER OFF ON RESUME: set this to '0' (or delete the + # line) and re-run this deploy — or revert the PR that added it. + # The component renders null when the flag is unset/'0'. + VITE_MAINTENANCE_MODE: ${{ github.event_name != 'pull_request' && '1' || '0' }} # NOTE: do NOT `cp dist/index.html dist/404.html` here. prerender.mjs # already writes dist/404.html as the bare SPA shell so every /app/* # bookmark / shared link / magic-link callback rehydrates the React diff --git a/playwright.config.ts b/playwright.config.ts index 35cd0ff..bc48c39 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,18 @@ export default defineConfig({ // self-skip anyway, but keeping them out of the default config means the // per-PR gate never boots a browser for a spec that always skips here, and // the two suites stay cleanly separated. - testIgnore: ['live-*.spec.ts'], + // + // auth-contract.spec.ts is ALSO excluded here: it makes UNCONDITIONAL real + // fetches to PROD (api.instanode.dev) to assert the AUTH-004 CORS envelope, + // so it does NOT belong in this mocked, same-origin (VITE_NO_PROXY=1) gate — + // it has its own dedicated config (playwright.auth-contract.config.ts) and + // workflow (auth-contract-e2e.yml) that run it against prod. Leaving it in + // the default glob made the required `playwright` job depend on prod being + // reachable: when the prod cluster is down/paused the fetch times out and + // the mocked gate fails for a reason that has nothing to do with the PR. + // The prod-targeting assertion still runs (and is allowed to go red while + // prod is down) in its own non-required workflow. + testIgnore: ['live-*.spec.ts', 'auth-contract.spec.ts'], fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/src/App.tsx b/src/App.tsx index 9e72db9..3476e37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,15 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation, useParams } from ' // sibling of . Renders null — no markup contribution. import { RouteTracker } from './components/RouteTracker' +// MaintenanceNotice — customer-facing scheduled-maintenance notice. Sits +// inside (it reads window.location to decide whether the +// dismissible modal applies on /app* + /login*) and OUTSIDE so +// the sticky banner persists across every route change. The whole thing is +// gated behind the build-time VITE_MAINTENANCE_MODE flag: when unset/'0' it +// renders null and contributes zero markup (the default in CI + locally). It +// is only '1' on the published GitHub Pages build (deploy-pages.yml). +import { MaintenanceNotice } from './components/MaintenanceNotice' + // Homepage — eagerly imported. It's the cold-load path and the most-visited // public surface, so it stays in the main entry chunk. import { MarketingPage } from './pages/MarketingPage' @@ -430,6 +439,11 @@ export function App() { during a lazy-chunk fetch (an unmount would skip the setPageViewName for that navigation). */} + {/* MaintenanceNotice renders the sticky banner on every route (and a + one-time modal on /app* + /login*) when VITE_MAINTENANCE_MODE='1'; + otherwise it returns null and adds no markup. Mounted above + AppRoutes so the banner sits at the top of the document flow. */} + ) diff --git a/src/components/MaintenanceNotice.test.tsx b/src/components/MaintenanceNotice.test.tsx new file mode 100644 index 0000000..c1368e6 --- /dev/null +++ b/src/components/MaintenanceNotice.test.tsx @@ -0,0 +1,216 @@ +/* MaintenanceNotice.test.tsx — full coverage for the scheduled-maintenance + * notice. The component is flag-gated (VITE_MAINTENANCE_MODE), so the tests + * drive the ON branch via the `enabled` prop and the per-route modal logic + * via the `pathname` prop — no need to stub import.meta.env or mount a + * router. The env-read helper (isMaintenanceEnabled) is covered separately. + * + * Coverage targets (every line of the new component): + * - enabled=false → renders nothing (the default everywhere except Pages) + * - enabled=true → sticky banner with the headline + body copy + * - modal shows on /app + /app/* + /login* and NOT on marketing routes + * - dismiss via button, overlay click, and Escape key — all close it and + * persist the dismissal in sessionStorage + * - a persisted dismissal keeps the modal closed on remount + * - isMaintenanceEnabled() reads the env flag + * - modalAppliesTo() route predicate + * - sessionStorage-throws fail-open paths + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { + MaintenanceNotice, + isMaintenanceEnabled, + modalAppliesTo, + MAINTENANCE_HEADLINE, + MAINTENANCE_BODY, +} from './MaintenanceNotice' + +beforeEach(() => { + window.sessionStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('MaintenanceNotice — flag gating', () => { + it('renders nothing when disabled', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryByTestId('maintenance-banner')).toBeNull() + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + }) + + it('renders nothing when called with no props and the env flag is off (default test env)', () => { + // In the unit-test env VITE_MAINTENANCE_MODE is unset, so the default + // (enabled omitted → isMaintenanceEnabled()) is false. This covers the + // env-read default-argument branch. + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) + +describe('MaintenanceNotice — sticky banner', () => { + it('shows the banner with headline + body when enabled, on any route', () => { + render() + const banner = screen.getByTestId('maintenance-banner') + expect(banner).toBeTruthy() + expect(banner.getAttribute('role')).toBe('status') + expect(banner.textContent).toContain(MAINTENANCE_HEADLINE) + expect(banner.textContent).toContain('Your data is safe') + }) + + it('does NOT show the modal on a marketing route', () => { + render() + expect(screen.getByTestId('maintenance-banner')).toBeTruthy() + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + }) + + it('resolves the current path from window.location when pathname is omitted', () => { + // No `pathname` prop → resolvePathname() reads window.location.pathname. + // jsdom defaults to "/", a marketing path, so the banner shows but the + // modal does not. This covers the resolvePathname window branch. + render() + expect(screen.getByTestId('maintenance-banner')).toBeTruthy() + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + }) +}) + +describe('MaintenanceNotice — modal on /app + /login', () => { + it('shows the modal on /app', () => { + render() + const modal = screen.getByTestId('maintenance-modal') + expect(modal).toBeTruthy() + expect(modal.getAttribute('aria-modal')).toBe('true') + expect(modal.textContent).toContain(MAINTENANCE_HEADLINE) + }) + + it('shows the modal on a nested /app/* route', () => { + render() + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + }) + + it('shows the modal on /login', () => { + render() + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + }) + + it('closes the modal and persists the dismissal when the button is clicked', () => { + render() + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + fireEvent.click(screen.getByTestId('maintenance-modal-dismiss')) + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + expect(window.sessionStorage.getItem('instanode.maintenanceModalDismissed')).toBe('1') + // Banner persists after dismiss. + expect(screen.getByTestId('maintenance-banner')).toBeTruthy() + }) + + it('closes the modal on overlay (backdrop) click', () => { + render() + const overlay = screen.getByTestId('maintenance-modal') + fireEvent.click(overlay) + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + }) + + it('does NOT close when clicking inside the dialog card (event target is a child)', () => { + render() + // Clicking the headline inside the card must not bubble-dismiss. + fireEvent.click(screen.getByText(MAINTENANCE_HEADLINE, { selector: 'h2' })) + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + }) + + it('closes the modal on Escape and ignores other keys', () => { + render() + // A non-Escape key is a no-op (covers the `if (e.key === 'Escape')` false branch). + fireEvent.keyDown(document, { key: 'a' }) + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + expect(window.sessionStorage.getItem('instanode.maintenanceModalDismissed')).toBe('1') + }) + + it('keeps the modal closed on a fresh mount once dismissed this session', () => { + window.sessionStorage.setItem('instanode.maintenanceModalDismissed', '1') + render() + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + // Banner still shows. + expect(screen.getByTestId('maintenance-banner')).toBeTruthy() + }) +}) + +describe('MaintenanceNotice — sessionStorage failure is non-fatal', () => { + // jsdom's sessionStorage is an exotic Storage object whose methods can't be + // replaced via vi.spyOn (the spy is silently ignored). To exercise the + // fail-open catch branches we swap the whole window.sessionStorage for a + // throwing stub via Object.defineProperty, then restore it after. + function withThrowingSessionStorage(stub: Partial, fn: () => void) { + const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage') + Object.defineProperty(window, 'sessionStorage', { + value: stub, + configurable: true, + writable: true, + }) + try { + fn() + } finally { + if (original) Object.defineProperty(window, 'sessionStorage', original) + } + } + + it('shows the modal when sessionStorage.getItem throws (fail-open on read)', () => { + withThrowingSessionStorage( + { + getItem() { + throw new Error('blocked') + }, + setItem() {}, + }, + () => { + render() + expect(screen.getByTestId('maintenance-modal')).toBeTruthy() + }, + ) + }) + + it('still closes the modal when sessionStorage.setItem throws (fail-open on write)', () => { + withThrowingSessionStorage( + { + getItem() { + return null + }, + setItem() { + throw new Error('blocked') + }, + }, + () => { + render() + fireEvent.click(screen.getByTestId('maintenance-modal-dismiss')) + expect(screen.queryByTestId('maintenance-modal')).toBeNull() + }, + ) + }) +}) + +describe('MaintenanceNotice — helpers', () => { + it('isMaintenanceEnabled reflects the (off) env flag in the test env', () => { + expect(isMaintenanceEnabled()).toBe(false) + }) + + it('modalAppliesTo matches only /app* and /login*', () => { + expect(modalAppliesTo('/app')).toBe(true) + expect(modalAppliesTo('/app/billing')).toBe(true) + expect(modalAppliesTo('/login')).toBe(true) + expect(modalAppliesTo('/login/callback')).toBe(true) + expect(modalAppliesTo('/')).toBe(false) + expect(modalAppliesTo('/pricing')).toBe(false) + expect(modalAppliesTo('/docs')).toBe(false) + }) + + it('exposes stable copy constants used by both surfaces', () => { + expect(MAINTENANCE_HEADLINE).toBe('Scheduled maintenance') + expect(MAINTENANCE_BODY).toContain('temporarily unavailable') + expect(MAINTENANCE_BODY).toContain('back shortly') + }) +}) diff --git a/src/components/MaintenanceNotice.tsx b/src/components/MaintenanceNotice.tsx new file mode 100644 index 0000000..aad6a0e --- /dev/null +++ b/src/components/MaintenanceNotice.tsx @@ -0,0 +1,251 @@ +/* MaintenanceNotice — customer-facing scheduled-maintenance notice. + * + * Context: the prod cluster (api.instanode.dev) is intentionally paused for + * scheduled maintenance. The SPA at instanode.dev still loads from GitHub + * Pages (via Cloudflare), so a visitor sees the page render but every API + * call fails. Without an explanation that reads as confusing breakage. This + * component makes the downtime read as INTENTIONAL and reassures the visitor + * their data is safe. + * + * Two surfaces, both on-brand (they reuse the design tokens in + * src/styles/tokens.css — the amber "warning" family + the .auth-card / + * .modal-overlay shapes — so they look intentional, not like a raw + * browser alert): + * + * 1. A STICKY top banner on every route (marketing + app + login). It + * stays put even after the modal is dismissed, so the message never + * fully disappears. + * 2. A one-time DISMISSIBLE modal on the surfaces where a customer would + * otherwise hit confusing API errors first — /app/* and /login*. It + * reinforces the same copy with a clear icon. Dismissal is remembered + * for the tab session (sessionStorage) so we don't trap the visitor in + * a re-popping dialog. + * + * TOGGLE: the whole thing is gated behind the build-time flag + * VITE_MAINTENANCE_MODE. When it is '1' the notice renders; when unset or + * '0' the component returns null and contributes ZERO markup (the default + * everywhere except the published GitHub Pages build — see + * .github/workflows/deploy-pages.yml, where VITE_MAINTENANCE_MODE: '1' is + * set on the build step only). To turn the notice OFF on resume, set + * VITE_MAINTENANCE_MODE back to '0' / remove it in deploy-pages.yml and + * re-run the deploy (or revert the PR that added this). + * + * The `enabled` and `pathname` props exist so unit tests can drive the ON + * branch and per-route modal logic deterministically without stubbing + * import.meta.env or mounting a full router. In the real app they are + * omitted and resolve from the env flag + the current location. + */ + +import { useEffect, useState } from 'react' + +// Single source of truth for the copy so the banner, the modal, and any +// future surface stay in lock-step (rule 16: one emitter per string). +export const MAINTENANCE_HEADLINE = 'Scheduled maintenance' +export const MAINTENANCE_BODY = + 'instanode is temporarily unavailable while we perform maintenance. ' + + 'Your data is safe and we’ll be back shortly. Thanks for your patience.' + +// sessionStorage key for the one-time modal dismissal. Scoped to the tab +// session so a returning visitor in a fresh tab still sees it once. +const MODAL_DISMISSED_KEY = 'instanode.maintenanceModalDismissed' + +// isMaintenanceEnabled — read the build-time flag. import.meta.env values +// are compile-time strings, so an unset flag is `undefined` and '0' is the +// explicit off. Exported so a test can assert the contract. +export function isMaintenanceEnabled(): boolean { + return import.meta.env.VITE_MAINTENANCE_MODE === '1' +} + +// modalAppliesTo — the modal only fires on the surfaces where a customer +// would otherwise hit confusing API failures first: the dashboard (/app*) +// and the login flow (/login*). Marketing routes get the banner only (no +// modal) so we don't nag a casual reader. +export function modalAppliesTo(pathname: string): boolean { + return pathname === '/app' || pathname.startsWith('/app/') || pathname.startsWith('/login') +} + +// resolvePathname — best-effort current path. Tests pass it explicitly; in +// the browser we read window.location. SSR (prerender) only ever renders +// public routes, so the modal branch never triggers there anyway, but we +// guard window access to stay SSR-safe: optional chaining returns undefined +// when `window` is absent (Node) and we fall back to '/' (a marketing path, +// so the modal stays off during prerender). +function resolvePathname(explicit?: string): string { + return explicit ?? globalThis.window?.location?.pathname ?? '/' +} + +interface Props { + /** Override the env flag — used by tests to exercise the ON branch. */ + enabled?: boolean + /** Override the current path — used by tests to drive modal routing. */ + pathname?: string +} + +export function MaintenanceNotice({ enabled, pathname }: Props = {}) { + const on = enabled ?? isMaintenanceEnabled() + const path = resolvePathname(pathname) + + // Modal visibility: shown on /app* + /login* until dismissed this session. + // Initialised lazily so we read sessionStorage exactly once on mount. + const [modalOpen, setModalOpen] = useState(() => { + // Modal only applies on /app* + /login* — never on the prerendered + // public routes, so this initializer's window/sessionStorage access is + // only ever reached in the browser. SSR short-circuits at modalAppliesTo. + if (!on || !modalAppliesTo(path)) return false + try { + return window.sessionStorage.getItem(MODAL_DISMISSED_KEY) !== '1' + } catch { + // sessionStorage can throw in locked-down privacy modes — fail open + // to showing the modal (the message is the whole point). + return true + } + }) + + // Escape closes the modal (keyboard-accessible), mirroring the other + // dialogs in the app (IssuePromoModal). Only bound while the modal is open. + useEffect(() => { + if (!modalOpen) return + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') dismissModal() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + // dismissModal is stable (defined below, no deps) — safe to omit. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalOpen]) + + if (!on) return null + + function dismissModal() { + setModalOpen(false) + try { + window.sessionStorage.setItem(MODAL_DISMISSED_KEY, '1') + } catch { + // Best-effort persistence; the in-memory state already closed it. + } + } + + return ( + <> + {/* ── sticky top banner — every route, persists after modal dismiss ── */} +
+ + + {MAINTENANCE_HEADLINE} + {' — '} + {MAINTENANCE_BODY} + +
+ + {/* ── one-time dismissible modal on /app* + /login* ── */} + {modalOpen && ( +
{ + if (e.target === e.currentTarget) dismissModal() + }} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.55)', + backdropFilter: 'blur(2px)', + WebkitBackdropFilter: 'blur(2px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + padding: 24, + }} + > +
+ +

+ {MAINTENANCE_HEADLINE} +

+

+ {MAINTENANCE_BODY} +

+ +
+
+ )} + + ) +} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index fcc4bda..1b5d632 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -60,6 +60,15 @@ import { NotFoundPage } from './pages/NotFoundPage' // procurement reviewer pasting /security into a browser sees the real // content on first byte instead of waiting for hydration. import { SecurityPage, LegalDocPage } from './pages/SecurityPage' +// MaintenanceNotice — rendered into the prerendered HTML so the scheduled- +// maintenance banner text is present on FIRST BYTE of every static page +// (instanode.dev/, /pricing, /docs, …), not only after client hydration. +// Gated by VITE_MAINTENANCE_MODE: when the flag is '1' at build time the +// banner copy is spliced into dist//index.html; otherwise it renders +// null and the static HTML is byte-identical to before. The dismissible +// modal only fires on /app* + /login*, which are never pre-rendered, so SSR +// emits the banner alone. +import { MaintenanceNotice } from './components/MaintenanceNotice' // SSRRoutes — the SSG-only route tree. Mirrors the public surface of the // client AppRoutes (everything reachable without auth). The /app/* subtree @@ -100,6 +109,10 @@ export function render(url: string): string { return renderToString( + {/* Banner first so its copy is present at the top of the + prerendered body; modal is route-gated to /app*+/login* and + never fires on the public (prerendered) routes. */} + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..d9f2076 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface ImportMetaEnv { + /** Scheduled-maintenance toggle. '1' renders the customer-facing + * maintenance banner + modal (src/components/MaintenanceNotice.tsx); + * unset or '0' renders nothing. Set on the GitHub Pages build step in + * .github/workflows/deploy-pages.yml; left unset in CI/test so the gate + * and Playwright suites build with the notice OFF. */ + readonly VITE_MAINTENANCE_MODE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}