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", + ); + }); + }); +});