From 33913c395ad058e3b979e33680f544da7098e2e5 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 11:01:17 +0000 Subject: [PATCH 1/4] wip: preserve interrupted email and demo-code auth work --- .audit/auth-email-and-demo-code-sign-in.tsv | 4 + e2e/login.spec.ts | 56 +++ src/lib/supabase/AuthProvider.tsx | 98 +++- src/locales/en.json | 102 +++- src/locales/es.json | 102 +++- src/locales/hu.json | 102 +++- src/pages/AdminPage.tsx | 435 +++++++++++++++++- src/pages/LoginPage.tsx | 204 +++++++- src/test/auth/AuthProvider.behavior.test.tsx | 84 ++++ src/test/auth/LoginPage.test.tsx | 111 +++++ src/test/pages/AdminPage.test.tsx | 126 +++++ supabase/functions/_shared/demo-code.test.ts | 25 + supabase/functions/_shared/demo-code.ts | 35 ++ .../admin/demo-code-contract.test.ts | 22 + supabase/functions/admin/index.ts | 188 ++++++++ supabase/functions/demo-code-login/index.ts | 21 +- ...60902000000_demo_access_admin_controls.sql | 40 ++ supabase/tests/demo_access_codes_rls_test.sql | 64 +++ 18 files changed, 1785 insertions(+), 34 deletions(-) create mode 100644 .audit/auth-email-and-demo-code-sign-in.tsv create mode 100644 e2e/login.spec.ts create mode 100644 src/test/auth/LoginPage.test.tsx create mode 100644 supabase/functions/_shared/demo-code.test.ts create mode 100644 supabase/functions/_shared/demo-code.ts create mode 100644 supabase/functions/admin/demo-code-contract.test.ts create mode 100644 supabase/migrations/20260902000000_demo_access_admin_controls.sql create mode 100644 supabase/tests/demo_access_codes_rls_test.sql diff --git a/.audit/auth-email-and-demo-code-sign-in.tsv b/.audit/auth-email-and-demo-code-sign-in.tsv new file mode 100644 index 000000000..522c4f35e --- /dev/null +++ b/.audit/auth-email-and-demo-code-sign-in.tsv @@ -0,0 +1,4 @@ +ts phase decision why evidence result +2026-09-02T00:00:00Z preflight reused the dedicated clean T3 worktree and feature branch it already exactly matched the requested isolation and avoided redundant worktree state git worktree list; git status --short --branch clean t3code/email-otp-demo-code-auth at origin/main +2026-09-02T00:00:01Z design preserved the established auth and admin design system the feature extends existing product surfaces and does not justify a redesign src/pages/LoginPage.tsx; src/pages/AdminPage.tsx implementation split into disjoint existing modules +2026-09-02T00:00:02Z security kept demo login on the existing hashed-code and OTP-handoff backend the plan requires normal Supabase sessions without exposing raw codes or service credentials supabase/functions/demo-code-login/index.ts; e2e/staging/auth.setup.ts shared contract locked before parallel edits diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts new file mode 100644 index 000000000..62fd378ca --- /dev/null +++ b/e2e/login.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { setLocale } from './helpers/auth'; + +test.describe('login passwordless flows', () => { + test.beforeEach(async ({ page }) => { + await setLocale(page, 'en'); + }); + + test('keeps Google primary and exposes email and demo-code tabs', async ({ page }) => { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + + await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible(); + }); + + test('sends an email code and focuses the verification field', async ({ page }) => { + let requestEmail: string | undefined; + await page.route('**/auth/v1/otp', async (route) => { + const body = route.request().postDataJSON() as { email?: unknown }; + requestEmail = typeof body.email === 'string' ? body.email : undefined; + await new Promise((resolve) => setTimeout(resolve, 250)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await page.getByRole('tab', { name: 'Email' }).click(); + await page.getByLabel('Email address').fill(' student@example.com '); + await page.getByRole('button', { name: 'Send code' }).click(); + + await expect(page.getByRole('button', { name: 'Sending code...' })).toBeVisible(); + await expect(page.getByText('Code sent', { exact: true })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Verification code' })).toBeFocused(); + expect(requestEmail).toBe('student@example.com'); + }); + + test('shows a demo-code backend error while keeping the form editable', async ({ page }) => { + await page.route('**/functions/v1/demo-code-login', (route) => route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ error: 'This demo code has expired.' }), + })); + + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await page.getByRole('tab', { name: 'Demo code' }).click(); + await page.getByRole('textbox', { name: 'Demo code' }).fill('DEMO-AB12-CD34'); + await page.getByRole('button', { name: 'Sign in with demo code' }).click(); + + await expect(page.getByRole('alert')).toHaveText('Edge Function returned a non-2xx status code'); + await expect(page.getByRole('textbox', { name: 'Demo code' })).toBeEnabled(); + }); +}); diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index 2213ea9cc..d468ab502 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -1,6 +1,6 @@ /* 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 type { EmailOtpType, Session, User } from '@supabase/supabase-js'; import { supabase } from './client'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; @@ -14,12 +14,66 @@ interface AuthContextValue { user: User | null; loading: boolean; signInWithGoogle: (options?: { driveAccess?: boolean }) => Promise; + signInWithEmailOtp: (email: string) => Promise; + verifyEmailOtp: (email: string, token: string) => Promise; + signInWithDemoCode: (code: string) => Promise; signOut: () => Promise; } const AuthContext = createContext(undefined); const AUTH_OPERATION_TIMEOUT_MS = 4000; const OAUTH_CODE_EXCHANGE_TIMEOUT_MS = 15_000; +const DEMO_FINGERPRINT_STORAGE_KEY = 'debate-demo-browser-fingerprint'; +const EMAIL_OTP_TYPES: EmailOtpType[] = ['signup', 'invite', 'magiclink', 'recovery', 'email_change', 'email']; + +type DemoCodeHandoff = { + email: string; + tokenHash: string; + type: EmailOtpType; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEmailOtpType(value: unknown): value is EmailOtpType { + return typeof value === 'string' && EMAIL_OTP_TYPES.some((type) => type === value); +} + +function parseDemoCodeHandoff(value: unknown): DemoCodeHandoff { + if (!isRecord(value) || typeof value.email !== 'string') { + throw new Error('Demo sign-in returned an invalid session handoff.'); + } + + const tokenHash = typeof value.tokenHash === 'string' + ? value.tokenHash + : typeof value.token_hash === 'string' + ? value.token_hash + : ''; + const email = value.email.trim(); + if (!email || !tokenHash) throw new Error('Demo sign-in returned an invalid session handoff.'); + + return { + email, + tokenHash, + type: isEmailOtpType(value.type) ? value.type : 'email', + }; +} + +function getDemoBrowserFingerprint() { + try { + const existing = sessionStorage.getItem(DEMO_FINGERPRINT_STORAGE_KEY); + if (existing) return existing; + + const fingerprint = typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + sessionStorage.setItem(DEMO_FINGERPRINT_STORAGE_KEY, fingerprint); + return fingerprint; + } catch { + return 'unavailable'; + } +} class AuthTimeoutError extends Error { constructor(message: string) { @@ -215,6 +269,48 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await openExternalUrl(data.url); }, + signInWithEmailOtp: async (email) => { + const normalizedEmail = email.trim(); + if (!normalizedEmail) throw new Error('Email is required.'); + + const { error } = await supabase.auth.signInWithOtp({ email: normalizedEmail }); + if (error) throw error; + }, + verifyEmailOtp: async (email, token) => { + const normalizedEmail = email.trim(); + const normalizedToken = token.trim(); + if (!normalizedEmail) throw new Error('Email is required.'); + if (!normalizedToken) throw new Error('Verification code is required.'); + + const { error } = await supabase.auth.verifyOtp({ + email: normalizedEmail, + token: normalizedToken, + type: 'email', + }); + if (error) throw error; + }, + signInWithDemoCode: async (code) => { + const normalizedCode = code.trim(); + if (!normalizedCode) throw new Error('Demo code is required.'); + + const { data, error } = await supabase.functions.invoke('demo-code-login', { + body: { + code: normalizedCode, + browserFingerprint: getDemoBrowserFingerprint(), + }, + }); + if (error) throw error; + + const handoff = parseDemoCodeHandoff(data); + // demo-code-login currently returns generateLink's hashed token and + // verification type. Supabase requires token_hash for magic links, while + // email OTPs use the email + token form. + const verification = handoff.type === 'email' + ? supabase.auth.verifyOtp({ email: handoff.email, token: handoff.tokenHash, type: 'email' }) + : supabase.auth.verifyOtp({ token_hash: handoff.tokenHash, type: handoff.type }); + const { error: verificationError } = await verification; + if (verificationError) throw verificationError; + }, signOut: async () => { await supabase.auth.signOut(); clearCrossfireDraftOnAuthChange(session?.user?.id, undefined); diff --git a/src/locales/en.json b/src/locales/en.json index e66313059..b089dceb4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -192,8 +192,102 @@ }, "login": { "title": "Sign in to DebateLab", - "subtitle": "Google sign-in is required to use the app.", - "google": "Continue with Google" + "subtitle": "Sign in with Google, email, or an admin-issued demo code.", + "google": "Continue with Google", + "googleTab": "Google", + "email": "Email", + "demoCode": "Demo code", + "emailTab": "Email", + "demoTab": "Demo code", + "orContinueWith": "Or continue with", + "emailLabel": "Email address", + "emailPlaceholder": "you@example.com", + "emailHint": "We'll send a six-digit code.", + "sendCode": "Send code", + "sendingCode": "Sending code...", + "codeSent": "Code sent", + "checkEmail": "Check your email for a six-digit code.", + "codeSentDesc": "Check your email for the six-digit code.", + "useDifferentEmail": "Use a different email", + "otpLabel": "Verification code", + "verificationCode": "Verification code", + "verificationCodePlaceholder": "123456", + "verifyCode": "Verify code", + "verifying": "Verifying...", + "signedIn": "Signed in", + "demoCodeLabel": "Demo code", + "demoCodePlaceholder": "DEMO-AB12-CD34", + "signInWithDemoCode": "Sign in with demo code", + "signingIn": "Signing in...", + "demoDescription": "Use an admin-issued code for temporary demo access.", + "invalidEmail": "Enter a valid email address.", + "invalidCode": "Enter a valid six-digit code.", + "invalidDemoCode": "Enter a valid demo code.", + "requiredEmail": "Enter your email address.", + "emailOtpError": "Could not send code.", + "requiredOtp": "Enter the verification code.", + "verifyOtpError": "Could not verify code.", + "requiredDemoCode": "Enter your demo code.", + "demoError": "Could not sign in with demo code.", + "demoCodeHint": "Enter the code provided by your event host.", + "redirecting": "Redirecting...", + "failed": "Could not start sign in", + "tryAgain": "Please try again.", + "termsNotice": "By continuing, you agree to our Terms and Privacy Policy." + }, + "admin": { + "demoAccess": "Demo access", + "createDemoCode": "Create demo code", + "createDemoCodeDescription": "Issue a one-time code for a demo account. The raw code appears only after creation.", + "label": "Label", + "labelPlaceholder": "e.g. September school demo", + "expiryOptional": "Expiry (optional)", + "maxUsesOptional": "Max uses (optional)", + "unlimited": "Unlimited", + "roleOptional": "Role (optional)", + "user": "User", + "adminRole": "Admin", + "banned": "Banned", + "creating": "Creating…", + "created": "Demo code created", + "copyNow": "Copy this code now. It will not be shown again.", + "copied": "Copied", + "copyCode": "Copy code", + "codesIssued": "{{count}} code", + "codesIssued_plural": "{{count}} codes issued", + "loading": "Loading demo codes…", + "loadFailed": "Unable to load demo codes: {{error}}", + "noneTitle": "No demo codes yet", + "noneDescription": "Create a code for your next demo or event.", + "tableCaption": "Issued demo access codes", + "assignedEmail": "Assigned email", + "expiry": "Expiry", + "uses": "Uses", + "lastUsed": "Last used", + "status": "Status", + "actions": "Actions", + "active": "active", + "disabled": "disabled", + "expired": "expired", + "exhausted": "exhausted", + "disable": "Disable", + "resetUses": "Reset uses", + "disableAriaLabel": "Disable demo code {{label}}", + "resetAriaLabel": "Reset use count for {{label}}", + "disableTitle": "Disable demo code?", + "resetTitle": "Reset use count?", + "disableDescription": "People will no longer be able to sign in with {{label}}.", + "resetDescription": "Set {{label}} use count back to zero.", + "cancel": "Cancel", + "disableCode": "Disable code", + "resetUseCount": "Reset use count", + "labelRequired": "Label is required.", + "maxUsesInvalid": "Max uses must be a whole number greater than zero.", + "expiryInvalid": "Expiry must be a valid date and time.", + "serverCodeMissing": "The server did not return the generated demo code.", + "createFailed": "Failed to create demo code.", + "copyFailed": "Could not copy the demo code. Select it and copy it manually.", + "actionFailed": "Demo code action failed." }, "notFound": { "title": "404", @@ -1062,7 +1156,7 @@ "lastUpdated": "Last updated", "date": "August 31, 2026", "s1": "Information We Collect", - "p1": "We collect information you provide when creating an account (name, email via Google OAuth) and usage data including generated content, saved items, and interaction patterns.", + "p1": "We collect information you provide when creating an account, including your name and email from Google sign-in or email sign-in, plus usage data such as generated content, saved items, and interaction patterns. If you use an admin-issued demo code, we process that code to authenticate your demo account.", "s2": "How We Use Your Information", "p2": "Your information is used to provide and improve our services, generate AI responses, maintain your saved content, and communicate service updates.", "s3": "AI Processing", @@ -1091,7 +1185,7 @@ "s2": "Service Description", "p2": "DebateLab is an AI-powered debate preparation tool that provides argument generation, analysis, and coaching features.", "s3": "Account Requirements", - "p3": "You must sign in with Google OAuth. You are responsible for all activity under your account.", + "p3": "You may sign in with Google, email-based sign-in, or an admin-issued demo code. Demo codes provide temporary access to a designated account. You are responsible for all activity under your account.", "s4": "Subscription and Billing", "p4": "Free trials last 3 days with limited requests. Pro subscriptions are $5/month plus tax, billed via Stripe. Cancel anytime from Settings.", "s5": "Acceptable Use", diff --git a/src/locales/es.json b/src/locales/es.json index 4bc89b289..9bd86ae8c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -192,8 +192,102 @@ }, "login": { "title": "Iniciar Sesión en DebateLab", - "subtitle": "Se requiere inicio de sesión con Google para usar la app.", - "google": "Continuar con Google" + "subtitle": "Inicia sesión con Google, email o un código demo emitido por un administrador.", + "google": "Continuar con Google", + "googleTab": "Google", + "email": "Email", + "demoCode": "Código demo", + "emailTab": "Email", + "demoTab": "Código demo", + "orContinueWith": "O continúa con", + "emailLabel": "Dirección de email", + "emailPlaceholder": "tu@ejemplo.com", + "emailHint": "Te enviaremos un código de seis dígitos.", + "sendCode": "Enviar código", + "sendingCode": "Enviando código...", + "codeSent": "Código enviado", + "checkEmail": "Revisa tu email para encontrar el código de seis dígitos.", + "codeSentDesc": "Revisa tu email para encontrar el código de seis dígitos.", + "useDifferentEmail": "Usar otro email", + "otpLabel": "Código de verificación", + "verificationCode": "Código de verificación", + "verificationCodePlaceholder": "123456", + "verifyCode": "Verificar código", + "verifying": "Verificando...", + "signedIn": "Sesión iniciada", + "demoCodeLabel": "Código demo", + "demoCodePlaceholder": "DEMO-AB12-CD34", + "signInWithDemoCode": "Iniciar sesión con código demo", + "signingIn": "Iniciando sesión...", + "demoDescription": "Usa un código emitido por un administrador para acceder temporalmente a la demo.", + "invalidEmail": "Introduce una dirección de email válida.", + "invalidCode": "Introduce un código válido de seis dígitos.", + "invalidDemoCode": "Introduce un código demo válido.", + "requiredEmail": "Introduce tu dirección de email.", + "emailOtpError": "No se pudo enviar el código.", + "requiredOtp": "Introduce el código de verificación.", + "verifyOtpError": "No se pudo verificar el código.", + "requiredDemoCode": "Introduce tu código demo.", + "demoError": "No se pudo iniciar sesión con el código demo.", + "demoCodeHint": "Introduce el código que te proporcionó el organizador del evento.", + "redirecting": "Redirigiendo...", + "failed": "No se pudo iniciar sesión", + "tryAgain": "Inténtalo de nuevo.", + "termsNotice": "Al continuar, aceptas nuestros Términos y nuestra Política de Privacidad." + }, + "admin": { + "demoAccess": "Acceso demo", + "createDemoCode": "Crear código demo", + "createDemoCodeDescription": "Emite un código de un solo uso para una cuenta demo. El código solo aparece después de crearlo.", + "label": "Etiqueta", + "labelPlaceholder": "p. ej., Demo escolar de septiembre", + "expiryOptional": "Caducidad (opcional)", + "maxUsesOptional": "Usos máximos (opcional)", + "unlimited": "Ilimitados", + "roleOptional": "Rol (opcional)", + "user": "Usuario", + "adminRole": "Administrador", + "banned": "Bloqueado", + "creating": "Creando…", + "created": "Código demo creado", + "copyNow": "Copia este código ahora. No volverá a mostrarse.", + "copied": "Copiado", + "copyCode": "Copiar código", + "codesIssued": "{{count}} código emitido", + "codesIssued_plural": "{{count}} códigos emitidos", + "loading": "Cargando códigos demo…", + "loadFailed": "No se pudieron cargar los códigos demo: {{error}}", + "noneTitle": "Aún no hay códigos demo", + "noneDescription": "Crea un código para tu próxima demo o evento.", + "tableCaption": "Códigos de acceso demo emitidos", + "assignedEmail": "Email asignado", + "expiry": "Caducidad", + "uses": "Usos", + "lastUsed": "Último uso", + "status": "Estado", + "actions": "Acciones", + "active": "activo", + "disabled": "deshabilitado", + "expired": "caducado", + "exhausted": "agotado", + "disable": "Deshabilitar", + "resetUses": "Restablecer usos", + "disableAriaLabel": "Deshabilitar código demo {{label}}", + "resetAriaLabel": "Restablecer usos de {{label}}", + "disableTitle": "¿Deshabilitar código demo?", + "resetTitle": "¿Restablecer contador de usos?", + "disableDescription": "Ya no se podrá iniciar sesión con {{label}}.", + "resetDescription": "Restablece a cero el contador de usos de {{label}}.", + "cancel": "Cancelar", + "disableCode": "Deshabilitar código", + "resetUseCount": "Restablecer contador", + "labelRequired": "La etiqueta es obligatoria.", + "maxUsesInvalid": "Los usos máximos deben ser un número entero mayor que cero.", + "expiryInvalid": "La caducidad debe ser una fecha y hora válidas.", + "serverCodeMissing": "El servidor no devolvió el código demo generado.", + "createFailed": "No se pudo crear el código demo.", + "copyFailed": "No se pudo copiar el código demo. Selecciónalo y cópialo manualmente.", + "actionFailed": "No se pudo realizar la acción sobre el código demo." }, "notFound": { "title": "404", @@ -1062,7 +1156,7 @@ "lastUpdated": "Última actualización", "date": "31 de agosto de 2026", "s1": "Información que Recopilamos", - "p1": "Recopilamos información que proporcionas al crear una cuenta (nombre, email vía Google OAuth) y datos de uso incluyendo contenido generado, elementos guardados y patrones de interacción.", + "p1": "Recopilamos la información que proporcionas al crear una cuenta, incluido tu nombre y correo electrónico, que pueden proceder del inicio de sesión con Google o por email, además de datos de uso como contenido generado, elementos guardados y patrones de interacción. Si usas un código demo emitido por un administrador, procesamos ese código para autenticar tu cuenta demo.", "s2": "Cómo Usamos Tu Información", "p2": "Tu información se usa para proporcionar y mejorar nuestros servicios, generar respuestas de IA, mantener tu contenido guardado y comunicar actualizaciones.", "s3": "Procesamiento de IA", @@ -1091,7 +1185,7 @@ "s2": "Descripción del Servicio", "p2": "DebateLab es una herramienta de preparación de debates impulsada por IA que proporciona generación de argumentos, análisis y funciones de coaching.", "s3": "Requisitos de Cuenta", - "p3": "Debes iniciar sesión con Google OAuth. Eres responsable de toda la actividad bajo tu cuenta.", + "p3": "Puedes iniciar sesión con Google, mediante un enlace enviado por email o con un código demo emitido por un administrador. Los códigos demo proporcionan acceso temporal a una cuenta designada. Eres responsable de toda la actividad de tu cuenta.", "s4": "Suscripción y Facturación", "p4": "Las pruebas gratuitas duran 3 días con solicitudes limitadas. Las suscripciones Pro cuestan $5/mes más impuestos, facturadas vía Stripe. Cancela en cualquier momento.", "s5": "Uso Aceptable", diff --git a/src/locales/hu.json b/src/locales/hu.json index cd7727628..d9e155249 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -192,8 +192,102 @@ }, "login": { "title": "Bejelentkezés a DebateLab-ba", - "subtitle": "Google bejelentkezés szükséges az alkalmazás használatához.", - "google": "Folytatás Google-lal" + "subtitle": "Jelentkezz be Google-lal, e-maillel vagy adminisztrátor által kiadott demókóddal.", + "google": "Folytatás Google-lal", + "googleTab": "Google", + "email": "E-mail", + "demoCode": "Demókód", + "emailTab": "E-mail", + "demoTab": "Demókód", + "orContinueWith": "Vagy folytasd ezzel", + "emailLabel": "E-mail-cím", + "emailPlaceholder": "te@pelda.hu", + "emailHint": "Hatjegyű kódot küldünk e-mailben.", + "sendCode": "Kód küldése", + "sendingCode": "Kód küldése...", + "codeSent": "Kód elküldve", + "checkEmail": "Nézd meg az e-mailedet a hatjegyű kódért.", + "codeSentDesc": "Nézd meg az e-mailedet a hatjegyű kódért.", + "useDifferentEmail": "Másik e-mail használata", + "otpLabel": "Ellenőrző kód", + "verificationCode": "Ellenőrző kód", + "verificationCodePlaceholder": "123456", + "verifyCode": "Kód ellenőrzése", + "verifying": "Ellenőrzés...", + "signedIn": "Bejelentkezve", + "demoCodeLabel": "Demókód", + "demoCodePlaceholder": "DEMO-AB12-CD34", + "signInWithDemoCode": "Bejelentkezés demókóddal", + "signingIn": "Bejelentkezés...", + "demoDescription": "Ideiglenes demóhozzáféréshez használj adminisztrátor által kiadott kódot.", + "invalidEmail": "Adj meg érvényes e-mail-címet.", + "invalidCode": "Adj meg érvényes, hatjegyű kódot.", + "invalidDemoCode": "Adj meg érvényes demókódot.", + "requiredEmail": "Adj meg e-mail-címet.", + "emailOtpError": "Nem sikerült elküldeni a kódot.", + "requiredOtp": "Add meg az ellenőrző kódot.", + "verifyOtpError": "A kód ellenőrzése nem sikerült.", + "requiredDemoCode": "Add meg a demókódot.", + "demoError": "Nem sikerült demókóddal bejelentkezni.", + "demoCodeHint": "Add meg az esemény szervezőjétől kapott kódot.", + "redirecting": "Átirányítás...", + "failed": "Nem sikerült elindítani a bejelentkezést", + "tryAgain": "Próbáld újra.", + "termsNotice": "A folytatással elfogadod a Felhasználási feltételeket és az Adatvédelmi irányelveket." + }, + "admin": { + "demoAccess": "Demóhozzáférés", + "createDemoCode": "Demókód létrehozása", + "createDemoCodeDescription": "Egyszer használható kód kiadása egy demófiókhoz. A nyers kód csak létrehozás után jelenik meg.", + "label": "Címke", + "labelPlaceholder": "például szeptemberi iskolai demó", + "expiryOptional": "Lejárat (opcionális)", + "maxUsesOptional": "Maximális használat (opcionális)", + "unlimited": "Korlátlan", + "roleOptional": "Szerepkör (opcionális)", + "user": "Felhasználó", + "adminRole": "Adminisztrátor", + "banned": "Letiltva", + "creating": "Létrehozás…", + "created": "Demókód létrehozva", + "copyNow": "Másold ki most ezt a kódot. Később nem jelenik meg újra.", + "copied": "Kimásolva", + "copyCode": "Kód másolása", + "codesIssued": "{{count}} kiadott kód", + "codesIssued_plural": "{{count}} kiadott kód", + "loading": "Demókódok betöltése…", + "loadFailed": "A demókódokat nem sikerült betölteni: {{error}}", + "noneTitle": "Még nincsenek demókódok", + "noneDescription": "Hozz létre kódot a következő demóhoz vagy eseményhez.", + "tableCaption": "Kiadott demó-hozzáférési kódok", + "assignedEmail": "Hozzárendelt e-mail", + "expiry": "Lejárat", + "uses": "Használatok", + "lastUsed": "Utoljára használva", + "status": "Állapot", + "actions": "Műveletek", + "active": "aktív", + "disabled": "letiltva", + "expired": "lejárt", + "exhausted": "kimerült", + "disable": "Letiltás", + "resetUses": "Használatok visszaállítása", + "disableAriaLabel": "A(z) {{label}} demókód letiltása", + "resetAriaLabel": "A(z) {{label}} használatszámának visszaállítása", + "disableTitle": "Letiltod a demókódot?", + "resetTitle": "Visszaállítod a használatszámot?", + "disableDescription": "A továbbiakban senki sem tud bejelentkezni a(z) {{label}} kóddal.", + "resetDescription": "A(z) {{label}} használatszámának visszaállítása nullára.", + "cancel": "Mégse", + "disableCode": "Kód letiltása", + "resetUseCount": "Használatszám visszaállítása", + "labelRequired": "A címke megadása kötelező.", + "maxUsesInvalid": "A maximális használatnak nullánál nagyobb egész számnak kell lennie.", + "expiryInvalid": "A lejáratnak érvényes dátumnak és időpontnak kell lennie.", + "serverCodeMissing": "A szerver nem küldte vissza a létrehozott demókódot.", + "createFailed": "A demókód létrehozása nem sikerült.", + "copyFailed": "A demókód másolása nem sikerült. Jelöld ki, majd másold ki kézzel.", + "actionFailed": "A demókód művelete nem sikerült." }, "notFound": { "title": "404", @@ -1062,7 +1156,7 @@ "lastUpdated": "Utolsó frissítés", "date": "2026. augusztus 31.", "s1": "Gyűjtött Információk", - "p1": "Gyűjtjük a fiók létrehozásakor megadott adatokat (név, email Google OAuth-on keresztül) és használati adatokat, beleértve a generált tartalmat, mentett elemeket és interakciós mintákat.", + "p1": "Gyűjtjük a fiók létrehozásakor megadott adatokat, például a nevedet és az e-mail-címedet, amelyek Google- vagy e-mailes bejelentkezésből származhatnak, valamint a használati adatokat, például a generált tartalmakat, a mentett elemeket és az interakciós mintákat. Adminisztrátor által kiadott demókód használatakor a kódot a demo-fiókod hitelesítéséhez dolgozzuk fel.", "s2": "Hogyan Használjuk az Információidat", "p2": "Az információidat szolgáltatásaink nyújtására és fejlesztésére, MI válaszok generálására, mentett tartalmad karbantartására és szolgáltatási frissítések kommunikálására használjuk.", "s3": "MI Feldolgozás", @@ -1091,7 +1185,7 @@ "s2": "Szolgáltatás Leírása", "p2": "A DebateLab egy MI-alapú vitafelkészítő eszköz, amely érvgenerálást, elemzést és coaching funkciókat biztosít.", "s3": "Fiókkövetelmények", - "p3": "Google OAuth-tal kell bejelentkezned. Felelős vagy a fiókod alatt végzett minden tevékenységért.", + "p3": "Google-lal, e-mailben kapott bejelentkezési hivatkozással vagy adminisztrátor által kiadott demókóddal jelentkezhetsz be. A demókódok ideiglenes hozzáférést adnak egy kijelölt fiókhoz. Felelős vagy a fiókod alatt végzett minden tevékenységért.", "s4": "Előfizetés és Számlázás", "p4": "Az ingyenes próba 3 napig tart korlátozott kérésekkel. A Pro előfizetés $5/hó plusz adó, Stripe-on keresztül számlázva. Bármikor lemondható a Beállításokból.", "s5": "Elfogadható Használat", diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index 5ce65ab75..bd2cf5541 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type FormEvent } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,7 +9,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { useToast } from '@/hooks/use-toast'; import { supabase, SUPABASE_URL } from '@/lib/supabase/client'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; -import { Activity, Users, DollarSign, AlertCircle, Shield, Settings2, Loader2, Ban, UserCheck, Ticket, CreditCard, Gift, Megaphone } from 'lucide-react'; +import { Activity, Users, DollarSign, AlertCircle, Shield, Settings2, Loader2, Ban, UserCheck, Ticket, CreditCard, Gift, Megaphone, KeyRound, Copy, RotateCcw } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, @@ -19,7 +19,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { formatEuropeanDate } from '@/lib/date-format'; +import { formatEuropeanDate, formatEuropeanDateTime } from '@/lib/date-format'; import { captureProductEvent } from '@/lib/observability'; import { AdminAnnouncements } from '@/components/admin/AdminAnnouncements'; @@ -66,6 +66,374 @@ interface Subscriber { current_period_end: string | null; } +interface DemoAccessCode { + id: string; + label: string; + email: string; + role: string; + expiresAt: string | null; + maxUses: number | null; + useCount: number; + lastUsedAt: string | null; + disabledAt: string | null; + createdAt: string | null; +} + +interface DemoAccessForm { + label: string; + expiresAt: string; + maxUses: string; + role: string; +} + +type DemoAction = 'disable' | 'reset'; + +const EMPTY_DEMO_FORM: DemoAccessForm = { label: '', expiresAt: '', maxUses: '', role: 'user' }; + +function normalizeDemoCode(value: unknown): DemoAccessCode | null { + if (!value || typeof value !== 'object') return null; + const row = value as Record; + if (typeof row.id !== 'string') return null; + const expiresAt = row.expires_at ?? row.expiresAt; + const maxUses = row.max_uses ?? row.maxUses; + const lastUsedAt = row.last_used_at ?? row.lastUsedAt; + const disabledAt = row.disabled_at ?? row.disabledAt; + const createdAt = row.created_at ?? row.createdAt; + const useCount = row.use_count ?? row.useCount; + return { + id: row.id, + label: typeof row.label === 'string' ? row.label : 'Unlabelled demo', + email: typeof row.email === 'string' ? row.email : 'Unknown email', + role: typeof row.role === 'string' ? row.role : 'user', + expiresAt: typeof expiresAt === 'string' ? expiresAt : null, + maxUses: typeof maxUses === 'number' ? maxUses : null, + useCount: typeof useCount === 'number' ? useCount : 0, + lastUsedAt: typeof lastUsedAt === 'string' ? lastUsedAt : null, + disabledAt: typeof disabledAt === 'string' ? disabledAt : null, + createdAt: typeof createdAt === 'string' ? createdAt : null, + }; +} + +function getDemoCodes(data: unknown): DemoAccessCode[] { + if (!data || typeof data !== 'object') return []; + const payload = data as Record; + const rows = payload.codes ?? payload.demo_codes ?? payload.demoCodes; + return Array.isArray(rows) ? rows.map(normalizeDemoCode).filter((row): row is DemoAccessCode => row !== null) : []; +} + +function getRawDemoCode(data: unknown): string | null { + if (!data || typeof data !== 'object') return null; + const payload = data as Record; + const nested = payload.demoCode ?? payload.demo_code ?? payload.codeRecord; + const candidates = [ + payload.rawCode, + payload.raw_code, + payload.code, + payload.demo_code, + nested && typeof nested === 'object' ? (nested as Record).code : null, + ]; + return candidates.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0)?.trim() || null; +} + +function getDemoCodeStatus(code: DemoAccessCode) { + if (code.disabledAt) return 'disabled'; + if (code.expiresAt && new Date(code.expiresAt).getTime() <= Date.now()) return 'expired'; + if (code.maxUses !== null && code.useCount >= code.maxUses) return 'exhausted'; + return 'active'; +} + +function formatDemoDate(value: string | null, includeTime = false) { + if (!value) return 'Never'; + return includeTime ? formatEuropeanDateTime(value, 'Invalid date') : formatEuropeanDate(value, 'Invalid date'); +} + +interface DemoAccessPanelProps { + codes: DemoAccessCode[]; + loading: boolean; + error: string | null; + onCreate: (form: DemoAccessForm) => Promise; + onDisable: (id: string) => Promise; + onReset: (id: string) => Promise; +} + +function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset }: DemoAccessPanelProps) { + const [form, setForm] = useState(EMPTY_DEMO_FORM); + const [formError, setFormError] = useState(null); + const [createdCode, setCreatedCode] = useState(null); + const [copied, setCopied] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [pendingAction, setPendingAction] = useState<{ type: DemoAction; code: DemoAccessCode } | null>(null); + const [actionLoading, setActionLoading] = useState(false); + const [actionError, setActionError] = useState(null); + + const updateForm = (updates: Partial) => { + setForm((current) => ({ ...current, ...updates })); + setFormError(null); + }; + + const handleCreate = async (event: FormEvent) => { + event.preventDefault(); + const label = form.label.trim(); + if (!label) { + setFormError('Label is required.'); + return; + } + const maxUses = form.maxUses.trim() ? Number(form.maxUses) : null; + if (maxUses !== null && (!Number.isInteger(maxUses) || maxUses < 1)) { + setFormError('Max uses must be a whole number greater than zero.'); + return; + } + let expiresAt: string | null = null; + if (form.expiresAt) { + const expiry = new Date(form.expiresAt); + if (Number.isNaN(expiry.getTime())) { + setFormError('Expiry must be a valid date and time.'); + return; + } + expiresAt = expiry.toISOString(); + } + + setSubmitting(true); + setFormError(null); + setCreatedCode(null); + setCopied(false); + try { + const rawCode = await onCreate({ label, expiresAt: expiresAt || '', maxUses: maxUses === null ? '' : String(maxUses), role: form.role }); + if (!rawCode) throw new Error('The server did not return the generated demo code.'); + setCreatedCode(rawCode); + setForm(EMPTY_DEMO_FORM); + } catch (createError) { + setFormError(createError instanceof Error ? createError.message : 'Failed to create demo code.'); + } finally { + setSubmitting(false); + } + }; + + const copyCreatedCode = async () => { + if (!createdCode) return; + let fallbackInput: HTMLTextAreaElement | null = null; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(createdCode); + } else { + fallbackInput = document.createElement('textarea'); + fallbackInput.value = createdCode; + fallbackInput.setAttribute('readonly', ''); + fallbackInput.style.position = 'fixed'; + fallbackInput.style.opacity = '0'; + document.body.appendChild(fallbackInput); + fallbackInput.select(); + if (!document.execCommand('copy')) throw new Error('Copy was rejected'); + } + setCopied(true); + } catch { + setFormError('Could not copy the demo code. Select it and copy it manually.'); + } finally { + fallbackInput?.remove(); + } + }; + + const confirmAction = async () => { + if (!pendingAction) return; + setActionLoading(true); + setActionError(null); + try { + if (pendingAction.type === 'disable') await onDisable(pendingAction.code.id); + else await onReset(pendingAction.code.id); + setPendingAction(null); + } catch (actionError) { + setActionError(actionError instanceof Error ? actionError.message : 'Demo code action failed.'); + } finally { + setActionLoading(false); + } + }; + + return ( +
+ + + + + Create demo code + + Issue a one-time code for a demo account. The raw code appears only after creation. + + +
+
+ + updateForm({ label: event.target.value })} + placeholder="e.g. September school demo" + autoComplete="off" + required + /> +
+
+ + updateForm({ expiresAt: event.target.value })} + /> +
+
+ + updateForm({ maxUses: event.target.value })} + placeholder="Unlimited" + /> +
+
+ + +
+ {formError &&

{formError}

} + +
+ {createdCode && ( +
+
+

Demo code created

+

Copy this code now. It will not be shown again.

+
+
+ {createdCode} + +
+
+ )} +
+
+ + + + Demo access + {codes.length} {codes.length === 1 ? 'code' : 'codes'} issued + + + {loading &&

Loading demo codes…

} + {error && ( +

Unable to load demo codes: {error}

+ )} + {!loading && codes.length === 0 && !error && ( +
+ +

No demo codes yet

+

Create a code for your next demo or event.

+
+ )} + {codes.length > 0 && ( +
+ + + + + + + + + + + + + + + + {codes.map((code) => { + const status = getDemoCodeStatus(code); + const statusVariant = status === 'active' ? 'default' : status === 'disabled' || status === 'expired' ? 'destructive' : 'secondary'; + return ( + + + + + + + + + + + ); + })} + +
Issued demo access codes
Assigned emailLabelRoleExpiryUsesLast usedStatusActions
{code.email} + {code.label} + {code.role}{formatDemoDate(code.expiresAt, true)}{code.useCount} / {code.maxUses === null ? 'Unlimited' : code.maxUses}{formatDemoDate(code.lastUsedAt, true)}{status} +
+ + +
+
+
+ )} +
+
+ + { if (!open && !actionLoading) setPendingAction(null); }}> + + + {pendingAction?.type === 'disable' ? 'Disable demo code?' : 'Reset use count?'} + + {pendingAction?.type === 'disable' + ? `People will no longer be able to sign in with ${pendingAction.code.label}.` + : `Set ${pendingAction?.code.label || 'this code'} use count back to zero.`} + + + {actionError &&

{actionError}

} + + + + +
+
+
+ ); +} + const COLORS: Record = { generate: '#3b82f6', attack: '#ef4444', @@ -112,6 +480,9 @@ export default function AdminPage() { const [isAdmin, setIsAdmin] = useState(null); const [promoCodes, setPromoCodes] = useState([]); const [subscribers, setSubscribers] = useState([]); + const [demoCodes, setDemoCodes] = useState([]); + const [demoCodesLoading, setDemoCodesLoading] = useState(true); + const [demoCodesError, setDemoCodesError] = useState(null); const [newPromo, setNewPromo] = useState({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '' }); const [giftTarget, setGiftTarget] = useState(null); const [giftDays, setGiftDays] = useState(30); @@ -193,11 +564,19 @@ export default function AdminPage() { const loadData = async () => { try { setLoading(true); - const [statsData, usersData, promoData, subData] = await Promise.all([ + const demoDataRequest: Promise = fetchAdminData('demo-codes'); + const demoDataPromise: Promise<{ data: unknown; error: Error | null }> = demoDataRequest + .then((data: unknown): { data: unknown; error: Error | null } => ({ data, error: null })) + .catch((error: unknown): { data: unknown; error: Error | null } => ({ + data: null, + error: error instanceof Error ? error : new Error('Failed to load demo codes'), + })); + const [statsData, usersData, promoData, subData, demoData] = await Promise.all([ fetchAdminData('stats'), fetchAdminData('users'), fetchAdminData('promo-codes').catch(() => ({ codes: [] as PromoCode[] })), fetchAdminData('subscribers').catch(() => ({ subscribers: [] as Subscriber[] })), + demoDataPromise, ]); const loadedUsers = usersData.users || []; const emailByUserId = new Map(); @@ -218,6 +597,8 @@ export default function AdminPage() { setUsers(loadedUsers); setPromoCodes(promoData.codes || []); setSubscribers(subData.subscribers || []); + setDemoCodes(getDemoCodes(demoData.data)); + setDemoCodesError(demoData.error?.message || null); setIsAdmin(true); } catch (err) { console.error('Admin load error:', err); @@ -232,6 +613,7 @@ export default function AdminPage() { toast({ title: 'Error', description: message || 'Failed to load admin data', variant: 'destructive' }); } } finally { + setDemoCodesLoading(false); setLoading(false); } }; @@ -292,6 +674,36 @@ export default function AdminPage() { } }; + const handleCreateDemoCode = async (form: DemoAccessForm) => { + const result = await postAdminData('create-demo-code', { + label: form.label, + expiresAt: form.expiresAt || null, + maxUses: form.maxUses ? Number(form.maxUses) : null, + role: form.role || 'user', + }); + const createdCode = getRawDemoCode(result); + const createdRow = normalizeDemoCode( + result && typeof result === 'object' + ? (result as Record).demoCode + ?? (result as Record).demo_code + ?? (result as Record).codeRecord + : null, + ); + if (createdRow) setDemoCodes((current) => [createdRow, ...current.filter((code) => code.id !== createdRow.id)]); + setDemoCodesError(null); + return createdCode; + }; + + const handleDisableDemoCode = async (id: string) => { + await postAdminData('disable-demo-code', { id }); + setDemoCodes((current) => current.map((code) => code.id === id ? { ...code, disabledAt: new Date().toISOString() } : code)); + }; + + const handleResetDemoCode = async (id: string) => { + await postAdminData('reset-demo-code', { id }); + setDemoCodes((current) => current.map((code) => code.id === id ? { ...code, useCount: 0, lastUsedAt: null } : code)); + }; + if (loading) { return (
@@ -397,6 +809,10 @@ export default function AdminPage() { Announcements + + + Demo access + Promo Codes @@ -640,6 +1056,17 @@ export default function AdminPage() { + + + + {/* Promo Codes Tab */}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 24731d5e5..1f4bc0587 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,15 +1,32 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Sparkles } from 'lucide-react'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useToast } from '@/hooks/use-toast'; +type AuthMode = 'email' | 'demo'; +type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; + +function isAuthMode(value: string): value is AuthMode { + return value === 'email' || value === 'demo'; +} + export default function LoginPage() { - const { user, signInWithGoogle } = useAuth(); + const { user, signInWithGoogle, signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode } = useAuth(); const [isRedirecting, setIsRedirecting] = useState(false); + const [authMode, setAuthMode] = useState('email'); + const [authStatus, setAuthStatus] = useState('idle'); + const [email, setEmail] = useState(''); + const [otp, setOtp] = useState(''); + const [demoCode, setDemoCode] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const otpInputRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation(); @@ -20,6 +37,10 @@ export default function LoginPage() { if (user) navigate(from, { replace: true }); }, [user, navigate, from]); + useEffect(() => { + if (authStatus === 'code-sent') otpInputRef.current?.focus(); + }, [authStatus]); + const handleGoogleSignIn = async () => { setIsRedirecting(true); try { @@ -34,6 +55,76 @@ export default function LoginPage() { } }; + const resetAuthState = (mode: AuthMode) => { + setAuthMode(mode); + setAuthStatus('idle'); + setErrorMessage(''); + }; + + const handleEmailSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const normalizedEmail = email.trim(); + if (!normalizedEmail) { + setAuthStatus('error'); + setErrorMessage(t('login.requiredEmail', 'Enter your email address.')); + return; + } + + setErrorMessage(''); + setAuthStatus('submitting'); + try { + await signInWithEmailOtp(normalizedEmail); + setOtp(''); + setAuthStatus('code-sent'); + } catch (error) { + setAuthStatus('error'); + setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.')); + } + }; + + const handleOtpSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const normalizedEmail = email.trim(); + const normalizedOtp = otp.trim(); + if (!normalizedOtp) { + setAuthStatus('error'); + setErrorMessage(t('login.requiredOtp', 'Enter the verification code.')); + return; + } + + setErrorMessage(''); + setAuthStatus('verifying'); + try { + await verifyEmailOtp(normalizedEmail, normalizedOtp); + setAuthStatus('success'); + } catch (error) { + setAuthStatus('error'); + setErrorMessage(error instanceof Error ? error.message : t('login.verifyOtpError', 'Could not verify code.')); + } + }; + + const handleDemoSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const normalizedCode = demoCode.trim(); + if (!normalizedCode) { + setAuthStatus('error'); + setErrorMessage(t('login.requiredDemoCode', 'Enter your demo code.')); + return; + } + + setErrorMessage(''); + setAuthStatus('submitting'); + try { + await signInWithDemoCode(normalizedCode); + setAuthStatus('success'); + } catch (error) { + setAuthStatus('error'); + setErrorMessage(error instanceof Error ? error.message : t('login.demoError', 'Could not sign in with demo code.')); + } + }; + + const isSubmitting = authStatus === 'submitting' || authStatus === 'verifying'; + return (
@@ -48,6 +139,115 @@ export default function LoginPage() { + + { + if (isAuthMode(value)) resetAuthState(value); + }} + className="w-full" + > + + {t('login.emailTab', 'Email')} + {t('login.demoTab', 'Demo code')} + + + {authStatus === 'code-sent' || authStatus === 'verifying' || ((authStatus === 'error' || authStatus === 'success') && otp.length > 0) ? ( +
void handleOtpSubmit(event)} className="space-y-4" aria-busy={authStatus === 'verifying'}> +
+ + setOtp(event.target.value)} + aria-describedby="login-otp-hint" + aria-invalid={authStatus === 'error'} + disabled={isSubmitting || authStatus === 'success'} + /> +

+ {t('login.codeSentDesc', 'Check your email for the six-digit code.')} +

+
+ + +
+ ) : ( +
void handleEmailSubmit(event)} className="space-y-4" aria-busy={authStatus === 'submitting'}> +
+ + setEmail(event.target.value)} + aria-describedby="login-email-hint" + aria-invalid={authStatus === 'error'} + /> +

+ {t('login.emailHint', 'We\'ll send a six-digit code.')} +

+
+ +
+ )} +
+ +
void handleDemoSubmit(event)} className="space-y-4" aria-busy={authStatus === 'submitting'}> +
+ + setDemoCode(event.target.value)} + aria-describedby="login-demo-code-hint" + aria-invalid={authStatus === 'error'} + /> +

+ {t('login.demoCodeHint', 'Enter the code provided by your event host.')} +

+
+ +
+
+
+ {authStatus === 'code-sent' && ( +

+ {t('login.codeSent', 'Code sent')} +

+ )} + {authStatus === 'success' && ( +

+ {t('login.signedIn', 'Signed in')} +

+ )} + {authStatus === 'error' && errorMessage && ( +

+ {errorMessage} +

+ )}
diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 0dc3d7b75..09f35c298 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -7,6 +7,9 @@ const { getSessionMock, onAuthStateChangeMock, signInWithOAuthMock, + signInWithOtpMock, + verifyOtpMock, + functionsInvokeMock, setSessionMock, exchangeCodeForSessionMock, signOutMock, @@ -24,6 +27,9 @@ const { const getSessionMock = vi.fn(); const onAuthStateChangeMock = vi.fn(); const signInWithOAuthMock = vi.fn(); + const signInWithOtpMock = vi.fn(); + const verifyOtpMock = vi.fn(); + const functionsInvokeMock = vi.fn(); const setSessionMock = vi.fn(); const exchangeCodeForSessionMock = vi.fn(); const signOutMock = vi.fn(); @@ -47,6 +53,9 @@ const { getSessionMock, onAuthStateChangeMock, signInWithOAuthMock, + signInWithOtpMock, + verifyOtpMock, + functionsInvokeMock, setSessionMock, exchangeCodeForSessionMock, signOutMock, @@ -70,10 +79,15 @@ vi.mock('@/lib/supabase/client', () => ({ getSession: getSessionMock, onAuthStateChange: onAuthStateChangeMock, signInWithOAuth: signInWithOAuthMock, + signInWithOtp: signInWithOtpMock, + verifyOtp: verifyOtpMock, setSession: setSessionMock, exchangeCodeForSession: exchangeCodeForSessionMock, signOut: signOutMock, }, + functions: { + invoke: functionsInvokeMock, + }, }, })); @@ -121,6 +135,18 @@ function AuthConsumer() { ); } +function PasswordlessAuthConsumer() { + const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode } = useAuth(); + + return ( +
+ + + +
+ ); +} + function SignInErrorConsumer() { const { signInWithGoogle } = useAuth(); const [error, setError] = useState(''); @@ -147,6 +173,9 @@ describe('AuthProvider behavior', () => { getSessionMock.mockResolvedValue({ data: { session: null }, error: null }); setSessionMock.mockResolvedValue({ data: { session: null }, error: null }); exchangeCodeForSessionMock.mockResolvedValue({ data: { session: null }, error: null }); + signInWithOtpMock.mockResolvedValue({ data: { user: null, session: null }, error: null }); + verifyOtpMock.mockResolvedValue({ data: { user: null, session: null }, error: null }); + functionsInvokeMock.mockResolvedValue({ data: { email: 'demo@example.com', tokenHash: 'hash', type: 'magiclink' }, error: null }); openExternalUrlMock.mockResolvedValue(undefined); }); @@ -385,6 +414,61 @@ describe('AuthProvider behavior', () => { }); }); + it('sends an email OTP for the normalized email address', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'email-otp' })); + + await waitFor(() => { + expect(signInWithOtpMock).toHaveBeenCalledWith({ email: 'student@example.com' }); + }); + }); + + it('verifies an email OTP with the normalized email and token', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'verify-email-otp' })); + + await waitFor(() => { + expect(verifyOtpMock).toHaveBeenCalledWith({ + email: 'student@example.com', + token: '123456', + type: 'email', + }); + }); + }); + + it('redeems a demo code and verifies the returned magic-link handoff', async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); + + await waitFor(() => { + expect(functionsInvokeMock).toHaveBeenCalledWith('demo-code-login', { + body: { + code: 'DEMO-AB12-CD34', + browserFingerprint: expect.any(String), + }, + }); + expect(verifyOtpMock).toHaveBeenCalledWith({ + token_hash: 'hash', + type: 'magiclink', + }); + }); + }); + it('exchanges PKCE codes from the callback URL and clears the query string', async () => { window.history.replaceState({}, '', '/app?code=pkce-code'); exchangeCodeForSessionMock.mockResolvedValue({ diff --git a/src/test/auth/LoginPage.test.tsx b/src/test/auth/LoginPage.test.tsx new file mode 100644 index 000000000..88bd87562 --- /dev/null +++ b/src/test/auth/LoginPage.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type LoginAuthMock = { + user: null; + signInWithGoogle: ReturnType; + signInWithEmailOtp: ReturnType; + verifyEmailOtp: ReturnType; + signInWithDemoCode: ReturnType; +}; + +const { authMock, toastMock } = vi.hoisted((): { authMock: LoginAuthMock; toastMock: ReturnType } => ({ + authMock: { + user: null, + signInWithGoogle: vi.fn(), + signInWithEmailOtp: vi.fn(), + verifyEmailOtp: vi.fn(), + signInWithDemoCode: vi.fn(), + }, + toastMock: vi.fn(), +})); + +vi.mock('@/lib/supabase/AuthProvider', () => ({ + useAuth: () => authMock, +})); + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: toastMock }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback || key, + }), +})); + +import LoginPage from '@/pages/LoginPage'; + +function renderLogin() { + return render( + + + , + ); +} + +describe('LoginPage passwordless auth', () => { + beforeEach(() => { + vi.clearAllMocks(); + authMock.user = null; + authMock.signInWithEmailOtp.mockResolvedValue(undefined); + authMock.verifyEmailOtp.mockResolvedValue(undefined); + authMock.signInWithDemoCode.mockResolvedValue(undefined); + }); + + it('keeps Google as the primary sign-in action and offers email and demo code paths', () => { + renderLogin(); + + expect(screen.getByRole('button', { name: 'login.google' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Email' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Demo code' })).toBeInTheDocument(); + }); + + it('sends an email OTP and moves focus to the verification code', async () => { + renderLogin(); + fireEvent.click(screen.getByRole('tab', { name: 'Email' })); + fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send code' })); + + await waitFor(() => { + expect(authMock.signInWithEmailOtp).toHaveBeenCalledWith('student@example.com'); + expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus(); + }); + expect(screen.getByText('Code sent')).toBeInTheDocument(); + }); + + it('shows verifying and success states while checking an email OTP', async () => { + let resolveVerification: (() => void) | undefined; + authMock.verifyEmailOtp.mockReturnValue(new Promise((resolve) => { + resolveVerification = resolve; + })); + + renderLogin(); + fireEvent.click(screen.getByRole('tab', { name: 'Email' })); + fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send code' })); + await screen.findByText('Code sent'); + fireEvent.change(screen.getByRole('textbox', { name: 'Verification code' }), { target: { value: '123456' } }); + fireEvent.click(screen.getByRole('button', { name: 'Verify code' })); + + expect(screen.getByText('Verifying...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Verifying...' })).toBeDisabled(); + + resolveVerification?.(); + await waitFor(() => expect(screen.getByText('Signed in')).toBeInTheDocument()); + }); + + it('shows demo code errors and returns to an editable idle form', async () => { + authMock.signInWithDemoCode.mockRejectedValue(new Error('This demo code has expired.')); + + renderLogin(); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo code' })); + await waitFor(() => expect(screen.getByRole('tab', { name: 'Demo code' })).toHaveAttribute('aria-selected', 'true')); + fireEvent.change(screen.getByRole('textbox', { name: 'Demo code' }), { target: { value: 'DEMO-AB12-CD34' } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('This demo code has expired.')); + expect(screen.getByRole('textbox', { name: 'Demo code' })).toBeEnabled(); + }); +}); diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx index 907f0e825..b0052dfd2 100644 --- a/src/test/pages/AdminPage.test.tsx +++ b/src/test/pages/AdminPage.test.tsx @@ -378,6 +378,132 @@ describe('AdminPage Integration', () => { expect(screen.getByText('7 requests missing token data')).toBeInTheDocument(); }); + it('creates a demo code and copies the raw code once', async () => { + const createdBody: Record = {}; + const copyMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copyMock } }); + const emptyStats = { + totals: { requests: 0, cost: 0, successRate: 100 }, + daily: [] as unknown[], + byFeature: {}, + byUser: [] as unknown[], + }; + const createdRow = { + id: 'demo-1', + label: 'September school demo', + email: 'demo-1@example.com', + role: 'user', + expires_at: '2026-09-30T12:00:00.000Z', + max_uses: 4, + use_count: 0, + last_used_at: null as string | null, + disabled_at: null as string | null, + created_at: '2026-09-02T12:00:00.000Z', + }; + + globalFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.includes('action=stats')) return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) }; + if (url.includes('action=users')) return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) }; + if (url.includes('action=promo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) }; + if (url.includes('action=subscribers')) return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) }; + if (url.includes('action=demo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) }; + if (url.includes('action=create-demo-code')) { + Object.assign(createdBody, JSON.parse(String(init?.body || '{}'))); + return { ok: true, status: 201, text: async () => JSON.stringify({ code: 'DEMO-7KQ4-MV9P', demoCode: createdRow }) }; + } + return { ok: false, status: 404, text: async () => '{}' }; + }); + + render( + + + + ); + + await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument()); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo access' }), { button: 0 }); + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'September school demo' } }); + fireEvent.change(screen.getByLabelText('Expiry (optional)'), { target: { value: '2026-09-30T12:00' } }); + fireEvent.change(screen.getByLabelText('Max uses (optional)'), { target: { value: '4' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create demo code' })); + + await waitFor(() => expect(createdBody.label).toBe('September school demo')); + expect(createdBody.expiresAt).toBe('2026-09-30T12:00:00.000Z'); + expect(createdBody.maxUses).toBe(4); + expect(createdBody.role).toBe('user'); + expect(screen.getByText('DEMO-7KQ4-MV9P')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy code' })); + await waitFor(() => expect(copyMock).toHaveBeenCalledWith('DEMO-7KQ4-MV9P')); + }); + + it('confirms disabling and resetting a demo code', async () => { + const demoId = 'demo-2'; + const disableBody: Record = {}; + const resetBody: Record = {}; + const emptyStats = { + totals: { requests: 0, cost: 0, successRate: 100 }, + daily: [] as unknown[], + byFeature: {}, + byUser: [] as unknown[], + }; + globalFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.includes('action=stats')) return { ok: true, status: 200, text: async () => JSON.stringify(emptyStats) }; + if (url.includes('action=users')) return { ok: true, status: 200, text: async () => JSON.stringify({ users: [] }) }; + if (url.includes('action=promo-codes')) return { ok: true, status: 200, text: async () => JSON.stringify({ codes: [] }) }; + if (url.includes('action=subscribers')) return { ok: true, status: 200, text: async () => JSON.stringify({ subscribers: [] }) }; + if (url.includes('action=demo-codes')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ codes: [{ + id: demoId, + label: 'Tournament room', + email: 'room@example.com', + role: 'user', + expires_at: null, + max_uses: 2, + use_count: 1, + last_used_at: '2026-09-02T12:00:00.000Z', + disabled_at: null, + created_at: '2026-09-01T12:00:00.000Z', + }] }), + }; + } + if (url.includes('action=disable-demo-code')) { + Object.assign(disableBody, JSON.parse(String(init?.body || '{}'))); + return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) }; + } + if (url.includes('action=reset-demo-code')) { + Object.assign(resetBody, JSON.parse(String(init?.body || '{}'))); + return { ok: true, status: 200, text: async () => JSON.stringify({ success: true }) }; + } + return { ok: false, status: 404, text: async () => '{}' }; + }); + + render( + + + + ); + + await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument()); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo access' }), { button: 0 }); + expect(screen.getByText('room@example.com')).toBeInTheDocument(); + expect(screen.getByText('1 / 2')).toBeInTheDocument(); + expect(screen.getByText('active')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disable demo code Tournament room' })); + fireEvent.click(await screen.findByRole('button', { name: 'Disable code' })); + await waitFor(() => expect(disableBody.id).toBe(demoId)); + expect(screen.getByText('disabled')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset use count for Tournament room' })); + fireEvent.click(await screen.findByRole('button', { name: 'Reset use count' })); + await waitFor(() => expect(resetBody.id).toBe(demoId)); + expect(screen.getByText('0 / 2')).toBeInTheDocument(); + }); + it('creates an announcement from the admin tab and adds it to history', async () => { const createdBody: Record = {}; const emptyStats = { diff --git a/supabase/functions/_shared/demo-code.test.ts b/supabase/functions/_shared/demo-code.test.ts new file mode 100644 index 000000000..8867bb330 --- /dev/null +++ b/supabase/functions/_shared/demo-code.test.ts @@ -0,0 +1,25 @@ +import { generateDemoCode, getDemoCodeDenialReason } from "./demo-code.ts"; + +Deno.test("generated demo codes use the login format", () => { + const code = generateDemoCode(); + if (!/^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) { + throw new Error(`unexpected demo code format: ${code}`); + } +}); + +const validCode = { disabled_at: null, expires_at: null, max_uses: 3, use_count: 0 }; + +Deno.test("expired demo codes are denied", () => { + const reason = getDemoCodeDenialReason({ ...validCode, expires_at: "2020-01-01T00:00:00.000Z" }); + if (reason !== "expired") throw new Error(`expected expired, got ${reason}`); +}); + +Deno.test("disabled demo codes are denied", () => { + const reason = getDemoCodeDenialReason({ ...validCode, disabled_at: "2026-01-01T00:00:00.000Z" }); + if (reason !== "disabled") throw new Error(`expected disabled, got ${reason}`); +}); + +Deno.test("demo codes at their max use count are denied", () => { + const reason = getDemoCodeDenialReason({ ...validCode, use_count: 3 }); + if (reason !== "max_uses") throw new Error(`expected max_uses, got ${reason}`); +}); diff --git a/supabase/functions/_shared/demo-code.ts b/supabase/functions/_shared/demo-code.ts new file mode 100644 index 000000000..563a05bba --- /dev/null +++ b/supabase/functions/_shared/demo-code.ts @@ -0,0 +1,35 @@ +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +export type DemoCodeState = { + disabled_at: string | null; + expires_at: string | null; + max_uses: number | null; + use_count: number; +}; + +export type DemoCodeDenialReason = "disabled" | "expired" | "max_uses" | null; + +export function generateDemoCode() { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + const value = [...bytes].map((byte) => CODE_ALPHABET[byte % CODE_ALPHABET.length]).join(""); + return `DEMO-${value.slice(0, 4)}-${value.slice(4)}`; +} + +export function getDemoCodeDenialReason(code: DemoCodeState, now = Date.now()): DemoCodeDenialReason { + if (code.disabled_at) return "disabled"; + if (code.expires_at && new Date(code.expires_at).getTime() <= now) return "expired"; + if (code.max_uses !== null && code.use_count >= code.max_uses) return "max_uses"; + return null; +} + +export async function hashDemoCode(code: string) { + const pepper = Deno.env.get("DEMO_CODE_PEPPER") || Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!pepper) throw new Error("Missing demo code pepper"); + + const bytes = new TextEncoder().encode(`${pepper}:${code}`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/supabase/functions/admin/demo-code-contract.test.ts b/supabase/functions/admin/demo-code-contract.test.ts new file mode 100644 index 000000000..77ff8c963 --- /dev/null +++ b/supabase/functions/admin/demo-code-contract.test.ts @@ -0,0 +1,22 @@ +Deno.test("admin exposes the demo-code lifecycle contract", async () => { + const source = await Deno.readTextFile(new URL("./index.ts", import.meta.url)); + + for (const action of ["demo-codes", "create-demo-code", "disable-demo-code", "reset-demo-code"]) { + if (!source.includes(`action === "${action}"`)) { + throw new Error(`missing admin action: ${action}`); + } + } + + if (!source.includes("codeHash") || !source.includes("generateDemoCode")) { + throw new Error("admin creation must hash and generate demo codes"); + } + if (!source.includes("code: rawCode")) { + throw new Error("create must return the raw code in its one-time response"); + } + if (source.includes("rawCode.replaceAll")) { + throw new Error("the assigned email must not reveal or derive the raw code"); + } + if (source.includes('["user", "admin", "banned"].includes(role)')) { + throw new Error("admin role cannot be assigned through a demo code"); + } +}); diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index d466b89d1..4dae08fcf 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -6,6 +6,7 @@ import { validateAnnouncementId, validateCreateAnnouncement, } from "../_shared/announcements.ts"; +import { generateDemoCode, hashDemoCode } from "../_shared/demo-code.ts"; // SECURITY: Allow specific origins for CORS const ALLOWED_ORIGINS = [ @@ -33,6 +34,8 @@ const supabase = createClient(supabaseUrl, supabaseServiceKey); const ADMIN_ANNOUNCEMENT_COLUMNS = "id,title,body,target_type,target_user_ids,starts_at,expires_at,deactivated_at,created_by,created_at"; +const ADMIN_DEMO_CODE_COLUMNS = + "id,label,email,role,expires_at,max_uses,use_count,created_by,last_used_at,disabled_at,created_at"; type UsageByUserRow = { user_id: string; @@ -458,6 +461,191 @@ Deno.serve(async (req) => { }); } + // GET /admin?action=demo-codes - List demo codes without hashes or raw codes + if (action === "demo-codes" && req.method === "GET") { + await assertCurrentAdmin(user.id); + const { data, error } = await supabase + .from("demo_access_codes") + .select(ADMIN_DEMO_CODE_COLUMNS) + .order("created_at", { ascending: false }); + + if (error) throw error; + + return new Response(JSON.stringify({ codes: data || [] }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // POST /admin?action=create-demo-code - Create a code and return its raw value once + if (action === "create-demo-code" && req.method === "POST") { + await assertCurrentAdmin(user.id); + const body = await req.json().catch(() => ({})); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return new Response(JSON.stringify({ error: "Request body must be a JSON object" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const label = typeof body.label === "string" ? body.label.trim() : ""; + const requestedEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + const role = body.role || "user"; + const rawMaxUses = body.maxUses; + const rawExpiresAt = body.expiresAt; + + if (!label || label.length > 120) { + return new Response(JSON.stringify({ error: "Label is required and must be 120 characters or fewer" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + if (requestedEmail && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(requestedEmail) || requestedEmail.length > 254)) { + return new Response(JSON.stringify({ error: "Invalid demo email" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Demo codes can never provision an admin account. Admin access is + // assigned through user_roles by a separate admin-only action. + if (!["user", "banned"].includes(role)) { + return new Response(JSON.stringify({ error: "Demo codes may only use user or banned roles" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + let maxUses: number | null = null; + if (rawMaxUses !== undefined && rawMaxUses !== null && rawMaxUses !== "") { + maxUses = Number(rawMaxUses); + if (!Number.isInteger(maxUses) || maxUses < 1 || maxUses > 1_000_000) { + return new Response(JSON.stringify({ error: "Max uses must be a positive integer" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } + + let expiresAt: string | null = null; + if (rawExpiresAt !== undefined && rawExpiresAt !== null && rawExpiresAt !== "") { + const parsedExpiresAt = new Date(rawExpiresAt); + if (typeof rawExpiresAt !== "string" || Number.isNaN(parsedExpiresAt.getTime()) || parsedExpiresAt.getTime() <= Date.now()) { + return new Response(JSON.stringify({ error: "Expiry must be a future date" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + expiresAt = parsedExpiresAt.toISOString(); + } + + const { data: duplicateEmail, error: duplicateError } = requestedEmail + ? await supabase.from("demo_access_codes").select("id").eq("email", requestedEmail).maybeSingle() + : { data: null, error: null }; + if (duplicateError) throw duplicateError; + if (duplicateEmail) { + return new Response(JSON.stringify({ error: "A demo code already uses that email" }), { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + let rawCode = ""; + let createdCode: Record | null = null; + for (let attempt = 0; attempt < 5 && !createdCode; attempt += 1) { + rawCode = generateDemoCode(); + const codeHash = await hashDemoCode(rawCode); + const email = requestedEmail || `demo-${crypto.randomUUID()}@debate.pi-stack.org`; + const { data, error } = await supabase + .from("demo_access_codes") + .insert({ + code_hash: codeHash, + label, + email, + role, + expires_at: expiresAt, + max_uses: maxUses, + created_by: user.id, + }) + .select(ADMIN_DEMO_CODE_COLUMNS) + .single(); + + if (!error) { + createdCode = data; + break; + } + if (error.code !== "23505") throw error; + } + + if (!createdCode) throw new Error("Could not create demo code"); + + return new Response(JSON.stringify({ code: rawCode, demoCode: createdCode }), { + status: 201, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // POST /admin?action=disable-demo-code - Disable a code without deleting its audit data + if (action === "disable-demo-code" && req.method === "POST") { + await assertCurrentAdmin(user.id); + const body = await req.json().catch(() => ({})); + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (!id) { + return new Response(JSON.stringify({ error: "Demo code ID is required" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { data, error } = await supabase + .from("demo_access_codes") + .update({ disabled_at: new Date().toISOString() }) + .eq("id", id) + .select(ADMIN_DEMO_CODE_COLUMNS) + .maybeSingle(); + if (error) throw error; + if (!data) { + return new Response(JSON.stringify({ error: "Demo code not found" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ demoCode: data }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // POST /admin?action=reset-demo-code - Reset usage while preserving the code's status + if (action === "reset-demo-code" && req.method === "POST") { + await assertCurrentAdmin(user.id); + const body = await req.json().catch(() => ({})); + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (!id) { + return new Response(JSON.stringify({ error: "Demo code ID is required" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { data, error } = await supabase + .from("demo_access_codes") + .update({ use_count: 0, last_used_at: null }) + .eq("id", id) + .select(ADMIN_DEMO_CODE_COLUMNS) + .maybeSingle(); + if (error) throw error; + if (!data) { + return new Response(JSON.stringify({ error: "Demo code not found" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ demoCode: data }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + // POST /admin?action=set-limit - Set rate limit if (action === "set-limit" && req.method === "POST") { const body = await req.json(); diff --git a/supabase/functions/demo-code-login/index.ts b/supabase/functions/demo-code-login/index.ts index 1781f1018..ecf27f7ba 100644 --- a/supabase/functions/demo-code-login/index.ts +++ b/supabase/functions/demo-code-login/index.ts @@ -1,4 +1,5 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { getDemoCodeDenialReason, hashDemoCode } from "../_shared/demo-code.ts"; const ALLOWED_ORIGINS = [ "https://debate.pi-stack.org", @@ -12,7 +13,6 @@ const ALLOWED_ORIGINS = [ const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; -const demoCodePepper = Deno.env.get("DEMO_CODE_PEPPER") || supabaseServiceKey; const supabase = createClient(supabaseUrl, supabaseServiceKey); type DemoAccessCode = { @@ -53,16 +53,6 @@ function sanitizeRedirectTo(value: unknown): string | undefined { } } -async function sha256Hex(input: string) { - const bytes = new TextEncoder().encode(input); - const digest = await crypto.subtle.digest("SHA-256", bytes); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -async function hashCode(code: string) { - return sha256Hex(`${demoCodePepper}:${code}`); -} - function getIpAddress(req: Request) { // The right-most entry in X-Forwarded-For is the one appended by our own // trusted edge proxy; earlier entries can be forged by the client. Taking @@ -175,7 +165,7 @@ Deno.serve(async (req) => { }); } - const codeHash = await hashCode(code); + const codeHash = await hashDemoCode(code); const { data: demoCode, error: lookupError } = await supabase .from("demo_access_codes") .select("*") @@ -192,7 +182,8 @@ Deno.serve(async (req) => { }); } - if (demoCode.disabled_at) { + const denialReason = getDemoCodeDenialReason(demoCode); + if (denialReason === "disabled") { await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "disabled" }); return new Response(JSON.stringify({ error: "This demo code has been disabled." }), { status: 403, @@ -200,7 +191,7 @@ Deno.serve(async (req) => { }); } - if (demoCode.expires_at && new Date(demoCode.expires_at).getTime() <= Date.now()) { + if (denialReason === "expired") { await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "expired" }); return new Response(JSON.stringify({ error: "This demo code has expired." }), { status: 403, @@ -208,7 +199,7 @@ Deno.serve(async (req) => { }); } - if (demoCode.max_uses !== null && demoCode.use_count >= demoCode.max_uses) { + if (denialReason === "max_uses") { await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "max_uses" }); return new Response(JSON.stringify({ error: "This demo code has no uses left." }), { status: 403, diff --git a/supabase/migrations/20260902000000_demo_access_admin_controls.sql b/supabase/migrations/20260902000000_demo_access_admin_controls.sql new file mode 100644 index 000000000..6537bb400 --- /dev/null +++ b/supabase/migrations/20260902000000_demo_access_admin_controls.sql @@ -0,0 +1,40 @@ +-- Demo codes are only reachable through the authenticated admin function and +-- the service-role demo login function. Keep both tables closed to the Data API. +REVOKE ALL ON TABLE public.demo_access_codes FROM PUBLIC, anon, authenticated; +REVOKE ALL ON TABLE public.demo_access_attempts FROM PUBLIC, anon, authenticated; +GRANT ALL ON TABLE public.demo_access_codes TO service_role; +GRANT ALL ON TABLE public.demo_access_attempts TO service_role; + +-- PostgreSQL resolves RETURNS TABLE columns as variables. Qualify the CTE +-- column so the reservation RPC works on current local and hosted versions. +CREATE OR REPLACE FUNCTION public.consume_demo_access_code(p_code_id uuid) +RETURNS TABLE(reserved boolean) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF auth.role() <> 'service_role' THEN + RAISE EXCEPTION 'Not authorized'; + END IF; + + RETURN QUERY + WITH consumed AS ( + UPDATE public.demo_access_codes + SET use_count = use_count + 1, + last_used_at = now() + WHERE id = p_code_id + AND disabled_at IS NULL + AND (expires_at IS NULL OR expires_at > now()) + AND (max_uses IS NULL OR use_count < max_uses) + RETURNING true AS reserved + ) + SELECT consumed.reserved FROM consumed + UNION ALL + SELECT false WHERE NOT EXISTS (SELECT 1 FROM consumed); +END; +$$; + +-- Keep the atomic login reservation private to the service role. +REVOKE ALL ON FUNCTION public.consume_demo_access_code(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.consume_demo_access_code(uuid) TO service_role; diff --git a/supabase/tests/demo_access_codes_rls_test.sql b/supabase/tests/demo_access_codes_rls_test.sql new file mode 100644 index 000000000..8a189506d --- /dev/null +++ b/supabase/tests/demo_access_codes_rls_test.sql @@ -0,0 +1,64 @@ +BEGIN; +SELECT plan(11); + +SELECT ok( + NOT has_table_privilege('anon', 'public.demo_access_codes', 'SELECT'), + 'anonymous callers cannot read demo codes' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.demo_access_codes', 'SELECT'), + 'authenticated callers cannot read demo codes directly' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.demo_access_codes', 'UPDATE'), + 'authenticated callers cannot mutate demo codes directly' +); +SELECT ok( + NOT has_table_privilege('anon', 'public.demo_access_attempts', 'SELECT'), + 'anonymous callers cannot read demo login attempts' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.demo_access_attempts', 'INSERT'), + 'authenticated callers cannot write demo login attempts' +); +SELECT ok( + has_table_privilege('service_role', 'public.demo_access_codes', 'SELECT,INSERT,UPDATE,DELETE'), + 'service role owns demo-code access' +); +SELECT ok( + has_function_privilege('anon', 'public.consume_demo_access_code(uuid)', 'EXECUTE') = false, + 'anonymous callers cannot reserve a demo code' +); +SELECT ok( + has_function_privilege('authenticated', 'public.consume_demo_access_code(uuid)', 'EXECUTE') = false, + 'authenticated callers cannot reserve a demo code' +); +SELECT ok( + has_function_privilege('service_role', 'public.consume_demo_access_code(uuid)', 'EXECUTE'), + 'service role can reserve a demo code' +); + +INSERT INTO auth.users (id, email) +VALUES ('33333333-3333-4333-8333-333333333333', 'demo-rls-test@example.com'); +INSERT INTO public.demo_access_codes (code_hash, label, email, max_uses, created_by) +VALUES ('demo-rls-test-hash', 'RLS test', 'demo-rls-test@example.com', 1, '33333333-3333-4333-8333-333333333333'); + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT results_eq( + $$SELECT reserved FROM public.consume_demo_access_code( + (SELECT id FROM public.demo_access_codes WHERE code_hash = 'demo-rls-test-hash') + )$$, + ARRAY[true], + 'the first reservation succeeds' +); +SELECT results_eq( + $$SELECT reserved FROM public.consume_demo_access_code( + (SELECT id FROM public.demo_access_codes WHERE code_hash = 'demo-rls-test-hash') + )$$, + ARRAY[false], + 'the max-use reservation is atomic' +); + +SELECT * FROM finish(); +ROLLBACK; From abb42fcf8c6233ff2a9fc1d151d96e5c489ad9d7 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 11:05:15 +0000 Subject: [PATCH 2/4] test: align auth tests with current router types --- src/test/pages/AdminPage.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx index a40397f81..e37c33ec8 100644 --- a/src/test/pages/AdminPage.test.tsx +++ b/src/test/pages/AdminPage.test.tsx @@ -415,7 +415,7 @@ describe('AdminPage Integration', () => { }); render( - + ); @@ -482,7 +482,7 @@ describe('AdminPage Integration', () => { }); render( - + ); From 0f2aaf8ea2cac8bfc188ed5fbb945a3d27a6601a Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 12:53:44 +0000 Subject: [PATCH 3/4] feat: add email OTP and demo-code sign-in --- .audit/auth-email-and-demo-code-sign-in.tsv | 5 + e2e/login.spec.ts | 43 ++++- src/lib/supabase/AuthProvider.tsx | 43 +++-- src/locales/en.json | 17 +- src/locales/es.json | 17 +- src/locales/hu.json | 17 +- src/pages/AdminPage.tsx | 176 ++++++++---------- src/pages/LoginPage.tsx | 45 ++++- src/test/auth/AuthProvider.behavior.test.tsx | 38 +++- src/test/auth/LoginPage.test.tsx | 19 +- src/test/pages/AdminPage.test.tsx | 4 +- supabase/functions/_shared/demo-code.test.ts | 29 ++- supabase/functions/_shared/demo-code.ts | 25 +++ .../admin/demo-code-contract.test.ts | 19 +- supabase/functions/admin/index.ts | 11 +- supabase/functions/demo-code-login/index.ts | 37 ++-- 16 files changed, 371 insertions(+), 174 deletions(-) diff --git a/.audit/auth-email-and-demo-code-sign-in.tsv b/.audit/auth-email-and-demo-code-sign-in.tsv index 522c4f35e..132f583b1 100644 --- a/.audit/auth-email-and-demo-code-sign-in.tsv +++ b/.audit/auth-email-and-demo-code-sign-in.tsv @@ -2,3 +2,8 @@ ts phase decision why evidence result 2026-09-02T00:00:00Z preflight reused the dedicated clean T3 worktree and feature branch it already exactly matched the requested isolation and avoided redundant worktree state git worktree list; git status --short --branch clean t3code/email-otp-demo-code-auth at origin/main 2026-09-02T00:00:01Z design preserved the established auth and admin design system the feature extends existing product surfaces and does not justify a redesign src/pages/LoginPage.tsx; src/pages/AdminPage.tsx implementation split into disjoint existing modules 2026-09-02T00:00:02Z security kept demo login on the existing hashed-code and OTP-handoff backend the plan requires normal Supabase sessions without exposing raw codes or service credentials supabase/functions/demo-code-login/index.ts; e2e/staging/auth.setup.ts shared contract locked before parallel edits +2026-09-07T12:52:55Z security replaced the whole-file role assertion with shared executable policy tests the old check confused the general set-role endpoint with demo creation supabase/functions/admin/demo-code-contract.test.ts; focused Deno test 10 focused Deno tests pass +2026-09-07T12:52:56Z security blocked demo codes from reusing unrelated Auth users and restricted new codes to user role independent review found an account-takeover path and unusable banned-code path supabase/functions/_shared/demo-code.test.ts; supabase/functions/demo-code-login/index.ts focused Deno, typecheck, lint, and component tests pass +2026-09-07T12:52:57Z database replayed all migrations under the shared lock and ran pgTAP this feature changes grants and a privileged atomic reservation function supabase/tests/demo_access_codes_rls_test.sql 27 database tests pass; local stack stopped +2026-09-07T12:52:58Z verification ran the credential-free release ladder after auth and UI fixes release requires current working-tree proof across frontend and Edge code npm run verify 975 Vitest, 130 Deno, build, and 134 Chromium tests pass +2026-09-07T12:52:59Z staging kept protected staging blocked instead of substituting production all three required STAGING values are absent locally and in the GitHub staging environment docs/testing.md; environment and GitHub configuration name checks BLOCKED pending staging URL, anon key, and dedicated demo code diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 62fd378ca..26aa5d209 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -7,11 +7,50 @@ test.describe('login passwordless flows', () => { }); test('keeps Google primary and exposes email and demo-code tabs', async ({ page }) => { + await page.route('**/auth/v1/authorize**', (route) => route.fulfill({ status: 200, body: 'ok' })); await page.goto('/login', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible(); + const googleButton = page.getByRole('button', { name: 'Continue with Google' }); + const expectedRedirect = `${new URL(page.url()).origin}/app`; + await expect(googleButton).toBeVisible(); await expect(page.getByRole('tab', { name: 'Email' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Demo code' })).toBeVisible(); + + const requestPromise = page.waitForRequest('**/auth/v1/authorize**'); + await googleButton.click(); + const requestUrl = new URL((await requestPromise).url()); + expect(requestUrl.searchParams.get('provider')).toBe('google'); + expect(requestUrl.searchParams.get('redirect_to')).toBe(expectedRedirect); + }); + + test('exchanges a demo-code magic-link hash for a Supabase session', async ({ page }) => { + let verifyBody: Record | undefined; + await page.route('**/functions/v1/demo-code-login', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ email: 'demo@example.com', tokenHash: 'demo-token-hash', type: 'magiclink' }), + })); + await page.route('**/auth/v1/verify', async (route) => { + verifyBody = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + access_token: 'stub-access', + refresh_token: 'stub-refresh', + expires_in: 3600, + token_type: 'bearer', + user: { id: '00000000-0000-4000-8000-000000000001', email: 'demo@example.com', app_metadata: {}, user_metadata: {} }, + }), + }); + }); + + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + await page.getByRole('tab', { name: 'Demo code' }).click(); + await page.getByRole('textbox', { name: 'Demo code' }).fill('demo-ab12-cd34'); + await page.getByRole('button', { name: 'Sign in with demo code' }).click(); + + await expect.poll(() => verifyBody).toMatchObject({ token_hash: 'demo-token-hash', type: 'magiclink' }); }); test('sends an email code and focuses the verification field', async ({ page }) => { @@ -50,7 +89,7 @@ test.describe('login passwordless flows', () => { await page.getByRole('textbox', { name: 'Demo code' }).fill('DEMO-AB12-CD34'); await page.getByRole('button', { name: 'Sign in with demo code' }).click(); - await expect(page.getByRole('alert')).toHaveText('Edge Function returned a non-2xx status code'); + await expect(page.getByRole('alert')).toHaveText('This demo code has expired.'); await expect(page.getByRole('textbox', { name: 'Demo code' })).toBeEnabled(); }); }); diff --git a/src/lib/supabase/AuthProvider.tsx b/src/lib/supabase/AuthProvider.tsx index d468ab502..e3b63cf9f 100644 --- a/src/lib/supabase/AuthProvider.tsx +++ b/src/lib/supabase/AuthProvider.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import type { EmailOtpType, Session, User } from '@supabase/supabase-js'; +import type { Session, User } from '@supabase/supabase-js'; import { supabase } from './client'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; @@ -24,24 +24,18 @@ const AuthContext = createContext(undefined); const AUTH_OPERATION_TIMEOUT_MS = 4000; const OAUTH_CODE_EXCHANGE_TIMEOUT_MS = 15_000; const DEMO_FINGERPRINT_STORAGE_KEY = 'debate-demo-browser-fingerprint'; -const EMAIL_OTP_TYPES: EmailOtpType[] = ['signup', 'invite', 'magiclink', 'recovery', 'email_change', 'email']; type DemoCodeHandoff = { email: string; tokenHash: string; - type: EmailOtpType; }; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function isEmailOtpType(value: unknown): value is EmailOtpType { - return typeof value === 'string' && EMAIL_OTP_TYPES.some((type) => type === value); -} - function parseDemoCodeHandoff(value: unknown): DemoCodeHandoff { - if (!isRecord(value) || typeof value.email !== 'string') { + if (!isRecord(value) || typeof value.email !== 'string' || value.type !== 'magiclink') { throw new Error('Demo sign-in returned an invalid session handoff.'); } @@ -56,10 +50,21 @@ function parseDemoCodeHandoff(value: unknown): DemoCodeHandoff { return { email, tokenHash, - type: isEmailOtpType(value.type) ? value.type : 'email', }; } +async function getFunctionError(error: unknown) { + if (isRecord(error) && error.context instanceof Response) { + try { + const body: unknown = await error.context.json(); + if (isRecord(body) && typeof body.error === 'string' && body.error) return new Error(body.error); + } catch { + // Fall back to the SDK error below when the response is not JSON. + } + } + return error instanceof Error ? error : new Error('Demo sign-in failed.'); +} + function getDemoBrowserFingerprint() { try { const existing = sessionStorage.getItem(DEMO_FINGERPRINT_STORAGE_KEY); @@ -273,7 +278,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const normalizedEmail = email.trim(); if (!normalizedEmail) throw new Error('Email is required.'); - const { error } = await supabase.auth.signInWithOtp({ email: normalizedEmail }); + const { error } = await supabase.auth.signInWithOtp({ + email: normalizedEmail, + options: { shouldCreateUser: false }, + }); if (error) throw error; }, verifyEmailOtp: async (email, token) => { @@ -290,7 +298,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (error) throw error; }, signInWithDemoCode: async (code) => { - const normalizedCode = code.trim(); + const normalizedCode = code.trim().toUpperCase().replace(/\s+/g, ''); if (!normalizedCode) throw new Error('Demo code is required.'); const { data, error } = await supabase.functions.invoke('demo-code-login', { @@ -299,16 +307,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { browserFingerprint: getDemoBrowserFingerprint(), }, }); - if (error) throw error; + if (error) throw await getFunctionError(error); const handoff = parseDemoCodeHandoff(data); - // demo-code-login currently returns generateLink's hashed token and - // verification type. Supabase requires token_hash for magic links, while - // email OTPs use the email + token form. - const verification = handoff.type === 'email' - ? supabase.auth.verifyOtp({ email: handoff.email, token: handoff.tokenHash, type: 'email' }) - : supabase.auth.verifyOtp({ token_hash: handoff.tokenHash, type: handoff.type }); - const { error: verificationError } = await verification; + const { error: verificationError } = await supabase.auth.verifyOtp({ + token_hash: handoff.tokenHash, + type: 'magiclink', + }); if (verificationError) throw verificationError; }, signOut: async () => { diff --git a/src/locales/en.json b/src/locales/en.json index b089dceb4..24ab26da0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -205,6 +205,8 @@ "emailHint": "We'll send a six-digit code.", "sendCode": "Send code", "sendingCode": "Sending code...", + "resendCode": "Resend code", + "resendingCode": "Resending code...", "codeSent": "Code sent", "checkEmail": "Check your email for a six-digit code.", "codeSentDesc": "Check your email for the six-digit code.", @@ -241,20 +243,18 @@ "createDemoCodeDescription": "Issue a one-time code for a demo account. The raw code appears only after creation.", "label": "Label", "labelPlaceholder": "e.g. September school demo", + "emailOptional": "Assigned email (optional)", "expiryOptional": "Expiry (optional)", "maxUsesOptional": "Max uses (optional)", "unlimited": "Unlimited", - "roleOptional": "Role (optional)", - "user": "User", - "adminRole": "Admin", - "banned": "Banned", + "role": "Role", "creating": "Creating…", "created": "Demo code created", "copyNow": "Copy this code now. It will not be shown again.", "copied": "Copied", "copyCode": "Copy code", - "codesIssued": "{{count}} code", - "codesIssued_plural": "{{count}} codes issued", + "codesIssued_one": "{{count}} code issued", + "codesIssued_other": "{{count}} codes issued", "loading": "Loading demo codes…", "loadFailed": "Unable to load demo codes: {{error}}", "noneTitle": "No demo codes yet", @@ -287,7 +287,10 @@ "serverCodeMissing": "The server did not return the generated demo code.", "createFailed": "Failed to create demo code.", "copyFailed": "Could not copy the demo code. Select it and copy it manually.", - "actionFailed": "Demo code action failed." + "actionFailed": "Demo code action failed.", + "never": "Never", + "invalidDate": "Invalid date", + "thisCode": "this code" }, "notFound": { "title": "404", diff --git a/src/locales/es.json b/src/locales/es.json index 9bd86ae8c..4b36061e7 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -205,6 +205,8 @@ "emailHint": "Te enviaremos un código de seis dígitos.", "sendCode": "Enviar código", "sendingCode": "Enviando código...", + "resendCode": "Reenviar código", + "resendingCode": "Reenviando código...", "codeSent": "Código enviado", "checkEmail": "Revisa tu email para encontrar el código de seis dígitos.", "codeSentDesc": "Revisa tu email para encontrar el código de seis dígitos.", @@ -241,20 +243,18 @@ "createDemoCodeDescription": "Emite un código de un solo uso para una cuenta demo. El código solo aparece después de crearlo.", "label": "Etiqueta", "labelPlaceholder": "p. ej., Demo escolar de septiembre", + "emailOptional": "Email asignado (opcional)", "expiryOptional": "Caducidad (opcional)", "maxUsesOptional": "Usos máximos (opcional)", "unlimited": "Ilimitados", - "roleOptional": "Rol (opcional)", - "user": "Usuario", - "adminRole": "Administrador", - "banned": "Bloqueado", + "role": "Rol", "creating": "Creando…", "created": "Código demo creado", "copyNow": "Copia este código ahora. No volverá a mostrarse.", "copied": "Copiado", "copyCode": "Copiar código", - "codesIssued": "{{count}} código emitido", - "codesIssued_plural": "{{count}} códigos emitidos", + "codesIssued_one": "{{count}} código emitido", + "codesIssued_other": "{{count}} códigos emitidos", "loading": "Cargando códigos demo…", "loadFailed": "No se pudieron cargar los códigos demo: {{error}}", "noneTitle": "Aún no hay códigos demo", @@ -287,7 +287,10 @@ "serverCodeMissing": "El servidor no devolvió el código demo generado.", "createFailed": "No se pudo crear el código demo.", "copyFailed": "No se pudo copiar el código demo. Selecciónalo y cópialo manualmente.", - "actionFailed": "No se pudo realizar la acción sobre el código demo." + "actionFailed": "No se pudo realizar la acción sobre el código demo.", + "never": "Nunca", + "invalidDate": "Fecha no válida", + "thisCode": "este código" }, "notFound": { "title": "404", diff --git a/src/locales/hu.json b/src/locales/hu.json index d9e155249..3a66d717c 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -205,6 +205,8 @@ "emailHint": "Hatjegyű kódot küldünk e-mailben.", "sendCode": "Kód küldése", "sendingCode": "Kód küldése...", + "resendCode": "Kód újraküldése", + "resendingCode": "Kód újraküldése...", "codeSent": "Kód elküldve", "checkEmail": "Nézd meg az e-mailedet a hatjegyű kódért.", "codeSentDesc": "Nézd meg az e-mailedet a hatjegyű kódért.", @@ -241,20 +243,18 @@ "createDemoCodeDescription": "Egyszer használható kód kiadása egy demófiókhoz. A nyers kód csak létrehozás után jelenik meg.", "label": "Címke", "labelPlaceholder": "például szeptemberi iskolai demó", + "emailOptional": "Hozzárendelt e-mail (opcionális)", "expiryOptional": "Lejárat (opcionális)", "maxUsesOptional": "Maximális használat (opcionális)", "unlimited": "Korlátlan", - "roleOptional": "Szerepkör (opcionális)", - "user": "Felhasználó", - "adminRole": "Adminisztrátor", - "banned": "Letiltva", + "role": "Szerepkör", "creating": "Létrehozás…", "created": "Demókód létrehozva", "copyNow": "Másold ki most ezt a kódot. Később nem jelenik meg újra.", "copied": "Kimásolva", "copyCode": "Kód másolása", - "codesIssued": "{{count}} kiadott kód", - "codesIssued_plural": "{{count}} kiadott kód", + "codesIssued_one": "{{count}} kiadott kód", + "codesIssued_other": "{{count}} kiadott kód", "loading": "Demókódok betöltése…", "loadFailed": "A demókódokat nem sikerült betölteni: {{error}}", "noneTitle": "Még nincsenek demókódok", @@ -287,7 +287,10 @@ "serverCodeMissing": "A szerver nem küldte vissza a létrehozott demókódot.", "createFailed": "A demókód létrehozása nem sikerült.", "copyFailed": "A demókód másolása nem sikerült. Jelöld ki, majd másold ki kézzel.", - "actionFailed": "A demókód művelete nem sikerült." + "actionFailed": "A demókód művelete nem sikerült.", + "never": "Soha", + "invalidDate": "Érvénytelen dátum", + "thisCode": "ez a kód" }, "notFound": { "title": "404", diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index bd2cf5541..f16eaed84 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -81,58 +82,44 @@ interface DemoAccessCode { interface DemoAccessForm { label: string; + email: string; expiresAt: string; maxUses: string; - role: string; } type DemoAction = 'disable' | 'reset'; -const EMPTY_DEMO_FORM: DemoAccessForm = { label: '', expiresAt: '', maxUses: '', role: 'user' }; +const EMPTY_DEMO_FORM: DemoAccessForm = { label: '', email: '', expiresAt: '', maxUses: '' }; function normalizeDemoCode(value: unknown): DemoAccessCode | null { if (!value || typeof value !== 'object') return null; const row = value as Record; if (typeof row.id !== 'string') return null; - const expiresAt = row.expires_at ?? row.expiresAt; - const maxUses = row.max_uses ?? row.maxUses; - const lastUsedAt = row.last_used_at ?? row.lastUsedAt; - const disabledAt = row.disabled_at ?? row.disabledAt; - const createdAt = row.created_at ?? row.createdAt; - const useCount = row.use_count ?? row.useCount; return { id: row.id, label: typeof row.label === 'string' ? row.label : 'Unlabelled demo', email: typeof row.email === 'string' ? row.email : 'Unknown email', role: typeof row.role === 'string' ? row.role : 'user', - expiresAt: typeof expiresAt === 'string' ? expiresAt : null, - maxUses: typeof maxUses === 'number' ? maxUses : null, - useCount: typeof useCount === 'number' ? useCount : 0, - lastUsedAt: typeof lastUsedAt === 'string' ? lastUsedAt : null, - disabledAt: typeof disabledAt === 'string' ? disabledAt : null, - createdAt: typeof createdAt === 'string' ? createdAt : null, + expiresAt: typeof row.expires_at === 'string' ? row.expires_at : null, + maxUses: typeof row.max_uses === 'number' ? row.max_uses : null, + useCount: typeof row.use_count === 'number' ? row.use_count : 0, + lastUsedAt: typeof row.last_used_at === 'string' ? row.last_used_at : null, + disabledAt: typeof row.disabled_at === 'string' ? row.disabled_at : null, + createdAt: typeof row.created_at === 'string' ? row.created_at : null, }; } function getDemoCodes(data: unknown): DemoAccessCode[] { if (!data || typeof data !== 'object') return []; const payload = data as Record; - const rows = payload.codes ?? payload.demo_codes ?? payload.demoCodes; + const rows = payload.codes; return Array.isArray(rows) ? rows.map(normalizeDemoCode).filter((row): row is DemoAccessCode => row !== null) : []; } function getRawDemoCode(data: unknown): string | null { if (!data || typeof data !== 'object') return null; const payload = data as Record; - const nested = payload.demoCode ?? payload.demo_code ?? payload.codeRecord; - const candidates = [ - payload.rawCode, - payload.raw_code, - payload.code, - payload.demo_code, - nested && typeof nested === 'object' ? (nested as Record).code : null, - ]; - return candidates.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0)?.trim() || null; + return typeof payload.code === 'string' && payload.code.trim() ? payload.code.trim() : null; } function getDemoCodeStatus(code: DemoAccessCode) { @@ -142,11 +129,6 @@ function getDemoCodeStatus(code: DemoAccessCode) { return 'active'; } -function formatDemoDate(value: string | null, includeTime = false) { - if (!value) return 'Never'; - return includeTime ? formatEuropeanDateTime(value, 'Invalid date') : formatEuropeanDate(value, 'Invalid date'); -} - interface DemoAccessPanelProps { codes: DemoAccessCode[]; loading: boolean; @@ -157,6 +139,7 @@ interface DemoAccessPanelProps { } function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset }: DemoAccessPanelProps) { + const { t } = useTranslation(); const [form, setForm] = useState(EMPTY_DEMO_FORM); const [formError, setFormError] = useState(null); const [createdCode, setCreatedCode] = useState(null); @@ -165,6 +148,9 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } const [pendingAction, setPendingAction] = useState<{ type: DemoAction; code: DemoAccessCode } | null>(null); const [actionLoading, setActionLoading] = useState(false); const [actionError, setActionError] = useState(null); + const formatDemoDate = (value: string | null) => value + ? formatEuropeanDateTime(value, t('admin.invalidDate')) + : t('admin.never'); const updateForm = (updates: Partial) => { setForm((current) => ({ ...current, ...updates })); @@ -175,19 +161,19 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } event.preventDefault(); const label = form.label.trim(); if (!label) { - setFormError('Label is required.'); + setFormError(t('admin.labelRequired')); return; } const maxUses = form.maxUses.trim() ? Number(form.maxUses) : null; if (maxUses !== null && (!Number.isInteger(maxUses) || maxUses < 1)) { - setFormError('Max uses must be a whole number greater than zero.'); + setFormError(t('admin.maxUsesInvalid')); return; } let expiresAt: string | null = null; if (form.expiresAt) { const expiry = new Date(form.expiresAt); if (Number.isNaN(expiry.getTime())) { - setFormError('Expiry must be a valid date and time.'); + setFormError(t('admin.expiryInvalid')); return; } expiresAt = expiry.toISOString(); @@ -198,12 +184,12 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } setCreatedCode(null); setCopied(false); try { - const rawCode = await onCreate({ label, expiresAt: expiresAt || '', maxUses: maxUses === null ? '' : String(maxUses), role: form.role }); - if (!rawCode) throw new Error('The server did not return the generated demo code.'); + const rawCode = await onCreate({ ...form, label, expiresAt: expiresAt || '', maxUses: maxUses === null ? '' : String(maxUses) }); + if (!rawCode) throw new Error(t('admin.serverCodeMissing')); setCreatedCode(rawCode); setForm(EMPTY_DEMO_FORM); } catch (createError) { - setFormError(createError instanceof Error ? createError.message : 'Failed to create demo code.'); + setFormError(createError instanceof Error ? createError.message : t('admin.createFailed')); } finally { setSubmitting(false); } @@ -227,7 +213,7 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } } setCopied(true); } catch { - setFormError('Could not copy the demo code. Select it and copy it manually.'); + setFormError(t('admin.copyFailed')); } finally { fallbackInput?.remove(); } @@ -242,7 +228,7 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } else await onReset(pendingAction.code.id); setPendingAction(null); } catch (actionError) { - setActionError(actionError instanceof Error ? actionError.message : 'Demo code action failed.'); + setActionError(actionError instanceof Error ? actionError.message : t('admin.actionFailed')); } finally { setActionLoading(false); } @@ -254,25 +240,36 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } - Create demo code + {t('admin.createDemoCode')} - Issue a one-time code for a demo account. The raw code appears only after creation. + {t('admin.createDemoCodeDescription')}
- + updateForm({ label: event.target.value })} - placeholder="e.g. September school demo" + placeholder={t('admin.labelPlaceholder')} autoComplete="off" required />
- + + updateForm({ email: event.target.value })} + autoComplete="off" + /> +
+
+
- + updateForm({ maxUses: event.target.value })} - placeholder="Unlimited" + placeholder={t('admin.unlimited')} />
-
- - -
{formError &&

{formError}

}
{createdCode && (
-

Demo code created

-

Copy this code now. It will not be shown again.

+

{t('admin.created')}

+

{t('admin.copyNow')}

{createdCode}
@@ -330,35 +315,35 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } - Demo access - {codes.length} {codes.length === 1 ? 'code' : 'codes'} issued + {t('admin.demoAccess')} + {t('admin.codesIssued', { count: codes.length })} - {loading &&

Loading demo codes…

} + {loading &&

{t('admin.loading')}

} {error && ( -

Unable to load demo codes: {error}

+

{t('admin.loadFailed', { error })}

)} {!loading && codes.length === 0 && !error && (
-

No demo codes yet

-

Create a code for your next demo or event.

+

{t('admin.noneTitle')}

+

{t('admin.noneDescription')}

)} {codes.length > 0 && (
- + - - - - - - - - + + + + + + + + @@ -372,10 +357,10 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } {code.label} - - - - + + + + @@ -413,19 +398,19 @@ function DemoAccessPanel({ codes, loading, error, onCreate, onDisable, onReset } { if (!open && !actionLoading) setPendingAction(null); }}> - {pendingAction?.type === 'disable' ? 'Disable demo code?' : 'Reset use count?'} + {pendingAction?.type === 'disable' ? t('admin.disableTitle') : t('admin.resetTitle')} {pendingAction?.type === 'disable' - ? `People will no longer be able to sign in with ${pendingAction.code.label}.` - : `Set ${pendingAction?.code.label || 'this code'} use count back to zero.`} + ? t('admin.disableDescription', { label: pendingAction.code.label }) + : t('admin.resetDescription', { label: pendingAction?.code.label || t('admin.thisCode') })} {actionError &&

{actionError}

} - +
@@ -489,6 +474,7 @@ export default function AdminPage() { const [giftNotes, setGiftNotes] = useState(''); const [giftSubmitting, setGiftSubmitting] = useState(false); const { toast } = useToast(); + const { t } = useTranslation(); const fetchAdminData = async (action: string) => { const { data: { session } } = await supabase.auth.getSession(); @@ -677,18 +663,14 @@ export default function AdminPage() { const handleCreateDemoCode = async (form: DemoAccessForm) => { const result = await postAdminData('create-demo-code', { label: form.label, + email: form.email.trim() || null, expiresAt: form.expiresAt || null, maxUses: form.maxUses ? Number(form.maxUses) : null, - role: form.role || 'user', }); const createdCode = getRawDemoCode(result); - const createdRow = normalizeDemoCode( - result && typeof result === 'object' - ? (result as Record).demoCode - ?? (result as Record).demo_code - ?? (result as Record).codeRecord - : null, - ); + const createdRow = normalizeDemoCode(result && typeof result === 'object' + ? (result as Record).demoCode + : null); if (createdRow) setDemoCodes((current) => [createdRow, ...current.filter((code) => code.id !== createdRow.id)]); setDemoCodesError(null); return createdCode; @@ -811,7 +793,7 @@ export default function AdminPage() { - Demo access + {t('admin.demoAccess')} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 1f4bc0587..3af133898 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -13,6 +13,9 @@ import { useToast } from '@/hooks/use-toast'; type AuthMode = 'email' | 'demo'; type AuthStatus = 'idle' | 'submitting' | 'code-sent' | 'verifying' | 'success' | 'error'; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const DEMO_CODE_PATTERN = /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/; + function isAuthMode(value: string): value is AuthMode { return value === 'email' || value === 'demo'; } @@ -24,6 +27,7 @@ export default function LoginPage() { const [authStatus, setAuthStatus] = useState('idle'); const [email, setEmail] = useState(''); const [otp, setOtp] = useState(''); + const [emailSent, setEmailSent] = useState(false); const [demoCode, setDemoCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const otpInputRef = useRef(null); @@ -59,14 +63,16 @@ export default function LoginPage() { setAuthMode(mode); setAuthStatus('idle'); setErrorMessage(''); + setOtp(''); + setEmailSent(false); }; const handleEmailSubmit = async (event: React.FormEvent) => { event.preventDefault(); const normalizedEmail = email.trim(); - if (!normalizedEmail) { + if (!EMAIL_PATTERN.test(normalizedEmail)) { setAuthStatus('error'); - setErrorMessage(t('login.requiredEmail', 'Enter your email address.')); + setErrorMessage(t('login.invalidEmail', 'Enter a valid email address.')); return; } @@ -75,6 +81,7 @@ export default function LoginPage() { try { await signInWithEmailOtp(normalizedEmail); setOtp(''); + setEmailSent(true); setAuthStatus('code-sent'); } catch (error) { setAuthStatus('error'); @@ -86,9 +93,9 @@ export default function LoginPage() { event.preventDefault(); const normalizedEmail = email.trim(); const normalizedOtp = otp.trim(); - if (!normalizedOtp) { + if (!/^\d{6}$/.test(normalizedOtp)) { setAuthStatus('error'); - setErrorMessage(t('login.requiredOtp', 'Enter the verification code.')); + setErrorMessage(t('login.invalidCode', 'Enter a valid six-digit code.')); return; } @@ -103,12 +110,25 @@ export default function LoginPage() { } }; + const handleResend = async () => { + setErrorMessage(''); + setAuthStatus('submitting'); + try { + await signInWithEmailOtp(email.trim()); + setOtp(''); + setAuthStatus('code-sent'); + } catch (error) { + setAuthStatus('error'); + setErrorMessage(error instanceof Error ? error.message : t('login.emailOtpError', 'Could not send code.')); + } + }; + const handleDemoSubmit = async (event: React.FormEvent) => { event.preventDefault(); - const normalizedCode = demoCode.trim(); - if (!normalizedCode) { + const normalizedCode = demoCode.trim().toUpperCase().replace(/\s+/g, ''); + if (!DEMO_CODE_PATTERN.test(normalizedCode)) { setAuthStatus('error'); - setErrorMessage(t('login.requiredDemoCode', 'Enter your demo code.')); + setErrorMessage(t('login.invalidDemoCode', 'Enter a valid demo code.')); return; } @@ -156,7 +176,7 @@ export default function LoginPage() { {t('login.demoTab', 'Demo code')} - {authStatus === 'code-sent' || authStatus === 'verifying' || ((authStatus === 'error' || authStatus === 'success') && otp.length > 0) ? ( + {emailSent ? (
void handleOtpSubmit(event)} className="space-y-4" aria-busy={authStatus === 'verifying'}>
@@ -167,7 +187,9 @@ export default function LoginPage() { type="text" inputMode="numeric" autoComplete="one-time-code" + placeholder={t('login.verificationCodePlaceholder', '123456')} pattern="[0-9]*" + maxLength={6} value={otp} onChange={(event) => setOtp(event.target.value)} aria-describedby="login-otp-hint" @@ -184,6 +206,9 @@ export default function LoginPage() { + ) : (
void handleEmailSubmit(event)} className="space-y-4" aria-busy={authStatus === 'submitting'}> @@ -193,7 +218,9 @@ export default function LoginPage() { id="login-email" name="email" type="email" + maxLength={254} autoComplete="email" + placeholder={t('login.emailPlaceholder', 'you@example.com')} value={email} onChange={(event) => setEmail(event.target.value)} aria-describedby="login-email-hint" @@ -218,6 +245,8 @@ export default function LoginPage() { name="demoCode" type="text" autoComplete="off" + placeholder={t('login.demoCodePlaceholder', 'DEMO-AB12-CD34')} + maxLength={14} value={demoCode} onChange={(event) => setDemoCode(event.target.value)} aria-describedby="login-demo-code-hint" diff --git a/src/test/auth/AuthProvider.behavior.test.tsx b/src/test/auth/AuthProvider.behavior.test.tsx index 09f35c298..aba082ede 100644 --- a/src/test/auth/AuthProvider.behavior.test.tsx +++ b/src/test/auth/AuthProvider.behavior.test.tsx @@ -137,12 +137,14 @@ function AuthConsumer() { function PasswordlessAuthConsumer() { const { signInWithEmailOtp, verifyEmailOtp, signInWithDemoCode } = useAuth(); + const [error, setError] = useState(''); return (
- + + {error}
); } @@ -424,7 +426,10 @@ describe('AuthProvider behavior', () => { fireEvent.click(screen.getByRole('button', { name: 'email-otp' })); await waitFor(() => { - expect(signInWithOtpMock).toHaveBeenCalledWith({ email: 'student@example.com' }); + expect(signInWithOtpMock).toHaveBeenCalledWith({ + email: 'student@example.com', + options: { shouldCreateUser: false }, + }); }); }); @@ -469,6 +474,35 @@ describe('AuthProvider behavior', () => { }); }); + it('surfaces a demo-code denial returned by the Edge Function', async () => { + functionsInvokeMock.mockResolvedValue({ + data: null, + error: { context: new Response(JSON.stringify({ error: 'This demo code has expired.' })) }, + }); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); + + await waitFor(() => expect(screen.getByTestId('passwordless-error')).toHaveTextContent('This demo code has expired.')); + }); + + it('rejects an invalid demo-code session handoff', async () => { + functionsInvokeMock.mockResolvedValue({ data: { tokenHash: 'hash', type: 'magiclink' }, error: null }); + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'demo-code' })); + + await waitFor(() => expect(screen.getByTestId('passwordless-error')).toHaveTextContent('invalid session handoff')); + }); + it('exchanges PKCE codes from the callback URL and clears the query string', async () => { window.history.replaceState({}, '', '/app?code=pkce-code'); exchangeCodeForSessionMock.mockResolvedValue({ diff --git a/src/test/auth/LoginPage.test.tsx b/src/test/auth/LoginPage.test.tsx index 88bd87562..c9429bd27 100644 --- a/src/test/auth/LoginPage.test.tsx +++ b/src/test/auth/LoginPage.test.tsx @@ -96,16 +96,33 @@ describe('LoginPage passwordless auth', () => { await waitFor(() => expect(screen.getByText('Signed in')).toBeInTheDocument()); }); + it('rejects malformed OTPs and supports resending', async () => { + renderLogin(); + fireEvent.click(screen.getByRole('tab', { name: 'Email' })); + fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'student@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send code' })); + await screen.findByText('Code sent'); + + fireEvent.change(screen.getByRole('textbox', { name: 'Verification code' }), { target: { value: '123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Verify code' })); + expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid six-digit code.'); + expect(authMock.verifyEmailOtp).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Resend code' })); + await waitFor(() => expect(authMock.signInWithEmailOtp).toHaveBeenCalledTimes(2)); + }); + it('shows demo code errors and returns to an editable idle form', async () => { authMock.signInWithDemoCode.mockRejectedValue(new Error('This demo code has expired.')); renderLogin(); fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo code' })); await waitFor(() => expect(screen.getByRole('tab', { name: 'Demo code' })).toHaveAttribute('aria-selected', 'true')); - fireEvent.change(screen.getByRole('textbox', { name: 'Demo code' }), { target: { value: 'DEMO-AB12-CD34' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'Demo code' }), { target: { value: ' demo-ab12-cd34 ' } }); fireEvent.click(screen.getByRole('button', { name: 'Sign in with demo code' })); await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('This demo code has expired.')); + expect(authMock.signInWithDemoCode).toHaveBeenCalledWith('DEMO-AB12-CD34'); expect(screen.getByRole('textbox', { name: 'Demo code' })).toBeEnabled(); }); }); diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx index e37c33ec8..9598a958b 100644 --- a/src/test/pages/AdminPage.test.tsx +++ b/src/test/pages/AdminPage.test.tsx @@ -423,14 +423,16 @@ describe('AdminPage Integration', () => { await waitFor(() => expect(screen.getByText('Admin Dashboard')).toBeInTheDocument()); fireEvent.mouseDown(screen.getByRole('tab', { name: 'Demo access' }), { button: 0 }); fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'September school demo' } }); + fireEvent.change(screen.getByLabelText('Assigned email (optional)'), { target: { value: 'school@example.com' } }); fireEvent.change(screen.getByLabelText('Expiry (optional)'), { target: { value: '2026-09-30T12:00' } }); fireEvent.change(screen.getByLabelText('Max uses (optional)'), { target: { value: '4' } }); fireEvent.click(screen.getByRole('button', { name: 'Create demo code' })); await waitFor(() => expect(createdBody.label).toBe('September school demo')); + expect(createdBody.email).toBe('school@example.com'); expect(createdBody.expiresAt).toBe('2026-09-30T12:00:00.000Z'); expect(createdBody.maxUses).toBe(4); - expect(createdBody.role).toBe('user'); + expect(createdBody.role).toBeUndefined(); expect(screen.getByText('DEMO-7KQ4-MV9P')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Copy code' })); diff --git a/supabase/functions/_shared/demo-code.test.ts b/supabase/functions/_shared/demo-code.test.ts index 8867bb330..90d2013e6 100644 --- a/supabase/functions/_shared/demo-code.test.ts +++ b/supabase/functions/_shared/demo-code.test.ts @@ -1,4 +1,11 @@ -import { generateDemoCode, getDemoCodeDenialReason } from "./demo-code.ts"; +import { + generateDemoCode, + getDemoCodeDenialReason, + isDemoCodeRateLimited, + isDemoUserForCode, + isValidDemoCode, + normalizeDemoCode, +} from "./demo-code.ts"; Deno.test("generated demo codes use the login format", () => { const code = generateDemoCode(); @@ -23,3 +30,23 @@ Deno.test("demo codes at their max use count are denied", () => { const reason = getDemoCodeDenialReason({ ...validCode, use_count: 3 }); if (reason !== "max_uses") throw new Error(`expected max_uses, got ${reason}`); }); + +Deno.test("demo code input is normalized and invalid formats are rejected", () => { + if (normalizeDemoCode(" demo-ab12-cd34 ") !== "DEMO-AB12-CD34") throw new Error("normalization failed"); + if (!isValidDemoCode("DEMO-AB12-CD34") || isValidDemoCode("DEMO-ADMIN")) { + throw new Error("format validation failed"); + } +}); + +Deno.test("ten failed attempts trigger rate limiting", () => { + if (isDemoCodeRateLimited([9]) || !isDemoCodeRateLimited([2, 10])) { + throw new Error("rate-limit threshold failed"); + } +}); + +Deno.test("existing users must belong to the same demo code", () => { + if (!isDemoUserForCode({ demo_access_code_id: "code-1" }, "code-1")) throw new Error("matching demo user rejected"); + if (isDemoUserForCode({}, "code-1") || isDemoUserForCode({ demo_access_code_id: "code-2" }, "code-1")) { + throw new Error("unrelated existing user accepted"); + } +}); diff --git a/supabase/functions/_shared/demo-code.ts b/supabase/functions/_shared/demo-code.ts index 563a05bba..b6029a748 100644 --- a/supabase/functions/_shared/demo-code.ts +++ b/supabase/functions/_shared/demo-code.ts @@ -9,6 +9,31 @@ export type DemoCodeState = { export type DemoCodeDenialReason = "disabled" | "expired" | "max_uses" | null; +export function isCreatableDemoRole(role: unknown): role is "user" { + return role === "user"; +} + +export function getDemoLoginRole(role: unknown): "user" | "banned" { + return role === "banned" ? "banned" : "user"; +} + +export function normalizeDemoCode(value: unknown) { + return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); +} + +export function isValidDemoCode(code: string) { + return /^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code); +} + +export function isDemoCodeRateLimited(failedAttemptCounts: number[]) { + return failedAttemptCounts.some((count) => count >= 10); +} + +export function isDemoUserForCode(metadata: unknown, codeId: string) { + return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) && + "demo_access_code_id" in metadata && metadata.demo_access_code_id === codeId; +} + export function generateDemoCode() { const bytes = new Uint8Array(8); crypto.getRandomValues(bytes); diff --git a/supabase/functions/admin/demo-code-contract.test.ts b/supabase/functions/admin/demo-code-contract.test.ts index 77ff8c963..081cadff2 100644 --- a/supabase/functions/admin/demo-code-contract.test.ts +++ b/supabase/functions/admin/demo-code-contract.test.ts @@ -1,5 +1,8 @@ +import { getDemoLoginRole, isCreatableDemoRole } from "../_shared/demo-code.ts"; + Deno.test("admin exposes the demo-code lifecycle contract", async () => { const source = await Deno.readTextFile(new URL("./index.ts", import.meta.url)); + const loginSource = await Deno.readTextFile(new URL("../demo-code-login/index.ts", import.meta.url)); for (const action of ["demo-codes", "create-demo-code", "disable-demo-code", "reset-demo-code"]) { if (!source.includes(`action === "${action}"`)) { @@ -16,7 +19,19 @@ Deno.test("admin exposes the demo-code lifecycle contract", async () => { if (source.includes("rawCode.replaceAll")) { throw new Error("the assigned email must not reveal or derive the raw code"); } - if (source.includes('["user", "admin", "banned"].includes(role)')) { - throw new Error("admin role cannot be assigned through a demo code"); + if (!loginSource.includes("isDemoUserForCode") || !loginSource.includes("email_conflict")) { + throw new Error("demo login must not reuse an unrelated existing account"); + } +}); + +Deno.test("create-demo-code only accepts the user role", () => { + if (!isCreatableDemoRole("user") || isCreatableDemoRole("admin") || isCreatableDemoRole("banned")) { + throw new Error("demo codes must create ordinary users"); + } +}); + +Deno.test("demo-code login downgrades legacy admin rows", () => { + if (getDemoLoginRole("admin") !== "user") { + throw new Error("legacy admin-valued demo codes must grant user access"); } }); diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index 4dae08fcf..f11fb4fa3 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -6,7 +6,7 @@ import { validateAnnouncementId, validateCreateAnnouncement, } from "../_shared/announcements.ts"; -import { generateDemoCode, hashDemoCode } from "../_shared/demo-code.ts"; +import { generateDemoCode, hashDemoCode, isCreatableDemoRole } from "../_shared/demo-code.ts"; // SECURITY: Allow specific origins for CORS const ALLOWED_ORIGINS = [ @@ -488,7 +488,7 @@ Deno.serve(async (req) => { } const label = typeof body.label === "string" ? body.label.trim() : ""; const requestedEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; - const role = body.role || "user"; + const role = body.role ?? "user"; const rawMaxUses = body.maxUses; const rawExpiresAt = body.expiresAt; @@ -506,10 +506,9 @@ Deno.serve(async (req) => { }); } - // Demo codes can never provision an admin account. Admin access is - // assigned through user_roles by a separate admin-only action. - if (!["user", "banned"].includes(role)) { - return new Response(JSON.stringify({ error: "Demo codes may only use user or banned roles" }), { + // Self-service demo codes always provision an ordinary user. + if (!isCreatableDemoRole(role)) { + return new Response(JSON.stringify({ error: "Demo codes may only use the user role" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); diff --git a/supabase/functions/demo-code-login/index.ts b/supabase/functions/demo-code-login/index.ts index ecf27f7ba..75d881ab6 100644 --- a/supabase/functions/demo-code-login/index.ts +++ b/supabase/functions/demo-code-login/index.ts @@ -1,5 +1,13 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -import { getDemoCodeDenialReason, hashDemoCode } from "../_shared/demo-code.ts"; +import { + getDemoCodeDenialReason, + getDemoLoginRole, + hashDemoCode, + isDemoCodeRateLimited, + isDemoUserForCode, + isValidDemoCode, + normalizeDemoCode, +} from "../_shared/demo-code.ts"; const ALLOWED_ORIGINS = [ "https://debate.pi-stack.org", @@ -36,10 +44,6 @@ function getCorsHeaders(origin: string | null) { }; } -function normalizeCode(value: unknown) { - return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); -} - // Only hand generateLink a redirectTo that points at one of our own origins, // so a client-supplied value can't be used for an open redirect off the auth // handoff link. @@ -115,7 +119,7 @@ async function isRateLimited(ipAddress: string | null, browserFingerprint: strin if (checks.length === 0) return false; const counts = await Promise.all(checks); - return counts.some((count) => count >= 10); + return isDemoCodeRateLimited(counts); } async function findUserByEmail(email: string) { @@ -146,7 +150,7 @@ Deno.serve(async (req) => { try { const body = await req.json().catch(() => ({})); - const code = normalizeCode(body.code); + const code = normalizeDemoCode(body.code); const browserFingerprint = String(body.browserFingerprint || "").slice(0, 256) || null; if (await isRateLimited(ipAddress, browserFingerprint)) { @@ -157,7 +161,7 @@ Deno.serve(async (req) => { }); } - if (!/^DEMO-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) { + if (!isValidDemoCode(code)) { await recordAttempt({ ipAddress, browserFingerprint, success: false, failureReason: "invalid_format" }); return new Response(JSON.stringify({ error: "Enter a valid demo code." }), { status: 400, @@ -207,10 +211,16 @@ Deno.serve(async (req) => { }); } - // Reserve the use atomically, and before touching the user's account, so a - // stale read plus an ordinary update can't let concurrent requests - // oversubscribe max_uses, and a declined/raced reservation never leaves - // behind a provisioned (pro-tier) account for a code that wasn't granted. + const existingUser = await findUserByEmail(demoCode.email); + if (existingUser && !isDemoUserForCode(existingUser.user_metadata, demoCode.id)) { + await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "email_conflict" }); + return new Response(JSON.stringify({ error: "This demo code cannot be used." }), { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + // Reserve the use atomically before creating or changing the demo account. const { data: reservation, error: reservationError } = await supabase.rpc("consume_demo_access_code", { p_code_id: demoCode.id, }); @@ -224,7 +234,6 @@ Deno.serve(async (req) => { }); } - const existingUser = await findUserByEmail(demoCode.email); const userResult = existingUser ? { user: existingUser } : (await supabase.auth.admin.createUser({ @@ -241,7 +250,7 @@ Deno.serve(async (req) => { // Never let a demo code grant admin: the schema allows demo_access_codes.role // to be "admin", but self-service demo sign-in must not be a privilege // escalation path. Admin status is granted separately, not through this flow. - const grantedRole = demoCode.role === "banned" ? "banned" : "user"; + const grantedRole = getDemoLoginRole(demoCode.role); const { error: roleError } = await supabase .from("user_roles") .upsert({ From cbf7225c7fc5891491f3579682856a8acf0b22c6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 13:30:30 +0000 Subject: [PATCH 4/4] fix(auth): bind demo accounts with server metadata --- .audit/auth-email-and-demo-code-sign-in.tsv | 3 + docs/architecture.md | 3 +- package.json | 2 +- supabase/README.md | 4 +- supabase/functions/demo-code-login/index.ts | 40 +++- .../functions/demo-code-login/index_test.ts | 191 ++++++++++++++++++ 6 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 supabase/functions/demo-code-login/index_test.ts diff --git a/.audit/auth-email-and-demo-code-sign-in.tsv b/.audit/auth-email-and-demo-code-sign-in.tsv index 132f583b1..786d49fba 100644 --- a/.audit/auth-email-and-demo-code-sign-in.tsv +++ b/.audit/auth-email-and-demo-code-sign-in.tsv @@ -7,3 +7,6 @@ ts phase decision why evidence result 2026-09-07T12:52:57Z database replayed all migrations under the shared lock and ran pgTAP this feature changes grants and a privileged atomic reservation function supabase/tests/demo_access_codes_rls_test.sql 27 database tests pass; local stack stopped 2026-09-07T12:52:58Z verification ran the credential-free release ladder after auth and UI fixes release requires current working-tree proof across frontend and Edge code npm run verify 975 Vitest, 130 Deno, build, and 134 Chromium tests pass 2026-09-07T12:52:59Z staging kept protected staging blocked instead of substituting production all three required STAGING values are absent locally and in the GitHub staging environment docs/testing.md; environment and GitHub configuration name checks BLOCKED pending staging URL, anon key, and dedicated demo code +2026-09-07T13:29:52Z security moved demo-account ownership from editable user_metadata to server-controlled app_metadata user_metadata cannot authorize reuse of an existing Auth account supabase/functions/demo-code-login/index_test.ts red-green proof covers spoofing, correct binding, unrelated-account non-mutation, and new-user binding +2026-09-07T13:29:53Z review preserved admin and banned roles and made attempt-store failures fail closed independent standards and security reviews found two additional privilege and rate-limit failures supabase/functions/demo-code-login/index.ts; index_test.ts 6 handler regressions and 137 total Edge tests pass +2026-09-07T13:29:54Z verification reran database and complete credential-free release gates after the final auth fixes old results do not prove the current tree ./.superset/db-gate.sh; npm run verify 27 pgTAP, 976 Vitest, 137 Deno, build, and 134 Chromium tests pass; local Supabase stopped diff --git a/docs/architecture.md b/docs/architecture.md index 773be6719..7bcc14b9d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,11 +72,12 @@ Do not infer current schema from old README examples or store query names. Repla Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive. +- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path. - RLS owns row visibility and mutation rights. - Shared backend helpers validate bearer tokens and admin roles. - Admin endpoints verify server-side roles. - Stripe webhook requests use Stripe signatures rather than user sessions. -- Demo-code login is a pre-session flow with hashing, rate limits, bounded use, and a rule that demo access cannot grant administrator status. +- Demo-code login is a pre-session flow with hashing, fail-closed rate limits, bounded use, and a rule that demo access cannot grant administrator status. Existing demo accounts are reusable only when server-controlled Auth `app_metadata` binds the user to that code. ## Billing and usage diff --git a/package.json b/package.json index 99a0687e6..6954aa357 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/supabase/README.md b/supabase/README.md index 3c4221f75..7ee5b164a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -41,8 +41,8 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh ### Authentication and administration -- `demo-code-login`: bounded pre-session access-code flow with normalized hashing, attempt limits, atomic use consumption, demo-user provisioning, and a prohibition on granting administrator status. -- `admin`: authenticated administrator operations with server-side role checks. +- `demo-code-login`: bounded pre-session access-code flow with normalized hashing, fail-closed attempt limits, atomic use consumption, server-owned demo-user bindings in Auth `app_metadata`, and a prohibition on granting administrator status. +- `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions. - `announcements`: authenticated announcement listing and dismissal. - `redeem-promo`: authenticated atomic promo redemption. diff --git a/supabase/functions/demo-code-login/index.ts b/supabase/functions/demo-code-login/index.ts index 75d881ab6..f9122da6a 100644 --- a/supabase/functions/demo-code-login/index.ts +++ b/supabase/functions/demo-code-login/index.ts @@ -99,7 +99,10 @@ async function isRateLimited(ipAddress: string | null, browserFingerprint: strin .eq("ip_address", ipAddress) .eq("success", false) .gte("created_at", since) - .then(({ count }) => count || 0), + .then(({ count, error }) => { + if (error) throw error; + return count || 0; + }), ), ); } @@ -112,7 +115,10 @@ async function isRateLimited(ipAddress: string | null, browserFingerprint: strin .eq("browser_fingerprint", browserFingerprint) .eq("success", false) .gte("created_at", since) - .then(({ count }) => count || 0), + .then(({ count, error }) => { + if (error) throw error; + return count || 0; + }), ), ); } @@ -133,7 +139,7 @@ async function findUserByEmail(email: string) { return null; } -Deno.serve(async (req) => { +export async function handleRequest(req: Request) { const origin = req.headers.get("origin"); const corsHeaders = getCorsHeaders(origin); @@ -212,7 +218,7 @@ Deno.serve(async (req) => { } const existingUser = await findUserByEmail(demoCode.email); - if (existingUser && !isDemoUserForCode(existingUser.user_metadata, demoCode.id)) { + if (existingUser && !isDemoUserForCode(existingUser.app_metadata, demoCode.id)) { await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "email_conflict" }); return new Response(JSON.stringify({ error: "This demo code cannot be used." }), { status: 409, @@ -220,6 +226,22 @@ Deno.serve(async (req) => { }); } + if (existingUser) { + const { data: existingRole, error: existingRoleError } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", existingUser.id) + .maybeSingle<{ role: string }>(); + if (existingRoleError) throw existingRoleError; + if (existingRole?.role === "admin" || existingRole?.role === "banned") { + await recordAttempt({ codeHash, ipAddress, browserFingerprint, success: false, failureReason: "account_role_conflict" }); + return new Response(JSON.stringify({ error: "This demo code cannot be used." }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } + // Reserve the use atomically before creating or changing the demo account. const { data: reservation, error: reservationError } = await supabase.rpc("consume_demo_access_code", { p_code_id: demoCode.id, @@ -239,8 +261,10 @@ Deno.serve(async (req) => { : (await supabase.auth.admin.createUser({ email: demoCode.email, email_confirm: true, - user_metadata: { + app_metadata: { demo_access_code_id: demoCode.id, + }, + user_metadata: { demo_label: demoCode.label, }, })).data; @@ -304,4 +328,8 @@ Deno.serve(async (req) => { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } -}); +} + +if (import.meta.main) { + Deno.serve(handleRequest); +} diff --git a/supabase/functions/demo-code-login/index_test.ts b/supabase/functions/demo-code-login/index_test.ts new file mode 100644 index 000000000..5c59c4465 --- /dev/null +++ b/supabase/functions/demo-code-login/index_test.ts @@ -0,0 +1,191 @@ +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +const demoCode = { + id: "11111111-1111-4111-8111-111111111111", + code_hash: "ignored-by-mock", + label: "Tournament room", + email: "demo@example.com", + role: "user", + expires_at: null, + max_uses: 5, + use_count: 0, + disabled_at: null, +}; + +type Scenario = { + appMetadata?: Record; + attemptLookupFails?: boolean; + currentRole?: string | null; + existingUser?: boolean; + userMetadata?: Record; +}; + +async function runScenario({ + appMetadata = {}, + attemptLookupFails = false, + currentRole = "user", + existingUser = true, + userMetadata = {}, +}: Scenario) { + const calls: Array<{ body: Record | null; method: string; pathname: string }> = []; + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + const bodyText = request.method === "GET" ? "" : await request.clone().text(); + const body = bodyText ? JSON.parse(bodyText) as Record : null; + calls.push({ body, method: request.method, pathname: url.pathname }); + + if (url.pathname === "/rest/v1/demo_access_attempts" && request.method !== "POST") { + return attemptLookupFails ? jsonResponse({ message: "attempt lookup failed" }, 500) : jsonResponse([]); + } + if (url.pathname === "/rest/v1/demo_access_codes") return jsonResponse(demoCode); + if (url.pathname === "/auth/v1/admin/users" && request.method === "GET") { + return jsonResponse({ + users: existingUser + ? [{ + id: "22222222-2222-4222-8222-222222222222", + email: demoCode.email, + app_metadata: appMetadata, + user_metadata: userMetadata, + }] + : [], + }); + } + if (url.pathname === "/rest/v1/demo_access_attempts") return jsonResponse([], 201); + if (url.pathname === "/rest/v1/user_roles" && request.method === "GET") { + return jsonResponse(currentRole === null ? null : { role: currentRole }); + } + if (url.pathname === "/rest/v1/rpc/consume_demo_access_code") return jsonResponse([{ reserved: true }]); + if (url.pathname === "/auth/v1/admin/users" && request.method === "POST") { + return jsonResponse({ + id: "33333333-3333-4333-8333-333333333333", + email: demoCode.email, + app_metadata: body?.app_metadata || {}, + user_metadata: body?.user_metadata || {}, + }); + } + if (url.pathname === "/rest/v1/user_roles" || url.pathname === "/rest/v1/user_settings") { + return jsonResponse([], 201); + } + if (url.pathname === "/auth/v1/admin/generate_link") { + return jsonResponse({ + action_link: "https://supabase.test/verify", + email_otp: "123456", + hashed_token: "test-token-hash", + redirect_to: "https://debate.pi-stack.org", + verification_type: "magiclink", + id: "33333333-3333-4333-8333-333333333333", + email: demoCode.email, + }); + } + throw new Error(`Unhandled Supabase request: ${request.method} ${url}`); + }) as typeof fetch; + + try { + const response = await handleRequest(new Request("http://127.0.0.1/functions/v1/demo-code-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: "DEMO-AB12-CD34", + ...(attemptLookupFails ? { browserFingerprint: "test-browser" } : {}), + }), + })); + return { calls, response }; + } finally { + globalThis.fetch = originalFetch; + } +} + +function countCalls(calls: Awaited>["calls"], pathname: string) { + return calls.filter((call) => call.pathname === pathname).length; +} + +Deno.test("spoofed user_metadata cannot authorize demo-account reuse", async () => { + const { calls, response } = await runScenario({ + userMetadata: { demo_access_code_id: demoCode.id }, + }); + + if (response.status !== 409) throw new Error(`spoofed metadata returned ${response.status}`); + if (countCalls(calls, "/rest/v1/rpc/consume_demo_access_code") !== 0) { + throw new Error("spoofed metadata consumed a demo-code use"); + } +}); + +Deno.test("matching app_metadata permits demo-account reuse", async () => { + const { calls, response } = await runScenario({ + appMetadata: { demo_access_code_id: demoCode.id }, + }); + + if (response.status !== 200) throw new Error(`matching server binding returned ${response.status}`); + if (countCalls(calls, "/rest/v1/rpc/consume_demo_access_code") !== 1) { + throw new Error("matching server binding did not reserve one use"); + } + if (countCalls(calls, "/auth/v1/admin/users") !== 1) { + throw new Error("matching server binding created another Auth user"); + } +}); + +Deno.test("unrelated Auth account is rejected before code, role, or settings mutation", async () => { + const { calls, response } = await runScenario({}); + + if (response.status !== 409) throw new Error(`unrelated account returned ${response.status}`); + for (const pathname of [ + "/rest/v1/rpc/consume_demo_access_code", + "/rest/v1/user_roles", + "/rest/v1/user_settings", + ]) { + if (countCalls(calls, pathname) !== 0) throw new Error(`unrelated account mutated ${pathname}`); + } +}); + +Deno.test("new demo users receive the server-owned code binding", async () => { + const { calls, response } = await runScenario({ existingUser: false }); + + if (response.status !== 200) throw new Error(`new demo user returned ${response.status}`); + const createCall = calls.find((call) => call.pathname === "/auth/v1/admin/users" && call.method === "POST"); + const appMetadata = createCall?.body?.app_metadata; + if ( + typeof appMetadata !== "object" || appMetadata === null || + !("demo_access_code_id" in appMetadata) || appMetadata.demo_access_code_id !== demoCode.id + ) { + throw new Error("new demo user lacks an app_metadata code binding"); + } +}); + +Deno.test("a bound account with a banned role stays banned and cannot consume a use", async () => { + const { calls, response } = await runScenario({ + appMetadata: { demo_access_code_id: demoCode.id }, + currentRole: "banned", + }); + + if (response.status !== 403) throw new Error(`bound banned account returned ${response.status}`); + if (countCalls(calls, "/rest/v1/rpc/consume_demo_access_code") !== 0) { + throw new Error("bound banned account consumed a demo-code use"); + } + if (calls.some((call) => call.pathname === "/rest/v1/user_roles" && call.method !== "GET")) { + throw new Error("bound banned account changed its role"); + } +}); + +Deno.test("attempt-store errors fail closed before code lookup", async () => { + const { calls, response } = await runScenario({ attemptLookupFails: true }); + + if (response.status !== 500) throw new Error(`attempt-store failure returned ${response.status}`); + if (countCalls(calls, "/rest/v1/demo_access_codes") !== 0) { + throw new Error("attempt-store failure continued to code lookup"); + } +});
Issued demo access codes{t('admin.tableCaption')}
Assigned emailLabelRoleExpiryUsesLast usedStatusActions{t('admin.assignedEmail')}{t('admin.label')}{t('admin.role')}{t('admin.expiry')}{t('admin.uses')}{t('admin.lastUsed')}{t('admin.status')}{t('admin.actions')}
{code.role}{formatDemoDate(code.expiresAt, true)}{code.useCount} / {code.maxUses === null ? 'Unlimited' : code.maxUses}{formatDemoDate(code.lastUsedAt, true)}{status}{formatDemoDate(code.expiresAt)}{code.useCount} / {code.maxUses === null ? t('admin.unlimited') : code.maxUses}{formatDemoDate(code.lastUsedAt)}{t(`admin.${status}`)}