From 1567f6e03edf5ac3285f7f0c230069af025fee64 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:26:40 +0000 Subject: [PATCH 1/3] Add dev/demo login selector for quick user switching When AUTH_DISABLED=true, the app now shows a login selector page that lists staff members and clients from the database. Selecting a user sets a localStorage-based session and sends X-Dev-User-Id header on all API requests. A persistent bottom bar shows the active persona with a "Switch user" link. - API: /api/dev/config (public) and /api/dev/users (auth-disabled only) - API: auth middleware reads X-Dev-User-Id header when auth is disabled - Frontend: DevLoginSelector page, DevSessionIndicator bar - Frontend: fetch interceptor injects X-Dev-User-Id on /api/* calls - Tests: 7 passing (5 nav + 2 dev login) Closes #60 Co-Authored-By: Paperclip --- apps/api/src/index.ts | 4 + apps/api/src/middleware/auth.ts | 4 +- apps/api/src/routes/dev.ts | 45 +++++ apps/web/src/App.tsx | 42 ++++- apps/web/src/__tests__/App.test.tsx | 113 +++++++++--- .../src/components/DevSessionIndicator.tsx | 41 +++++ apps/web/src/lib/devFetch.ts | 26 +++ apps/web/src/main.tsx | 3 + apps/web/src/pages/DevLoginSelector.tsx | 169 ++++++++++++++++++ 9 files changed, 421 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/routes/dev.ts create mode 100644 apps/web/src/components/DevSessionIndicator.tsx create mode 100644 apps/web/src/lib/devFetch.ts create mode 100644 apps/web/src/pages/DevLoginSelector.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ed494a2..0e169e7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -13,6 +13,7 @@ import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { authMiddleware } from "./middleware/auth.js"; +import { devRouter } from "./routes/dev.js"; import { startReminderScheduler } from "./services/reminders.js"; const app = new Hono(); @@ -33,6 +34,9 @@ app.get("/health", (c) => c.json({ status: "ok" })); // Public booking routes — no auth required, must be registered before auth middleware app.route("/api/book", bookRouter); +// Dev/demo routes — config is always public, users endpoint is guarded internally +app.route("/api/dev", devRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index f8d6380..44f4100 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -40,7 +40,9 @@ if (process.env.AUTH_DISABLED === "true") { export const authMiddleware: MiddlewareHandler = async (c, next) => { if (process.env.AUTH_DISABLED === "true") { - c.set("jwtPayload", { sub: "dev-user" } as JwtPayload); + const devUserId = c.req.header("X-Dev-User-Id"); + const sub = devUserId ?? "dev-user"; + c.set("jwtPayload", { sub } as JwtPayload); await next(); return; } diff --git a/apps/api/src/routes/dev.ts b/apps/api/src/routes/dev.ts new file mode 100644 index 0000000..dfc5708 --- /dev/null +++ b/apps/api/src/routes/dev.ts @@ -0,0 +1,45 @@ +import { Hono } from "hono"; +import { getDb, staff, clients, eq, sql } from "@groombook/db"; + +const devRouter = new Hono(); + +// GET /api/dev/config — tells the frontend whether auth is disabled +devRouter.get("/config", (c) => { + return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" }); +}); + +// GET /api/dev/users — list staff and clients for the login selector +// Only available when AUTH_DISABLED=true +devRouter.get("/users", async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + + const staffList = await db + .select({ + id: staff.id, + name: staff.name, + email: staff.email, + role: staff.role, + }) + .from(staff) + .where(eq(staff.active, true)) + .orderBy(staff.name); + + const clientList = await db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + petCount: sql`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"), + }) + .from(clients) + .orderBy(clients.name) + .limit(20); + + return c.json({ staff: staffList, clients: clientList }); +}); + +export { devRouter }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7df2c75..a9e9d89 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route, Link, useLocation } from "react-router-dom"; +import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom"; +import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ServicesPage } from "./pages/Services.js"; @@ -8,6 +9,8 @@ import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; +import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; +import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, @@ -105,14 +108,43 @@ function AdminLayout() { export function App() { const location = useLocation(); + const [authDisabled, setAuthDisabled] = useState(null); + + useEffect(() => { + fetch("/api/dev/config") + .then((r) => r.json()) + .then((data) => setAuthDisabled(data.authDisabled === true)) + .catch(() => setAuthDisabled(false)); + }, []); + + // Show login selector page + if (location.pathname === "/login") { + return ; + } + + // While checking auth config, render nothing briefly + if (authDisabled === null) return null; + + // If auth is disabled and no dev user is selected, redirect to login selector + if (authDisabled && !getDevUser() && location.pathname !== "/login") { + return ; + } if (location.pathname.startsWith("/admin")) { return ( - - } /> - + <> + + } /> + + {authDisabled && } + ); } - return ; + return ( + <> + + {authDisabled && } + + ); } diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx index 0052984..7da9657 100644 --- a/apps/web/src/__tests__/App.test.tsx +++ b/apps/web/src/__tests__/App.test.tsx @@ -1,40 +1,51 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen, within, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { App } from "../App.js"; -// Prevent fetch errors from page components loading data on mount +// Mock fetch to return appropriate responses based on URL beforeEach(() => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => [], - } as unknown as Response); + localStorage.clear(); + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: false }), + } as Response); + } + return Promise.resolve({ + ok: true, + json: async () => [], + } as Response); + }) as unknown as typeof fetch; }); -function renderApp(route = "/admin") { +async function renderApp(route = "/admin") { render( ); - return screen.getByRole("navigation"); + // Wait for the config fetch to resolve + const nav = await screen.findByRole("navigation"); + return nav; } describe("App navigation", () => { - it("renders the Groom Book brand", () => { - const nav = renderApp(); + it("renders the Groom Book brand", async () => { + const nav = await renderApp(); expect( within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) ).toBeInTheDocument(); }); - it("renders the Book CTA button", () => { - const nav = renderApp(); + it("renders the Book CTA button", async () => { + const nav = await renderApp(); expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument(); }); - it("renders all primary nav links", () => { - const nav = renderApp(); + it("renders all primary nav links", async () => { + const nav = await renderApp(); const expectedLinks = [ "Appointments", "Clients", @@ -49,22 +60,84 @@ describe("App navigation", () => { }); }); - it("highlights the active route link", () => { - const nav = renderApp("/admin/clients"); + it("highlights the active route link", async () => { + const nav = await renderApp("/admin/clients"); const clientsLink = within(nav).getByText("Clients"); // Active links use fontWeight 600 expect(clientsLink).toHaveStyle({ fontWeight: "600" }); }); - it("renders customer portal at root", () => { + it("renders customer portal at root", async () => { render( ); // Customer portal should render at root - no admin nav present - expect( - screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).not.toBeInTheDocument(); + }); + }); +}); + +describe("Dev login selector", () => { + it("redirects to /login when auth is disabled and no user selected", async () => { + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: true }), + } as Response); + } + if (url === "/api/dev/users") { + return Promise.resolve({ + ok: true, + json: async () => ({ + staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }], + clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }], + }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + + render( + + + + ); + + // Should redirect to login selector and show dev login UI + await screen.findByText("Dev Login Selector"); + expect(screen.getByText("Sarah")).toBeInTheDocument(); + expect(screen.getByText("Client A")).toBeInTheDocument(); + }); + + it("does not redirect when a dev user is already selected", async () => { + localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" })); + + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: true }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + + render( + + + + ); + + // Should show admin nav, not login selector + const nav = await screen.findByRole("navigation"); + expect( + within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? "")) + ).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/DevSessionIndicator.tsx b/apps/web/src/components/DevSessionIndicator.tsx new file mode 100644 index 0000000..993698d --- /dev/null +++ b/apps/web/src/components/DevSessionIndicator.tsx @@ -0,0 +1,41 @@ +import { Link } from "react-router-dom"; +import { getDevUser } from "../pages/DevLoginSelector.js"; + +export function DevSessionIndicator() { + const user = getDevUser(); + if (!user) return null; + + return ( +
+ + Dev mode: acting as {user.name} ({user.type}) + + + Switch user + +
+ ); +} diff --git a/apps/web/src/lib/devFetch.ts b/apps/web/src/lib/devFetch.ts new file mode 100644 index 0000000..67603ed --- /dev/null +++ b/apps/web/src/lib/devFetch.ts @@ -0,0 +1,26 @@ +import { getDevUser } from "../pages/DevLoginSelector.js"; + +const originalFetch = window.fetch; + +/** + * Patches global fetch to include X-Dev-User-Id header on API requests + * when a dev user is selected via the login selector. + */ +export function installDevFetchInterceptor() { + window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { + const user = getDevUser(); + if (!user) return originalFetch(input, init); + + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + + // Only inject header for API calls + if (!url.startsWith("/api/")) return originalFetch(input, init); + + const headers = new Headers(init?.headers); + if (!headers.has("X-Dev-User-Id")) { + headers.set("X-Dev-User-Id", user.id); + } + + return originalFetch(input, { ...init, headers }); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b683e11..3920a8d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,8 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App.js"; +import { installDevFetchInterceptor } from "./lib/devFetch.js"; import "./index.css"; +installDevFetchInterceptor(); + const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/apps/web/src/pages/DevLoginSelector.tsx b/apps/web/src/pages/DevLoginSelector.tsx new file mode 100644 index 0000000..e171613 --- /dev/null +++ b/apps/web/src/pages/DevLoginSelector.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +interface StaffUser { + id: string; + name: string; + email: string; + role: string; +} + +interface ClientUser { + id: string; + name: string; + email: string | null; + petCount: number; +} + +export function DevLoginSelector() { + const navigate = useNavigate(); + const [staff, setStaff] = useState([]); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/dev/users") + .then((r) => r.json()) + .then((data) => { + setStaff(data.staff ?? []); + setClients(data.clients ?? []); + }) + .finally(() => setLoading(false)); + }, []); + + function selectUser(type: "staff" | "client", id: string, name: string) { + localStorage.setItem("dev-user", JSON.stringify({ type, id, name })); + navigate(type === "staff" ? "/admin" : "/"); + } + + function skipLogin() { + localStorage.removeItem("dev-user"); + navigate("/admin"); + } + + if (loading) { + return ( +
+

Loading users...

+
+ ); + } + + return ( +
+
+
+

+ GroomBook +

+

+ Dev Login Selector +

+
+ +

Staff

+
+ {staff.map((s) => ( + + ))} +
+ +

Clients

+
+ {clients.map((cl) => ( + + ))} +
+ +
+ +
+
+
+ ); +} + +export function getDevUser(): { type: string; id: string; name: string } | null { + try { + const raw = localStorage.getItem("dev-user"); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +export function clearDevUser() { + localStorage.removeItem("dev-user"); +} + +const containerStyle: React.CSSProperties = { + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "system-ui, sans-serif", + background: "#f0f2f5", + padding: "1rem", +}; + +const cardStyle: React.CSSProperties = { + background: "#fff", + borderRadius: 12, + padding: "2rem", + width: "100%", + maxWidth: 420, + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)", +}; + +const sectionStyle: React.CSSProperties = { + fontSize: 11, + fontWeight: 600, + color: "#6b7280", + textTransform: "uppercase", + letterSpacing: "0.05em", + margin: "0 0 0.5rem", +}; + +const userButtonStyle: React.CSSProperties = { + display: "block", + width: "100%", + padding: "0.75rem 1rem", + border: "1px solid #e5e7eb", + borderRadius: 8, + background: "#fff", + cursor: "pointer", + textAlign: "left", + transition: "border-color 0.15s, background 0.15s", +}; + +const skipButtonStyle: React.CSSProperties = { + padding: "0.5rem 1.25rem", + border: "1px solid #d1d5db", + borderRadius: 6, + background: "transparent", + cursor: "pointer", + fontSize: 13, + color: "#6b7280", +}; -- 2.52.0 From e62343fdd36142643f8efc03d7f0196bf93eb04f Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:43:33 +0000 Subject: [PATCH 2/3] fix(e2e): seed dev user in localStorage to prevent login redirect E2E tests were failing because the dev login selector redirects to /login when AUTH_DISABLED=true and no dev user is in localStorage. Added a shared Playwright fixture that pre-seeds localStorage with a default dev user before each test. Also rebased onto latest main to resolve merge conflict in App.test.tsx. Co-Authored-By: Paperclip --- apps/e2e/tests/book.spec.ts | 2 +- apps/e2e/tests/clients.spec.ts | 2 +- apps/e2e/tests/fixtures.ts | 22 ++++++++++++++++++++++ apps/e2e/tests/navigation.spec.ts | 2 +- apps/web/src/lib/devFetch.ts | 2 ++ 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 apps/e2e/tests/fixtures.ts diff --git a/apps/e2e/tests/book.spec.ts b/apps/e2e/tests/book.spec.ts index ecec404..c3f30a5 100644 --- a/apps/e2e/tests/book.spec.ts +++ b/apps/e2e/tests/book.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Booking portal happy-path E2E test. diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 4e681da..8b0ea62 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Client management E2E tests. diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts new file mode 100644 index 0000000..3150ffe --- /dev/null +++ b/apps/e2e/tests/fixtures.ts @@ -0,0 +1,22 @@ +import { test as base } from "@playwright/test"; + +/** + * Custom test fixture that seeds a dev user in localStorage before each test. + * + * When AUTH_DISABLED=true, the app redirects to /login if no dev-user is set. + * This fixture ensures E2E tests bypass that redirect by pre-selecting a + * default staff user. + */ +export const test = base.extend({ + page: async ({ page }, use) => { + await page.addInitScript(() => { + localStorage.setItem( + "dev-user", + JSON.stringify({ type: "staff", id: "dev-user", name: "Dev User" }) + ); + }); + await use(page); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts index 39d9530..e79a5f6 100644 --- a/apps/e2e/tests/navigation.spec.ts +++ b/apps/e2e/tests/navigation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect } from "./fixtures.js"; /** * Navigation smoke tests — verifies that each page loads without errors. diff --git a/apps/web/src/lib/devFetch.ts b/apps/web/src/lib/devFetch.ts index 67603ed..42078ce 100644 --- a/apps/web/src/lib/devFetch.ts +++ b/apps/web/src/lib/devFetch.ts @@ -5,6 +5,8 @@ const originalFetch = window.fetch; /** * Patches global fetch to include X-Dev-User-Id header on API requests * when a dev user is selected via the login selector. + * + * Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true). */ export function installDevFetchInterceptor() { window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { -- 2.52.0 From ec1bab3a438b7070584a5b6efcbea69ca50f86c2 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Thu, 19 Mar 2026 03:53:05 +0000 Subject: [PATCH 3/3] fix(e2e): mock /api/dev/config to bypass auth redirect in tests The fixture now also mocks /api/dev/config to return authDisabled: false, preventing the app from entering the redirect flow during E2E tests. Previously only seeded localStorage, but the async config fetch from the real Docker API was still triggering the redirect check. Co-Authored-By: Paperclip --- apps/e2e/tests/fixtures.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts index 3150ffe..bf26d5f 100644 --- a/apps/e2e/tests/fixtures.ts +++ b/apps/e2e/tests/fixtures.ts @@ -1,14 +1,22 @@ import { test as base } from "@playwright/test"; /** - * Custom test fixture that seeds a dev user in localStorage before each test. + * Custom test fixture that bypasses the dev login redirect for E2E tests. * - * When AUTH_DISABLED=true, the app redirects to /login if no dev-user is set. - * This fixture ensures E2E tests bypass that redirect by pre-selecting a - * default staff user. + * When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to + * /login if no dev-user is in localStorage. This fixture: + * 1. Mocks /api/dev/config to return authDisabled: false + * 2. Seeds localStorage with a dev user as a fallback + * + * This ensures E2E tests render pages directly without the login redirect. */ export const test = base.extend({ page: async ({ page }, use) => { + // Mock the dev config endpoint so the app skips the auth-disabled redirect + await page.route("**/api/dev/config", (route) => + route.fulfill({ json: { authDisabled: false } }) + ); + // Seed localStorage as a fallback in case the mock is bypassed await page.addInitScript(() => { localStorage.setItem( "dev-user", -- 2.52.0