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

Merged
ghost merged 3 commits from feat/dev-login-selector into main 2026-03-19 07:35:07 +00:00
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",
};