Add tenant module entitlement administration
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user