diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index fc9f2e2..bb68b23 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -90,6 +90,12 @@ export async function initAuth(): Promise { database: drizzleAdapter(getDb(), { provider: "pg" }), secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod", baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 10, + window: 60, + storage: "database", + }, plugins: [ genericOAuth({ config: [ @@ -177,6 +183,12 @@ export async function initAuth(): Promise { }), secret: BETTER_AUTH_SECRET, baseURL: BETTER_AUTH_URL, + rateLimit: { + enabled: true, + max: 10, + window: 60, + storage: "database", + }, account: { storeStateStrategy: "cookie" as const, }, diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 786fdbc..b8473e8 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { and, eq, getDb, isNull, staff } from "@groombook/db"; +import { eq, getDb, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -90,25 +90,6 @@ 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 diff --git a/apps/api/src/routes/staff.ts b/apps/api/src/routes/staff.ts index bf5c8c2..813bd62 100644 --- a/apps/api/src/routes/staff.ts +++ b/apps/api/src/routes/staff.ts @@ -18,6 +18,10 @@ const createStaffSchema = z.object({ const updateStaffSchema = createStaffSchema.partial().omit({ email: true }); +const linkUserSchema = z.object({ + userId: z.string().min(1), +}); + staffRouter.get("/me", async (c) => { const staffRow = c.get("staff"); return c.json(staffRow); @@ -106,6 +110,32 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => { return c.json(row); }); +staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => { + const db = getDb(); + const targetId = c.req.param("id"); + const body = c.req.valid("json"); + const currentStaff = c.get("staff"); + + if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) { + return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403); + } + + const [existing] = await db + .select() + .from(staff) + .where(eq(staff.id, targetId)) + .limit(1); + if (!existing) return c.json({ error: "Not found" }, 404); + + const [updated] = await db + .update(staff) + .set({ userId: body.userId, updatedAt: new Date() }) + .where(eq(staff.id, targetId)) + .returning(); + + return c.json(updated); +}); + staffRouter.delete("/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bf34c03..11a436c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom"; +import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; @@ -18,7 +18,7 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { GlobalSearch } from "./components/GlobalSearch.js"; -import { useSession, signIn } from "./lib/auth-client.js"; +import { useSession, signIn, signOut } from "./lib/auth-client.js"; function LoginPage() { const [isLoading, setIsLoading] = useState(false); @@ -181,6 +181,7 @@ const NAV_LINKS = [ function AdminLayout() { const location = useLocation(); + const navigate = useNavigate(); const { branding } = useBranding(); const logoSrc = branding.logoBase64 && branding.logoMimeType @@ -261,6 +262,25 @@ function AdminLayout() { ); })} +