Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { MetricGrid } from "@govoplan/core-webui";
|
|
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}
|
|
titleHelp={<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />}
|
|
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>
|
|
</>
|
|
}
|
|
>
|
|
{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 && (
|
|
<>
|
|
<MetricGrid columns={5} minimum="compact">
|
|
<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" />
|
|
</MetricGrid>
|
|
|
|
{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();
|
|
}
|