587 lines
24 KiB
TypeScript
587 lines
24 KiB
TypeScript
import { Activity, Bell, BookUser, BriefcaseBusiness, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, FolderKanban, Form, Gavel, Inbox, Landmark, LayoutDashboard, LayoutTemplate, ListChecks, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Vote, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
|
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
|
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule, ProductAreaContribution, QuickAccessToolMetadata } from "../types";
|
|
import {
|
|
hasUiCapability as hasUiCapabilityForModules,
|
|
moduleInstalled as moduleInstalledForModules,
|
|
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
|
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
|
routeContributionsForModules as routeContributionsForModuleList,
|
|
uiCapabilities as uiCapabilitiesForModules,
|
|
uiCapability as uiCapabilityForModules } from
|
|
"./moduleLogic";
|
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
|
import {
|
|
isViewSurfaceVisible,
|
|
navigationViewSurfaceId,
|
|
routeViewSurfaceId,
|
|
viewSurfaceCatalogueForModules
|
|
} from "./views";
|
|
|
|
export const fallbackDashboardNavItem: PlatformNavItem =
|
|
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
|
|
|
type InstalledWebModuleLoader = {
|
|
packageName: string;
|
|
load: () => Promise<{ default: PlatformWebModule }>;
|
|
};
|
|
|
|
const installedWebModuleLoaders =
|
|
installedWebModuleLoaderSource as unknown as InstalledWebModuleLoader[];
|
|
|
|
export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
|
return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem];
|
|
}
|
|
|
|
const localModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
|
const loadedLocalModules = new Map<string, PlatformWebModule>();
|
|
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
|
|
|
declare global {
|
|
interface Window {
|
|
__GOVOPLAN_REMOTE_MODULE_KEYS__?: Record<string, JsonWebKey>;
|
|
}
|
|
}
|
|
|
|
type RemoteAssetManifest = {
|
|
contractVersion?: string;
|
|
moduleId?: string;
|
|
entry?: string;
|
|
entryIntegrity?: string;
|
|
moduleExport?: string;
|
|
};
|
|
|
|
const iconByName: Record<string, LucideIcon> = {
|
|
activity: Activity,
|
|
admin: Shield,
|
|
bell: Bell,
|
|
"book-user": BookUser,
|
|
"briefcase-business": BriefcaseBusiness,
|
|
building: Building2,
|
|
"building-2": Building2,
|
|
calendar: CalendarDays,
|
|
"calendar-clock": CalendarClock,
|
|
campaign: Mails,
|
|
"clipboard-pen-line": ClipboardPenLine,
|
|
dashboard: LayoutDashboard,
|
|
"database-zap": DatabaseZap,
|
|
file: Folder,
|
|
files: Folder,
|
|
folder: Folder,
|
|
"folder-kanban": FolderKanban,
|
|
form: Form,
|
|
gavel: Gavel,
|
|
inbox: Inbox,
|
|
landmark: Landmark,
|
|
"layout-template": LayoutTemplate,
|
|
"list-tree": ListTree,
|
|
"list-checks": ListChecks,
|
|
mail: Mail,
|
|
notifications: Bell,
|
|
organizations: Building2,
|
|
operator: RadioTower,
|
|
"radio-tower": RadioTower,
|
|
reports: ClipboardPenLine,
|
|
"shield-check": ShieldCheck,
|
|
templates: LayoutTemplate,
|
|
users: Users,
|
|
vote: Vote,
|
|
waypoints: Waypoints,
|
|
workflow: WorkflowIcon
|
|
};
|
|
|
|
export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined {
|
|
if (!iconName) return undefined;
|
|
return iconByName[iconName];
|
|
}
|
|
|
|
function resolveNavItemIcon(item: PlatformNavItem): PlatformNavItem {
|
|
return {
|
|
...item,
|
|
icon: item.icon ?? iconComponentForName(item.iconName)
|
|
};
|
|
}
|
|
|
|
function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem {
|
|
return {
|
|
to: item.path,
|
|
label: item.label,
|
|
iconName: item.icon ?? null,
|
|
allOf: item.required_all,
|
|
anyOf: item.required_any,
|
|
order: item.order,
|
|
surfaceId: item.surface_id ?? undefined,
|
|
navigationId: item.navigation_id ?? item.surface_id ?? undefined,
|
|
navigationVisible: item.navigation_visible,
|
|
navigationLocked: item.navigation_locked,
|
|
navigationOrderSource: item.navigation_order_source,
|
|
navigationVisibilitySource: item.navigation_visibility_source,
|
|
navigationLockSource: item.navigation_lock_source,
|
|
navigationLayers: item.navigation_layers
|
|
};
|
|
}
|
|
|
|
function viewSurfaceFromMetadata(
|
|
item: NonNullable<NonNullable<PlatformModuleInfo["frontend"]>["view_surfaces"]>[number]
|
|
): PlatformViewSurface {
|
|
return {
|
|
id: item.id,
|
|
moduleId: item.module_id,
|
|
kind: item.kind,
|
|
label: item.label,
|
|
parentId: item.parent_id,
|
|
description: item.description,
|
|
order: item.order,
|
|
defaultVisible: item.default_visible,
|
|
required: item.required
|
|
};
|
|
}
|
|
|
|
function mergeViewSurfaces(
|
|
module: PlatformWebModule,
|
|
info: PlatformModuleInfo
|
|
): PlatformViewSurface[] | undefined {
|
|
const surfaces = new Map<string, PlatformViewSurface>();
|
|
for (const surface of info.frontend?.view_surfaces ?? []) {
|
|
const normalized = viewSurfaceFromMetadata(surface);
|
|
surfaces.set(normalized.id, normalized);
|
|
}
|
|
for (const surface of module.viewSurfaces ?? []) {
|
|
surfaces.set(surface.id, {
|
|
...surfaces.get(surface.id),
|
|
...surface,
|
|
required: Boolean(surfaces.get(surface.id)?.required || surface.required)
|
|
});
|
|
}
|
|
return surfaces.size ? [...surfaces.values()] : undefined;
|
|
}
|
|
|
|
function routesWithServerMetadata(
|
|
module: PlatformWebModule,
|
|
info: PlatformModuleInfo
|
|
) {
|
|
const routesByPath = new Map(
|
|
(info.frontend?.routes ?? []).map((route) => [route.path, route])
|
|
);
|
|
return (module.routes ?? []).map((route) => {
|
|
const metadata = routesByPath.get(route.path);
|
|
return {
|
|
...route,
|
|
surfaceId:
|
|
metadata?.surface_id ??
|
|
route.surfaceId ??
|
|
routeViewSurfaceId(module.id, route.path)
|
|
};
|
|
});
|
|
}
|
|
|
|
function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribution[] {
|
|
return (info.frontend?.product_areas ?? []).map((area) => ({
|
|
id: area.id,
|
|
moduleId: area.module_id,
|
|
label: area.label,
|
|
description: area.description,
|
|
iconName: area.icon,
|
|
surfaceIds: area.surface_ids,
|
|
order: area.order
|
|
}));
|
|
}
|
|
|
|
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
|
|
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
|
|
contractVersion: tool.contract_version,
|
|
id: tool.id,
|
|
moduleId: tool.module_id,
|
|
categoryId: tool.category_id,
|
|
label: tool.label,
|
|
description: tool.description,
|
|
surfaceId: tool.surface_id,
|
|
iconName: tool.icon,
|
|
fullPagePath: tool.full_page_path,
|
|
allOf: tool.required_all,
|
|
anyOf: tool.required_any,
|
|
order: tool.order,
|
|
defaultEnabled: tool.default_enabled,
|
|
modes: tool.modes,
|
|
availability: tool.availability,
|
|
acceptedReferenceKinds: tool.accepted_reference_kinds,
|
|
returnedReferenceKinds: tool.returned_reference_kinds,
|
|
helpContextId: tool.help_context_id
|
|
}));
|
|
}
|
|
|
|
|
|
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
|
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
|
if (enabledNames.size === 0 || !module.runtimeUiCapabilities) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(module.runtimeUiCapabilities).filter(([name]) => enabledNames.has(name))
|
|
);
|
|
}
|
|
|
|
function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo): PlatformWebModule {
|
|
const backendNav = info.frontend?.nav.length ? info.frontend.nav : info.nav;
|
|
return {
|
|
...module,
|
|
label: info.name,
|
|
version: info.version,
|
|
dependencies: info.dependencies,
|
|
optionalDependencies: info.optional_dependencies,
|
|
remoteAssetManifest: info.frontend?.asset_manifest ?? module.remoteAssetManifest,
|
|
remoteAssetSignature: info.frontend?.asset_manifest_signature ?? module.remoteAssetSignature,
|
|
remoteAssetPublicKeyId: info.frontend?.asset_manifest_public_key_id ?? module.remoteAssetPublicKeyId,
|
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
|
routes: routesWithServerMetadata(module, info),
|
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
|
viewSurfaces: mergeViewSurfaces(module, info),
|
|
productAreas: productAreasFromMetadata(info),
|
|
productSurfaceMetadata: info.frontend?.product_surfaces,
|
|
quickAccessTools: quickAccessToolsFromMetadata(info),
|
|
helpContexts: info.help_contexts ?? module.helpContexts,
|
|
uiCapabilities: {
|
|
...(module.uiCapabilities ?? {}),
|
|
...runtimeUiCapabilitiesForModule(module, info)
|
|
}
|
|
};
|
|
}
|
|
|
|
function filterPublicRoutes(
|
|
module: PlatformWebModule,
|
|
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
|
) {
|
|
if (!routes?.length) return [];
|
|
const allowedPaths = new Set(routes.map((route) => route.path));
|
|
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
|
}
|
|
|
|
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
|
return {
|
|
id: info.id,
|
|
name: info.name,
|
|
version: info.version,
|
|
dependencies: [],
|
|
optional_dependencies: [],
|
|
enabled: true,
|
|
help_contexts: info.help_contexts,
|
|
runtime_ui_capabilities: [],
|
|
nav: [],
|
|
frontend: {
|
|
...info.frontend,
|
|
routes: [],
|
|
nav: [],
|
|
settings_routes: []
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function loadInstalledPublicWebModules(
|
|
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
|
): Promise<PlatformWebModule[]> {
|
|
if (!platformModules?.length) return [];
|
|
const loaded = await loadInstalledWebModules(platformModules.map(publicModuleInfo));
|
|
return loaded.filter((module) => module.publicRoutes?.length);
|
|
}
|
|
|
|
export async function loadRemotePublicWebModules(
|
|
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
|
alreadyLoaded: PlatformWebModule[] = []
|
|
): Promise<PlatformWebModule[]> {
|
|
if (!platformModules?.length) return [];
|
|
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
|
return loaded.filter((module) => module.publicRoutes?.length);
|
|
}
|
|
|
|
export async function loadInstalledWebModules(
|
|
platformModules: PlatformModuleInfo[] | null | undefined
|
|
): Promise<PlatformWebModule[]> {
|
|
if (!platformModules?.length) return [];
|
|
|
|
const enabledModules = platformModules.filter((module) => module.enabled);
|
|
const resolved = await Promise.all(enabledModules.map(async (info) => {
|
|
const local = await loadInstalledWebModule(info);
|
|
return local ? applyServerMetadata(local, info) : null;
|
|
}));
|
|
return resolved.filter((module): module is PlatformWebModule => module !== null);
|
|
}
|
|
|
|
export async function loadRemoteWebModules(
|
|
platformModules: PlatformModuleInfo[] | null | undefined,
|
|
alreadyLoaded: PlatformWebModule[] = [])
|
|
: Promise<PlatformWebModule[]> {
|
|
if (!platformModules?.length) return [];
|
|
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
|
|
const candidates = platformModules.filter((module) => {
|
|
return module.enabled && !loadedIds.has(module.id) && Boolean(module.frontend?.asset_manifest);
|
|
});
|
|
if (candidates.length === 0) return [];
|
|
const loaded = await Promise.all(candidates.map((module) => {
|
|
const cacheKey = `${module.id}:${module.frontend?.asset_manifest ?? ""}:${module.version}`;
|
|
let promise = remoteModuleCache.get(cacheKey);
|
|
if (!promise) {
|
|
promise = loadRemoteWebModule(module).catch((error) => {
|
|
console.warn("GovOPlaN remote module was not loaded:", module.id, error);
|
|
return null;
|
|
});
|
|
remoteModuleCache.set(cacheKey, promise);
|
|
}
|
|
return promise;
|
|
}));
|
|
return loaded.filter((module): module is PlatformWebModule => module !== null);
|
|
}
|
|
|
|
async function loadInstalledWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
|
|
if (!info.frontend) return null;
|
|
const packageName = info.frontend?.package_name;
|
|
if (packageName) {
|
|
const loader = installedWebModuleLoaders.find((candidate) => candidate.packageName === packageName);
|
|
return loader ? loadInstalledWebModulePackage(loader, info.id) : null;
|
|
}
|
|
|
|
// Legacy manifests may omit package_name. Keep compatibility without making
|
|
// every modern module eager: only this fallback imports all installed descriptors.
|
|
const candidates = await Promise.all(
|
|
installedWebModuleLoaders.map((loader) => loadInstalledWebModulePackage(loader))
|
|
);
|
|
return candidates.find((candidate) => candidate?.id === info.id) ?? null;
|
|
}
|
|
|
|
async function loadInstalledWebModulePackage(
|
|
loader: InstalledWebModuleLoader,
|
|
expectedModuleId?: string
|
|
): Promise<PlatformWebModule | null> {
|
|
let promise = localModuleCache.get(loader.packageName);
|
|
if (!promise) {
|
|
promise = loader.load().
|
|
then((imported) => {
|
|
const module = imported.default;
|
|
if (!isPlatformWebModule(module)) {
|
|
throw new Error(`${loader.packageName} does not export a PlatformWebModule`);
|
|
}
|
|
loadedLocalModules.set(loader.packageName, module);
|
|
return module;
|
|
}).
|
|
catch((error) => {
|
|
localModuleCache.delete(loader.packageName);
|
|
console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error);
|
|
return null;
|
|
});
|
|
localModuleCache.set(loader.packageName, promise);
|
|
}
|
|
const module = await promise;
|
|
if (module && expectedModuleId && module.id !== expectedModuleId) {
|
|
console.warn(
|
|
"GovOPlaN installed WebUI package id mismatch:",
|
|
loader.packageName,
|
|
`expected ${expectedModuleId}, received ${module.id}`
|
|
);
|
|
return null;
|
|
}
|
|
return module;
|
|
}
|
|
|
|
async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
|
|
const frontend = info.frontend;
|
|
if (!frontend?.asset_manifest) return null;
|
|
const manifestUrl = absoluteModuleAssetUrl(frontend.asset_manifest);
|
|
const manifestResponse = await fetch(manifestUrl, { credentials: "same-origin" });
|
|
if (!manifestResponse.ok) throw new Error(`manifest fetch failed with HTTP ${manifestResponse.status}`);
|
|
const manifestBuffer = await manifestResponse.arrayBuffer();
|
|
if (frontend.asset_manifest_integrity) {
|
|
const ok = await verifyIntegrity(manifestBuffer, frontend.asset_manifest_integrity);
|
|
if (!ok) throw new Error("i18n:govoplan-core.manifest_integrity_check_failed.6e3913bc");
|
|
}
|
|
const manifestText = new TextDecoder().decode(manifestBuffer);
|
|
if (frontend.asset_manifest_signature && frontend.asset_manifest_public_key_id) {
|
|
const signatureOk = await verifyManifestSignature(
|
|
manifestBuffer,
|
|
frontend.asset_manifest_signature,
|
|
frontend.asset_manifest_public_key_id
|
|
);
|
|
if (signatureOk === false) throw new Error("i18n:govoplan-core.manifest_signature_check_failed.a9860176");
|
|
if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("i18n:govoplan-core.manifest_signature_key_is_not_trusted_by_this_sh.3bb2f629");
|
|
} else if (!frontend.asset_manifest_integrity) {
|
|
throw new Error("i18n:govoplan-core.remote_manifests_need_an_integrity_hash_or_a_ver.bfbb7daa");
|
|
}
|
|
const manifest = JSON.parse(manifestText) as RemoteAssetManifest;
|
|
const contractVersion = manifest.contractVersion ?? frontend.asset_manifest_contract_version ?? "1";
|
|
if (contractVersion !== "1") throw new Error(`unsupported remote asset contract ${contractVersion}`);
|
|
if (manifest.moduleId && manifest.moduleId !== info.id) throw new Error(`manifest module id ${manifest.moduleId} does not match ${info.id}`);
|
|
if (!manifest.entry) throw new Error("i18n:govoplan-core.remote_manifest_has_no_entry.8220dbc9");
|
|
if (!manifest.entryIntegrity) throw new Error("i18n:govoplan-core.remote_entry_needs_an_integrity_hash.3b69ae0a");
|
|
|
|
const entryUrl = absoluteModuleAssetUrl(manifest.entry, manifestUrl);
|
|
const entryResponse = await fetch(entryUrl, { credentials: "same-origin" });
|
|
if (!entryResponse.ok) throw new Error(`entry fetch failed with HTTP ${entryResponse.status}`);
|
|
const entryBuffer = await entryResponse.arrayBuffer();
|
|
if (!(await verifyIntegrity(entryBuffer, manifest.entryIntegrity))) throw new Error("i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89");
|
|
const blobUrl = URL.createObjectURL(new Blob([entryBuffer], { type: "text/javascript" }));
|
|
try {
|
|
const imported = (await import(/* @vite-ignore */blobUrl)) as Record<string, unknown>;
|
|
const exported = manifest.moduleExport ? imported[manifest.moduleExport] : imported.default ?? imported.module;
|
|
if (!isPlatformWebModule(exported)) throw new Error("i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741");
|
|
if (exported.id !== info.id) throw new Error(`entry module id ${exported.id} does not match ${info.id}`);
|
|
return applyServerMetadata(exported, info);
|
|
} finally {
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
}
|
|
|
|
function absoluteModuleAssetUrl(value: string, base = window.location.origin): string {
|
|
return new URL(value, base).toString();
|
|
}
|
|
|
|
async function verifyIntegrity(buffer: ArrayBuffer, integrity: string): Promise<boolean> {
|
|
const separator = integrity.indexOf("-");
|
|
const algorithm = separator > 0 ? integrity.slice(0, separator) : "";
|
|
const expected = separator > 0 ? integrity.slice(separator + 1) : "";
|
|
if (!algorithm || !expected) return false;
|
|
const digestName = {
|
|
"SHA-256": "SHA-256",
|
|
"SHA-384": "SHA-384",
|
|
"SHA-512": "SHA-512",
|
|
SHA256: "SHA-256",
|
|
SHA384: "SHA-384",
|
|
SHA512: "SHA-512"
|
|
}[algorithm.toUpperCase()];
|
|
if (!digestName) return false;
|
|
const digest = await crypto.subtle.digest(digestName, buffer);
|
|
return constantTimeEqual(base64FromBuffer(digest), expected) || constantTimeEqual(hexFromBuffer(digest), expected.toLowerCase());
|
|
}
|
|
|
|
async function verifyManifestSignature(buffer: ArrayBuffer, signature: string, keyId: string): Promise<boolean | null> {
|
|
const jwk = window.__GOVOPLAN_REMOTE_MODULE_KEYS__?.[keyId];
|
|
if (!jwk) return null;
|
|
const isOkp = jwk.kty === "OKP";
|
|
const importAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", namedCurve: jwk.crv ?? "P-256" };
|
|
const verifyAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", hash: "SHA-256" };
|
|
const key = await crypto.subtle.importKey("jwk", jwk, importAlgorithm as AlgorithmIdentifier, false, ["verify"]);
|
|
return crypto.subtle.verify(verifyAlgorithm as AlgorithmIdentifier, key, base64ToBuffer(signature), buffer);
|
|
}
|
|
|
|
function isPlatformWebModule(value: unknown): value is PlatformWebModule {
|
|
if (!value || typeof value !== "object") return false;
|
|
const item = value as Partial<PlatformWebModule>;
|
|
return typeof item.id === "string" && typeof item.label === "string" && typeof item.version === "string";
|
|
}
|
|
|
|
function base64FromBuffer(buffer: ArrayBuffer): string {
|
|
let binary = "";
|
|
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte);
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToBuffer(value: string): ArrayBuffer {
|
|
const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
const binary = atob(padded);
|
|
const buffer = new ArrayBuffer(binary.length);
|
|
const bytes = new Uint8Array(buffer);
|
|
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
return buffer;
|
|
}
|
|
|
|
function hexFromBuffer(buffer: ArrayBuffer): string {
|
|
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function constantTimeEqual(left: string, right: string): boolean {
|
|
if (left.length !== right.length) return false;
|
|
let result = 0;
|
|
for (let index = 0; index < left.length; index += 1) result |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
return result === 0;
|
|
}
|
|
|
|
export function installedLocalWebModules(): PlatformWebModule[] {
|
|
return [...loadedLocalModules.values()];
|
|
}
|
|
|
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T | null {
|
|
return uiCapabilityForModules<T>(capabilityName, modules);
|
|
}
|
|
|
|
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T[] {
|
|
return uiCapabilitiesForModules<T>(capabilityName, modules);
|
|
}
|
|
|
|
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
|
return hasUiCapabilityForModules(capabilityName, modules);
|
|
}
|
|
|
|
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
|
return moduleInstalledForModules(moduleId, modules);
|
|
}
|
|
|
|
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
|
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
|
|
}
|
|
|
|
export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
|
return routeContributionsForModuleList(modules);
|
|
}
|
|
|
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
|
return publicRouteContributionsForModuleList(modules);
|
|
}
|
|
|
|
export function dashboardWidgetsForModules(
|
|
modules: PlatformWebModule[] = installedLocalWebModules(),
|
|
projection?: EffectiveViewProjection | null
|
|
): DashboardWidgetContribution[] {
|
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
|
flatMap((capability) => capability.widgets ?? []).
|
|
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
|
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
|
}
|
|
|
|
export function navItemsForModules(
|
|
modules: PlatformWebModule[],
|
|
projection?: EffectiveViewProjection | null
|
|
): PlatformNavItem[] {
|
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
|
const moduleItems = modules.flatMap((module) =>
|
|
(module.navItems ?? []).map((item) => ({
|
|
...item,
|
|
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
|
|
}))
|
|
);
|
|
return [...shellNavItemsForModules(modules), ...moduleItems].
|
|
map(resolveNavItemIcon).
|
|
filter((item) => item.navigationVisible !== false).
|
|
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
|
}
|
|
|
|
export function configurableNavigationItemsForModules(
|
|
modules: PlatformWebModule[]
|
|
): PlatformNavItem[] {
|
|
return modules
|
|
.flatMap((module) =>
|
|
(module.navItems ?? []).map((item) => ({
|
|
...item,
|
|
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
|
|
navigationId: item.navigationId ?? item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
|
|
}))
|
|
)
|
|
.map(resolveNavItemIcon)
|
|
.sort((left, right) =>
|
|
(left.navigationLayers?.module?.order ?? left.order ?? 100)
|
|
- (right.navigationLayers?.module?.order ?? right.order ?? 100)
|
|
|| left.label.localeCompare(right.label)
|
|
);
|
|
}
|
|
|
|
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
|
return navItemsForModules(modules, projection).filter((item) => {
|
|
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
|
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): string {
|
|
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
|
|
}
|