From b78e45b5c5dbd1f7264157cee1f2bfe20bf3a6d6 Mon Sep 17 00:00:00 2001 From: The Dogfather Date: Sat, 28 Mar 2026 01:23:10 +0000 Subject: [PATCH 01/26] =?UTF-8?q?fix(auth):=20dev=20login=20403=20?= =?UTF-8?q?=E2=80=94=20resolve=20staff=20by=20id,=20not=20oidcSub=20(GRO-1?= =?UTF-8?q?50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DevLoginSelector stores the staff database id in localStorage and sends it as X-Dev-User-Id. The resolveStaffMiddleware incorrectly looked up staff by oidcSub instead of id, causing all API endpoints to return 403 for every user in dev mode. Co-Authored-By: Paperclip --- apps/api/src/__tests__/rbac.test.ts | 2 +- apps/api/src/middleware/rbac.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index b052507..d8c26bf 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -165,7 +165,7 @@ describe("resolveStaffMiddleware", () => { }); const res = await app.request("/test", { - headers: { "X-Dev-User-Id": GROOMER.oidcSub! }, + headers: { "X-Dev-User-Id": GROOMER.id }, }); expect(res.status).toBe(200); expect(capturedStaff!.role).toBe("groomer"); diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 24a6753..98d9405 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -41,11 +41,11 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( await next(); return; } - // Treat X-Dev-User-Id as the oidcSub + // Treat X-Dev-User-Id as the staff database id (the frontend stores staff.id) const [row] = await db .select() .from(staff) - .where(eq(staff.oidcSub, devUserId)); + .where(eq(staff.id, devUserId)); if (!row) { return c.json( { error: "Forbidden: no staff record found for X-Dev-User-Id" }, -- 2.52.0 From dc67b2bf449c8b4fbd7e0463b8f93a4487184b66 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:53:20 +0000 Subject: [PATCH 02/26] =?UTF-8?q?fix(gro-158):=20admin=20page=20blank=20?= =?UTF-8?q?=E2=80=94=20TypeError:=20b.filter=20is=20not=20a=20function=20(?= =?UTF-8?q?#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes TypeError: b.filter is not a function on admin page.\n\nReviewed by: groombook-cto[bot], groombook-ceo[bot]\nCI: all checks passing --- apps/web/src/pages/Appointments.tsx | 15 ++++++++++++--- apps/web/src/pages/DevLoginSelector.tsx | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 4d64b1b..386354d 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -131,9 +131,18 @@ export function AppointmentsPage() { setError(null); Promise.all([ loadAppointments(), - fetch("/api/clients").then((r) => r.json() as Promise).then(setClients), - fetch("/api/services").then((r) => r.json() as Promise).then(setServices), - fetch("/api/staff").then((r) => r.json() as Promise).then(setStaff), + fetch("/api/clients").then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }).then(setClients), + fetch("/api/services").then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }).then(setServices), + fetch("/api/staff").then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }).then(setStaff), ]) .catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error")) .finally(() => setLoading(false)); diff --git a/apps/web/src/pages/DevLoginSelector.tsx b/apps/web/src/pages/DevLoginSelector.tsx index e171613..6de753b 100644 --- a/apps/web/src/pages/DevLoginSelector.tsx +++ b/apps/web/src/pages/DevLoginSelector.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; interface StaffUser { id: string; + userId: string | null; name: string; email: string; role: string; @@ -66,7 +67,7 @@ export function DevLoginSelector() { {staff.map((s) => ( - @@ -136,7 +144,11 @@ function ManagePets({ readOnly }: { readOnly: boolean }) { ))} {!readOnly && ( - diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 04c2bc1..4277bfc 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -176,7 +176,11 @@ function AppointmentCard({ )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
- diff --git a/apps/web/src/portal/sections/Dashboard.tsx b/apps/web/src/portal/sections/Dashboard.tsx index 2f82cc7..289d1c7 100644 --- a/apps/web/src/portal/sections/Dashboard.tsx +++ b/apps/web/src/portal/sections/Dashboard.tsx @@ -77,13 +77,25 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{!readOnly && (
- - -
diff --git a/apps/web/src/portal/sections/PetProfiles.tsx b/apps/web/src/portal/sections/PetProfiles.tsx index 3f10d20..5496534 100644 --- a/apps/web/src/portal/sections/PetProfiles.tsx +++ b/apps/web/src/portal/sections/PetProfiles.tsx @@ -54,8 +54,8 @@ export function PetProfiles({ readOnly }: Props) {

Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}

{!readOnly && ( - )} -- 2.52.0 From 3a31ad71c2e8ff6ad1cecb978453b5baa306ad2c Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:39:46 +0000 Subject: [PATCH 05/26] feat(schema): add is_super_user to staff table (GRO-201) Add boolean is_super_user column (default false) to staff table. Update Staff interface in shared types. Mark first manager as super user in both seed modes. Update test fixtures to include isSuperUser field. Co-authored-by: groombook-ci[bot] Co-authored-by: Claude Opus 4.6 --- .../src/__tests__/groomerIsolation.test.ts | 1 + apps/api/src/__tests__/petPhotos.test.ts | 1 + apps/api/src/__tests__/rbac.test.ts | 1 + .../db/migrations/0019_concerned_sunfire.sql | 84 +++++++++++++++++++ packages/db/src/factories.ts | 1 + packages/db/src/schema.ts | 2 + packages/db/src/seed.ts | 18 ++-- packages/types/src/index.ts | 1 + 8 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 packages/db/migrations/0019_concerned_sunfire.sql diff --git a/apps/api/src/__tests__/groomerIsolation.test.ts b/apps/api/src/__tests__/groomerIsolation.test.ts index 04f087f..9f0838e 100644 --- a/apps/api/src/__tests__/groomerIsolation.test.ts +++ b/apps/api/src/__tests__/groomerIsolation.test.ts @@ -17,6 +17,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: null, role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/apps/api/src/__tests__/petPhotos.test.ts b/apps/api/src/__tests__/petPhotos.test.ts index 19b8564..29f22c9 100644 --- a/apps/api/src/__tests__/petPhotos.test.ts +++ b/apps/api/src/__tests__/petPhotos.test.ts @@ -9,6 +9,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: null, role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/apps/api/src/__tests__/rbac.test.ts b/apps/api/src/__tests__/rbac.test.ts index e213ed7..c79e821 100644 --- a/apps/api/src/__tests__/rbac.test.ts +++ b/apps/api/src/__tests__/rbac.test.ts @@ -10,6 +10,7 @@ const MANAGER: StaffRow = { oidcSub: "oidc-manager-sub", userId: "ba-user-manager", role: "manager", + isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, diff --git a/packages/db/migrations/0019_concerned_sunfire.sql b/packages/db/migrations/0019_concerned_sunfire.sql new file mode 100644 index 0000000..a1321eb --- /dev/null +++ b/packages/db/migrations/0019_concerned_sunfire.sql @@ -0,0 +1,84 @@ +CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "waitlist_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "client_id" uuid NOT NULL, + "pet_id" uuid NOT NULL, + "service_id" uuid NOT NULL, + "preferred_date" text NOT NULL, + "preferred_time" text NOT NULL, + "status" "waitlist_status" DEFAULT 'active' NOT NULL, + "notified_at" timestamp, + "expires_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint +ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint +ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint +ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint +CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint +ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint +ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token"); \ No newline at end of file diff --git a/packages/db/src/factories.ts b/packages/db/src/factories.ts index df67583..eedc6e5 100644 --- a/packages/db/src/factories.ts +++ b/packages/db/src/factories.ts @@ -52,6 +52,7 @@ export function buildStaff(overrides: Partial = {}): StaffRow { oidcSub: `oidc-${id}`, userId: null, role: "groomer", + isSuperUser: false, active: true, icalToken: null, createdAt: new Date("2025-01-01T00:00:00Z"), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e1bb62f..3c75c9f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -159,6 +159,8 @@ export const staff = pgTable("staff", { // Better-Auth user ID — links staff business record to auth identity userId: text("user_id").references(() => user.id, { onDelete: "set null" }), role: staffRoleEnum("role").notNull().default("groomer"), + // Super users bypass appointment-booking restrictions and access admin panels + isSuperUser: boolean("is_super_user").notNull().default(false), active: boolean("active").notNull().default(true), // Token for iCal calendar feed subscription (no auth required) icalToken: text("ical_token").unique(), diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index cd68a31..f7936be 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -287,6 +287,7 @@ async function seedKnownUsers() { email: "demo-manager@groombook.dev", oidcSub: "demo-manager-001", role: "manager", + isSuperUser: true, active: true, }); console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)"); @@ -384,24 +385,24 @@ async function seed() { // ── Staff ── // Deterministic staff IDs so they can be referenced in scripts/tests const managerStaff = [ - { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const }, + { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true }, ]; const receptionistStaff = [ - { id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const }, + { id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false }, ]; const groomers = [ - { id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const }, - { id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const }, - { id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const }, + { id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false }, + { id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false }, + { id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false }, ]; // Bathers are groomers by role but serve as the secondary staff (bather) on appointments const bathers = [ - { id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const }, - { id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const }, - { id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const }, + { id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false }, + { id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false }, + { id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false }, ]; const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; @@ -411,6 +412,7 @@ async function seed() { name: s.name, email: s.email, role: s.role, + isSuperUser: s.isSuperUser, active: true, }); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b019921..198b8b3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -72,6 +72,7 @@ export interface Staff { name: string; email: string; role: "groomer" | "receptionist" | "manager"; + isSuperUser: boolean; active: boolean; createdAt: string; updatedAt: string; -- 2.52.0 From 6872342d8f0132f1989457466788af6e5eadd877 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:10:50 +0000 Subject: [PATCH 06/26] fix(auth): resolve redirect loop and mount Better-Auth as sub-app (#144) ## Changes - Replace toNodeHandler with auth.handler(c.req.raw) sub-app mount for Hono compatibility - Add /api/auth/ path skip in authMiddleware and resolveStaffMiddleware - Add OIDC_INTERNAL_BASE env var for split-horizon (hairpin NAT) URL resolution - Replace render-time signIn.social() with LoginPage component (fixes redirect loop) - Change auth-client baseURL to relative (empty string) for deployed environments - Add POST /api/portal/appointments/:id/reschedule endpoint with session auth - Add RescheduleFlow modal, PetForm component, and wire Dashboard/Appointments UI ## CTO Note Auth fix is P0-critical. Portal mock data (UAT blocker) predates this PR and is tracked separately in GRO-218. Co-Authored-By: Paperclip --- apps/api/src/index.ts | 15 +- apps/api/src/lib/auth.ts | 19 ++- apps/api/src/middleware/auth.ts | 6 + apps/api/src/middleware/rbac.ts | 6 + apps/api/src/routes/portal.ts | 101 +++++++++++- apps/web/src/App.tsx | 60 ++++++- apps/web/src/lib/auth-client.ts | 2 +- apps/web/src/portal/CustomerPortal.tsx | 20 ++- .../src/portal/sections/AccountSettings.tsx | 31 ++-- apps/web/src/portal/sections/Appointments.tsx | 148 +++++++++++++++++- apps/web/src/portal/sections/Dashboard.tsx | 21 +-- apps/web/src/portal/sections/PetForm.tsx | 87 ++++++++++ apps/web/src/portal/sections/PetProfiles.tsx | 17 +- 13 files changed, 480 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/portal/sections/PetForm.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d1820f5..251c112 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,6 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; -import { toNodeHandler } from "better-auth/node"; import { auth } from "./lib/auth.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; @@ -68,19 +67,17 @@ app.get("/api/branding", async (c) => { // Public iCal calendar feed — token auth in URL, no auth middleware required app.route("/api/calendar", calendarRouter); -// Better-Auth handler — public, handles OAuth callbacks, session management -// Mounted BEFORE auth middleware so it's accessible without authentication -app.on(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], "/api/auth/**", (c) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { incoming, outgoing } = c.env as any; - return toNodeHandler(auth)(incoming, outgoing); -}); - // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); api.use("*", resolveStaffMiddleware); +// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes +// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths +const authRouter = new Hono(); +authRouter.all("/*", (c) => auth.handler(c.req.raw)); +api.route("/auth", authRouter); + // ── Role guards ──────────────────────────────────────────────────────────────── // Manager-only: admin settings, reports, invoices, impersonation // Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 3dda63b..8467513 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -4,6 +4,7 @@ import { genericOAuth } from "better-auth/plugins"; import { getDb } from "@groombook/db"; const OIDC_ISSUER = process.env.OIDC_ISSUER; +const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID; const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; @@ -28,9 +29,21 @@ export const auth = betterAuth({ providerId: "authentik", clientId: OIDC_CLIENT_ID ?? "", clientSecret: OIDC_CLIENT_SECRET ?? "", - discoveryUrl: OIDC_ISSUER - ? `${OIDC_ISSUER}/.well-known/openid-configuration` - : undefined, + // When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT: + // - authorizationUrl: external (browser redirect, no server-side fetch) + // - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin) + // When not set, fall back to discoveryUrl for local dev. + ...(OIDC_INTERNAL_BASE + ? { + authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`, + tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`, + userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`, + } + : { + discoveryUrl: OIDC_ISSUER + ? `${OIDC_ISSUER}/.well-known/openid-configuration` + : undefined, + }), scopes: ["openid", "profile", "email"], }, ], diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 66ec3d4..dbdbb1f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -23,6 +23,12 @@ if (process.env.AUTH_DISABLED === "true") { } export const authMiddleware: MiddlewareHandler = async (c, next) => { + // Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt) + if (c.req.path.startsWith("/api/auth/")) { + await next(); + return; + } + if (process.env.AUTH_DISABLED === "true") { const devUserId = c.req.header("X-Dev-User-Id"); const sub = devUserId ?? "dev-user"; diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 1bc2228..78c46f2 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -22,6 +22,12 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( c, next ) => { + // Better-Auth's own routes handle their own auth — skip staff resolution + if (c.req.path.startsWith("/api/auth/")) { + await next(); + return; + } + const db = getDb(); if (process.env.AUTH_DISABLED === "true") { diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 7003a43..9335c5d 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; +import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); @@ -212,6 +212,105 @@ portalRouter.post("/appointments/:id/cancel", async (c) => { }); }); +// ─── Appointment reschedule ────────────────────────────────────────────────── + +const rescheduleSchema = z.object({ + startTime: z.string().datetime(), +}); + +portalRouter.post( + "/appointments/:id/reschedule", + zValidator("json", rescheduleSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422); + } + + const newStart = new Date(body.startTime); + const durationMs = appt.endTime.getTime() - appt.startTime.getTime(); + const newEnd = new Date(newStart.getTime() + durationMs); + + const [existingConflict] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, appt.staffId!), + lt(appointments.startTime, newEnd), + gt(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, id) + ) + ) + .limit(1); + + if (existingConflict) { + return c.json({ error: "The selected time slot is no longer available" }, 409); + } + + const [updated] = await db + .update(appointments) + .set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + startTime: updated.startTime, + endTime: updated.endTime, + status: updated.status, + updatedAt: updated.updatedAt, + }); + } +); + // ─── Client-facing waitlist routes ─────────────────────────────────────────── const createWaitlistEntrySchema = z.object({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8840370..e7a103d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -19,6 +19,61 @@ import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { GlobalSearch } from "./components/GlobalSearch.js"; import { useSession, signIn } from "./lib/auth-client.js"; +function LoginPage() { + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async () => { + setIsLoading(true); + await signIn.social({ provider: "authentik", callbackURL: window.location.origin }); + }; + + return ( +
+
+

GroomBook

+

+ Sign in to continue +

+ +
+
+ ); +} + const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, { to: "/admin/clients", label: "Clients" }, @@ -170,10 +225,9 @@ export function App() { return ; } - // Production mode: if no session, redirect to Authentik sign-in + // Production mode: if no session, show login page (avoids redirect loops) if (!authDisabled && !session) { - signIn.social({ provider: "authentik" }); - return null; + return ; } return ( diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 1a4587b..12ff8ed 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,7 +1,7 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3000", + baseURL: import.meta.env.VITE_API_URL ?? "", }); export const { signIn, signOut, useSession } = authClient; \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 65a17e3..575cd37 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -5,7 +5,7 @@ import { Settings, LogOut, Shield, } from "lucide-react"; import { Dashboard } from "./sections/Dashboard.js"; -import { AppointmentsSection } from "./sections/Appointments.js"; +import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js"; import { PetProfiles } from "./sections/PetProfiles.js"; import { ReportCards } from "./sections/ReportCards.js"; import { BillingPayments } from "./sections/BillingPayments.js"; @@ -33,6 +33,8 @@ export function CustomerPortal() { const [activeSection, setActiveSection] = useState
("dashboard"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); + const [showReschedule, setShowReschedule] = useState(false); + const [rescheduleAppointment, setRescheduleAppointment] = useState | null>(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); const { branding } = useBranding(); @@ -107,12 +109,17 @@ export function CustomerPortal() { } }; + const handleReschedule = useCallback((appointment: Record) => { + setRescheduleAppointment(appointment); + setShowReschedule(true); + }, []); + const isReadOnly = session?.status === "active"; const renderSection = () => { switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": return ; case "pets": @@ -158,6 +165,15 @@ export function CustomerPortal() { /> )} + {showReschedule && rescheduleAppointment && ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { setShowReschedule(false); setRescheduleAppointment(null); }} + sessionId={session?.id ?? null} + /> + )} + {/* Mobile Header */}
- @@ -145,9 +155,8 @@ function ManagePets({ readOnly }: { readOnly: boolean }) { ))} {!readOnly && ( @@ -376,6 +383,133 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint ); } +export function RescheduleFlow({ + appointment: appt, + onClose, + sessionId, +}: { + appointment: Appointment; + onClose: () => void; + sessionId?: string | null; +}) { + const [selectedDate, setSelectedDate] = useState(""); + const [selectedTime, setSelectedTime] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"]; + + async function handleSubmit() { + if (!selectedDate || !selectedTime) return; + + const [hoursMinutes = "", period = ""] = selectedTime.split(" "); + const [hoursStr = "0", minutesStr = "0"] = hoursMinutes.split(":"); + let hours = parseInt(hoursStr, 10); + const minutes = parseInt(minutesStr ?? "0", 10); + if (period === "PM" && hours !== 12) hours += 12; + if (period === "AM" && hours === 12) hours = 0; + const isoTime = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`; + const startTime = new Date(`${selectedDate}T${isoTime}`).toISOString(); + + setSubmitting(true); + setError(null); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (sessionId) headers["X-Impersonation-Session-Id"] = sessionId; + const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { + method: "POST", + headers, + body: JSON.stringify({ startTime }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to reschedule" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setSuccess(true); + setTimeout(() => { window.location.reload(); }, 1500); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to reschedule"); + setSubmitting(false); + } + } + + return ( +
+
+
+

Reschedule Appointment

+ +
+ +
+ {success ? ( +
+
+

Appointment Rescheduled!

+

Redirecting...

+
+ ) : ( + <> + {/* Current appointment summary */} +
+

{appt.petName} — {appt.services.join(", ")}

+

+ {formatDate(appt.date)} at {appt.time} with {appt.groomerName} +

+
+ +

Pick a New Date & Time

+ setSelectedDate(e.target.value)} + min={new Date().toISOString().split("T")[0]} + className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3" + /> + {selectedDate && ( +
+ {availableTimes.map(time => ( + + ))} +
+ )} + + {error &&

{error}

} + +
+ + +
+ + )} +
+
+
+ ); +} + function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) { const [step, setStep] = useState(1); const [selectedPet, setSelectedPet] = useState(null); diff --git a/apps/web/src/portal/sections/Dashboard.tsx b/apps/web/src/portal/sections/Dashboard.tsx index 289d1c7..baffebe 100644 --- a/apps/web/src/portal/sections/Dashboard.tsx +++ b/apps/web/src/portal/sections/Dashboard.tsx @@ -4,6 +4,8 @@ import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSI interface Props { onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void; readOnly: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onReschedule?: (appointment: any) => void; } function daysUntil(dateStr: string): number { @@ -18,7 +20,7 @@ function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } -export function Dashboard({ onNavigate, readOnly }: Props) { +export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) { const nextAppt = UPCOMING_APPOINTMENTS[0]; const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0); const recentEvents = [ @@ -78,24 +80,15 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {!readOnly && (
- -
diff --git a/apps/web/src/portal/sections/PetForm.tsx b/apps/web/src/portal/sections/PetForm.tsx new file mode 100644 index 0000000..626b042 --- /dev/null +++ b/apps/web/src/portal/sections/PetForm.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { X, Save } from "lucide-react"; +import type { Pet } from "../mockData.js"; + +interface Props { + pet?: Pet; + onSave: (pet: Pet) => void; + onCancel: () => void; +} + +export function PetForm({ pet, onSave, onCancel }: Props) { + const [name, setName] = useState(pet?.name ?? ""); + const [breed, setBreed] = useState(pet?.breed ?? ""); + const [weight, setWeight] = useState(pet?.weight ?? 0); + const [notes, setNotes] = useState(pet?.allergies ?? ""); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!pet) return; + onSave({ ...pet, name, breed, weight, allergies: notes }); + } + + return ( +
+
+

{pet ? "Edit Pet" : "Add Pet"}

+ +
+
+
+ + setName(e.target.value)} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setBreed(e.target.value)} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setWeight(Number(e.target.value))} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ +