diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx
index bc988a445..1fa1fe176 100644
--- a/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx
+++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProcessingMenu.tsx
@@ -1,4 +1,4 @@
-import { type NetworkToolKind, useAppStore } from "@geolibre/core";
+import { type NetworkToolKind, useAppCapability, useAppStore } from "@geolibre/core";
import { isEarthEngineAvailable } from "@geolibre/plugins";
import {
Button,
@@ -73,6 +73,9 @@ export function ProcessingMenu({
const setAssistantOpen = useAppStore((s) => s.setAssistantOpen);
const setDashboardOpen = useAppStore((s) => s.setDashboardOpen);
const setProcessingHistoryOpen = useAppStore((s) => s.setProcessingHistoryOpen);
+ const processingCap = useAppCapability("processing:run");
+ const sidecarCap = useAppCapability("processing:sidecar");
+ const assistantCap = useAppCapability("assistant:use");
// Format Conversion, Raster tools, and AI Segmentation require the Python
// sidecar, which cannot run on Android/iOS — hide them on mobile so they don't
@@ -143,7 +146,10 @@ export function ProcessingMenu({
{show("processing.assistant") && (
<>
- setAssistantOpen(true)}>
+ setAssistantOpen(true)}
+ disabled={!assistantCap.granted}
+ >
{t("toolbar.command.assistant")}
@@ -157,7 +163,10 @@ export function ProcessingMenu({
does; pairs with the GeoLibre Toolbox trigger below. Reuses the
dialog's own heading string, already translated in every locale. */}
{showWhitebox && (
- setProcessingOpen(true)}>
+ setProcessingOpen(true)}
+ disabled={!processingCap.granted}
+ >
{t("processing.whitebox.toolbox")}
)}
diff --git a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx
index 0eb862971..b26e01ae3 100644
--- a/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx
+++ b/apps/geolibre-desktop/src/components/layout/toolbar/ProjectMenu.tsx
@@ -1,4 +1,4 @@
-import { projectPathLabel, useAppStore } from "@geolibre/core";
+import { projectPathLabel, useAppCapability, useAppStore } from "@geolibre/core";
import {
Button,
DropdownMenu,
@@ -102,6 +102,8 @@ export function ProjectMenu({
const clearRecentProjects = useAppStore((s) => s.clearRecentProjects);
const setStorymapPanelOpen = useAppStore((s) => s.setStorymapPanelOpen);
const uiProfile = useDesktopSettingsStore((s) => s.desktopSettings.uiProfile);
+ const saveCapability = useAppCapability("project:save");
+ const shareCapability = useAppCapability("project:share");
const show = (id: string) => isMenuItemVisible(uiProfile, id);
// A deployment that turned sharing off should not advertise it; one that named
// a host we rejected should say so rather than leave the user wondering.
@@ -271,25 +273,25 @@ export function ProjectMenu({
)}
{showSaveGroup && }
{show("project.save") && (
-
+
{t("common.save")}
)}
{show("project.saveAs") && (
-
+
{t("toolbar.item.saveAsEllipsis")}
)}
{show("project.duplicate") && onDuplicate && (
-
+
{t("toolbar.item.duplicate")}
)}
{show("project.saveAsTemplate") && onSaveAsTemplate && (
-
+
{t("toolbar.item.saveAsTemplateEllipsis")}
@@ -298,7 +300,7 @@ export function ProjectMenu({
<>
diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts
index cb06c82e5..7a8bac263 100644
--- a/packages/core/src/capabilities.ts
+++ b/packages/core/src/capabilities.ts
@@ -1,4 +1,10 @@
-import type { GeoLibreLayer, LayerCapabilities } from "./types";
+import type {
+ AppCapabilities,
+ AppPrivilege,
+ AppRole,
+ GeoLibreLayer,
+ LayerCapabilities,
+} from "./types";
/**
* Default inferred capabilities for a layer based on its type and metadata.
@@ -93,3 +99,119 @@ export function normalizeLayerCapabilities(raw: unknown): LayerCapabilities | un
return hasAny ? caps : undefined;
}
+
+/**
+ * All supported application privilege identifiers in GeoLibre.
+ */
+export const ALL_APP_PRIVILEGES: readonly AppPrivilege[] = [
+ "layers:edit",
+ "layers:add-remote",
+ "layers:add-local",
+ "processing:run",
+ "processing:sidecar",
+ "project:save",
+ "project:share",
+ "project:share-public",
+ "plugins:install",
+ "assistant:use",
+ "connections:manage",
+ "export:data",
+ "export:image",
+ "settings:manage",
+] as const;
+
+/**
+ * Standard privilege bundles for named application roles.
+ */
+export const ROLE_PRIVILEGES: Record, readonly AppPrivilege[]> = {
+ viewer: ["export:image", "export:data"],
+ editor: [
+ "export:image",
+ "export:data",
+ "layers:edit",
+ "layers:add-local",
+ "layers:add-remote",
+ "processing:run",
+ "project:save",
+ ],
+ publisher: [
+ "export:image",
+ "export:data",
+ "layers:edit",
+ "layers:add-local",
+ "layers:add-remote",
+ "processing:run",
+ "project:save",
+ "project:share",
+ "project:share-public",
+ "processing:sidecar",
+ "assistant:use",
+ ],
+ administrator: ALL_APP_PRIVILEGES,
+};
+
+/**
+ * Resolves the effective privilege list for a given role, applying custom overrides if specified.
+ */
+export function resolveRolePrivileges(
+ role: AppRole,
+ customPrivileges?: readonly AppPrivilege[],
+): AppPrivilege[] {
+ if (role === "custom") {
+ if (!customPrivileges || customPrivileges.length === 0) return [];
+ const validSet = new Set(ALL_APP_PRIVILEGES);
+ return [...new Set(customPrivileges.filter((p) => validSet.has(p)))];
+ }
+ return [...ROLE_PRIVILEGES[role]];
+}
+
+/**
+ * Intersects multiple sets of privileges to derive the effective permissions when multiple
+ * policies (e.g. deployment, organization, and share link) apply simultaneously.
+ */
+export function intersectPrivileges(...privilegeSets: (readonly AppPrivilege[])[]): AppPrivilege[] {
+ if (privilegeSets.length === 0) return [];
+ if (privilegeSets.length === 1) return [...new Set(privilegeSets[0])];
+ let current = new Set(privilegeSets[0]);
+ for (let i = 1; i < privilegeSets.length; i++) {
+ const nextSet = new Set(privilegeSets[i]);
+ current = new Set([...current].filter((p) => nextSet.has(p)));
+ }
+ return [...current];
+}
+
+/**
+ * Evaluates whether an application capability set grants a specific privilege.
+ */
+export function hasAppPrivilege(
+ capabilities: AppCapabilities | undefined,
+ privilege: AppPrivilege,
+): boolean {
+ if (!capabilities) return true;
+ return capabilities.privileges.includes(privilege);
+}
+
+/**
+ * Creates the default unconstrained application capabilities (Administrator role).
+ */
+export function createDefaultAppCapabilities(): AppCapabilities {
+ return {
+ role: "administrator",
+ privileges: [...ALL_APP_PRIVILEGES],
+ };
+}
+
+/**
+ * Normalizes an untrusted array of privilege strings.
+ */
+export function normalizeAppPrivileges(raw: unknown): AppPrivilege[] | undefined {
+ if (!Array.isArray(raw)) return undefined;
+ const validSet = new Set(ALL_APP_PRIVILEGES);
+ const result: AppPrivilege[] = [];
+ for (const item of raw) {
+ if (typeof item === "string" && validSet.has(item)) {
+ result.push(item as AppPrivilege);
+ }
+ }
+ return result.length > 0 ? [...new Set(result)] : [];
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index cb45927e5..f40966fb2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -52,6 +52,7 @@ export {
redo,
undo,
useAppStore,
+ useAppCapability,
type AppState,
type ConversionToolKind,
type GpsStatusFix,
diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts
index f08ffd309..fcfe7ab79 100644
--- a/packages/core/src/store.ts
+++ b/packages/core/src/store.ts
@@ -17,6 +17,12 @@ import {
DEFAULT_PROJECT_NAME,
} from "./project";
import { initialLayerStyle } from "./layer-defaults";
+import {
+ createDefaultAppCapabilities,
+ hasAppPrivilege,
+ normalizeAppPrivileges,
+ resolveRolePrivileges,
+} from "./capabilities";
import {
createDefaultPrintLayout,
printLayoutConfigsEqual,
@@ -41,6 +47,9 @@ import {
MAX_PROCESSING_HISTORY,
MIN_DASHBOARD_COLUMNS,
type AddTileLayerOptions,
+ type AppCapabilities,
+ type AppPrivilege,
+ type AppRole,
type CollabInvite,
type CollaborationChatMessage,
type CollaborationParticipant,
@@ -304,6 +313,12 @@ export interface AppState {
// excluded from the project file (project.ts never reads it) and from undo
// history (partialize never lists it).
collaboration: CollaborationState;
+ /**
+ * Ephemeral application capability model (issue #1672). Gating role and
+ * privileges for the current session/deployment. Excluded from the project file
+ * and undo history.
+ */
+ capabilities: AppCapabilities;
ui: {
processingOpen: boolean;
/**
@@ -577,6 +592,25 @@ export interface AppState {
clearRecentProjects: () => void;
markSaved: () => void;
+ /**
+ * Assign an application role (e.g. "viewer", "editor", "publisher", "administrator", "custom"),
+ * deriving the effective privileges and optional reason.
+ */
+ setAppRole: (
+ role: AppRole,
+ options?: { customPrivileges?: AppPrivilege[]; reason?: string },
+ ) => void;
+ /** Set explicit custom privileges and an optional reason. */
+ setAppPrivileges: (privileges: AppPrivilege[], reason?: string) => void;
+ /** Grant an individual application privilege. */
+ grantAppPrivilege: (privilege: AppPrivilege) => void;
+ /** Revoke an individual application privilege with an optional reason. */
+ revokeAppPrivilege: (privilege: AppPrivilege, reason?: string) => void;
+ /** Reset application capabilities back to the default unconstrained Administrator role. */
+ resetAppCapabilities: () => void;
+ /** Check if the current capabilities grant the requested privilege. */
+ hasAppPrivilege: (privilege: AppPrivilege) => boolean;
+
addLayer: (layer: GeoLibreLayer, beforeLayerId?: string | null) => void;
removeLayer: (id: string) => void;
updateLayer: (id: string, patch: Partial) => void;
@@ -1045,6 +1079,7 @@ export const useAppStore = create()(
recentProjects: [],
attributeFilter: "",
collaboration: DEFAULT_COLLABORATION_STATE,
+ capabilities: createDefaultAppCapabilities(),
ui: {
processingOpen: false,
processingInitialTool: null,
@@ -2324,6 +2359,58 @@ export const useAppStore = create()(
});
}
},
+
+ setAppRole: (role, options) => {
+ const privileges = resolveRolePrivileges(role, options?.customPrivileges);
+ set({
+ capabilities: {
+ role,
+ privileges,
+ reason: options?.reason,
+ },
+ });
+ },
+
+ setAppPrivileges: (privileges, reason) => {
+ set({
+ capabilities: {
+ role: "custom",
+ privileges: normalizeAppPrivileges(privileges) ?? [],
+ reason,
+ },
+ });
+ },
+
+ grantAppPrivilege: (privilege) => {
+ const current = get().capabilities;
+ if (current.privileges.includes(privilege)) return;
+ set({
+ capabilities: {
+ ...current,
+ privileges: [...current.privileges, privilege],
+ },
+ });
+ },
+
+ revokeAppPrivilege: (privilege, reason) => {
+ const current = get().capabilities;
+ if (!current.privileges.includes(privilege)) return;
+ set({
+ capabilities: {
+ ...current,
+ privileges: current.privileges.filter((p) => p !== privilege),
+ reason: reason ?? current.reason,
+ },
+ });
+ },
+
+ resetAppCapabilities: () => {
+ set({ capabilities: createDefaultAppCapabilities() });
+ },
+
+ hasAppPrivilege: (privilege) => {
+ return hasAppPrivilege(get().capabilities, privilege);
+ },
}),
{
// Only these fields participate in undo/redo; everything else (selection,
@@ -2514,3 +2601,17 @@ export function clearHistory(): void {
notifyProjectRestoreHistory();
}
}
+
+/**
+ * React hook for consuming application capability state for a specific privilege.
+ *
+ * @param privilege - The privilege to check.
+ * @returns `{ granted: boolean, reason?: string }`
+ */
+export function useAppCapability(privilege: AppPrivilege): { granted: boolean; reason?: string } {
+ const capabilities = useAppStore((state) => state.capabilities);
+ return {
+ granted: capabilities.privileges.includes(privilege),
+ reason: capabilities.reason,
+ };
+}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index c434ee7b4..5b7eb93ff 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -978,6 +978,43 @@ export interface LayerCapabilities {
export?: boolean;
}
+/**
+ * Application privilege identifiers defining discrete capabilities in GeoLibre.
+ */
+export type AppPrivilege =
+ | "layers:edit"
+ | "layers:add-remote"
+ | "layers:add-local"
+ | "processing:run"
+ | "processing:sidecar"
+ | "project:save"
+ | "project:share"
+ | "project:share-public"
+ | "plugins:install"
+ | "assistant:use"
+ | "connections:manage"
+ | "export:data"
+ | "export:image"
+ | "settings:manage";
+
+/**
+ * Standard named roles bundling application privileges.
+ */
+export type AppRole = "viewer" | "editor" | "publisher" | "administrator" | "custom";
+
+/**
+ * Ephemeral application capabilities state defining the active role, effective privileges,
+ * and optional restriction reason for the current session/deployment.
+ */
+export interface AppCapabilities {
+ /** The assigned application role. */
+ role: AppRole;
+ /** List of granted privileges for the active role or custom configuration. */
+ privileges: AppPrivilege[];
+ /** Optional human-readable restriction reason (e.g. "Action disabled by deployment policy"). */
+ reason?: string;
+}
+
export interface GeoLibreLayer {
id: string;
name: string;
diff --git a/tests/app-capabilities.test.ts b/tests/app-capabilities.test.ts
new file mode 100644
index 000000000..9d3bdbbc9
--- /dev/null
+++ b/tests/app-capabilities.test.ts
@@ -0,0 +1,262 @@
+import assert from "node:assert/strict";
+import { beforeEach, describe, it } from "node:test";
+import {
+ ALL_APP_PRIVILEGES,
+ ROLE_PRIVILEGES,
+ createDefaultAppCapabilities,
+ hasAppPrivilege,
+ intersectPrivileges,
+ normalizeAppPrivileges,
+ projectFromStore,
+ resolveRolePrivileges,
+ serializeProject,
+ undo,
+ redo,
+ useAppStore,
+ type AppPrivilege,
+ type AppRole,
+} from "../packages/core/src/index";
+
+describe("application capability model", () => {
+ beforeEach(() => {
+ useAppStore.getState().newProject({ name: "CapabilityTest" });
+ useAppStore.getState().resetAppCapabilities();
+ });
+
+ describe("privilege and role definitions", () => {
+ it("defines all 14 standard application privileges", () => {
+ assert.equal(ALL_APP_PRIVILEGES.length, 14);
+ assert.ok(ALL_APP_PRIVILEGES.includes("layers:edit"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("layers:add-remote"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("layers:add-local"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("processing:run"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("processing:sidecar"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("project:save"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("project:share"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("project:share-public"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("plugins:install"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("assistant:use"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("connections:manage"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("export:data"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("export:image"));
+ assert.ok(ALL_APP_PRIVILEGES.includes("settings:manage"));
+ });
+
+ it("viewer role only grants export privileges", () => {
+ const viewerPrivileges = ROLE_PRIVILEGES.viewer;
+ assert.deepEqual([...viewerPrivileges].sort(), ["export:data", "export:image"].sort());
+ });
+
+ it("editor role grants viewer privileges plus local authoring and processing", () => {
+ const editorPrivileges = ROLE_PRIVILEGES.editor;
+ assert.ok(editorPrivileges.includes("export:data"));
+ assert.ok(editorPrivileges.includes("export:image"));
+ assert.ok(editorPrivileges.includes("layers:edit"));
+ assert.ok(editorPrivileges.includes("layers:add-local"));
+ assert.ok(editorPrivileges.includes("layers:add-remote"));
+ assert.ok(editorPrivileges.includes("processing:run"));
+ assert.ok(editorPrivileges.includes("project:save"));
+ // Editor does not have public share, plugin install, or sidecar
+ assert.ok(!editorPrivileges.includes("project:share-public"));
+ assert.ok(!editorPrivileges.includes("plugins:install"));
+ });
+
+ it("publisher role grants editor privileges plus sharing, sidecar, and assistant", () => {
+ const publisherPrivileges = ROLE_PRIVILEGES.publisher;
+ assert.ok(publisherPrivileges.includes("project:share"));
+ assert.ok(publisherPrivileges.includes("project:share-public"));
+ assert.ok(publisherPrivileges.includes("processing:sidecar"));
+ assert.ok(publisherPrivileges.includes("assistant:use"));
+ assert.ok(!publisherPrivileges.includes("plugins:install"));
+ assert.ok(!publisherPrivileges.includes("connections:manage"));
+ });
+
+ it("administrator role grants every application privilege", () => {
+ assert.deepEqual(ROLE_PRIVILEGES.administrator, ALL_APP_PRIVILEGES);
+ });
+ });
+
+ describe("resolveRolePrivileges", () => {
+ it("resolves predefined bundles for standard roles", () => {
+ assert.deepEqual(resolveRolePrivileges("viewer"), ROLE_PRIVILEGES.viewer);
+ assert.deepEqual(resolveRolePrivileges("editor"), ROLE_PRIVILEGES.editor);
+ assert.deepEqual(resolveRolePrivileges("publisher"), ROLE_PRIVILEGES.publisher);
+ assert.deepEqual(resolveRolePrivileges("administrator"), ALL_APP_PRIVILEGES);
+ });
+
+ it("resolves custom privileges filtering out unknown entries and duplicates", () => {
+ const custom = resolveRolePrivileges("custom", [
+ "export:image",
+ "processing:run",
+ "export:image", // duplicate
+ "unknown:privilege" as AppPrivilege,
+ ]);
+ assert.deepEqual(custom, ["export:image", "processing:run"]);
+ });
+
+ it("returns empty array for custom role without privileges", () => {
+ assert.deepEqual(resolveRolePrivileges("custom"), []);
+ assert.deepEqual(resolveRolePrivileges("custom", []), []);
+ });
+ });
+
+ describe("intersectPrivileges", () => {
+ it("returns single set unchanged when only one set is provided", () => {
+ assert.deepEqual(intersectPrivileges(["export:data", "export:image"]), [
+ "export:data",
+ "export:image",
+ ]);
+ });
+
+ it("returns empty array when given no sets", () => {
+ assert.deepEqual(intersectPrivileges(), []);
+ });
+
+ it("correctly computes intersection of multiple role privilege sets", () => {
+ const deploymentPrivileges: AppPrivilege[] = [
+ "export:image",
+ "export:data",
+ "processing:run",
+ ];
+ const userPrivileges: AppPrivilege[] = ["export:image", "layers:edit", "processing:run"];
+ const shareLinkPrivileges: AppPrivilege[] = ["export:image", "export:data"];
+
+ const effective = intersectPrivileges(
+ deploymentPrivileges,
+ userPrivileges,
+ shareLinkPrivileges,
+ );
+ assert.deepEqual(effective, ["export:image"]);
+ });
+
+ it("returns empty array when privilege sets are disjoint", () => {
+ const setA: AppPrivilege[] = ["plugins:install"];
+ const setB: AppPrivilege[] = ["export:data"];
+ assert.deepEqual(intersectPrivileges(setA, setB), []);
+ });
+ });
+
+ describe("hasAppPrivilege", () => {
+ it("returns true when privilege is present in capabilities", () => {
+ const caps = { role: "viewer" as AppRole, privileges: ["export:image" as AppPrivilege] };
+ assert.equal(hasAppPrivilege(caps, "export:image"), true);
+ assert.equal(hasAppPrivilege(caps, "layers:edit"), false);
+ });
+
+ it("defaults to true when capabilities object is undefined", () => {
+ assert.equal(hasAppPrivilege(undefined, "layers:edit"), true);
+ });
+ });
+
+ describe("normalizeAppPrivileges", () => {
+ it("returns undefined for non-array values", () => {
+ assert.equal(normalizeAppPrivileges(null), undefined);
+ assert.equal(normalizeAppPrivileges("export:data"), undefined);
+ assert.equal(normalizeAppPrivileges({}), undefined);
+ });
+
+ it("filters out invalid strings and deduplicates", () => {
+ const normalized = normalizeAppPrivileges(["layers:edit", "bogus", "layers:edit", 123]);
+ assert.deepEqual(normalized, ["layers:edit"]);
+ });
+
+ it("returns empty array for an empty array input", () => {
+ assert.deepEqual(normalizeAppPrivileges([]), []);
+ });
+ });
+
+ describe("store integration", () => {
+ it("initializes with default Administrator capabilities", () => {
+ const state = useAppStore.getState();
+ assert.equal(state.capabilities.role, "administrator");
+ assert.equal(state.capabilities.privileges.length, ALL_APP_PRIVILEGES.length);
+ assert.equal(state.hasAppPrivilege("plugins:install"), true);
+ });
+
+ it("setAppRole updates role, derived privileges, and reason", () => {
+ useAppStore.getState().setAppRole("viewer", { reason: "Kiosk deployment" });
+ const state = useAppStore.getState();
+ assert.equal(state.capabilities.role, "viewer");
+ assert.deepEqual(state.capabilities.privileges, ROLE_PRIVILEGES.viewer);
+ assert.equal(state.capabilities.reason, "Kiosk deployment");
+ assert.equal(state.hasAppPrivilege("export:data"), true);
+ assert.equal(state.hasAppPrivilege("layers:edit"), false);
+ });
+
+ it("setAppPrivileges updates custom privileges and reason", () => {
+ useAppStore
+ .getState()
+ .setAppPrivileges(["export:data", "processing:run"], "Custom classroom");
+ const state = useAppStore.getState();
+ assert.equal(state.capabilities.role, "custom");
+ assert.deepEqual(state.capabilities.privileges, ["export:data", "processing:run"]);
+ assert.equal(state.capabilities.reason, "Custom classroom");
+ assert.equal(state.hasAppPrivilege("processing:run"), true);
+ assert.equal(state.hasAppPrivilege("plugins:install"), false);
+ });
+
+ it("grantAppPrivilege adds a privilege without duplicates", () => {
+ useAppStore.getState().setAppRole("viewer");
+ assert.equal(useAppStore.getState().hasAppPrivilege("processing:run"), false);
+
+ useAppStore.getState().grantAppPrivilege("processing:run");
+ assert.equal(useAppStore.getState().hasAppPrivilege("processing:run"), true);
+
+ // Granting again does not create duplicate entries
+ const countBefore = useAppStore.getState().capabilities.privileges.length;
+ useAppStore.getState().grantAppPrivilege("processing:run");
+ assert.equal(useAppStore.getState().capabilities.privileges.length, countBefore);
+ });
+
+ it("revokeAppPrivilege removes a privilege and optionally sets reason", () => {
+ useAppStore.getState().setAppRole("editor");
+ assert.equal(useAppStore.getState().hasAppPrivilege("layers:edit"), true);
+
+ useAppStore.getState().revokeAppPrivilege("layers:edit", "Read-only mode");
+ assert.equal(useAppStore.getState().hasAppPrivilege("layers:edit"), false);
+ assert.equal(useAppStore.getState().capabilities.reason, "Read-only mode");
+ });
+
+ it("resetAppCapabilities restores default administrator role", () => {
+ useAppStore.getState().setAppRole("viewer", { reason: "Restricted" });
+ useAppStore.getState().resetAppCapabilities();
+
+ const state = useAppStore.getState();
+ assert.equal(state.capabilities.role, "administrator");
+ assert.equal(state.capabilities.privileges.length, ALL_APP_PRIVILEGES.length);
+ assert.equal(state.capabilities.reason, undefined);
+ });
+
+ it("capabilities slice is ephemeral and excluded from project serialization and undo history", () => {
+ useAppStore.getState().setAppRole("viewer", { reason: "Demo mode" });
+ const project = projectFromStore(useAppStore.getState());
+ assert.equal(
+ "capabilities" in project,
+ false,
+ "projectFromStore must not include capabilities",
+ );
+ const serialized = JSON.parse(serializeProject(project));
+ assert.equal(
+ "capabilities" in serialized,
+ false,
+ "capabilities must not be serialized into project file",
+ );
+
+ // Undo/redo must not alter capabilities state
+ useAppStore.getState().setBasemapOpacity(0.5);
+ useAppStore.getState().setAppRole("editor");
+ undo();
+ assert.equal(
+ useAppStore.getState().capabilities.role,
+ "editor",
+ "undo must not revert ephemeral capabilities",
+ );
+ redo();
+ assert.equal(
+ useAppStore.getState().capabilities.role,
+ "editor",
+ "redo must not alter ephemeral capabilities",
+ );
+ });
+ });
+});