Add tenant module entitlement administration
This commit is contained in:
@@ -186,6 +186,42 @@ export type ModuleCatalogResponse = {
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type TenantModuleAvailability = "unavailable" | "available" | "forced";
|
||||
|
||||
export type TenantModuleEntitlementItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
dependencies: string[];
|
||||
runtime_active: boolean;
|
||||
availability: TenantModuleAvailability;
|
||||
selected: boolean;
|
||||
effective: boolean;
|
||||
forced: boolean;
|
||||
derived_dependency: boolean;
|
||||
tenant_can_toggle: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type TenantModuleEntitlementResponse = {
|
||||
tenant_id: string;
|
||||
revision: number;
|
||||
configured: boolean;
|
||||
available_modules: string[];
|
||||
forced_modules: string[];
|
||||
selected_modules: string[];
|
||||
effective_modules: string[];
|
||||
derived_dependencies: string[];
|
||||
modules: TenantModuleEntitlementItem[];
|
||||
diagnostics: Array<{ code: string; message: string; severity: string }>;
|
||||
};
|
||||
|
||||
export type TenantModuleTarget = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export type ModuleInstallPlanItem = {
|
||||
module_id: string;
|
||||
action: "install" | "update" | "uninstall";
|
||||
@@ -504,6 +540,46 @@ export function updateModuleState(settings: ApiSettings, enabledModules: string[
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSystemTenantModules(settings: ApiSettings, tenantId: string): Promise<TenantModuleEntitlementResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`);
|
||||
}
|
||||
|
||||
export async function fetchTenantModuleTargets(settings: ApiSettings): Promise<TenantModuleTarget[]> {
|
||||
const response = await apiFetch<{ tenants: TenantModuleTarget[] }>(settings, "/api/v1/admin/system/tenant-module-targets");
|
||||
return response.tenants;
|
||||
}
|
||||
|
||||
export function updateSystemTenantModules(
|
||||
settings: ApiSettings,
|
||||
tenantId: string,
|
||||
payload: {
|
||||
available_modules: string[];
|
||||
forced_modules: string[];
|
||||
enabled_modules: string[];
|
||||
expected_revision: number;
|
||||
change_request_id?: string | null;
|
||||
}
|
||||
): Promise<TenantModuleEntitlementResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchTenantModules(settings: ApiSettings): Promise<TenantModuleEntitlementResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/modules");
|
||||
}
|
||||
|
||||
export function updateTenantModules(
|
||||
settings: ApiSettings,
|
||||
payload: { enabled_modules: string[]; expected_revision: number }
|
||||
): Promise<TenantModuleEntitlementResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/modules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
|
||||
<div className="admin-overview-grid">
|
||||
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
|
||||
{availableSections.has("system-tenant-modules") && <AreaLink title="Tenant modules" text="Set per-tenant availability, forced modules, and current selection." onClick={() => onSelect("system-tenant-modules")} />}
|
||||
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
||||
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
|
||||
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
|
||||
@@ -61,6 +62,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />}
|
||||
{availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />}
|
||||
{availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" onClick={() => onSelect("system-retention")} />}
|
||||
{availableSections.has("system-view-policy") && <AreaLink title="View policy" text="Limit View actions, definitions, and surfaces across the instance." onClick={() => onSelect("system-view-policy")} />}
|
||||
</div>
|
||||
</Card>}
|
||||
|
||||
@@ -81,6 +83,8 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />}
|
||||
{availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />}
|
||||
{availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" onClick={() => onSelect("tenant-retention")} />}
|
||||
{availableSections.has("tenant-modules") && <AreaLink title="Modules" text="Choose modules made available to this tenant by system policy." onClick={() => onSelect("tenant-modules")} />}
|
||||
{availableSections.has("tenant-view-policy") && <AreaLink title="View policy" text="Narrow inherited View actions and available surfaces for this tenant." onClick={() => onSelect("tenant-view-policy")} />}
|
||||
{availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />}
|
||||
{availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
|
||||
</div>
|
||||
@@ -91,6 +95,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />}
|
||||
{availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />}
|
||||
{availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" onClick={() => onSelect("tenant-group-retention")} />}
|
||||
{availableSections.has("group-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected group." onClick={() => onSelect("group-view-policy")} />}
|
||||
</div>
|
||||
</Card>}
|
||||
|
||||
@@ -99,6 +104,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />}
|
||||
{availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />}
|
||||
{availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" onClick={() => onSelect("tenant-user-retention")} />}
|
||||
{availableSections.has("user-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected user." onClick={() => onSelect("user-view-policy")} />}
|
||||
</div>
|
||||
</Card>}
|
||||
</>}
|
||||
@@ -106,11 +112,11 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
|
||||
}
|
||||
|
||||
const platformSectionIds = ["system-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
|
||||
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention"];
|
||||
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-settings", "tenant-audit"];
|
||||
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention"];
|
||||
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention"];
|
||||
const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
|
||||
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention", "system-view-policy"];
|
||||
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-view-policy", "tenant-modules", "tenant-settings", "tenant-audit"];
|
||||
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention", "group-view-policy"];
|
||||
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention", "user-view-policy"];
|
||||
|
||||
function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean {
|
||||
return candidates.some((section) => sections.has(section));
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
adminErrorMessage,
|
||||
Button,
|
||||
Card,
|
||||
dispatchPlatformModulesChanged,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
MetricCard,
|
||||
SearchableSelect,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import { RefreshCw, Save, Undo2 } from "lucide-react";
|
||||
import {
|
||||
fetchSystemTenantModules,
|
||||
fetchTenantModuleTargets,
|
||||
fetchTenantModules,
|
||||
updateSystemTenantModules,
|
||||
updateTenantModules,
|
||||
type TenantModuleAvailability,
|
||||
type TenantModuleEntitlementResponse,
|
||||
type TenantModuleTarget
|
||||
} from "../../api/admin";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scope: "system" | "tenant";
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
available: Set<string>;
|
||||
forced: Set<string>;
|
||||
enabled: Set<string>;
|
||||
};
|
||||
|
||||
const DOCUMENTATION = {
|
||||
contextId: "admin.tenant-modules",
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
|
||||
export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) {
|
||||
const [targets, setTargets] = useState<TenantModuleTarget[]>([]);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [state, setState] = useState<TenantModuleEntitlementResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const targetOptions = useMemo<SearchableSelectOption[]>(() => targets.map((target) => ({
|
||||
value: target.id,
|
||||
label: target.name,
|
||||
description: `${target.slug}${target.is_active ? "" : " - inactive"}`,
|
||||
searchText: `${target.name} ${target.slug}`
|
||||
})), [targets]);
|
||||
|
||||
const dirty = Boolean(state && draft && (
|
||||
!sameSet(draft.available, new Set(state.available_modules))
|
||||
|| !sameSet(draft.forced, new Set(state.forced_modules))
|
||||
|| !sameSet(draft.enabled, new Set(state.selected_modules))
|
||||
));
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: discard
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void initialize();
|
||||
}, [scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
if (scope === "system") {
|
||||
const loadedTargets = await fetchTenantModuleTargets(settings);
|
||||
setTargets(loadedTargets);
|
||||
const nextTarget = loadedTargets.some((item) => item.id === targetId)
|
||||
? targetId
|
||||
: loadedTargets[0]?.id ?? "";
|
||||
setTargetId(nextTarget);
|
||||
if (nextTarget) {
|
||||
await load(nextTarget, false);
|
||||
} else {
|
||||
setState(null);
|
||||
setDraft(null);
|
||||
}
|
||||
} else {
|
||||
setTargets([]);
|
||||
setTargetId("");
|
||||
await load(undefined, false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
setState(null);
|
||||
setDraft(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function load(nextTargetId = targetId, manageLoading = true) {
|
||||
if (scope === "system" && !nextTargetId) return;
|
||||
if (manageLoading) setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = scope === "system"
|
||||
? await fetchSystemTenantModules(settings, nextTargetId)
|
||||
: await fetchTenantModules(settings);
|
||||
setState(loaded);
|
||||
setDraft(draftFromState(loaded));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
if (manageLoading) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTarget(nextTargetId: string) {
|
||||
if (!nextTargetId || nextTargetId === targetId) return;
|
||||
setTargetId(nextTargetId);
|
||||
await load(nextTargetId);
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (state) setDraft(draftFromState(state));
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!state || !draft || !dirty) return true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const loaded = scope === "system"
|
||||
? await updateSystemTenantModules(settings, targetId, {
|
||||
available_modules: sorted(draft.available),
|
||||
forced_modules: sorted(draft.forced),
|
||||
enabled_modules: sorted(draft.enabled),
|
||||
expected_revision: state.revision
|
||||
})
|
||||
: await updateTenantModules(settings, {
|
||||
enabled_modules: sorted(draft.enabled),
|
||||
expected_revision: state.revision
|
||||
});
|
||||
setState(loaded);
|
||||
setDraft(draftFromState(loaded));
|
||||
setSuccess("Tenant module selection saved.");
|
||||
dispatchPlatformModulesChanged();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setAvailability(moduleId: string, availability: TenantModuleAvailability) {
|
||||
setDraft((current) => {
|
||||
if (!current) return current;
|
||||
const next = cloneDraft(current);
|
||||
if (availability === "unavailable") {
|
||||
next.available.delete(moduleId);
|
||||
next.forced.delete(moduleId);
|
||||
next.enabled.delete(moduleId);
|
||||
} else {
|
||||
next.available.add(moduleId);
|
||||
if (availability === "forced") next.forced.add(moduleId);
|
||||
else next.forced.delete(moduleId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setEnabled(moduleId: string, enabled: boolean) {
|
||||
setDraft((current) => {
|
||||
if (!current) return current;
|
||||
const next = cloneDraft(current);
|
||||
if (enabled) next.enabled.add(moduleId);
|
||||
else next.enabled.delete(moduleId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const labels = scope === "system"
|
||||
? {
|
||||
title: "Tenant modules",
|
||||
description: "Set a tenant's module ceiling, forced modules, and current selection."
|
||||
}
|
||||
: {
|
||||
title: "Modules",
|
||||
description: "Enable or disable modules made available to this tenant by system policy."
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title={labels.title}
|
||||
description={labels.description}
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
title="Reload saved module policy"
|
||||
aria-label="Reload saved module policy"
|
||||
onClick={() => void load()}
|
||||
disabled={loading || busy || (scope === "system" && !targetId)}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
<Button onClick={discard} disabled={!dirty || busy}>
|
||||
<Undo2 size={16} /> Discard
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>
|
||||
<Save size={16} /> {busy ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{scope === "system" && (
|
||||
<FormField label="Tenant" documentation={DOCUMENTATION}>
|
||||
<SearchableSelect
|
||||
value={targetId}
|
||||
options={targetOptions}
|
||||
onChange={(value) => void selectTarget(value)}
|
||||
placeholder="Select tenant"
|
||||
searchPlaceholder="Search tenants..."
|
||||
disabled={loading || busy || targetOptions.length === 0}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{state && draft && (
|
||||
<>
|
||||
<div className="metric-grid module-management-metrics">
|
||||
<MetricCard label="Available" value={draft.available.size} tone="info" />
|
||||
<MetricCard label="Forced" value={draft.forced.size} tone="warning" />
|
||||
<MetricCard label="Selected" value={draft.enabled.size} />
|
||||
<MetricCard label="Effective now" value={state.effective_modules.length} tone="good" />
|
||||
</div>
|
||||
|
||||
{state.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
|
||||
{diagnostic.message}
|
||||
</DismissibleAlert>
|
||||
))}
|
||||
|
||||
<Card title={scope === "system" ? "Module policy and tenant selection" : "Tenant module selection"}>
|
||||
<div className="module-management-list">
|
||||
{state.modules.map((module) => {
|
||||
const availability = draftAvailability(draft, module.id);
|
||||
const effectiveSelection = draft.enabled.has(module.id) || availability === "forced" || module.derived_dependency;
|
||||
const selectionLocked = availability !== "available" || module.derived_dependency;
|
||||
return (
|
||||
<div className={`module-management-row${dirtyModule(state, draft, module.id) ? " pending" : ""}`} key={module.id}>
|
||||
<div className="module-management-main">
|
||||
<div className="module-management-title">
|
||||
<strong>{module.name}</strong>
|
||||
<code>{module.id}</code>
|
||||
<StatusBadge
|
||||
status={module.runtime_active ? "success" : "neutral"}
|
||||
label={module.runtime_active ? "Runtime active" : "Runtime inactive"}
|
||||
/>
|
||||
{module.effective && <StatusBadge status="info" label="Effective" />}
|
||||
</div>
|
||||
<div className="module-management-details">
|
||||
<span>{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"}</span>
|
||||
{module.reason && <span>{module.reason}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-management-toggle">
|
||||
{scope === "system" && (
|
||||
<label>
|
||||
<span>System policy</span>
|
||||
<select
|
||||
value={availability}
|
||||
onChange={(event) => setAvailability(module.id, event.target.value as TenantModuleAvailability)}
|
||||
disabled={!canWrite || busy || (module.id === "access" || module.id === "admin")}
|
||||
>
|
||||
<option value="unavailable">Unavailable</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="forced">Forced</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
label="Enabled for tenant"
|
||||
checked={effectiveSelection}
|
||||
onChange={(checked) => setEnabled(module.id, checked)}
|
||||
disabled={!canWrite || busy || selectionLocked}
|
||||
help={module.reason || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !state && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
{scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromState(state: TenantModuleEntitlementResponse): Draft {
|
||||
return {
|
||||
available: new Set(state.available_modules),
|
||||
forced: new Set(state.forced_modules),
|
||||
enabled: new Set(state.selected_modules)
|
||||
};
|
||||
}
|
||||
|
||||
function cloneDraft(draft: Draft): Draft {
|
||||
return {
|
||||
available: new Set(draft.available),
|
||||
forced: new Set(draft.forced),
|
||||
enabled: new Set(draft.enabled)
|
||||
};
|
||||
}
|
||||
|
||||
function draftAvailability(draft: Draft, moduleId: string): TenantModuleAvailability {
|
||||
if (draft.forced.has(moduleId)) return "forced";
|
||||
if (draft.available.has(moduleId)) return "available";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
function dirtyModule(state: TenantModuleEntitlementResponse, draft: Draft, moduleId: string): boolean {
|
||||
return state.available_modules.includes(moduleId) !== draft.available.has(moduleId)
|
||||
|| state.forced_modules.includes(moduleId) !== draft.forced.has(moduleId)
|
||||
|| state.selected_modules.includes(moduleId) !== draft.enabled.has(moduleId);
|
||||
}
|
||||
|
||||
function sameSet(left: Set<string>, right: Set<string>): boolean {
|
||||
return left.size === right.size && [...left].every((item) => right.has(item));
|
||||
}
|
||||
|
||||
function sorted(values: Set<string>): string[] {
|
||||
return [...values].sort();
|
||||
}
|
||||
@@ -9,5 +9,6 @@ export {
|
||||
createTenantReferenceProvider
|
||||
} from "./features/admin/configurationReferenceProviders";
|
||||
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
||||
export { default as TenantModuleManagementPanel } from "./features/admin/TenantModuleManagementPanel";
|
||||
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
|
||||
+34
-1
@@ -9,6 +9,7 @@ const ConfigurationChangesPanel = lazy(() => import("./features/admin/Configurat
|
||||
const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel"));
|
||||
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
||||
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
||||
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
|
||||
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
||||
|
||||
const translations = {
|
||||
@@ -108,6 +109,36 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "system-tenant-modules",
|
||||
moduleId: "admin",
|
||||
kind: "management",
|
||||
surfaceId: "admin.section.system-tenant-modules",
|
||||
label: "Tenant modules",
|
||||
group: "SYSTEM",
|
||||
order: 86,
|
||||
allOf: ["system:settings:read"],
|
||||
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||
settings,
|
||||
scope: "system",
|
||||
canWrite: hasScope(auth, "system:settings:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "tenant-modules",
|
||||
moduleId: "admin",
|
||||
kind: "management",
|
||||
surfaceId: "admin.section.tenant-modules",
|
||||
label: "Modules",
|
||||
group: "TENANT",
|
||||
order: 60,
|
||||
anyOf: ["admin:module:read", "admin:module:write", "system:settings:read"],
|
||||
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||
settings,
|
||||
scope: "tenant",
|
||||
canWrite: hasScope(auth, "admin:module:write") || hasScope(auth, "system:settings:write")
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "system-groups",
|
||||
moduleId: "admin",
|
||||
@@ -140,7 +171,9 @@ export const adminModule: PlatformWebModule = {
|
||||
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", order: 30 },
|
||||
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.tenant_roles.51aca82d", order: 40 },
|
||||
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
|
||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 }
|
||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 },
|
||||
{ id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 },
|
||||
{ id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections,
|
||||
|
||||
Reference in New Issue
Block a user