From e5422975d62f7a1b6127cb1a18c772666d4a8455 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 08:51:47 +0000 Subject: [PATCH 1/9] feat(staging): add the admin staging switcher and staging sync procedure Admins get a Developer card in Settings that signs out, sets the debate-supabase-env key that src/lib/supabase/env.ts already reads, and reloads. While staging is active, every page shows a strip with Switch to Production, and the Settings card shows to everyone. A new staging session starts signed out and need not have the admin role, so the way back cannot depend on either. If sign-out fails the switch still happens; each project keeps its own auth storage key. docs/operations.md gains the staging sync procedure, built on npm run backend:status --project-ref from plan item 6. It also records why the staging job is skipped: STAGING_E2E_ENABLED is already true, and the Quality run it waits for fails before any runner starts. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/operations.md | 27 ++++++ src/App.tsx | 2 + src/components/StagingBanner.tsx | 40 ++++++++ .../features/settings/EnvironmentSwitcher.tsx | 63 ++++++++++++ src/lib/supabase/switch-env.ts | 17 ++++ src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/hu.json | 1 + src/pages/SettingsPage.tsx | 3 + .../components/EnvironmentSwitcher.test.tsx | 96 +++++++++++++++++++ .../pages/SettingsAdminNavigation.test.tsx | 22 ++++- 12 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 src/components/StagingBanner.tsx create mode 100644 src/components/features/settings/EnvironmentSwitcher.tsx create mode 100644 src/lib/supabase/switch-env.ts create mode 100644 src/test/components/EnvironmentSwitcher.test.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 176cc50f3..9e4671dac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Interface language and AI-output language are separate settings. Interface langu ## Supabase environments and sessions -[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. +[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging). [`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. diff --git a/docs/operations.md b/docs/operations.md index 3740be747..ca365c6f4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -63,6 +63,33 @@ The staging browser suite disables trace, screenshot, and video capture. It crea Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass. +`STAGING_E2E_ENABLED` has been `true` since 2026-09-18, and the job passed through 2026-09-23. Since then it has been skipped because the `Quality` run it waits for fails before any runner starts (see [When GitHub Actions cannot run](#when-github-actions-cannot-run)). Restoring Actions minutes restores the staging job; nothing in the workflow needs changing. + +## Staging sync + +Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Every backend change goes to staging first, passes the staging browser suite, and only then goes to production. Steps 1 and 2 are read-only. Steps 3 and 4 change staging and need Adam's authorization each time. + +1. Confirm the target. `supabase projects list` must show the staging ref under the staging name. Stop if a command, link, or environment variable points at the production ref. +2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. Where that script is not available, run its two read-only queries directly: + + ```bash + supabase migration list --project-ref brifeyypjauyusmkptkx + supabase functions list --project-ref brifeyypjauyusmkptkx + ``` + + A migration with a local version and no remote version is pending. A function whose folder changed (`git log -- supabase/functions/ supabase/functions/_shared`) after its remote `updated_at` is stale. Save both lists as the rollback baseline. +3. Apply migrations. Run `supabase db push --project-ref brifeyypjauyusmkptkx --dry-run` and check that it lists exactly the pending files from step 2. Then run it without `--dry-run`. Never use `--include-all` to paper over divergent history. +4. Deploy functions, one at a time, only the ones step 2 marked stale: `supabase functions deploy --project-ref brifeyypjauyusmkptkx --use-api`. [`supabase/config.toml`](../supabase/config.toml) supplies each function's JWT setting. Never use `--prune`. +5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings. + +Production then follows [Supabase changes](#supabase-changes) with the same list of migrations and functions. + +## Switching the frontend to staging + +[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) reads the `debate-supabase-env` localStorage key once at startup. The value `staging` selects staging, and anything else selects production. Each project keeps its own auth session under `debate-auth-`. + +Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. + ## Supabase changes Before applying a migration or deploying a function: diff --git a/src/App.tsx b/src/App.tsx index 0d1fe67a9..3a855c0cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { useOnboardingStore } from "@/hooks/use-onboarding"; import { OfflineIndicator } from "@/components/OfflineIndicator"; +import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; import { routeLoaders } from "@/lib/route-modules"; import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton"; @@ -278,6 +279,7 @@ const App = () => ( + diff --git a/src/components/StagingBanner.tsx b/src/components/StagingBanner.tsx new file mode 100644 index 000000000..bf36ad902 --- /dev/null +++ b/src/components/StagingBanner.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlaskConical } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/lib/supabase/AuthProvider'; +import { getActiveEnv } from '@/lib/supabase/env'; +import { reloadPage, switchEnv } from '@/lib/supabase/switch-env'; + +/** + * Shown on every route while staging is active. A fresh staging session starts + * signed out and Settings needs sign-in, so this is the way back that always works. + */ +export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) { + const { t } = useTranslation(); + const { signOut } = useAuth(); + const [switching, setSwitching] = useState(false); + if (getActiveEnv() !== 'staging') return null; + + return ( +
+
+ ); +} diff --git a/src/components/features/settings/EnvironmentSwitcher.tsx b/src/components/features/settings/EnvironmentSwitcher.tsx new file mode 100644 index 000000000..fcfe5f70a --- /dev/null +++ b/src/components/features/settings/EnvironmentSwitcher.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Server } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useAuth } from '@/lib/supabase/AuthProvider'; +import { getActiveEnv } from '@/lib/supabase/env'; +import { reloadPage, switchEnv, type RemoteEnv } from '@/lib/supabase/switch-env'; + +type SwitchProps = { reload?: () => void }; + +/** Admin control in Settings. The caller decides who sees it. */ +export function EnvironmentSwitcher({ reload = reloadPage }: SwitchProps) { + const { t } = useTranslation(); + const { signOut } = useAuth(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [switching, setSwitching] = useState(false); + const active = getActiveEnv(); + if (active === 'local') return null; + + const target: RemoteEnv = active === 'staging' ? 'production' : 'staging'; + const label = target === 'staging' ? t('settings.switchToStaging') : t('settings.switchToProduction'); + + return ( + + +
+ + {t('settings.developer')} + {active} +
+ {t('settings.developerDesc')} +
+ + + + { if (!switching) setConfirmOpen(open); }}> + + + {t('settings.envSwitchTitle')} + {t('settings.envSwitchDesc', { target })} + + + + + + + +
+ ); +} diff --git a/src/lib/supabase/switch-env.ts b/src/lib/supabase/switch-env.ts new file mode 100644 index 000000000..cbb20f7f8 --- /dev/null +++ b/src/lib/supabase/switch-env.ts @@ -0,0 +1,17 @@ +import { setActiveEnv } from '@/lib/supabase/env'; + +export type RemoteEnv = 'production' | 'staging'; + +export const reloadPage = () => window.location.reload(); + +// Each project keeps its own auth storage key, so a failed sign-out cannot leak +// a session across projects. Switch anyway so a broken project cannot trap anyone. +export async function switchEnv(target: RemoteEnv, signOut: () => Promise, reload: () => void) { + try { + await signOut(); + } catch (error) { + console.warn('Sign-out before environment switch failed:', error); + } + setActiveEnv(target); + reload(); +} diff --git a/src/locales/en.json b/src/locales/en.json index be6e4f240..f9c93886d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -380,6 +380,7 @@ "envSwitchTitle": "Switch Environment", "envSwitchDesc": "You'll be signed out and the app will reload pointing at {{target}}. You'll need to sign in again.", "envSwitchConfirm": "Switch & Sign Out", + "stagingBanner": "Staging backend", "dangerZone": "Danger Zone", "dangerDesc": "Permanently delete all your saved data. This only affects your account ({{email}}).", "deleteAllData": "Delete All My Data", diff --git a/src/locales/es.json b/src/locales/es.json index 515962493..b12b0fe4b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -380,6 +380,7 @@ "envSwitchTitle": "Cambiar entorno", "envSwitchDesc": "Se cerrará tu sesión y la app se recargará apuntando a {{target}}. Tendrás que iniciar sesión de nuevo.", "envSwitchConfirm": "Cambiar y cerrar sesión", + "stagingBanner": "Backend de staging", "dangerZone": "Zona de peligro", "dangerDesc": "Elimina permanentemente todos tus datos guardados. Solo afecta a tu cuenta ({{email}}).", "deleteAllData": "Eliminar todos mis datos", diff --git a/src/locales/hu.json b/src/locales/hu.json index 6bfd53361..ec51c9ce6 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -380,6 +380,7 @@ "envSwitchTitle": "Környezet váltása", "envSwitchDesc": "Kijelentkeztetünk és az app újratöltődik {{target}} felé mutatva. Újra be kell jelentkezned.", "envSwitchConfirm": "Váltás és kijelentkezés", + "stagingBanner": "Staging háttérrendszer", "dangerZone": "Veszélyzóna", "dangerDesc": "Véglegesen törli az összes mentett adatodat. Ez csak a fiókodat érinti ({{email}}).", "deleteAllData": "Összes adatom törlése", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 62cb67155..c03f9640e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -21,10 +21,12 @@ import { useDebateStore } from '@/lib/stores/debate-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { supabase } from '@/lib/supabase/client'; +import { getActiveEnv } from '@/lib/supabase/env'; import { useToast } from '@/hooks/use-toast'; import { useSubscription } from '@/hooks/use-subscription'; import { redirectToPortal } from '@/lib/stripe'; import { PricingModal } from '@/components/features/subscription/PricingModal'; +import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentSwitcher'; import { formatEuropeanDate } from '@/lib/date-format'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; @@ -288,6 +290,7 @@ export default function SettingsPage() { )} + {(isAdmin || getActiveEnv() === 'staging') && } {/* Danger Zone */} diff --git a/src/test/components/EnvironmentSwitcher.test.tsx b/src/test/components/EnvironmentSwitcher.test.tsx new file mode 100644 index 000000000..f85ec0adb --- /dev/null +++ b/src/test/components/EnvironmentSwitcher.test.tsx @@ -0,0 +1,96 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EnvironmentSwitcher } from '@/components/features/settings/EnvironmentSwitcher'; +import { StagingBanner } from '@/components/StagingBanner'; + +const mocks = vi.hoisted(() => ({ + env: 'production' as 'local' | 'production' | 'staging', + setActiveEnv: vi.fn(), + signOut: vi.fn(), +})); +vi.mock('@/lib/supabase/env', () => ({ + getActiveEnv: () => mocks.env, + setActiveEnv: mocks.setActiveEnv, +})); +vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ signOut: mocks.signOut }) })); + +beforeEach(() => { + mocks.env = 'production'; + mocks.setActiveEnv.mockReset(); + mocks.signOut.mockReset().mockResolvedValue(undefined); +}); +afterEach(cleanup); + +describe('EnvironmentSwitcher', () => { + it('switches production to staging only after confirmation, signing out first', async () => { + const order: string[] = []; + mocks.signOut.mockImplementation(async () => { order.push('signOut'); }); + mocks.setActiveEnv.mockImplementation((env: string) => { order.push(`set:${env}`); }); + const reload = vi.fn(() => { order.push('reload'); }); + render(); + + expect(screen.getByText('production')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to Staging' })); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toHaveTextContent('pointing at staging'); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch & Sign Out' })); }); + expect(order).toEqual(['signOut', 'set:staging', 'reload']); + }); + + it('leaves the environment alone when the dialog is cancelled', () => { + const reload = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to Staging' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mocks.signOut).not.toHaveBeenCalled(); + expect(mocks.setActiveEnv).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + + it('offers production while staging is active', () => { + mocks.env = 'staging'; + render(); + expect(screen.getByRole('button', { name: 'Switch to Production' })).toBeInTheDocument(); + }); + + it('still switches when sign-out fails, so nobody is stranded on a broken project', async () => { + mocks.env = 'staging'; + mocks.signOut.mockRejectedValue(new Error('staging unreachable')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reload = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch & Sign Out' })); }); + expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('renders nothing in local preview', () => { + mocks.env = 'local'; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('StagingBanner', () => { + it('is hidden outside staging', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + mocks.env = 'local'; + rerender(); + expect(container).toBeEmptyDOMElement(); + }); + + it('marks staging and returns to production without needing a staging session', async () => { + mocks.env = 'staging'; + const reload = vi.fn(); + render(); + expect(screen.getByRole('status')).toHaveTextContent('Staging backend'); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); }); + expect(mocks.signOut).toHaveBeenCalledOnce(); + expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); + expect(reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/pages/SettingsAdminNavigation.test.tsx b/src/test/pages/SettingsAdminNavigation.test.tsx index 7b45f9f74..b660cd53f 100644 --- a/src/test/pages/SettingsAdminNavigation.test.tsx +++ b/src/test/pages/SettingsAdminNavigation.test.tsx @@ -7,7 +7,9 @@ import SettingsPage from '@/pages/SettingsPage'; const mocks = vi.hoisted(() => ({ user: { id: 'admin-a', email: 'admin@example.com' } as { id: string; email: string } | null, role: vi.fn(), + env: 'production' as 'production' | 'staging', })); +vi.mock('@/lib/supabase/env', () => ({ getActiveEnv: () => mocks.env, setActiveEnv: vi.fn() })); vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ user: mocks.user, signOut: vi.fn() }) })); vi.mock('@/lib/supabase/client', () => ({ SUPABASE_URL: 'https://example.invalid', @@ -36,7 +38,7 @@ function mount(client = new QueryClient({ defaultOptions: { queries: { retry: fa const ui = () => ; return { ...render(ui()), client, ui }; } -beforeEach(() => { mocks.user = { id: 'admin-a', email: 'admin@example.com' }; mocks.role.mockReset(); }); +beforeEach(() => { mocks.user = { id: 'admin-a', email: 'admin@example.com' }; mocks.env = 'production'; mocks.role.mockReset(); }); afterEach(() => { cleanup(); clients.splice(0).forEach(client => client.clear()); }); describe('Settings admin navigation', () => { @@ -93,4 +95,22 @@ describe('Settings admin navigation', () => { await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading...' })).not.toBeInTheDocument()); expect(screen.queryByRole('link', { name: 'Open Admin Panel' })).not.toBeInTheDocument(); }); + + it('shows the environment switcher to admins only, and to everyone on staging', async () => { + mocks.role.mockResolvedValue({ data: { role: 'user' }, error: null }); + const view = mount(); + await waitFor(() => expect(screen.queryByRole('status', { name: 'Loading...' })).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Switch to Staging' })).not.toBeInTheDocument(); + view.unmount(); + + mocks.role.mockResolvedValue({ data: { role: 'admin' }, error: null }); + const admin = mount(); + expect(await screen.findByRole('button', { name: 'Switch to Staging' })).toBeInTheDocument(); + admin.unmount(); + + mocks.env = 'staging'; + mocks.role.mockResolvedValue({ data: null, error: null }); + mount(); + expect(screen.getByRole('button', { name: 'Switch to Production' })).toBeInTheDocument(); + }); }); From 529d868ccb6908a389666b2cef3f018a8874e940 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:07:35 +0000 Subject: [PATCH 2/9] docs(staging): note the CSP dependency and which sign-in works on staging The switcher needs connect-src to keep allowing the staging project, which today rides on the *.supabase.co wildcard that plan item 4 wants to narrow. Only demo-code sign-in is known to work on staging from the deployed frontend; Google and email depend on staging's Auth redirect URLs. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/operations.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/operations.md b/docs/operations.md index ca365c6f4..084a9c405 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -80,7 +80,7 @@ Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production A migration with a local version and no remote version is pending. A function whose folder changed (`git log -- supabase/functions/ supabase/functions/_shared`) after its remote `updated_at` is stale. Save both lists as the rollback baseline. 3. Apply migrations. Run `supabase db push --project-ref brifeyypjauyusmkptkx --dry-run` and check that it lists exactly the pending files from step 2. Then run it without `--dry-run`. Never use `--include-all` to paper over divergent history. 4. Deploy functions, one at a time, only the ones step 2 marked stale: `supabase functions deploy --project-ref brifeyypjauyusmkptkx --use-api`. [`supabase/config.toml`](../supabase/config.toml) supplies each function's JWT setting. Never use `--prune`. -5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings. +5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings and sign in there with a staging demo code. Google and email sign-in on staging work only if the staging Auth redirect URLs include the frontend origin. Production then follows [Supabase changes](#supabase-changes) with the same list of migrations and functions. @@ -90,6 +90,8 @@ Production then follows [Supabase changes](#supabase-changes) with the same list Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. +The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly. + ## Supabase changes Before applying a migration or deploying a function: From 7a8a4dfb2415a36e2fb0601a3ae6cfa5c8b5dcf9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 11:07:51 +0000 Subject: [PATCH 3/9] fix(staging): stop a stalled sign-out from blocking the environment switch AuthProvider.signOut waits on the Supabase logout request with no timeout, and both switch controls disable themselves on click. An unresponsive staging project could therefore leave the switch pending forever, which is the trap the staging strip exists to avoid. Wait at most 4 s, the same as AuthProvider's auth operation timeout, then switch anyway. Found by Codex review. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/supabase/switch-env.ts | 18 +++++++++++++++--- .../components/EnvironmentSwitcher.test.tsx | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/lib/supabase/switch-env.ts b/src/lib/supabase/switch-env.ts index cbb20f7f8..067698c0f 100644 --- a/src/lib/supabase/switch-env.ts +++ b/src/lib/supabase/switch-env.ts @@ -4,13 +4,25 @@ export type RemoteEnv = 'production' | 'staging'; export const reloadPage = () => window.location.reload(); -// Each project keeps its own auth storage key, so a failed sign-out cannot leak -// a session across projects. Switch anyway so a broken project cannot trap anyone. +// Matches AuthProvider's auth operation timeout. +const SIGN_OUT_WAIT_MS = 4000; + +// Each project keeps its own auth storage key, so a failed or stalled sign-out +// cannot leak a session across projects. Switch anyway so a broken or +// unresponsive project cannot trap anyone. export async function switchEnv(target: RemoteEnv, signOut: () => Promise, reload: () => void) { + let timer: ReturnType | undefined; try { - await signOut(); + await Promise.race([ + signOut(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('sign-out timed out')), SIGN_OUT_WAIT_MS); + }), + ]); } catch (error) { console.warn('Sign-out before environment switch failed:', error); + } finally { + clearTimeout(timer); } setActiveEnv(target); reload(); diff --git a/src/test/components/EnvironmentSwitcher.test.tsx b/src/test/components/EnvironmentSwitcher.test.tsx index f85ec0adb..a6768c375 100644 --- a/src/test/components/EnvironmentSwitcher.test.tsx +++ b/src/test/components/EnvironmentSwitcher.test.tsx @@ -93,4 +93,22 @@ describe('StagingBanner', () => { expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); expect(reload).toHaveBeenCalledOnce(); }); + + it('switches back even when sign-out hangs on an unresponsive project', async () => { + vi.useFakeTimers(); + try { + mocks.env = 'staging'; + mocks.signOut.mockReturnValue(new Promise(() => {})); + const reload = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); + await act(async () => { await vi.advanceTimersByTimeAsync(3999); }); + expect(reload).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); + expect(reload).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); }); From 8abfacafcd8c8501a4d91e6bf1545bda25e9dd83 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 11:19:22 +0000 Subject: [PATCH 4/9] fix(staging): clear local data when leaving a project, even if sign-out hangs The persisted app stores are shared by production and staging. AuthProvider resets them only once sign-out completes and SIGNED_OUT fires, so a timed-out sign-out let one project's cached results, such as a generated case, reload into the other. AuthProvider now offers leaveEnvironment. It waits at most 4 s for sign-out, then always drops this project's stored session and resets the session, settings (keeping the interface language), sparring, debate and debate context stores. The switcher and the staging strip use it. client.ts exports AUTH_STORAGE_KEY so the key is defined once. Found by Codex review. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/StagingBanner.tsx | 4 +-- .../features/settings/EnvironmentSwitcher.tsx | 4 +-- src/lib/supabase/AuthProvider.tsx | 24 +++++++++++-- src/lib/supabase/client.ts | 5 ++- src/lib/supabase/switch-env.ts | 22 +++--------- src/test/auth/AuthProvider.behavior.test.tsx | 34 +++++++++++++++++++ .../components/EnvironmentSwitcher.test.tsx | 20 +---------- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/src/components/StagingBanner.tsx b/src/components/StagingBanner.tsx index bf36ad902..e94b2d648 100644 --- a/src/components/StagingBanner.tsx +++ b/src/components/StagingBanner.tsx @@ -12,7 +12,7 @@ import { reloadPage, switchEnv } from '@/lib/supabase/switch-env'; */ export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) { const { t } = useTranslation(); - const { signOut } = useAuth(); + const { leaveEnvironment } = useAuth(); const [switching, setSwitching] = useState(false); if (getActiveEnv() !== 'staging') return null; @@ -30,7 +30,7 @@ export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) disabled={switching} onClick={() => { setSwitching(true); - void switchEnv('production', signOut, reload); + void switchEnv('production', leaveEnvironment, reload); }} > {t('settings.switchToProduction')} diff --git a/src/components/features/settings/EnvironmentSwitcher.tsx b/src/components/features/settings/EnvironmentSwitcher.tsx index fcfe5f70a..0c9d454ab 100644 --- a/src/components/features/settings/EnvironmentSwitcher.tsx +++ b/src/components/features/settings/EnvironmentSwitcher.tsx @@ -14,7 +14,7 @@ type SwitchProps = { reload?: () => void }; /** Admin control in Settings. The caller decides who sees it. */ export function EnvironmentSwitcher({ reload = reloadPage }: SwitchProps) { const { t } = useTranslation(); - const { signOut } = useAuth(); + const { leaveEnvironment } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); const [switching, setSwitching] = useState(false); const active = getActiveEnv(); @@ -50,7 +50,7 @@ export function EnvironmentSwitcher({ reload = reloadPage }: SwitchProps) { disabled={switching} onClick={() => { setSwitching(true); - void switchEnv(target, signOut, reload); + void switchEnv(target, leaveEnvironment, reload); }} > {t('settings.envSwitchConfirm')} diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index ea743d82f..ceffafe06 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { Session, User } from '@supabase/supabase-js'; -import { setActiveAccessToken, supabase } from './client'; +import { AUTH_STORAGE_KEY, setActiveAccessToken, supabase } from './client'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; @@ -20,6 +20,8 @@ interface AuthContextValue { verifyEmailOtp: (email: string, token: string) => Promise; signInWithDemoCode: (code: string) => Promise; signOut: () => Promise; + /** Ends the session and clears this project's local data before an environment switch. Never hangs. */ + leaveEnvironment: () => Promise; } const AuthContext = createContext(undefined); @@ -336,7 +338,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { resetSettingsStore(); resetSparringStore(); }, - }), [session, loading, resetSessionStore, resetSettingsStore, resetSparringStore]); + leaveEnvironment: async () => { + try { + await withAuthTimeout(supabase.auth.signOut(), 'Sign-out timed out'); + } catch (error) { + console.warn('Sign-out before environment switch failed:', error); + } + // A hung sign-out never fires SIGNED_OUT, and the persisted stores are + // shared by both projects. Drop this project's stored session and clear + // every user-specific store so nothing crosses into the other project. + localStorage.removeItem(AUTH_STORAGE_KEY); + localStorage.removeItem(`${AUTH_STORAGE_KEY}-user`); + clearCrossfireDraftOnAuthChange(session?.user?.id, undefined); + resetSessionStore(); + resetSettingsStore(true); + resetSparringStore(); + resetDebateStore(); + useDebateContextStore.getState().resetLocalData(); + }, + }), [session, loading, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]); return {children}; } diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 18f852df4..99f89edfc 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -8,6 +8,9 @@ import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env'; export { SUPABASE_URL }; +/** Where supabase-js keeps this project's session; each project has its own. */ +export const AUTH_STORAGE_KEY = `debate-auth-${getActiveEnv()}`; + export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: true, @@ -15,7 +18,7 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { // AuthProvider owns the one PKCE exchange and URL cleanup path. detectSessionInUrl: false, flowType: 'pkce', - storageKey: `debate-auth-${getActiveEnv()}`, + storageKey: AUTH_STORAGE_KEY, }, }); diff --git a/src/lib/supabase/switch-env.ts b/src/lib/supabase/switch-env.ts index 067698c0f..b3703b14d 100644 --- a/src/lib/supabase/switch-env.ts +++ b/src/lib/supabase/switch-env.ts @@ -4,25 +4,13 @@ export type RemoteEnv = 'production' | 'staging'; export const reloadPage = () => window.location.reload(); -// Matches AuthProvider's auth operation timeout. -const SIGN_OUT_WAIT_MS = 4000; - -// Each project keeps its own auth storage key, so a failed or stalled sign-out -// cannot leak a session across projects. Switch anyway so a broken or -// unresponsive project cannot trap anyone. -export async function switchEnv(target: RemoteEnv, signOut: () => Promise, reload: () => void) { - let timer: ReturnType | undefined; +// leaveEnvironment is bounded and clears this project's local session and data +// itself, so a failure here cannot trap anyone or leak data across projects. +export async function switchEnv(target: RemoteEnv, leaveEnvironment: () => Promise, reload: () => void) { try { - await Promise.race([ - signOut(), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('sign-out timed out')), SIGN_OUT_WAIT_MS); - }), - ]); + await leaveEnvironment(); } catch (error) { - console.warn('Sign-out before environment switch failed:', error); - } finally { - clearTimeout(timer); + console.warn('Leaving the current environment failed:', error); } setActiveEnv(target); reload(); diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 1b7d3370e..659d865d6 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -73,6 +73,7 @@ const { }); vi.mock('@/lib/supabase/client', () => ({ + AUTH_STORAGE_KEY: 'debate-auth-production', SUPABASE_URL: 'https://example.supabase.co', setActiveAccessToken: vi.fn(), supabase: { @@ -121,6 +122,7 @@ vi.mock('@/lib/stores/sparring-store', () => ({ import { AuthProvider, useAuth } from '@/lib/supabase/AuthProvider'; import { crossfireStorageKey } from '@/lib/crossfire/storage'; +import { useDebateStore } from '@/lib/stores/debate-store'; function AuthConsumer() { const { loading, user, signInWithGoogle, signOut } = useAuth(); @@ -616,6 +618,38 @@ describe('AuthProvider behavior', () => { expect(resetSettingsStoreMock).toHaveBeenCalled(); }); + it('leaveEnvironment clears this project\'s session and data even when sign-out hangs', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + signOutMock.mockReturnValue(new Promise(() => {})); + localStorage.setItem('debate-auth-production', 'session'); + localStorage.setItem('debate-auth-production-user', 'user'); + localStorage.setItem('debate-auth-staging', 'other-project-session'); + useDebateStore.setState({ currentDebateId: 'debate-1', oneOff: true }); + let left = false; + function Leaver() { + const { leaveEnvironment } = useAuth(); + return ; + } + render(); + + fireEvent.click(screen.getByRole('button', { name: 'leave' })); + await act(async () => { await vi.advanceTimersByTimeAsync(4000); }); + + expect(left).toBe(true); + expect(signOutMock).toHaveBeenCalled(); + expect(localStorage.getItem('debate-auth-production')).toBeNull(); + expect(localStorage.getItem('debate-auth-production-user')).toBeNull(); + expect(localStorage.getItem('debate-auth-staging')).toBe('other-project-session'); + expect(resetSessionStoreMock).toHaveBeenCalled(); + expect(resetSettingsStoreMock).toHaveBeenCalled(); + expect(resetSparringStoreMock).toHaveBeenCalled(); + expect(useDebateStore.getState()).toMatchObject({ currentDebateId: null, oneOff: false }); + } finally { + vi.useRealTimers(); + } + }); + it('unsubscribes auth listener on unmount', async () => { const { unmount } = render( diff --git a/src/test/components/EnvironmentSwitcher.test.tsx b/src/test/components/EnvironmentSwitcher.test.tsx index a6768c375..070d0a3fd 100644 --- a/src/test/components/EnvironmentSwitcher.test.tsx +++ b/src/test/components/EnvironmentSwitcher.test.tsx @@ -12,7 +12,7 @@ vi.mock('@/lib/supabase/env', () => ({ getActiveEnv: () => mocks.env, setActiveEnv: mocks.setActiveEnv, })); -vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ signOut: mocks.signOut }) })); +vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ leaveEnvironment: mocks.signOut }) })); beforeEach(() => { mocks.env = 'production'; @@ -93,22 +93,4 @@ describe('StagingBanner', () => { expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); expect(reload).toHaveBeenCalledOnce(); }); - - it('switches back even when sign-out hangs on an unresponsive project', async () => { - vi.useFakeTimers(); - try { - mocks.env = 'staging'; - mocks.signOut.mockReturnValue(new Promise(() => {})); - const reload = vi.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: 'Switch to Production' })); - await act(async () => { await vi.advanceTimersByTimeAsync(3999); }); - expect(reload).not.toHaveBeenCalled(); - await act(async () => { await vi.advanceTimersByTimeAsync(1); }); - expect(mocks.setActiveEnv).toHaveBeenCalledWith('production'); - expect(reload).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); }); From f8f7772eb9b4240812221d6952ccf739b4499419 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 11:30:31 +0000 Subject: [PATCH 5/9] fix(staging): clear Google tokens and form drafts on a switch; keep the strip clickable - leaveEnvironment now clears the cached Google provider token, which export falls back to. A hung sign-out never fires SIGNED_OUT, so the token could otherwise reach the other project's account. Both paths share one helper. - It also calls the session store's clearSession, which empties the persisted form drafts and active results that resetLocalData leaves behind. - On the landing page the fixed navbar covered the staging strip and caught its clicks. The strip now stacks above it. A new Playwright test clicks Switch to Production on / and /login once the navbar has slid in. Found by Codex review. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/operations.md | 2 +- e2e/staging-switch.spec.ts | 32 ++++++++++++++++++++ src/components/StagingBanner.tsx | 2 +- src/lib/supabase/AuthProvider.tsx | 19 ++++++++---- src/test/auth/AuthProvider.behavior.test.tsx | 11 +++++++ 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 e2e/staging-switch.spec.ts diff --git a/docs/operations.md b/docs/operations.md index 084a9c405..1d80a4c18 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -88,7 +88,7 @@ Production then follows [Supabase changes](#supabase-changes) with the same list [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) reads the `debate-supabase-env` localStorage key once at startup. The value `staging` selects staging, and anything else selects production. Each project keeps its own auth session under `debate-auth-`. -Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. +Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary. The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly. diff --git a/e2e/staging-switch.spec.ts b/e2e/staging-switch.spec.ts new file mode 100644 index 000000000..20c4ba44e --- /dev/null +++ b/e2e/staging-switch.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +// The strip is the way back from staging, so it must stay usable on every +// route, including the landing page with its fixed navigation bar. +for (const path of ['/', '/login']) { + const landing = path === '/'; + test(`the staging strip returns to production from ${path}`, async ({ page }) => { + // Never reach a real Supabase project from this test. + await page.route('https://*.supabase.co/**', (route) => route.abort()); + await page.addInitScript(() => { + if (!sessionStorage.getItem('staging-switch-test-started')) { + sessionStorage.setItem('staging-switch-test-started', '1'); + localStorage.setItem('debate-supabase-env', 'staging'); + } + }); + await page.goto(path); + // The landing navigation is fixed and slides in from above. Click only once + // it has arrived, so the test sees what a person sees. + if (landing) { + const landingNav = page.locator('nav.fixed'); + await landingNav.waitFor(); + await expect.poll(() => landingNav.evaluate((nav) => getComputedStyle(nav).transform)).toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/); + } + + const strip = page.getByRole('status').filter({ hasText: 'Staging backend' }); + await expect(strip).toBeVisible(); + await strip.getByRole('button', { name: 'Switch to Production' }).click({ timeout: 5_000 }); + + await expect(strip).toHaveCount(0); + expect(await page.evaluate(() => localStorage.getItem('debate-supabase-env'))).toBeNull(); + }); +} diff --git a/src/components/StagingBanner.tsx b/src/components/StagingBanner.tsx index e94b2d648..7e694d0ce 100644 --- a/src/components/StagingBanner.tsx +++ b/src/components/StagingBanner.tsx @@ -19,7 +19,7 @@ export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) return (