Adds the Rolle help icon and grouped permissions modal for limited backoffice employees, plus i18n and Playwright coverage.
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { API_HOST, seedAuthenticatedState } from "./support/network.js";
|
|
import { isDesktopProject } from "./support/projects";
|
|
|
|
const json = (body: unknown, status = 200) => ({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const limitedManagerPermissions = [
|
|
"user",
|
|
"limited_backoffice_access",
|
|
"limited_backoffice_prices_manage",
|
|
"limited_backoffice_employees_manage",
|
|
"department_access_1",
|
|
];
|
|
|
|
const sessionData = {
|
|
id: 50,
|
|
customer_number: 12345,
|
|
group_id: 77,
|
|
email: "manager@example.com",
|
|
phone: {
|
|
number: "12345678",
|
|
country_code: 45,
|
|
},
|
|
notifications: {
|
|
wash_certificate_email: null,
|
|
email_notifications_enabled: true,
|
|
sms_notifications_enabled: false,
|
|
},
|
|
created_at: "2026-01-01T00:00:00.000Z",
|
|
updated_at: "2026-01-01T00:00:00.000Z",
|
|
display_name: "Limited Manager",
|
|
permissions: limitedManagerPermissions,
|
|
economic_customer: [],
|
|
runtime_config: {
|
|
economic: {
|
|
transaction_draft_customer_number: null,
|
|
default_distribution_department_id: null,
|
|
},
|
|
release: {},
|
|
},
|
|
};
|
|
|
|
const superuserSessionData = {
|
|
...sessionData,
|
|
id: 51,
|
|
customer_number: 12346,
|
|
email: "superuser@example.com",
|
|
display_name: "Superuser",
|
|
permissions: ["user", "superuser", "limited_backoffice_access", "department_access_1"],
|
|
};
|
|
|
|
const assignedDepartments = [{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false }];
|
|
|
|
const pricePayload = {
|
|
department: { id: 1, name: "Assigned Depot", description: "" },
|
|
categories: [
|
|
{
|
|
id: 10,
|
|
name: "Exterior",
|
|
description: "",
|
|
products: [
|
|
{
|
|
id: 101,
|
|
name: "Truck wash",
|
|
description: "Standard wash",
|
|
price: 125,
|
|
default_price: 8742,
|
|
},
|
|
{
|
|
id: 102,
|
|
name: "Trailer wash",
|
|
description: "",
|
|
price: 95,
|
|
default_price: 9842,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const rolePermissionGroups = {
|
|
viewer: [{ key: "account", capabilities: ["sign_in", "view_own_permissions"] }],
|
|
cashier: [
|
|
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
|
|
{
|
|
key: "orders",
|
|
capabilities: [
|
|
"view_orders",
|
|
"create_orders",
|
|
"edit_orders",
|
|
"view_order_items",
|
|
"create_order_items",
|
|
"update_order_lines",
|
|
"remove_order_lines",
|
|
"charge_orders",
|
|
],
|
|
},
|
|
],
|
|
booking_coordinator: [
|
|
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
|
|
{ key: "orders", capabilities: ["view_orders"] },
|
|
{
|
|
key: "bookings",
|
|
capabilities: [
|
|
"view_department_bookings",
|
|
"view_own_bookings",
|
|
"update_bookings",
|
|
"create_bookings",
|
|
"mark_bookings_complete",
|
|
"send_booking_confirmations",
|
|
],
|
|
},
|
|
{
|
|
key: "time_bookings",
|
|
capabilities: ["view_time_booking_entries", "create_time_booking_entries", "edit_time_booking_entries"],
|
|
},
|
|
],
|
|
operations_lead: [
|
|
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
|
|
{
|
|
key: "orders",
|
|
capabilities: [
|
|
"view_orders",
|
|
"create_orders",
|
|
"edit_orders",
|
|
"delete_orders",
|
|
"view_order_items",
|
|
"create_order_items",
|
|
"update_order_lines",
|
|
"remove_order_lines",
|
|
"charge_orders",
|
|
],
|
|
},
|
|
{
|
|
key: "bookings",
|
|
capabilities: [
|
|
"view_department_bookings",
|
|
"view_own_bookings",
|
|
"update_bookings",
|
|
"create_bookings",
|
|
"mark_bookings_complete",
|
|
"send_booking_confirmations",
|
|
],
|
|
},
|
|
{ key: "reports", capabilities: ["view_order_statistics", "view_booking_statistics"] },
|
|
],
|
|
department_admin: [
|
|
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
|
|
{
|
|
key: "orders",
|
|
capabilities: [
|
|
"view_orders",
|
|
"create_orders",
|
|
"edit_orders",
|
|
"delete_orders",
|
|
"view_order_items",
|
|
"create_order_items",
|
|
"update_order_lines",
|
|
"remove_order_lines",
|
|
"charge_orders",
|
|
],
|
|
},
|
|
{
|
|
key: "bookings",
|
|
capabilities: [
|
|
"view_department_bookings",
|
|
"view_own_bookings",
|
|
"update_bookings",
|
|
"create_bookings",
|
|
"mark_bookings_complete",
|
|
"send_booking_confirmations",
|
|
],
|
|
},
|
|
{ key: "reports", capabilities: ["view_order_statistics", "view_booking_statistics"] },
|
|
{
|
|
key: "limited_backoffice",
|
|
capabilities: ["open_limited_backoffice", "manage_department_prices", "manage_employee_access"],
|
|
},
|
|
],
|
|
};
|
|
|
|
const rolesPayload = [
|
|
{ key: "viewer", label: "Viewer", description: "Can view.", permission_groups: rolePermissionGroups.viewer },
|
|
{ key: "cashier", label: "Cashier", description: "Can sell.", permission_groups: rolePermissionGroups.cashier },
|
|
{
|
|
key: "booking_coordinator",
|
|
label: "Booking coordinator",
|
|
description: "Can coordinate.",
|
|
permission_groups: rolePermissionGroups.booking_coordinator,
|
|
},
|
|
{
|
|
key: "operations_lead",
|
|
label: "Operations lead",
|
|
description: "Can coordinate operations.",
|
|
permission_groups: rolePermissionGroups.operations_lead,
|
|
},
|
|
{
|
|
key: "department_admin",
|
|
label: "Department admin",
|
|
description: "Can administer.",
|
|
permission_groups: rolePermissionGroups.department_admin,
|
|
},
|
|
{ key: "superuser", label: "Superuser", description: "Must not render." },
|
|
];
|
|
|
|
const employeesPayload = [
|
|
{
|
|
id: 501,
|
|
customer_number: 900000501,
|
|
display_name: "Casey Clerk",
|
|
email: "casey@example.com",
|
|
active: true,
|
|
role: { key: "cashier", label: "Cashier", description: "Can sell." },
|
|
departments: [{ id: 1, name: "Assigned Depot" }],
|
|
created_at: "2026-01-01 00:00:00",
|
|
updated_at: "2026-01-01 00:00:00",
|
|
},
|
|
];
|
|
|
|
async function seedLimitedBackofficeSession(page, token = "limited-backoffice-token") {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("locale", "en");
|
|
});
|
|
await seedAuthenticatedState(page, token);
|
|
}
|
|
|
|
async function mockLimitedBackofficeApi(page, authSessionData = sessionData) {
|
|
const calls: string[] = [];
|
|
const forbiddenCalls: string[] = [];
|
|
const priceUpdateCalls: unknown[] = [];
|
|
|
|
await page.route(API_HOST, async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const pathname = url.pathname;
|
|
const method = request.method();
|
|
calls.push(`${method} ${pathname}`);
|
|
|
|
if (pathname.endsWith("/products") || pathname.endsWith("/superuser/department/prices")) {
|
|
forbiddenCalls.push(`${method} ${pathname}`);
|
|
await route.fulfill(json({ message: "Forbidden endpoint called" }, 599));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/auth/session") && method === "GET") {
|
|
await route.fulfill(json({ data: authSessionData }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/ping") && method === "GET") {
|
|
await route.fulfill(json({ data: { ok: true } }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/worker/version") && method === "GET") {
|
|
await route.fulfill(json({ data: { version: "limited-backoffice-test" } }));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(pathname.endsWith("/auth/recaptcha/pre-check") || pathname.endsWith("/auth/reCAPTCHA/public")) &&
|
|
method === "GET"
|
|
) {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
recaptcha: { enabled: false, site_key: "" },
|
|
rate_limit: { enabled: false, limit: 0, remaining: 0, reset: 0, warning: null },
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/limited-backoffice/departments") && method === "GET") {
|
|
await route.fulfill(json({ data: assignedDepartments }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "GET") {
|
|
await route.fulfill(json({ data: pricePayload }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
|
|
priceUpdateCalls.push(request.postDataJSON?.() || null);
|
|
await route.fulfill(json({ data: pricePayload }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/limited-backoffice/roles") && method === "GET") {
|
|
await route.fulfill(json({ data: rolesPayload }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") {
|
|
await route.fulfill(json({ data: employeesPayload }));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: [] }));
|
|
});
|
|
|
|
return {
|
|
calls,
|
|
forbiddenCalls,
|
|
priceUpdateCalls,
|
|
};
|
|
}
|
|
|
|
test.describe("Limited backoffice", () => {
|
|
test("shows the limited backoffice desktop nav item without an icon for limited managers", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
|
|
|
await seedLimitedBackofficeSession(page);
|
|
await mockLimitedBackofficeApi(page);
|
|
|
|
await page.goto("/backoffice/departments/1/prices");
|
|
|
|
const backofficeNavItem = page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible');
|
|
|
|
await expect(backofficeNavItem).toHaveCount(1);
|
|
await expect(backofficeNavItem.locator(".icon, i, svg")).toHaveCount(0);
|
|
});
|
|
|
|
test("hides the limited backoffice desktop nav item for superusers", async ({ page }, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
|
|
|
await seedLimitedBackofficeSession(page, "limited-backoffice-superuser-token");
|
|
await mockLimitedBackofficeApi(page, superuserSessionData);
|
|
|
|
await page.goto("/backoffice/departments/1/prices");
|
|
|
|
await expect(page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible')).toHaveCount(0);
|
|
});
|
|
|
|
test("limits price management to assigned departments and explicit prices", async ({ page }, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
|
|
|
await seedLimitedBackofficeSession(page);
|
|
const api = await mockLimitedBackofficeApi(page);
|
|
|
|
await page.goto("/backoffice/departments/1/prices");
|
|
|
|
await expect(page.getByTestId("limited-prices-title")).toBeVisible();
|
|
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Assigned Depot");
|
|
await expect(page.getByTestId("limited-backoffice-department-select")).not.toContainText("Other Depot");
|
|
await expect(page.getByTestId("limited-price-row-101")).toContainText("Truck wash");
|
|
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
|
|
|
|
await expect(page.locator("body")).not.toContainText("8742");
|
|
await expect(page.locator("body")).not.toContainText(/default price/i);
|
|
await expect(page.locator("body")).not.toContainText("department_access_1");
|
|
await expect(page.locator("body")).not.toContainText("limited_backoffice_prices_manage");
|
|
|
|
await expect(page.getByTestId("limited-prices-save")).toBeEnabled();
|
|
await page.getByTestId("limited-price-input-101").fill("");
|
|
await expect(page.getByTestId("limited-prices-save")).toBeDisabled();
|
|
await expect(page.getByTestId("limited-price-validation")).toBeVisible();
|
|
|
|
expect(api.priceUpdateCalls).toEqual([]);
|
|
expect(api.forbiddenCalls).toEqual([]);
|
|
});
|
|
|
|
test("shows only limited role presets in employee access", async ({ page }, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
|
|
|
await seedLimitedBackofficeSession(page);
|
|
const api = await mockLimitedBackofficeApi(page);
|
|
|
|
await page.goto("/backoffice/employees");
|
|
|
|
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
|
|
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
|
|
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
|
|
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
|
|
await expect(page.locator("body")).not.toContainText("department_access_1");
|
|
await expect(page.locator("body")).not.toContainText("limited_backoffice_employees_manage");
|
|
await expect(page.locator("body")).not.toContainText("raw_permissions");
|
|
|
|
expect(api.forbiddenCalls).toEqual([]);
|
|
});
|
|
|
|
test("shows grouped human-readable role permission help without raw permission keys", async ({ page }, testInfo) => {
|
|
await seedLimitedBackofficeSession(page);
|
|
await mockLimitedBackofficeApi(page);
|
|
|
|
await page.goto("/backoffice/employees");
|
|
|
|
const helpButton = page.getByTestId("limited-employee-role-help");
|
|
await expect(helpButton).toBeVisible();
|
|
await expect(helpButton).toHaveAttribute("title", "View role permissions");
|
|
|
|
if (isDesktopProject(testInfo)) {
|
|
await helpButton.hover();
|
|
await expect(page.locator(".tooltip-content").filter({ hasText: "View role permissions" })).toBeVisible();
|
|
}
|
|
|
|
await helpButton.click();
|
|
|
|
const modal = page.getByTestId("limited-role-permissions-modal");
|
|
await expect(modal).toBeVisible();
|
|
await expect(modal).toContainText("Role permissions");
|
|
await expect(modal).toContainText("Every capability is limited to the departments selected for the employee.");
|
|
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Cashier");
|
|
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Orders");
|
|
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View orders");
|
|
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create order lines");
|
|
await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Selected");
|
|
|
|
for (const rawPermission of [
|
|
"list_orders",
|
|
"add_order",
|
|
"edit_order_items",
|
|
"delete_order_items",
|
|
"department_timebookings_entries_get",
|
|
"limited_backoffice_employees_manage",
|
|
"department_access_1",
|
|
"raw_permissions",
|
|
]) {
|
|
await expect(page.locator("body")).not.toContainText(rawPermission);
|
|
}
|
|
|
|
await page.getByTestId("limited-role-permissions-close-footer").click();
|
|
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
|
|
|
|
await helpButton.click();
|
|
await expect(page.getByTestId("limited-role-permissions-modal")).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
|
|
});
|
|
|
|
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
|
|
|
await seedLimitedBackofficeSession(page);
|
|
const api = await mockLimitedBackofficeApi(page);
|
|
|
|
await page.goto("/backoffice/departments/2/prices");
|
|
|
|
await expect(page.getByTestId("limited-prices-forbidden")).toBeVisible();
|
|
await expect(page.locator("body")).not.toContainText("Secret Depot");
|
|
await expect(page.getByTestId("limited-prices-table")).toHaveCount(0);
|
|
|
|
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/2/prices"))).toBe(false);
|
|
expect(api.forbiddenCalls).toEqual([]);
|
|
});
|
|
});
|