Add dev/demo login selector for quick user switching #62
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* Booking portal happy-path E2E test.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* Client management E2E tests.
|
||||
|
||||
@@ -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,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
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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} · {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",
|
||||
};
|
||||
Reference in New Issue
Block a user