feat(policies): org-wide policies with admin-only editing (#6625)

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-06-11 21:23:06 +01:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 47e5977a31
commit ef65e6b015
7 changed files with 160 additions and 110 deletions
@@ -0,0 +1,65 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Policy editing is admin-only (mirrors the backend's enforcement on the
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
* isAdmin`, surfaced through `usePolicies()`:
* - login enabled + non-admin → read-only: the setup wizard shows the
* "Managed by your organization" locked state instead of the steps.
* - login enabled + admin → full setup flow ("Step 1 of 2").
* - login disabled (single-user/desktop) → open to the local operator.
*
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
* it's the one we open.
*/
const LOCKED_TITLE = "Managed by your organization";
const LOCKED_DESC = "Contact an admin to change this policy.";
/** Open the Security policy from the right-sidebar Policies list. */
async function openSecurityPolicy(page: import("@playwright/test").Page) {
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
await expect(row).toBeVisible({ timeout: 15_000 });
await row.click();
// The wizard header confirms we opened the right policy in either state.
await expect(page.getByText("Set up Security Policy")).toBeVisible();
}
test.describe("Policy editing gate — non-admin (login on)", () => {
test.use({
stubOptions: { enableLogin: true, isAdmin: false },
seedJwt: true,
});
test("non-admin gets the read-only locked state", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
// The editable flow must NOT be reachable.
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
});
});
test.describe("Policy editing gate — admin (login on)", () => {
test.use({ stubOptions: { enableLogin: true, isAdmin: true }, seedJwt: true });
test("admin can reach the setup wizard", async ({ page }) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
test.describe("Policy editing gate — single-user (login off)", () => {
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
test("local operator can reach the setup wizard with no admin role", async ({
page,
}) => {
await openSecurityPolicy(page);
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
});
});
@@ -249,7 +249,7 @@ export function PolicyDetailPanel({
<Banner
tone="neutral"
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
description="Managed by your organization. Contact an admin to change settings."
description="Managed by your organization. Contact an admin to change this policy."
/>
)}
</div>
@@ -7,6 +7,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
loadPolicies,
onPoliciesChange,
@@ -63,6 +64,7 @@ function toStoreRequest(
export function usePolicies() {
const [policies, setPolicies] = useState<PoliciesByCategory>(loadPolicies);
const { config } = useAppConfig();
useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []);
@@ -291,9 +293,17 @@ export function usePolicies() {
return folder.id;
}, []);
// Configuration is open to signed-in users; real per-org gating is a backend
// concern (the mock owner/admin/member permission model has been removed).
const canConfigure = true;
// Editing/creating policies is admin-only, mirroring the backend's enforcement
// on the save/delete endpoints. On single-user deployments (login disabled)
// there's no admin concept, so the local operator can always configure. When
// login is enabled, only admins can — non-admins get the read-only surface.
// The `config != null` guard keeps the gate CLOSED until app-config resolves:
// config is null while loading (and in bare test renders), and a failed/partial
// fetch can yield `{ enableLogin: true }` with isAdmin omitted. Without it a
// non-admin would briefly see edit controls during load, and an admin would be
// wrongly locked out on a transient config-fetch error.
const canConfigure =
config != null && (!config.enableLogin || config.isAdmin === true);
return {
policies,