Add dev/demo login selector for quick user switching (#62)

* 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 <noreply@paperclip.ing>

* 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 <noreply@paperclip.ing>

* 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 <noreply@paperclip.ing>

---------

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #62.
This commit is contained in:
groombook-paperclip[bot]
2026-03-19 07:35:07 +00:00
committed by GitHub
parent 1cf1f19e1d
commit 3388895912
13 changed files with 456 additions and 29 deletions
+4
View File
@@ -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);
+3 -1
View File
@@ -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;
}
+45
View File
@@ -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<number>`(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 };
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures.js";
/**
* Booking portal happy-path E2E test.
+1 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "./fixtures.js";
/**
* Client management E2E tests.
+30
View File
@@ -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";
+1 -1
View File
@@ -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.
+37 -5
View File
@@ -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<boolean | null>(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 <DevLoginSelector />;
}
// 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 <Navigate to="/login" replace />;
}
if (location.pathname.startsWith("/admin")) {
return (
<Routes>
<Route path="/admin/*" element={<AdminLayout />} />
</Routes>
<>
<Routes>
<Route path="/admin/*" element={<AdminLayout />} />
</Routes>
{authDisabled && <DevSessionIndicator />}
</>
);
}
return <CustomerPortal />;
return (
<>
<CustomerPortal />
{authDisabled && <DevSessionIndicator />}
</>
);
}
+93 -20
View File
@@ -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(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
);
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(
<MemoryRouter initialEntries={["/"]}>
<App />
</MemoryRouter>
);
// 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(
<MemoryRouter initialEntries={["/admin"]}>
<App />
</MemoryRouter>
);
// 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(
<MemoryRouter initialEntries={["/admin"]}>
<App />
</MemoryRouter>
);
// 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();
});
});
@@ -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 (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "#1a202c",
color: "#e2e8f0",
padding: "0.4rem 1rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.75rem",
fontSize: 12,
zIndex: 9999,
}}
>
<span>
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
</span>
<Link
to="/login"
style={{
color: "#4f8a6f",
textDecoration: "underline",
fontSize: 12,
}}
>
Switch user
</Link>
</div>
);
}
+28
View File
@@ -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 });
};
}
+3
View File
@@ -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");
+169
View File
@@ -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<StaffUser[]>([]);
const [clients, setClients] = useState<ClientUser[]>([]);
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 (
<div style={containerStyle}>
<p style={{ color: "#6b7280" }}>Loading users...</p>
</div>
);
}
return (
<div style={containerStyle}>
<div style={cardStyle}>
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
<h1 style={{ margin: 0, fontSize: 22, color: "#1a202c" }}>
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
</h1>
<p style={{ margin: "0.5rem 0 0", color: "#6b7280", fontSize: 14 }}>
Dev Login Selector
</p>
</div>
<h2 style={sectionStyle}>Staff</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{staff.map((s) => (
<button
key={s.id}
onClick={() => selectUser("staff", s.id, s.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{s.role} &middot; {s.email}
</div>
</button>
))}
</div>
<h2 style={{ ...sectionStyle, marginTop: "1.5rem" }}>Clients</h2>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{clients.map((cl) => (
<button
key={cl.id}
onClick={() => selectUser("client", cl.id, cl.name)}
style={userButtonStyle}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{cl.name}</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{cl.petCount} pet{cl.petCount !== 1 ? "s" : ""}
{cl.email ? ` \u00b7 ${cl.email}` : ""}
</div>
</button>
))}
</div>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<button onClick={skipLogin} style={skipButtonStyle}>
Continue as default dev user
</button>
</div>
</div>
</div>
);
}
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",
};