From 711981e6f34e38da20b38b1348f10b8491e9dc5a Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 14:30:25 +0000 Subject: [PATCH 1/2] fix(api): auto-link staff to Better-Auth user via email on first SSO login (GRO-480) When a staff record exists with a matching email but no userId (e.g. seed data or admin UI-created records), resolveStaffMiddleware now auto-links it to the Better-Auth user record on first SSO login instead of returning 403. Safety: only links when userId IS NULL, never overwrites an existing link. Email matching is safe since it comes from the trusted SSO provider (Authentik). Staff emails are unique by schema. Co-Authored-By: Paperclip --- apps/api/src/middleware/rbac.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index d5e764e..9075ee9 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from "hono"; -import { eq, getDb, staff } from "@groombook/db"; +import { isNull } from "drizzle-orm"; +import { and, eq, getDb, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -89,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .from(staff) .where(eq(staff.oidcSub, jwt.sub)); if (!fallbackRow) { + // Auto-link: staff record exists with matching email but no userId — link it now + if (jwt.email) { + const [linkedStaff] = await db + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), isNull(staff.userId))); + if (linkedStaff) { + await db + .update(staff) + .set({ userId: jwt.sub }) + .where(eq(staff.id, linkedStaff.id)); + console.log( + `[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}` + ); + c.set("staff", linkedStaff); + await next(); + return; + } + } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 From e39924b236afde31e0d9577871050f645964c20b Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Sun, 5 Apr 2026 14:39:22 +0000 Subject: [PATCH 2/2] fix(api): import isNull from @groombook/db instead of drizzle-orm directly drizzle-orm is not a direct dependency of @groombook/api, causing TS2307 at typecheck time. Re-export isNull from @groombook/db and update the import in rbac.ts. Co-Authored-By: Paperclip --- apps/api/src/middleware/rbac.ts | 3 +-- packages/db/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 9075ee9..1fab0cc 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { isNull } from "drizzle-orm"; -import { and, eq, getDb, staff } from "@groombook/db"; +import { and, eq, getDb, isNull, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9cd8c01..8b3b01f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,7 +4,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; export { encryptSecret, decryptSecret } from "./crypto.js"; -export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null;