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
+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",
};