mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
co-authored by
Claude Opus 4.8
parent
47e5977a31
commit
ef65e6b015
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user