diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 244ba96e1..3fcdc9739 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -110,6 +110,11 @@ const ALWAYS = [ file: 'src/tests/workspaces.test.ts src/tests/workspaces-sharing-limits.test.ts', label: 'Workspaces: create, switch, settings, sharing limits', }, + { + key: 'demo-role', + file: 'src/tests/demo-role.test.ts', + label: 'Published demo: Demo role and demo mode', + }, { key: 'sql-editor', file: [ diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 0d9d9980d..f0e27f0ee 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -665,6 +665,14 @@ export const TestIds = { adminSignInSettings: 'admin-sign-in-settings', /** button · Copy */ signInCopy: 'sign-in-copy', + /** section · Demo mode */ + signInDemo: 'sign-in-demo', + /** textarea · Demo login: demo@example.com / its password */ + signInDemoHint: 'sign-in-demo-hint', + /** button · Save hint */ + signInDemoSave: 'sign-in-demo-save', + /** input */ + signInDemoToggle: 'sign-in-demo-toggle', /** section · Email for invites and password resets */ signInMail: 'sign-in-mail', /** input · Fox */ @@ -744,6 +752,10 @@ export const TestIds = { /** button */ commandPaletteItem: (id: string | number) => `command-palette-item-${id}`, }, + DemoBanner: { + /** div */ + demoBanner: 'demo-banner', + }, featureRegistry: { /** data · Snapshots */ syncPaneHistoryBtn: 'sync-pane-history-btn', @@ -895,6 +907,8 @@ export const TestIds = { accountVerifySubmit: 'account-verify-submit', }, AuthPage: { + /** div */ + authDemoHint: 'auth-demo-hint', /** button · Forgot password? */ authForgotLink: 'auth-forgot-link', /** button · Have an invite or reset code? */ @@ -3769,6 +3783,7 @@ export type TestId = | 'auth-code-back' | 'auth-code-continue' | 'auth-code-form' + | 'auth-demo-hint' | 'auth-forgot-back' | 'auth-forgot-email' | 'auth-forgot-form' @@ -4160,6 +4175,7 @@ export type TestId = | 'db-access-status' | `db-access-toggle-group-${string}` | 'db-access-unsupported' + | 'demo-banner' | 'deploy-cancel-btn' | 'deploy-confirm-btn' | 'deploy-confirm-dialog' @@ -4606,6 +4622,10 @@ export type TestId = | 'settings-view' | 'settings-workspace' | 'sign-in-copy' + | 'sign-in-demo' + | 'sign-in-demo-hint' + | 'sign-in-demo-save' + | 'sign-in-demo-toggle' | 'sign-in-mail' | 'sign-in-mail-from' | 'sign-in-mail-host' diff --git a/apps/e2e/src/helpers/accounts.ts b/apps/e2e/src/helpers/accounts.ts index de84e15e0..73baf92c2 100644 --- a/apps/e2e/src/helpers/accounts.ts +++ b/apps/e2e/src/helpers/accounts.ts @@ -19,10 +19,16 @@ export interface ApiResult { } /** One request to `/api` as the session in `cookie`. A body that is not JSON reads as null. */ -export async function apiCall(method: string, path: string, body: unknown, cookie: string): Promise { +export async function apiCall( + method: string, + path: string, + body: unknown, + cookie: string, + headers: Record = {} +): Promise { const res = await fetch(`${API_URL}/api${path}`, { method, - headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie }, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie, ...headers }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); return { status: res.status, json: await res.json().catch(() => null) }; diff --git a/apps/e2e/src/tests/demo-role.test.ts b/apps/e2e/src/tests/demo-role.test.ts new file mode 100644 index 000000000..e7cf1ff92 --- /dev/null +++ b/apps/e2e/src/tests/demo-role.test.ts @@ -0,0 +1,152 @@ +/** + * A published demo, in a real browser against the running server. + * + * One shared login with the Demo role: a visitor reads the login off the + * sign-in page, sees the demo banner, and queries the sample database an + * admin put in its workspace. Saving anything is refused, by the server. + * + * Runs as the suites' admin plus one throwaway demo account, and turns demo + * mode off again at the end, since every other suite shares this install. + * + * Requires the web app + API (`npm run dev`) and a `sqlite3` CLI on PATH. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, clickWhen, waitFor, BASE_URL } from '../helpers/driver.js'; +import { sessionCookie } from '../helpers/app-session.js'; +import { apiCall, signInAs } from '../helpers/accounts.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { byTestId } from '../helpers/test-ids.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +const RUN = Date.now().toString(36); +// Per-run directory: the backend pools SQLite handles by file path. +const DIR = `/tmp/foxschema-e2e-demo-${RUN}`; +const DB = join(DIR, 'sample.db'); +const CONNECTION = `Demo sample ${RUN}`; +const DEMO_EMAIL = `e2e-demo-${RUN}@foxschema.test`; +const DEMO_PASSWORD = `try-fox-${randomBytes(6).toString('hex')}`; +const HINT = `Demo login: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`; + +let admin = ''; +let adminId = ''; +let demoId = ''; +let demoWorkspace = ''; +let demoCookie = ''; +const pages: Page[] = []; + +function hasSqlite3(): boolean { + try { + execSync('which sqlite3', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const api = (method: string, path: string, body?: unknown, cookie = admin, headers: Record = {}) => + apiCall(method, path, body, cookie, headers); + +describe.skipIf(!hasSqlite3())('a published demo', () => { + beforeAll(async () => { + rmSync(DIR, { recursive: true, force: true }); + mkdirSync(DIR, { recursive: true }); + execFileSync('sqlite3', [DB], { + input: `CREATE TABLE planets (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + INSERT INTO planets VALUES (1, 'Mercury'), (2, 'Venus'), (3, 'Earth');`, + }); + + admin = await sessionCookie(); + adminId = (await api('GET', '/auth/me')).json.user.id; + const made = await api('POST', '/admin/users', { email: DEMO_EMAIL, password: DEMO_PASSWORD, role: 'demo' }); + expect(made.status, JSON.stringify(made.json)).toBe(200); + demoId = made.json.user.id; + + // The demo cannot add connections: an admin joins its workspace, adds the + // sample database, and leaves — the path docs/DEPLOYMENT.md describes. + demoCookie = await signInAs(DEMO_EMAIL, DEMO_PASSWORD); + demoWorkspace = (await api('GET', '/workspaces', undefined, demoCookie)).json.currentId; + expect((await api('PUT', `/workspaces/${demoWorkspace}/members/${adminId}`, { role: 'owner' })).status).toBe(200); + const saved = await api( + 'POST', + '/connections', + { name: CONNECTION, dialect: 'sqlite', option: { database: DB } }, + admin, + { 'x-fox-workspace': demoWorkspace } + ); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + expect((await api('DELETE', `/workspaces/${demoWorkspace}/members/${adminId}`, {})).status).toBe(200); + + expect((await api('PUT', '/admin/sign-in/demo', { on: true, hint: HINT })).status).toBe(200); + }, 120_000); + + afterAll(async () => { + for (const page of pages) await quitDriver(page); + await api('PUT', '/admin/sign-in/demo', { on: false, hint: '' }); + if (demoId) await api('PUT', `/admin/users/${demoId}/active`, { active: false }); + rmSync(DIR, { recursive: true, force: true }); + }); + + it('the sign-in page says it is a demo and shows the login to use', async () => { + const page = await buildDriver({ signedIn: false }); + pages.push(page); + await page.goto(BASE_URL); + await waitFor(page, byTestId('auth-demo-hint'), 30_000); + expect(await page.locator(byTestId('auth-demo-hint')).innerText()).toContain(HINT); + await saveScreenshot(page, 'demo-sign-in'); + + // A visitor signs in with exactly what the page shows. + await page.fill(byTestId('auth-login-email'), DEMO_EMAIL); + await page.fill('#auth-password', DEMO_PASSWORD); + await clickWhen(page, byTestId('auth-login-submit')); + await page.waitForSelector(`${byTestId('onboarding-skip')}, ${byTestId('demo-banner')}`, { timeout: 30_000 }); + const skip = page.locator(byTestId('onboarding-skip')); + if (await skip.count()) await skip.click(); + await waitFor(page, byTestId('demo-banner'), 15_000); + expect(await page.locator(byTestId('demo-banner')).innerText()).toMatch(/Nothing here can be saved/); + await saveScreenshot(page, 'demo-banner'); + }, 120_000); + + it('a visitor queries the sample database the admin set up', async () => { + const page = pages[0]!; + const sql = new SqlEditorPage(page); + await sql.openView(); + await sql.checkConnection(CONNECTION); + await sql.setSql('SELECT name FROM planets ORDER BY id;'); + await sql.run(); + await sql.waitForResults(); + const text = await sql.resultsText(); + for (const planet of ['Mercury', 'Venus', 'Earth']) expect(text).toContain(planet); + await saveScreenshot(page, 'demo-query'); + }, 120_000); + + it('saving anything is refused, and the profile is locked', async () => { + const refused = [ + await api('POST', '/connections', { name: 'mine', dialect: 'sqlite', option: { database: '/tmp/x.db' } }, demoCookie), + await api('POST', '/workspaces', { name: 'mine' }, demoCookie), + await api('POST', '/auth/sign-out-others', {}, demoCookie), + ]; + for (const r of refused) { + expect(r.status, JSON.stringify(r.json)).toBe(403); + expect(r.json.error).toMatch(/read-only demo/); + } + // A write SQL statement is refused by the permissions the Demo role lacks. + const conn = (await api('GET', '/connections', undefined, demoCookie)).json.connections.find( + (c: { name: string }) => c.name === CONNECTION + ); + const write = await api('POST', '/sql/execute', { connectionId: conn.id, statements: ['DELETE FROM planets'] }, demoCookie); + expect(write.status, JSON.stringify(write.json)).toBe(403); + expect(execFileSync('sqlite3', [DB, 'SELECT COUNT(*) FROM planets'], { encoding: 'utf8' }).trim()).toBe('3'); + + // The browser offers no way to sign the other visitors out. + const page = pages[0]!; + await page.goto(BASE_URL); + await clickWhen(page, byTestId('profile-menu-trigger')); + await waitFor(page, byTestId('profile-sign-out'), 10_000); + expect(await page.locator(byTestId('profile-sign-out-others')).count()).toBe(0); + }, 120_000); +}); diff --git a/apps/web/src/frontend/App.tsx b/apps/web/src/frontend/App.tsx index f93db7069..24edcefbe 100644 --- a/apps/web/src/frontend/App.tsx +++ b/apps/web/src/frontend/App.tsx @@ -10,6 +10,7 @@ import { apiGetPreferences } from '@/shared/api/authApi'; import { ToastHost } from '@/app/shell/ToastHost'; import { AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react'; import { BackendOfflineBanner } from '@/app/shell/BackendOfflineBanner'; +import { DemoBanner } from '@/app/shell/DemoBanner'; import { CommandPalette } from '@/app/shell/CommandPalette'; import { WORKSPACE_VIEWS, redirectFor } from '@/app/features/featureRegistry'; import type { ActiveView } from '@/platform/viewIds'; @@ -62,6 +63,7 @@ const Workspace: React.FC = () => { {/* Above every other banner: when the backend is gone, nothing else on screen is trustworthy and no other message explains why. */} + {accountReminder && ( diff --git a/apps/web/src/frontend/app/shell/DemoBanner.test.tsx b/apps/web/src/frontend/app/shell/DemoBanner.test.tsx new file mode 100644 index 000000000..2ab8238b3 --- /dev/null +++ b/apps/web/src/frontend/app/shell/DemoBanner.test.tsx @@ -0,0 +1,46 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { useAuthStore } from '@/platform/authStore'; +import { DemoBanner } from './DemoBanner'; + +const signIn = (role: string, demo: { on: boolean } | null) => + useAuthStore.setState({ + user: { id: 'u', email: 'u@example.com', onboardingCompleted: true, role, permissions: [] }, + status: 'ready', + demo, + } as never); + +beforeEach(() => { + useAuthStore.setState({ demo: null }); +}); + +describe('DemoBanner', () => { + it('tells a demo visitor what works and that nothing is saved', () => { + signIn('demo', { on: true }); + render(); + expect(screen.getByTestId('demo-banner').textContent).toMatch(/browse, compare, and run queries.*Nothing here can be saved/); + }); + + it('reminds an admin that demo mode is on', () => { + signIn('admin', { on: true }); + render(); + expect(screen.getByTestId('demo-banner').textContent).toMatch(/Demo mode is on/); + }); + + it('is absent when demo mode is off, or the server did not say', () => { + signIn('demo', { on: false }); + const { unmount } = render(); + expect(screen.queryByTestId('demo-banner')).toBeNull(); + unmount(); + + signIn('demo', null); + render(); + expect(screen.queryByTestId('demo-banner')).toBeNull(); + }); +}); diff --git a/apps/web/src/frontend/app/shell/DemoBanner.tsx b/apps/web/src/frontend/app/shell/DemoBanner.tsx new file mode 100644 index 000000000..e0a892c8f --- /dev/null +++ b/apps/web/src/frontend/app/shell/DemoBanner.tsx @@ -0,0 +1,33 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Shown on every page while an admin has demo mode on, so a visitor knows + * why saving does nothing before the first refusal tells them. + */ +import React from 'react'; +import { Eye } from 'lucide-react'; +import { isDemoRole } from '@foxschema/shared'; +import { useAuthStore } from '@/platform/authStore'; + +export const DemoBanner: React.FC = () => { + const demo = useAuthStore((s) => s.demo); + const role = useAuthStore((s) => s.user?.role); + if (!demo?.on) return null; + return ( +
+ + Demo + + {isDemoRole(role) + ? 'Explore the sample databases: browse, compare, and run queries. Nothing here can be saved or changed.' + : 'Demo mode is on: visitors see this banner, and the sign-in page shows the demo login.'} + +
+ ); +}; diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx index e1f4bd476..e052d1ab9 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.test.tsx @@ -62,6 +62,16 @@ describe('ProfileMenu', () => { await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Signed out 2 other sessions' }))); }); + it('does not offer a demo login to sign the other visitors out', () => { + useAuthStore.setState({ + user: { id: 'demo', email: 'demo@example.com', onboardingCompleted: true, role: 'demo', permissions: [] }, + } as never); + render(); + fireEvent.click(screen.getByTestId('profile-menu-trigger')); + expect(screen.getByTestId('profile-sign-out')).toBeTruthy(); + expect(screen.queryByTestId('profile-sign-out-others')).toBeNull(); + }); + it('opens Access control from the avatar menu for the local admin', async () => { render(); fireEvent.click(screen.getByTestId('profile-menu-trigger')); diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 3ec5cf2bc..6cc55d790 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -1,4 +1,5 @@ import React, { lazy, useEffect, useRef, useState } from 'react'; +import { isDemoRole } from '@foxschema/shared'; import { createPortal } from 'react-dom'; import { LogOut, Palette, ArrowUpCircle, Globe, Shield, MonitorX, MessageSquarePlus, UserPlus } from 'lucide-react'; import { apiSignOutOthers } from '@/shared/api/authApi'; @@ -158,21 +159,24 @@ export function ProfileMenu(): React.ReactElement | null { foxschema.com - + {/* A demo login is every visitor at once; the server refuses this for it. */} + {!isDemoRole(user?.role) && ( + + )}