diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd48fc6..08f9243 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -309,17 +309,39 @@ jobs:
- name: Update dev overlay image tags
env:
TAG: ${{ needs.docker.outputs.tag }}
+ SHA: ${{ github.sha }}
run: |
if [ -z "$TAG" ]; then
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
fi
+ SHORT_SHA="${SHA::7}"
echo "Updating dev overlay image tags to: $TAG"
+ echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
cd /tmp/infra
DEV_KUST="apps/groombook/overlays/dev/kustomization.yaml"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$DEV_KUST"
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$DEV_KUST"
+
+ # Update migrate Job name to include short SHA (immutable template fix)
+ MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
+ if [ -f "$MIGRATE_JOB" ]; then
+ yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
+ # Ensure ttlSecondsAfterFinished is set for automatic cleanup
+ yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$MIGRATE_JOB"
+ fi
+
+ # Update seed Job name to include short SHA (immutable template fix)
+ SEED_JOB="apps/groombook/base/seed-job.yaml"
+ if [ -f "$SEED_JOB" ]; then
+ yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
+ yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
+ # Ensure ttlSecondsAfterFinished is set for automatic cleanup
+ yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$SEED_JOB"
+ fi
+
git -C /tmp/infra diff --stat
- name: Create PR on groombook/infra
@@ -335,8 +357,8 @@ jobs:
git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-image-tags-${TAG}"
- git add apps/groombook/overlays/dev/
- git commit -m "chore: update image tags to ${TAG}"
+ git add apps/groombook/overlays/dev/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
+ git commit -m "chore: update image tags and migration/seed Job names to ${TAG}"
git push -u origin "chore/update-image-tags-${TAG}"
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 () => {