mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
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:
parent
fac0d1885c
commit
d5cd0659a2
10 changed files with 260 additions and 84 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
17
src/domain/auth/api/permissions-api.ts
Normal file
17
src/domain/auth/api/permissions-api.ts
Normal 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;
|
||||
}
|
||||
32
src/domain/auth/use-cases/use-permissions.ts
Normal file
32
src/domain/auth/use-cases/use-permissions.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue