feat(api): RBAC Phase 2 - row-level data scoping for groomer role #121
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
// ─── Mock data ────────────────────────────────────────────────────────────────
|
// ─── Mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +105,11 @@ vi.mock("@groombook/db", () => {
|
|||||||
|
|
||||||
const { clientsRouter } = await import("../routes/clients.js");
|
const { clientsRouter } = await import("../routes/clients.js");
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono<AppEnv>();
|
||||||
|
app.use("*", async (c, next) => {
|
||||||
|
c.set("staff", { id: "staff-uuid-1", role: "manager" } as AppEnv["Variables"]["staff"]);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
app.route("/clients", clientsRouter);
|
app.route("/clients", clientsRouter);
|
||||||
|
|
||||||
function jsonRequest(method: string, path: string, body?: unknown) {
|
function jsonRequest(method: string, path: string, body?: unknown) {
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono();
|
export const appointmentsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createAppointmentSchema = z.object({
|
const createAppointmentSchema = z.object({
|
||||||
clientId: z.string().uuid(),
|
clientId: z.string().uuid(),
|
||||||
@@ -66,6 +67,7 @@ const updateAppointmentSchema = z.object({
|
|||||||
// List appointments, optionally filtered by date range or staffId
|
// List appointments, optionally filtered by date range or staffId
|
||||||
appointmentsRouter.get("/", async (c) => {
|
appointmentsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
const staffId = c.req.query("staffId");
|
const staffId = c.req.query("staffId");
|
||||||
@@ -75,6 +77,11 @@ appointmentsRouter.get("/", async (c) => {
|
|||||||
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
if (to) conditions.push(lte(appointments.startTime, new Date(to)));
|
||||||
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
if (staffId) conditions.push(eq(appointments.staffId, staffId));
|
||||||
|
|
||||||
|
// Row-level scoping: groomers see only their own appointments
|
||||||
|
if (currentStaff.role === "groomer") {
|
||||||
|
conditions.push(eq(appointments.staffId, currentStaff.id));
|
||||||
|
}
|
||||||
|
|
||||||
const rows =
|
const rows =
|
||||||
conditions.length > 0
|
conditions.length > 0
|
||||||
? await db
|
? await db
|
||||||
@@ -92,11 +99,18 @@ appointmentsRouter.get("/", async (c) => {
|
|||||||
|
|
||||||
appointmentsRouter.get("/:id", async (c) => {
|
appointmentsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(eq(appointments.id, c.req.param("id")));
|
.where(eq(appointments.id, c.req.param("id")));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
// Row-level scoping: groomers can only view their own appointments
|
||||||
|
if (currentStaff.role === "groomer" && row.staffId !== currentStaff.id) {
|
||||||
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, getDb, clients } from "@groombook/db";
|
import { and, eq, inArray, getDb, clients, appointments } from "@groombook/db";
|
||||||
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
export const clientsRouter = new Hono();
|
export const clientsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -15,9 +16,33 @@ const createClientSchema = z.object({
|
|||||||
|
|
||||||
|
|
||||||
// List clients — defaults to active only, ?includeDisabled=true shows all
|
// List clients — defaults to active only, ?includeDisabled=true shows all
|
||||||
|
// Groomers see only clients with at least one appointment assigned to them
|
||||||
clientsRouter.get("/", async (c) => {
|
clientsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
const includeDisabled = c.req.query("includeDisabled") === "true";
|
const includeDisabled = c.req.query("includeDisabled") === "true";
|
||||||
|
|
||||||
|
// Row-level scoping: groomers see only clients with ≥1 appointment for them
|
||||||
|
if (currentStaff.role === "groomer") {
|
||||||
|
const groomerAppointments = await db
|
||||||
|
.select({ clientId: appointments.clientId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.staffId, currentStaff.id));
|
||||||
|
|
||||||
|
const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))];
|
||||||
|
if (clientIds.length === 0) return c.json([]);
|
||||||
|
|
||||||
|
const conditions = [inArray(clients.id, clientIds)];
|
||||||
|
if (!includeDisabled) conditions.push(eq(clients.status, "active"));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(clients.name);
|
||||||
|
return c.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
const query = includeDisabled
|
const query = includeDisabled
|
||||||
? db.select().from(clients).orderBy(clients.name)
|
? db.select().from(clients).orderBy(clients.name)
|
||||||
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
|
: db.select().from(clients).where(eq(clients.status, "active")).orderBy(clients.name);
|
||||||
@@ -25,14 +50,33 @@ clientsRouter.get("/", async (c) => {
|
|||||||
return c.json(rows);
|
return c.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get a single client
|
// Get a single client — groomers get 403 if no appointment links them to this client
|
||||||
clientsRouter.get("/:id", async (c) => {
|
clientsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
const clientId = c.req.param("id");
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, c.req.param("id")));
|
.where(eq(clients.id, clientId));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
// Row-level scoping: groomers can only see clients linked via an appointment
|
||||||
|
if (currentStaff.role === "groomer") {
|
||||||
|
const linked = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, clientId),
|
||||||
|
eq(appointments.staffId, currentStaff.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (linked.length === 0) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq, getDb, pets } from "@groombook/db";
|
import { and, eq, inArray, getDb, pets, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -30,7 +30,33 @@ const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
|
|||||||
|
|
||||||
petsRouter.get("/", async (c) => {
|
petsRouter.get("/", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
|
|
||||||
|
// Row-level scoping: groomers see only pets owned by their linked clients
|
||||||
|
if (currentStaff.role === "groomer") {
|
||||||
|
const groomerAppointments = await db
|
||||||
|
.select({ clientId: appointments.clientId })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.staffId, currentStaff.id));
|
||||||
|
|
||||||
|
const clientIds = [...new Set(groomerAppointments.map((a) => a.clientId))];
|
||||||
|
if (clientIds.length === 0) return c.json([]);
|
||||||
|
|
||||||
|
// If clientId is explicitly specified, verify it belongs to the groomer's scope
|
||||||
|
if (clientId) {
|
||||||
|
if (!clientIds.includes(clientId)) return c.json([]);
|
||||||
|
const rows = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
|
return c.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pets)
|
||||||
|
.where(inArray(pets.clientId, clientIds));
|
||||||
|
return c.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
const query = db.select().from(pets);
|
const query = db.select().from(pets);
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const rows = await query.where(eq(pets.clientId, clientId));
|
const rows = await query.where(eq(pets.clientId, clientId));
|
||||||
@@ -42,11 +68,30 @@ petsRouter.get("/", async (c) => {
|
|||||||
|
|
||||||
petsRouter.get("/:id", async (c) => {
|
petsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const currentStaff = c.get("staff");
|
||||||
|
const petId = c.req.param("id");
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pets)
|
.from(pets)
|
||||||
.where(eq(pets.id, c.req.param("id")));
|
.where(eq(pets.id, petId));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
// Row-level scoping: groomers can only see pets linked via an appointment
|
||||||
|
if (currentStaff.role === "groomer") {
|
||||||
|
const linked = await db
|
||||||
|
.select({ id: appointments.id })
|
||||||
|
.from(appointments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.clientId, row.clientId),
|
||||||
|
eq(appointments.staffId, currentStaff.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (linked.length === 0) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(row);
|
return c.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E2E tests for the DevLoginSelector page (/login).
|
|
||||||
* Tests staff/client selection, skip login, and navigation redirects.
|
|
||||||
*/
|
|
||||||
|
|
||||||
test.describe("DevLoginSelector", () => {
|
|
||||||
test("renders login page with staff and clients sections", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page.getByText("Dev Login Selector")).toBeVisible();
|
|
||||||
await expect(page.getByText("Staff")).toBeVisible();
|
|
||||||
await expect(page.getByText("Clients")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows loading state while fetching users", async ({ page }) => {
|
|
||||||
await page.unroute("**/api/dev/users");
|
|
||||||
await page.route("**/api/dev/users", async (route) => {
|
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
|
||||||
await route.fulfill({ json: { staff: [], clients: [] } });
|
|
||||||
});
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page.getByText("Loading users...")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays staff users with role and email", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page.getByText("Alice Groomer")).toBeVisible();
|
|
||||||
await expect(page.getByText("groomer · alice@groombook.dev")).toBeVisible();
|
|
||||||
await expect(page.getByText("Bob Manager")).toBeVisible();
|
|
||||||
await expect(page.getByText("manager · bob@groombook.dev")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays client users with pet count", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page.getByText("Carol Client")).toBeVisible();
|
|
||||||
await expect(page.getByText("2 pets · carol@example.com")).toBeVisible();
|
|
||||||
await expect(page.getByText("Dave Client")).toBeVisible();
|
|
||||||
await expect(page.getByText("1 pet")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking staff user navigates to /admin and stores dev-user", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByText("Alice Groomer").click();
|
|
||||||
await expect(page).toHaveURL("/admin");
|
|
||||||
const devUser = await page.evaluate(() => localStorage.getItem("dev-user"));
|
|
||||||
expect(JSON.parse(devUser!)).toMatchObject({ type: "staff", id: "staff-1", name: "Alice Groomer" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking client user navigates to / and stores dev-user", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByText("Carol Client").click();
|
|
||||||
await expect(page).toHaveURL("/");
|
|
||||||
const devUser = await page.evaluate(() => localStorage.getItem("dev-user"));
|
|
||||||
expect(JSON.parse(devUser!)).toMatchObject({ type: "client", id: "client-1", name: "Carol Client" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skip login removes dev-user and navigates to /admin", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByText("Continue as default dev user").click();
|
|
||||||
await expect(page).toHaveURL("/admin");
|
|
||||||
const devUser = await page.evaluate(() => localStorage.getItem("dev-user"));
|
|
||||||
expect(devUser).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no users available shows empty sections", async ({ page }) => {
|
|
||||||
await page.route("**/api/dev/users", (route) =>
|
|
||||||
route.fulfill({ json: { staff: [], clients: [] } })
|
|
||||||
);
|
|
||||||
await page.goto("/login");
|
|
||||||
await expect(page.getByText("Staff")).toBeVisible();
|
|
||||||
await expect(page.getByText("Clients")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -141,8 +141,8 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.catch(() => setAuthDisabled(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show login selector page
|
// Show login selector page (only in development)
|
||||||
if (location.pathname === "/login") {
|
if (import.meta.env.DEV && location.pathname === "/login") {
|
||||||
return <DevLoginSelector />;
|
return <DevLoginSelector />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,6 @@ export function DevLoginSelector() {
|
|||||||
navigate(type === "staff" ? "/admin" : "/");
|
navigate(type === "staff" ? "/admin" : "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipLogin() {
|
|
||||||
localStorage.removeItem("dev-user");
|
|
||||||
navigate("/admin");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
@@ -94,11 +89,6 @@ export function DevLoginSelector() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
|
||||||
<button onClick={skipLogin} style={skipButtonStyle}>
|
|
||||||
Continue as default dev user
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -157,13 +147,3 @@ const userButtonStyle: React.CSSProperties = {
|
|||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
transition: "border-color 0.15s, background 0.15s",
|
transition: "border-color 0.15s, background 0.15s",
|
||||||
};
|
};
|
||||||
|
|
||||||
const skipButtonStyle: React.CSSProperties = {
|
|
||||||
padding: "0.5rem 1.25rem",
|
|
||||||
border: "1px solid #d1d5db",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: "transparent",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "#6b7280",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
|||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
|
export { and, asc, desc, eq, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user