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>
This commit is contained in:
Groom Book CTO
2026-03-19 03:26:40 +00:00
parent 1cf1f19e1d
commit 1567f6e03e
9 changed files with 421 additions and 26 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 };