diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 135e129..4d0b704 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -446,4 +446,52 @@ portalRouter.delete("/waitlist/:id", async (c) => { .returning(); return c.json({ ok: true }); -}); \ No newline at end of file +}); + +// ─── Dev-mode session creation ────────────────────────────────────────────── +// Allows the dev login selector to vend an impersonation session for a client +// without requiring manager auth. Only available when AUTH_DISABLED=true. + +const devSessionSchema = z.object({ + clientId: z.string().uuid(), +}); + +portalRouter.post( + "/dev-session", + zValidator("json", devSessionSchema), + async (c) => { + if (process.env.AUTH_DISABLED !== "true") { + return c.json({ error: "Not available when auth is enabled" }, 403); + } + + const db = getDb(); + const body = c.req.valid("json"); + + // Verify client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, body.clientId)) + .limit(1); + if (!client) { + return c.json({ error: "Client not found" }, 404); + } + + // Create a long-lived impersonation session for the dev client. + // Use a fixed "dev-staff" staffId so multiple dev sessions don't conflict + // with the one-active-session-per-staff rule in the real impersonation flow. + const DEV_STAFF_ID = "00000000-0000-0000-0000-000000000000"; + + const [session] = await db + .insert(impersonationSessions) + .values({ + staffId: DEV_STAFF_ID, + clientId: body.clientId, + reason: "dev-mode-client-portal", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + }) + .returning(); + + return c.json(session, 201); + } +); \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 2a5e8e1..80d4e5b 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -14,6 +14,7 @@ import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import { useBranding } from "../BrandingContext.js"; +import { getDevUser } from "../pages/DevLoginSelector.js"; import type { ImpersonationSession } from "@groombook/types"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; @@ -40,35 +41,57 @@ export function CustomerPortal() { const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); - // On mount: load session from ?sessionId= URL param + // On mount: load session from ?sessionId= URL param OR from dev user in localStorage const initDone = useRef(false); useEffect(() => { if (initDone.current) return; initDone.current = true; const sessionId = searchParams.get("sessionId"); - if (!sessionId) return; - fetch(`/api/impersonation/sessions/${sessionId}`) - .then((r) => { - if (!r.ok) return null; - return r.json() as Promise; + if (sessionId) { + // Real impersonation session from URL param + fetch(`/api/impersonation/sessions/${sessionId}`) + .then((r) => { + if (!r.ok) return null; + return r.json() as Promise; + }) + .then((s) => { + if (s && s.status === "active") { + setSession(s); + fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.name) setClientName(data.name); }) + .catch(() => {}); + } + setSearchParams({}, { replace: true }); + }) + .catch(() => { + setSearchParams({}, { replace: true }); + }); + return; + } + + // Dev mode: check for dev user in localStorage and create a dev session + const devUser = getDevUser(); + if (devUser && devUser.type === "client") { + fetch("/api/portal/dev-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: devUser.id }), }) - .then((s) => { - if (s && s.status === "active") { - setSession(s); - // Fetch client name for display - fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) - .then(r => r.ok ? r.json() : null) - .then(data => { if (data?.name) setClientName(data.name); }) - .catch(() => {}); - } - // Clean sessionId from URL - setSearchParams({}, { replace: true }); - }) - .catch(() => { - setSearchParams({}, { replace: true }); - }); + .then((r) => { + if (!r.ok) return null; + return r.json() as Promise; + }) + .then((s) => { + if (s && s.id) { + setSession(s); + setClientName(devUser.name); + } + }) + .catch(() => {}); + } }, []); const handleEnd = useCallback(async () => {