diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 8b4009bf..e38e59fe 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -86,6 +86,7 @@ export const API_PATHS = { getList: "User", create: "team-members", detail: "team-members", + myPermissions: "team-members/me/permissions", }, contact: { getList: "Contact/GetContactList", diff --git a/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts index 98534285..b835e35a 100644 --- a/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts @@ -1,8 +1,4 @@ -import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions"; -import type { TeamMemberRole } from "@/domain/team-members/types/team-member"; -import { useAuthContext } from "@/providers/auth-context"; - -const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"]; +import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions"; export interface CompletionTemplatePermissions { canCreate: boolean; @@ -10,27 +6,12 @@ export interface CompletionTemplatePermissions { canDelete: boolean; } -function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] { - const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase()); - return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase())); -} - -/** - * The signed-in user's session carries roles, not per-user permission overrides, so each key - * resolves to the role default the Team Members screen and the server policy start from. - */ -export function completionTemplatePermissions( - userRoles: string | null | undefined, -): CompletionTemplatePermissions { - const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)])); +/** Template actions follow the signed-in user's effective permissions, never their role alone. */ +export function useCompletionTemplatePermissions(): CompletionTemplatePermissions { + const permissions = usePermissions(); return { - canCreate: granted.has("createCompletionDocTemplates"), - canEdit: granted.has("editCompletionDocTemplates"), - canDelete: granted.has("deleteCompletionDocTemplates"), + canCreate: hasPermission(permissions, "createCompletionDocTemplates"), + canEdit: hasPermission(permissions, "editCompletionDocTemplates"), + canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"), }; } - -export function useCompletionTemplatePermissions(): CompletionTemplatePermissions { - const { user } = useAuthContext(); - return completionTemplatePermissions(user?.userRoles); -} diff --git a/src/app/(protected)/team-members/_components/team-member-permissions.ts b/src/app/(protected)/team-members/_components/team-member-permissions.ts index bd1097f5..1a746caf 100644 --- a/src/app/(protected)/team-members/_components/team-member-permissions.ts +++ b/src/app/(protected)/team-members/_components/team-member-permissions.ts @@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record = { }; export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number]; const DISPATCHER_DEFAULTS = new Set([ "createVendors", "editVendors", diff --git a/src/domain/auth/api/permissions-api.ts b/src/domain/auth/api/permissions-api.ts new file mode 100644 index 00000000..292fce4c --- /dev/null +++ b/src/domain/auth/api/permissions-api.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; + +export const UNEXPECTED_PERMISSIONS_RESPONSE = + "The server sent an unexpected permissions response."; + +const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) }); + +/** Keys the signed-in user holds after role defaults and their individual overrides. */ +export async function getMyPermissions(): Promise { + const data = await apiGet(API_PATHS.teamMember.myPermissions); + const result = effectivePermissionsSchema.safeParse(handleApiResponse(data)); + if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE); + return result.data.permissions; +} diff --git a/src/domain/auth/use-cases/use-permissions.ts b/src/domain/auth/use-cases/use-permissions.ts new file mode 100644 index 00000000..dab5649c --- /dev/null +++ b/src/domain/auth/use-cases/use-permissions.ts @@ -0,0 +1,32 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions"; +import { getMyPermissions } from "@/domain/auth/api/permissions-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { useAuthContext } from "@/providers/auth-context"; + +/** + * The signed-in user's effective permissions as the server computes them. Keyed by user so a + * different sign-in never reads another person's answer, and refetched on every mount and window + * focus so an override change applies on the next page load. + */ +export function usePermissions(): UseQueryResult { + const { user } = useAuthContext(); + const userId = user?.id ?? null; + return useQuery({ + queryKey: queryKeys.auth.permissions(userId ?? ""), + queryFn: getMyPermissions, + enabled: userId !== null, + staleTime: 0, + refetchOnWindowFocus: true, + meta: { suppressErrorToast: true }, + }); +} + +/** Holds only for a successful server answer that lists the key; loading and errors deny. */ +export function hasPermission(query: UseQueryResult, key: PermissionKey): boolean { + return !query.isError && query.data?.includes(key) === true; +} + +export function useHasPermission(key: PermissionKey): boolean { + return hasPermission(usePermissions(), key); +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 3368da22..8594e055 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -10,6 +10,8 @@ export const queryKeys = { auth: { all: ["auth"] as const, session: () => [...queryKeys.auth.all, "session"] as const, + permissions: (userId: string | number) => + [...queryKeys.auth.all, "permissions", userId] as const, }, workOrders: { all: ["workOrders"] as const, diff --git a/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts index 15e7909c..94f4ac7f 100644 --- a/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts +++ b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts @@ -60,13 +60,39 @@ function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) { }); } -/** Answers the template endpoints the way the backend contract describes. */ +export const MY_PERMISSIONS_URL = "team-members/me/permissions"; + +export const TEMPLATE_PERMISSIONS = [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", +]; + +/** What the effective-permissions endpoint does: answer with keys, fail, or never settle. */ +export type PermissionsAnswer = string[] | Error | "pending"; + +export const permissionsAnswer: { current: PermissionsAnswer } = { + current: TEMPLATE_PERMISSIONS, +}; + +function answerPermissions(): Promise { + const answer = permissionsAnswer.current; + if (answer === "pending") return new Promise(() => undefined); + if (answer instanceof Error) return Promise.reject(answer); + return Promise.resolve({ permissions: answer }); +} + +/** + * Answers the template endpoints the way the backend contract describes, and the signed-in + * user's effective permissions with `permissionsAnswer.current`. + */ export function serveTemplates( api: ApiMocks, templates: CompletionTemplateDto[], linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] }, ) { api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => { + if (url === MY_PERMISSIONS_URL) return answerPermissions(); if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked); const term = options?.searchParams?.search?.toLowerCase() ?? ""; return Promise.resolve( diff --git a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx index 30f1070c..25580ff9 100644 --- a/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -1,9 +1,18 @@ -import { screen, waitFor, within } from "@testing-library/react"; +import { focusManager, QueryClient } from "@tanstack/react-query"; +import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; import { renderWithProviders } from "@/test/test-utils"; -import { BACKFLOW, SCRUBBER, serveTemplates, templateDto } from "./completion-docs-fixtures"; +import { + BACKFLOW, + MY_PERMISSIONS_URL, + permissionsAnswer, + SCRUBBER, + serveTemplates, + TEMPLATE_PERMISSIONS, + templateDto, +} from "./completion-docs-fixtures"; const api = vi.hoisted(() => ({ apiGet: vi.fn(), @@ -11,7 +20,7 @@ const api = vi.hoisted(() => ({ apiPut: vi.fn(), apiDeleteNoContent: vi.fn(), })); -const auth = vi.hoisted(() => ({ userRoles: "Admin" })); +const auth = vi.hoisted(() => ({ id: "u1", userRoles: "Admin" })); vi.mock("@/api/api", async (importOriginal) => ({ ...(await importOriginal()), @@ -20,7 +29,7 @@ vi.mock("@/api/api", async (importOriginal) => ({ vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), - useAuthContext: () => ({ user: { id: "u1", userRoles: auth.userRoles } }), + useAuthContext: () => ({ user: { id: auth.id, userRoles: auth.userRoles } }), })); // delay: null skips per-keystroke timer yields that only slow typing-heavy tests down. @@ -38,12 +47,24 @@ async function openTemplate(name: string) { return screen.findByRole("dialog"); } +/** Lets the effective-permissions request finish (or stay pending) before asserting absence. */ +async function permissionsRequested() { + await waitFor(() => expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL)); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); +} + beforeEach(() => { user = userEvent.setup({ delay: null }); + auth.id = "u1"; auth.userRoles = "Admin"; + permissionsAnswer.current = TEMPLATE_PERMISSIONS; serveTemplates(api, [BACKFLOW, SCRUBBER]); }); +afterEach(() => { + focusManager.setFocused(undefined); +}); + describe("Completion Docs page", { timeout: 15_000 }, () => { it("lists every template with its Document Title, Last Updated and Created By", async () => { renderPage(); @@ -127,41 +148,150 @@ describe("Completion Docs page", { timeout: 15_000 }, () => { expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument(); expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument(); }); - - it("gives an Admin every template action", async () => { - renderPage(); - const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); - - expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument(); - expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); - const dialog = await openTemplate("Backflow Test Report"); - expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); - expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument(); - }); - - it("hides delete from a Scheduler, who may still add and edit", async () => { - auth.userRoles = "Scheduler"; - renderPage(); - const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); - - expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument(); - expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); - const dialog = await openTemplate("Backflow Test Report"); - expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); - expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); - }); - - it("hides add, edit and delete entirely from a user without those permissions", async () => { - auth.userRoles = "Dispatcher"; - serveTemplates(api, [templateDto()]); - renderPage(); - const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); - - expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); - expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); - expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument(); - const dialog = await openTemplate("Backflow Test Report"); - expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); - expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); - }); }); + +describe( + "Completion Docs actions follow the server's effective permissions", + { timeout: 15_000 }, + () => { + async function expectEveryAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + } + + async function expectNoAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + } + + it("gives every template action to a user the server grants all three", async () => { + renderPage(); + + await expectEveryAction(); + expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL); + }); + + it("shows every action to a Dispatcher whose overrides grant them", async () => { + auth.userRoles = "Dispatcher"; + renderPage(); + + await expectEveryAction(); + }); + + it("hides Delete when the effective permissions omit it, even for an Admin", async () => { + permissionsAnswer.current = ["createCompletionDocTemplates", "editCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); + + it("hides Edit from a Scheduler whose edit permission was revoked", async () => { + auth.userRoles = "Scheduler"; + permissionsAnswer.current = ["createCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + const dialog = await openTemplate("Backflow Test Report"); + expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument(); + }); + + it("hides add, edit and delete entirely from a user without those permissions", async () => { + auth.userRoles = "Dispatcher"; + permissionsAnswer.current = ["createWorkOrders"]; + serveTemplates(api, [templateDto()]); + renderPage(); + + await expectNoAction(); + }); + + it("hides every action while the permissions are still loading", async () => { + permissionsAnswer.current = "pending"; + renderPage(); + + await expectNoAction(); + }); + + it("hides every action when the permissions fail to load, and still lists templates", async () => { + permissionsAnswer.current = new Error("Network down"); + renderPage(); + + expect(await screen.findByRole("row", { name: /Scrubber Pit Augering/ })).toBeInTheDocument(); + await expectNoAction(); + }); + + it("hides the actions again when a later permissions refresh fails", async () => { + renderPage(); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + + permissionsAnswer.current = new Error("Network down"); + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + await waitFor(() => + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(), + ); + }); + + it("picks up a changed override when the window regains focus", async () => { + permissionsAnswer.current = []; + renderPage(); + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + + permissionsAnswer.current = ["createCompletionDocTemplates"]; + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + }); + + it("never reuses one signed-in user's permissions for the next", async () => { + // Keeps the first answer cached after unmount, so only the per-user key can keep it out. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const first = renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + first.unmount(); + + auth.id = "u2"; + permissionsAnswer.current = "pending"; + renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + }); + }, +); diff --git a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx index 87f0276b..8d45ebcf 100644 --- a/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx +++ b/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx @@ -42,7 +42,7 @@ function renderPage() { async function openNewTemplate() { renderPage(); await screen.findByRole("row", { name: /Backflow Test Report/ }); - await user.click(screen.getByRole("button", { name: /Add Template/ })); + await user.click(await screen.findByRole("button", { name: /Add Template/ })); return screen.findByRole("dialog"); } diff --git a/src/test/domain/completion-templates/completion-template-mapper.test.ts b/src/test/domain/completion-templates/completion-template-mapper.test.ts index 0b7209f7..b1c9966a 100644 --- a/src/test/domain/completion-templates/completion-template-mapper.test.ts +++ b/src/test/domain/completion-templates/completion-template-mapper.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions"; import { mapCompletionTemplate, toCompletionTemplateInput, @@ -52,16 +51,3 @@ describe("toCompletionTemplateInput", () => { }); }); }); - -describe("completionTemplatePermissions", () => { - it.each([ - ["Admin", { canCreate: true, canEdit: true, canDelete: true }], - ["Scheduler", { canCreate: true, canEdit: true, canDelete: false }], - ["Dispatcher", { canCreate: false, canEdit: false, canDelete: false }], - ["dispatcher, admin", { canCreate: true, canEdit: true, canDelete: true }], - ["Vendor", { canCreate: false, canEdit: false, canDelete: false }], - [undefined, { canCreate: false, canEdit: false, canDelete: false }], - ])("resolves %s to the role defaults", (roles, expected) => { - expect(completionTemplatePermissions(roles)).toEqual(expected); - }); -});