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