From 853c55fd042330f00c2e0fe57557bac5bef3dbee Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 14:50:50 +0000 Subject: [PATCH 1/4] fix(staff): count only active super users in last-super-user guardrail Add active=true filter to all 3 superUserCount queries in staff.ts (revoke, deactivate, delete) so inactive super users aren't counted, preventing false positives when checking the last-super-user guardrail. Co-Authored-By: Paperclip --- apps/api/src/routes/staff.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts index 7a4a8b5..bf5c8c2 100644 --- a/apps/api/src/routes/staff.ts +++ b/apps/api/src/routes/staff.ts @@ -65,7 +65,7 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); // just need count; fetch 2 to know if > 1 if (superUserCount.length <= 1) { return c.json( @@ -86,7 +86,7 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); if (superUserCount.length <= 1) { return c.json( @@ -142,7 +142,7 @@ staffRouter.delete("/:id", async (c) => { const superUserCount = await db .select({ id: staff.id }) .from(staff) - .where(eq(staff.isSuperUser, true)) + .where(and(eq(staff.isSuperUser, true), eq(staff.active, true))) .limit(2); if (superUserCount.length <= 1) { return c.json( From 51431c7bc1d3d7a14568bd10d4c5842f72e9a171 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:07:49 +0000 Subject: [PATCH 2/4] fix(portal): wire dev client login to portal session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a client user selects their account from the dev login selector, the portal previously had no way to establish an authenticated session — it only checked for a ?sessionId= URL param (used by the real staff impersonation flow). This caused the portal to always show "Hi, Guest". Changes: - POST /api/portal/dev-session: new endpoint (auth-disabled only) that creates an impersonation session for a given clientId, using a fixed dev staff ID to avoid conflicts with the one-active-session-per-staff rule in the real impersonation flow. Sessions are long-lived (24h). - CustomerPortal: on mount, after checking for ?sessionId=, also check for a dev client user in localStorage and call /api/portal/dev-session to obtain a session. This mirrors the real impersonation flow so all existing portal API calls (which require X-Impersonation-Session-Id) work without modification. cc @cpfarhood Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 50 +++++++++++++++++++- apps/web/src/portal/CustomerPortal.tsx | 65 +++++++++++++++++--------- 2 files changed, 93 insertions(+), 22 deletions(-) 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 () => { From 08e2f8c1ab3352d51ddd406e9b1fa6338d20621f Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 17:10:58 +0000 Subject: [PATCH 3/4] fix(web): add missing PWA icon and favicon assets Adds pwa-192x192.png, pwa-512x512.png, and favicon.svg to the web public directory. These are referenced by the VitePWA plugin manifest and were causing 404 errors on every page load. cc @cpfarhood Co-Authored-By: Paperclip --- apps/web/public/favicon.svg | 4 ++++ apps/web/public/pwa-192x192.png | Bin 0 -> 547 bytes apps/web/public/pwa-512x512.png | Bin 0 -> 1881 bytes 3 files changed, 4 insertions(+) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/public/pwa-192x192.png create mode 100644 apps/web/public/pwa-512x512.png diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..fe558ed --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + 🐾 + diff --git a/apps/web/public/pwa-192x192.png b/apps/web/public/pwa-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..40c71324e6f5b1e4456e76b6570f14496ac4e455 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS-8IT;if4me0l z9hF Date: Mon, 30 Mar 2026 18:09:09 +0000 Subject: [PATCH 4/4] fix(api): use valid staff ID for dev-session impersonation The hardcoded DEV_STAFF_ID (all zeros) did not exist in the staff table, causing a foreign-key violation and 500 error. Now falls back to the demo-manager (KNOWN_STAFF_ID from seed) or any active staff record instead. Co-Authored-By: Paperclip --- apps/api/src/routes/portal.ts | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 4d0b704..7d3289c 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -477,15 +477,36 @@ portalRouter.post( 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"; + // Find a staff record to associate with the dev impersonation session. + // Use the demo-manager if it exists (created by seed with known ID), + // otherwise fall back to the first active staff record. + // This avoids hardcoding a UUID that may not exist in all environments. + const DEMO_STAFF_ID = "00000000-0000-0000-0000-000000000001"; + + let staffId = DEMO_STAFF_ID; + const [demoStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.id, DEMO_STAFF_ID)) + .limit(1); + + if (!demoStaff) { + // Fall back to any active staff member + const [firstStaff] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.active, true)) + .limit(1); + if (!firstStaff) { + return c.json({ error: "No staff records found. Run the database seed." }, 500); + } + staffId = firstStaff.id; + } const [session] = await db .insert(impersonationSessions) .values({ - staffId: DEV_STAFF_ID, + staffId, clientId: body.clientId, reason: "dev-mode-client-portal", expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours