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/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..bf26d5f --- /dev/null +++ b/apps/e2e/tests/fixtures.ts @@ -0,0 +1,30 @@ +import { test as base } from "@playwright/test"; + +/** + * Custom test fixture that bypasses the dev login redirect for E2E tests. + * + * 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", + 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/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..42078ce --- /dev/null +++ b/apps/web/src/lib/devFetch.ts @@ -0,0 +1,28 @@ +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. + * + * Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true). + */ +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", +};