fix(completion-docs): gate template actions on the user's effective permissions

Add Template, Edit and Delete now follow the permissions the server
computes for the signed-in user, role defaults plus their individual
overrides, instead of role defaults alone. The actions stay hidden while
the permissions load or when they fail to load.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 12:34:01 -03:00
parent fac0d1885c
commit d5cd0659a2
10 changed files with 260 additions and 84 deletions

View file

@ -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",

View file

@ -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);
}

View file

@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record<string, string> = {
};
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",

View file

@ -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<string[]> {
const data = await apiGet<unknown>(API_PATHS.teamMember.myPermissions);
const result = effectivePermissionsSchema.safeParse(handleApiResponse<unknown>(data));
if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE);
return result.data.permissions;
}

View file

@ -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<string[], Error> {
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<string[], Error>, key: PermissionKey): boolean {
return !query.isError && query.data?.includes(key) === true;
}
export function useHasPermission(key: PermissionKey): boolean {
return hasPermission(usePermissions(), key);
}

View file

@ -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,

View file

@ -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<unknown> {
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(

View file

@ -1,9 +1,18 @@
import { screen, 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<typeof import("@/api/api")>()),
@ -20,7 +29,7 @@ vi.mock("@/api/api", async (importOriginal) => ({
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
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();
@ -112,41 +133,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(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
queryClient,
});
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
first.unmount();
auth.id = "u2";
permissionsAnswer.current = "pending";
renderWithProviders(<CompletionDocsPage />, {
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();
});
},
);

View file

@ -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");
}

View file

@ -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);
});
});