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 9e5de96bd..43c17957b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -63,6 +63,35 @@ 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 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. + +## 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. 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. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below 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. + ## Backend drift check `npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref ` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role. diff --git a/e2e/staging-switch.spec.ts b/e2e/staging-switch.spec.ts new file mode 100644 index 000000000..9f9d7ebee --- /dev/null +++ b/e2e/staging-switch.spec.ts @@ -0,0 +1,59 @@ +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(); + if (landing) { + // The fixed navigation must sit below the strip, not underneath it. + const stripBox = await strip.boundingBox(); + const navBox = await page.locator('nav.fixed').boundingBox(); + expect(navBox!.y).toBeGreaterThanOrEqual(stripBox!.y + stripBox!.height - 1); + } + 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(); + }); +} + +test('switching in one tab reloads the other tabs onto the same project', async ({ context }) => { + await context.route('https://*.supabase.co/**', (route) => route.abort()); + await context.addInitScript(() => { + if (!sessionStorage.getItem('staging-switch-test-started')) { + sessionStorage.setItem('staging-switch-test-started', '1'); + localStorage.setItem('debate-supabase-env', 'staging'); + } + }); + const switching = await context.newPage(); + const other = await context.newPage(); + await switching.goto('/login'); + await other.goto('/login'); + const stripIn = (page: typeof other) => page.getByRole('status').filter({ hasText: 'Staging backend' }); + await expect(stripIn(other)).toBeVisible(); + + await stripIn(switching).getByRole('button', { name: 'Switch to Production' }).click(); + + await expect(stripIn(switching)).toHaveCount(0); + await expect(stripIn(other)).toHaveCount(0); +}); 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..30f6ce440 --- /dev/null +++ b/src/components/StagingBanner.tsx @@ -0,0 +1,54 @@ +import { useEffect, 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, useReloadOnEnvChange } from '@/lib/supabase/switch-env'; + +const BANNER_HEIGHT = '2.25rem'; // h-9 + +/** + * 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. + * Mounted at the app root in every environment, it also keeps other tabs in step. + */ +export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) { + const { t } = useTranslation(); + const { leaveEnvironment, discardTabState } = useAuth(); + const [switching, setSwitching] = useState(false); + useReloadOnEnvChange(discardTabState, reload); + const staging = getActiveEnv() === 'staging'; + // Fixed and sticky headers offset themselves by this height so the strip + // never covers them and they never cover it. + useEffect(() => { + if (!staging) return; + document.documentElement.style.setProperty('--staging-banner-height', BANNER_HEIGHT); + return () => { + document.documentElement.style.removeProperty('--staging-banner-height'); + }; + }, [staging]); + if (!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..0c9d454ab --- /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 { leaveEnvironment } = 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/components/landing/Navbar.tsx b/src/components/landing/Navbar.tsx index f618097bf..a96fe6dbd 100644 --- a/src/components/landing/Navbar.tsx +++ b/src/components/landing/Navbar.tsx @@ -24,7 +24,7 @@ const Navbar = () => { initial={{ y: -100 }} animate={{ y: 0 }} transition={{ duration: 0.6, ease: "easeOut" }} - className="fixed left-0 right-0 top-0 z-50 nav-blur" + className="fixed left-0 right-0 top-[var(--staging-banner-height,0px)] z-50 nav-blur" aria-label={t('nav.mainNavigation')} >
diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index abee7bd0b..a00e02ee4 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -175,7 +175,7 @@ export function AppLayout() { return (
{/* Header */} -
+
setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0">
diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index ea743d82f..bb02e838e 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 { createContext, useCallback, 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,14 @@ 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; + /** + * Clears only this tab's own storage (Google token, sparring state). For a tab + * that reloads because another tab switched projects; it must not touch the + * shared stores, which may already belong to the new project. + */ + discardTabState: () => void; } const AuthContext = createContext(undefined); @@ -96,6 +104,14 @@ function withAuthTimeout(operation: Promise, message: string, timeoutMs = ]); } +function clearGoogleTokens() { + sessionStorage.removeItem('google_provider_token'); + localStorage.removeItem('google_provider_token'); + // Remove refresh tokens written by older clients as well; this client + // never persists them because they are long-lived credentials. + localStorage.removeItem('google_provider_refresh_token'); +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); @@ -104,6 +120,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const hydrateSparringStore = useSparringStore((state) => state.hydrateSavedSessions); const prepareSparringStore = useSparringStore((state) => state.prepareForUser); const resetSessionStore = useSessionStore((state) => state.resetLocalData); + const clearSessionStore = useSessionStore((state) => state.clearSession); const resetSettingsStore = useSettingsStore((state) => state.resetLocalData); const resetSparringStore = useSparringStore((state) => state.resetLocalData); const hydrateDebateStore = useDebateStore((state) => state.hydrate); @@ -236,11 +253,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { resetSparringStore(); resetDebateStore(); useDebateContextStore.getState().resetLocalData(); - sessionStorage.removeItem('google_provider_token'); - localStorage.removeItem('google_provider_token'); - // Remove refresh tokens written by older clients as well; this client - // never persists them because they are long-lived credentials. - localStorage.removeItem('google_provider_refresh_token'); + clearGoogleTokens(); return; } @@ -262,6 +275,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }; }, [hydrateDebateStore, hydrateSessionStore, hydrateSettingsStore, hydrateSparringStore, prepareSparringStore, resetDebateStore, resetSessionStore, resetSettingsStore, resetSparringStore]); + // sessionStorage holds this tab's Google token and sparring state. + const discardTabState = useCallback(() => { + clearGoogleTokens(); + resetSparringStore(); + }, [resetSparringStore]); + + // The persisted stores are shared by both projects. Drop this project's + // stored session and clear every user-specific store so nothing crosses. + const discardLocalState = useCallback(() => { + localStorage.removeItem(AUTH_STORAGE_KEY); + localStorage.removeItem(`${AUTH_STORAGE_KEY}-user`); + discardTabState(); + clearCrossfireDraftOnAuthChange(session?.user?.id, undefined); + resetSessionStore(); + clearSessionStore(); + resetSettingsStore(true); + resetDebateStore(); + useDebateContextStore.getState().resetLocalData(); + }, [session, clearSessionStore, discardTabState, resetDebateStore, resetSessionStore, resetSettingsStore]); + const value = useMemo(() => ({ session, user: session?.user ?? null, @@ -336,7 +369,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { resetSettingsStore(); resetSparringStore(); }, - }), [session, loading, resetSessionStore, resetSettingsStore, resetSparringStore]); + leaveEnvironment: async () => { + try { + // Local scope: switching backends must not sign the account out on other devices. + await withAuthTimeout(supabase.auth.signOut({ scope: 'local' }), 'Sign-out timed out'); + } catch (error) { + console.warn('Sign-out before environment switch failed:', error); + } + // A hung sign-out never fires SIGNED_OUT, so clear local state directly. + discardLocalState(); + }, + discardTabState, + }), [session, loading, discardLocalState, discardTabState, 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/env.ts b/src/lib/supabase/env.ts index a5061c540..ee4045ae0 100644 --- a/src/lib/supabase/env.ts +++ b/src/lib/supabase/env.ts @@ -11,7 +11,7 @@ type EnvironmentInput = { type StorageReader = { getItem(key: string): string | null }; type SupabaseConfig = { anonKey: string; env: SupabaseEnv; url: string }; -const STORAGE_KEY = "debate-supabase-env"; +export const ENV_STORAGE_KEY = "debate-supabase-env"; const ENVS: Record, { anonKey: string; url: string }> = { production: { @@ -37,7 +37,7 @@ export function resolveSupabaseConfig(environment: EnvironmentInput, storage: St return { anonKey, env: "local", url: parsedUrl.href.replace(/\/$/, "") }; } - return storage.getItem(STORAGE_KEY) === "staging" + return storage.getItem(ENV_STORAGE_KEY) === "staging" ? { ...ENVS.staging, env: "staging" } : { ...ENVS.production, env: "production" }; } @@ -59,8 +59,8 @@ export function getActiveEnv(): SupabaseEnv { export function setActiveEnv(env: Exclude): void { if (active.env === "local") return; - if (env === "production") localStorage.removeItem(STORAGE_KEY); - else localStorage.setItem(STORAGE_KEY, env); + if (env === "production") localStorage.removeItem(ENV_STORAGE_KEY); + else localStorage.setItem(ENV_STORAGE_KEY, env); } export const SUPABASE_URL = active.url; diff --git a/src/lib/supabase/switch-env.ts b/src/lib/supabase/switch-env.ts new file mode 100644 index 000000000..9c0122921 --- /dev/null +++ b/src/lib/supabase/switch-env.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { ENV_STORAGE_KEY, getActiveEnv, setActiveEnv } from '@/lib/supabase/env'; + +export type RemoteEnv = 'production' | 'staging'; + +export const reloadPage = () => window.location.reload(); + +// 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 leaveEnvironment(); + } catch (error) { + console.warn('Leaving the current environment failed:', error); + } + setActiveEnv(target); + reload(); +} + +/** + * Every tab shares the persisted stores, but each tab picks its project once at + * startup. When another tab switches projects, reload this one before it can + * merge the other project's data or send its own to the wrong project. + */ +export function useReloadOnEnvChange(discardTabState: () => void, reload: () => void = reloadPage) { + useEffect(() => { + const active = getActiveEnv(); + if (active === 'local') return; + const onStorage = (event: StorageEvent) => { + if (event.key !== null && event.key !== ENV_STORAGE_KEY) return; + const next: RemoteEnv = localStorage.getItem(ENV_STORAGE_KEY) === 'staging' ? 'staging' : 'production'; + if (next === active) return; + // The switching tab already cleared the shared stores, and they may now + // hold the new project's data. Clear only what belongs to this tab. + discardTabState(); + reload(); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, [discardTabState, 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/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 1b7d3370e..130415ce9 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -17,6 +17,7 @@ const { hydrateSessionStoreMock, hydrateSettingsStoreMock, resetSessionStoreMock, + clearSessionStoreMock, resetSettingsStoreMock, hydrateSparringStoreMock, prepareSparringStoreMock, @@ -37,6 +38,7 @@ const { const hydrateSessionStoreMock = vi.fn(); const hydrateSettingsStoreMock = vi.fn(); const resetSessionStoreMock = vi.fn(); + const clearSessionStoreMock = vi.fn(); const resetSettingsStoreMock = vi.fn(); const hydrateSparringStoreMock = vi.fn(); const prepareSparringStoreMock = vi.fn(); @@ -63,6 +65,7 @@ const { hydrateSessionStoreMock, hydrateSettingsStoreMock, resetSessionStoreMock, + clearSessionStoreMock, resetSettingsStoreMock, hydrateSparringStoreMock, prepareSparringStoreMock, @@ -73,6 +76,7 @@ const { }); vi.mock('@/lib/supabase/client', () => ({ + AUTH_STORAGE_KEY: 'debate-auth-production', SUPABASE_URL: 'https://example.supabase.co', setActiveAccessToken: vi.fn(), supabase: { @@ -101,6 +105,7 @@ vi.mock('@/lib/stores/session-store', () => ({ useSessionStore: (selector: (state: any) => any) => selector({ hydrateFromServer: hydrateSessionStoreMock, resetLocalData: resetSessionStoreMock, + clearSession: clearSessionStoreMock, }), })); @@ -121,6 +126,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 +622,68 @@ 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'); + sessionStorage.setItem('google_provider_token', 'production-google-token'); + localStorage.setItem('google_provider_token', 'legacy-google-token'); + localStorage.setItem('google_provider_refresh_token', 'legacy-refresh-token'); + 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); + // Leaving must end only this browser's session, not the account's sessions on other devices. + expect(signOutMock).toHaveBeenCalledWith({ scope: 'local' }); + 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(sessionStorage.getItem('google_provider_token')).toBeNull(); + expect(localStorage.getItem('google_provider_token')).toBeNull(); + expect(localStorage.getItem('google_provider_refresh_token')).toBeNull(); + expect(resetSessionStoreMock).toHaveBeenCalled(); + expect(clearSessionStoreMock).toHaveBeenCalled(); + expect(resetSettingsStoreMock).toHaveBeenCalled(); + expect(resetSparringStoreMock).toHaveBeenCalled(); + expect(useDebateStore.getState()).toMatchObject({ currentDebateId: null, oneOff: false }); + } finally { + vi.useRealTimers(); + } + }); + + it('discardTabState clears only this tab\'s own storage, not shared stores', () => { + sessionStorage.setItem('google_provider_token', 'old-project-google-token'); + localStorage.setItem('debate-auth-production', 'session'); + useDebateStore.setState({ currentDebateId: 'debate-1' }); + let discard: (() => void) | undefined; + function Holder(): null { + discard = useAuth().discardTabState; + return null; + } + render(); + + act(() => discard!()); + + expect(sessionStorage.getItem('google_provider_token')).toBeNull(); + expect(resetSparringStoreMock).toHaveBeenCalled(); + expect(localStorage.getItem('debate-auth-production')).toBe('session'); + expect(resetSessionStoreMock).not.toHaveBeenCalled(); + expect(clearSessionStoreMock).not.toHaveBeenCalled(); + expect(resetSettingsStoreMock).not.toHaveBeenCalled(); + expect(useDebateStore.getState().currentDebateId).toBe('debate-1'); + }); + 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 new file mode 100644 index 000000000..afbdcf65c --- /dev/null +++ b/src/test/components/EnvironmentSwitcher.test.tsx @@ -0,0 +1,129 @@ +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(), + discard: vi.fn(), +})); +vi.mock('@/lib/supabase/env', () => ({ + ENV_STORAGE_KEY: 'debate-supabase-env', + getActiveEnv: () => mocks.env, + setActiveEnv: mocks.setActiveEnv, +})); +vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ leaveEnvironment: mocks.signOut, discardTabState: mocks.discard }) })); + +beforeEach(() => { + mocks.env = 'production'; + mocks.setActiveEnv.mockReset(); + mocks.signOut.mockReset().mockResolvedValue(undefined); + mocks.discard.mockReset(); +}); +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(); + }); + + it('reloads this tab when another tab switches to a different project', () => { + const reload = vi.fn(); + render(); + const changeTo = (value: string | null, key: string | null = 'debate-supabase-env') => { + if (value === null) localStorage.removeItem('debate-supabase-env'); + else localStorage.setItem('debate-supabase-env', value); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); + }; + + changeTo(null, 'debate-trainer-storage'); + changeTo(null); + expect(reload).not.toHaveBeenCalled(); + expect(mocks.discard).not.toHaveBeenCalled(); + mocks.discard.mockImplementation(() => expect(reload).not.toHaveBeenCalled()); + changeTo('staging'); + expect(mocks.discard).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); + localStorage.removeItem('debate-supabase-env'); + }); + + it('ignores other tabs in local preview', () => { + mocks.env = 'local'; + const reload = vi.fn(); + render(); + localStorage.setItem('debate-supabase-env', 'staging'); + window.dispatchEvent(new StorageEvent('storage', { key: 'debate-supabase-env', newValue: 'staging' })); + expect(reload).not.toHaveBeenCalled(); + localStorage.removeItem('debate-supabase-env'); + }); +}); 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(); + }); });