mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +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",
|
getList: "User",
|
||||||
create: "team-members",
|
create: "team-members",
|
||||||
detail: "team-members",
|
detail: "team-members",
|
||||||
|
myPermissions: "team-members/me/permissions",
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
getList: "Contact/GetContactList",
|
getList: "Contact/GetContactList",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
|
import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-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"];
|
|
||||||
|
|
||||||
export interface CompletionTemplatePermissions {
|
export interface CompletionTemplatePermissions {
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
|
|
@ -10,27 +6,12 @@ export interface CompletionTemplatePermissions {
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
|
/** Template actions follow the signed-in user's effective permissions, never their role alone. */
|
||||||
const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
|
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
|
||||||
return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
|
const permissions = usePermissions();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)]));
|
|
||||||
return {
|
return {
|
||||||
canCreate: granted.has("createCompletionDocTemplates"),
|
canCreate: hasPermission(permissions, "createCompletionDocTemplates"),
|
||||||
canEdit: granted.has("editCompletionDocTemplates"),
|
canEdit: hasPermission(permissions, "editCompletionDocTemplates"),
|
||||||
canDelete: granted.has("deleteCompletionDocTemplates"),
|
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 const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys);
|
||||||
|
export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number];
|
||||||
const DISPATCHER_DEFAULTS = new Set([
|
const DISPATCHER_DEFAULTS = new Set([
|
||||||
"createVendors",
|
"createVendors",
|
||||||
"editVendors",
|
"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: {
|
auth: {
|
||||||
all: ["auth"] as const,
|
all: ["auth"] as const,
|
||||||
session: () => [...queryKeys.auth.all, "session"] as const,
|
session: () => [...queryKeys.auth.all, "session"] as const,
|
||||||
|
permissions: (userId: string | number) =>
|
||||||
|
[...queryKeys.auth.all, "permissions", userId] as const,
|
||||||
},
|
},
|
||||||
workOrders: {
|
workOrders: {
|
||||||
all: ["workOrders"] as const,
|
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(
|
export function serveTemplates(
|
||||||
api: ApiMocks,
|
api: ApiMocks,
|
||||||
templates: CompletionTemplateDto[],
|
templates: CompletionTemplateDto[],
|
||||||
linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] },
|
linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] },
|
||||||
) {
|
) {
|
||||||
api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => {
|
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);
|
if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked);
|
||||||
const term = options?.searchParams?.search?.toLowerCase() ?? "";
|
const term = options?.searchParams?.search?.toLowerCase() ?? "";
|
||||||
return Promise.resolve(
|
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 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 CompletionDocsPage from "@/app/(protected)/completion-docs/index";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
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(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
apiGet: vi.fn(),
|
apiGet: vi.fn(),
|
||||||
|
|
@ -11,7 +20,7 @@ const api = vi.hoisted(() => ({
|
||||||
apiPut: vi.fn(),
|
apiPut: vi.fn(),
|
||||||
apiDeleteNoContent: 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) => ({
|
vi.mock("@/api/api", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/api/api")>()),
|
...(await importOriginal<typeof import("@/api/api")>()),
|
||||||
|
|
@ -20,7 +29,7 @@ vi.mock("@/api/api", async (importOriginal) => ({
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
...(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.
|
// 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");
|
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(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup({ delay: null });
|
user = userEvent.setup({ delay: null });
|
||||||
|
auth.id = "u1";
|
||||||
auth.userRoles = "Admin";
|
auth.userRoles = "Admin";
|
||||||
|
permissionsAnswer.current = TEMPLATE_PERMISSIONS;
|
||||||
serveTemplates(api, [BACKFLOW, SCRUBBER]);
|
serveTemplates(api, [BACKFLOW, SCRUBBER]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
focusManager.setFocused(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Completion Docs page", { timeout: 15_000 }, () => {
|
describe("Completion Docs page", { timeout: 15_000 }, () => {
|
||||||
it("lists every template with its Document Title, Last Updated and Created By", async () => {
|
it("lists every template with its Document Title, Last Updated and Created By", async () => {
|
||||||
renderPage();
|
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 defined yet")).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText("No procedure added 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() {
|
async function openNewTemplate() {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByRole("row", { name: /Backflow Test Report/ });
|
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");
|
return screen.findByRole("dialog");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions";
|
|
||||||
import {
|
import {
|
||||||
mapCompletionTemplate,
|
mapCompletionTemplate,
|
||||||
toCompletionTemplateInput,
|
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