diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 135e129..7d3289c 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -446,4 +446,73 @@ 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); + } + + // 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, + 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/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( 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 0000000..40c7132 Binary files /dev/null and b/apps/web/public/pwa-192x192.png differ diff --git a/apps/web/public/pwa-512x512.png b/apps/web/public/pwa-512x512.png new file mode 100644 index 0000000..867e147 Binary files /dev/null and b/apps/web/public/pwa-512x512.png differ 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 () => {