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 () => {