feat: add governed configuration reference selectors
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
@@ -555,7 +555,25 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
const pageSize = 500;
|
||||
const templates: GovernanceTemplateItem[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await apiFetch<{
|
||||
templates: GovernanceTemplateItem[];
|
||||
pages?: number;
|
||||
}>(
|
||||
settings,
|
||||
apiPath("/api/v1/admin/system/governance-templates", {
|
||||
kind,
|
||||
page,
|
||||
page_size: pageSize
|
||||
})
|
||||
);
|
||||
templates.push(...response.templates);
|
||||
if (page >= (response.pages ?? 1)) {
|
||||
return templates;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Download, Play, RefreshCw } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, Button, Card, DataGrid, StatusBadge, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, Button, Card, DataGrid, FormField, ReferenceSelect, StatusBadge, ToggleSwitch, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import {
|
||||
applyConfigurationPackage,
|
||||
createConfigurationChangeRequest,
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
type ConfigurationPackagePlanItem,
|
||||
type ConfigurationPackageRequiredData } from
|
||||
"../../api/admin";
|
||||
import {
|
||||
createChangeRequestReferenceProvider,
|
||||
createTenantReferenceProvider
|
||||
} from "./configurationReferenceProviders";
|
||||
|
||||
const SAMPLE_ACCESS_PACKAGE = {
|
||||
package_id: "govoplan.access.minimal-office",
|
||||
@@ -60,12 +64,14 @@ type ApplyResult = {
|
||||
updated_refs: Record<string, string>;
|
||||
};
|
||||
|
||||
export default function ConfigurationPackagesPanel({ settings, canWrite }: {settings: ApiSettings;canWrite: boolean;}) {
|
||||
export default function ConfigurationPackagesPanel({ settings, auth, canWrite }: {settings: ApiSettings;auth: AuthInfo;canWrite: boolean;}) {
|
||||
const [catalogValidation, setCatalogValidation] = useState<Record<string, unknown> | null>(null);
|
||||
const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2));
|
||||
const [tenantId, setTenantId] = useState("");
|
||||
const activeTenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
const [tenantId, setTenantId] = useState(activeTenantId);
|
||||
const [suppliedDataText, setSuppliedDataText] = useState("{}");
|
||||
const [changeRequestId, setChangeRequestId] = useState("");
|
||||
const [manualReferences, setManualReferences] = useState(false);
|
||||
const [dryRun, setDryRun] = useState<DryRunResult | null>(null);
|
||||
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
||||
const [exportText, setExportText] = useState("");
|
||||
@@ -78,6 +84,22 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
|
||||
const parsedPackage = useMemo(() => parseObject(packageText), [packageText]);
|
||||
const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]);
|
||||
const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value);
|
||||
const tenantProvider = useMemo(
|
||||
() => createTenantReferenceProvider(settings),
|
||||
[settings.accessToken, settings.apiBaseUrl, settings.apiKey]
|
||||
);
|
||||
const changeRequestProvider = useMemo(
|
||||
() => createChangeRequestReferenceProvider(settings, {
|
||||
purpose: "configuration_packages.apply",
|
||||
tenantId
|
||||
}),
|
||||
[
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
tenantId
|
||||
]
|
||||
);
|
||||
|
||||
async function loadCatalog() {
|
||||
setLoading(true);
|
||||
@@ -93,6 +115,9 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
|
||||
}
|
||||
|
||||
useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]);
|
||||
useEffect(() => {
|
||||
setTenantId((current) => current || activeTenantId);
|
||||
}, [activeTenantId]);
|
||||
|
||||
async function runDryRun() {
|
||||
const manifest = requireParsedPackage();
|
||||
@@ -228,8 +253,50 @@ export default function ConfigurationPackagesPanel({ settings, canWrite }: {sett
|
||||
|
||||
<Card title="i18n:govoplan-admin.package.7431e3df">
|
||||
<div className="module-installer-request-grid">
|
||||
<label className="wide"><span>i18n:govoplan-admin.tenant_id.59eba244</span><input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" /></label>
|
||||
<label className="wide"><span>i18n:govoplan-admin.change_request_id.96ee3239</span><input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." /></label>
|
||||
<div className="wide">
|
||||
<ToggleSwitch
|
||||
checked={manualReferences}
|
||||
onChange={setManualReferences}
|
||||
label="Enter reference IDs manually"
|
||||
help="Use only when importing a package that intentionally references a target no longer returned by the live catalog."
|
||||
/>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<FormField label="i18n:govoplan-admin.tenant_id.59eba244">
|
||||
{manualReferences ? (
|
||||
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" />
|
||||
) : (
|
||||
<ReferenceSelect
|
||||
value={tenantId}
|
||||
onChange={(value) => {
|
||||
setTenantId(value);
|
||||
setChangeRequestId("");
|
||||
}}
|
||||
provider={tenantProvider}
|
||||
aria-label="Configuration package tenant"
|
||||
placeholder="Select a tenant"
|
||||
disabled={Boolean(busy)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<FormField label="i18n:govoplan-admin.change_request_id.96ee3239">
|
||||
{manualReferences ? (
|
||||
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." />
|
||||
) : (
|
||||
<ReferenceSelect
|
||||
value={changeRequestId}
|
||||
onChange={(value) => setChangeRequestId(value)}
|
||||
provider={changeRequestProvider}
|
||||
aria-label="Configuration package change request"
|
||||
placeholder="Select an eligible request (optional)"
|
||||
emptyText="No eligible configuration requests."
|
||||
disabled={Boolean(busy)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
<label className="wide"><span>i18n:govoplan-admin.package_json.a2b10f38</span><textarea rows={18} value={packageText} onChange={(event) => setPackageText(event.target.value)} /></label>
|
||||
<label className="wide"><span>i18n:govoplan-admin.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label>
|
||||
</div>
|
||||
|
||||
191
webui/src/features/admin/configurationReferenceProviders.ts
Normal file
191
webui/src/features/admin/configurationReferenceProviders.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
filterSearchableSelectOptions,
|
||||
unavailableReferenceOption,
|
||||
type ApiSettings,
|
||||
type ConfigurationReferenceSelectorsUiCapability,
|
||||
type ReferenceOption,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchConfigurationChanges,
|
||||
fetchTenants,
|
||||
type ConfigurationChangeRequest
|
||||
} from "../../api/admin";
|
||||
|
||||
export const configurationReferenceSelectors: ConfigurationReferenceSelectorsUiCapability = {
|
||||
tenantProvider: createTenantReferenceProvider,
|
||||
changeRequestProvider: createChangeRequestReferenceProvider
|
||||
};
|
||||
|
||||
export function createTenantReferenceProvider(
|
||||
settings: ApiSettings
|
||||
): ReferenceOptionProvider {
|
||||
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||
const tenants = await fetchTenants(settings);
|
||||
if (signal.aborted) throw abortError();
|
||||
return tenants.map((tenant) => ({
|
||||
value: tenant.id,
|
||||
label: tenant.name || tenant.slug || tenant.id,
|
||||
description: [
|
||||
tenant.slug,
|
||||
tenant.is_active ? null : "Inactive",
|
||||
tenant.id
|
||||
].filter(Boolean).join(" · "),
|
||||
kind: "tenant",
|
||||
availability: tenant.is_active ? "available" : "inactive",
|
||||
disabled: !tenant.is_active,
|
||||
sourceModule: "tenancy",
|
||||
provenance: {
|
||||
tenantId: tenant.id,
|
||||
active: tenant.is_active
|
||||
}
|
||||
}));
|
||||
}
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
return retainSelected(
|
||||
filterSearchableSelectOptions(options, query, context.limit),
|
||||
options,
|
||||
context.selectedValues
|
||||
);
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value, "Unavailable tenant")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createChangeRequestReferenceProvider(
|
||||
settings: ApiSettings,
|
||||
{
|
||||
purpose,
|
||||
tenantId
|
||||
}: {
|
||||
purpose: string;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
): ReferenceOptionProvider {
|
||||
async function catalogue(signal: AbortSignal): Promise<{
|
||||
eligible: ReferenceOption[];
|
||||
all: ReferenceOption[];
|
||||
}> {
|
||||
const response = await fetchConfigurationChanges(settings);
|
||||
if (signal.aborted) throw abortError();
|
||||
const matching = response.requests.filter(
|
||||
(request) =>
|
||||
request.key === purpose
|
||||
&& requestTargetsTenant(request, tenantId)
|
||||
);
|
||||
const all = matching.map(changeRequestOption);
|
||||
return {
|
||||
eligible: all.filter((option) => !option.disabled),
|
||||
all
|
||||
};
|
||||
}
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
return retainSelected(
|
||||
filterSearchableSelectOptions(
|
||||
options.eligible,
|
||||
query,
|
||||
context.limit
|
||||
),
|
||||
options.all,
|
||||
context.selectedValues
|
||||
);
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
const byValue = new Map(
|
||||
options.all.map((option) => [option.value, option])
|
||||
);
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(
|
||||
value,
|
||||
"Unavailable configuration request"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function changeRequestOption(
|
||||
request: ConfigurationChangeRequest
|
||||
): ReferenceOption {
|
||||
const closed = request.status === "applied" || request.status === "rejected";
|
||||
const eligible = request.status === "approved" && request.dry_run;
|
||||
return {
|
||||
value: request.id,
|
||||
label: request.label || request.key,
|
||||
description: [
|
||||
request.status.split("_").join(" "),
|
||||
request.dry_run ? null : "dry run not recorded",
|
||||
formatTimestamp(request.requested_at),
|
||||
`requested by ${request.requested_by}`,
|
||||
request.id
|
||||
].filter(Boolean).join(" · "),
|
||||
searchText: `${request.id} ${request.requested_by} ${request.status}`,
|
||||
kind: "configuration_change_request",
|
||||
availability: closed
|
||||
? "unavailable"
|
||||
: eligible
|
||||
? "available"
|
||||
: "inactive",
|
||||
disabled: !eligible,
|
||||
sourceModule: "admin",
|
||||
provenance: {
|
||||
purpose: request.key,
|
||||
target: request.target ?? {},
|
||||
requestedBy: request.requested_by,
|
||||
requestedAt: request.requested_at,
|
||||
status: request.status,
|
||||
dryRunRecorded: request.dry_run
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function requestTargetsTenant(
|
||||
request: ConfigurationChangeRequest,
|
||||
tenantId?: string | null
|
||||
): boolean {
|
||||
if (!tenantId) return true;
|
||||
const targetTenant = request.target?.tenant_id;
|
||||
return !targetTenant || String(targetTenant) === tenantId;
|
||||
}
|
||||
|
||||
function retainSelected(
|
||||
matches: readonly ReferenceOption[],
|
||||
catalogue: readonly ReferenceOption[],
|
||||
selectedValues: readonly string[]
|
||||
): ReferenceOption[] {
|
||||
const result = [...matches];
|
||||
const returned = new Set(result.map((option) => option.value));
|
||||
const byValue = new Map(catalogue.map((option) => [option.value, option]));
|
||||
for (const value of selectedValues) {
|
||||
if (returned.has(value)) continue;
|
||||
result.push(
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export * from "./module";
|
||||
export * from "./api/admin";
|
||||
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
|
||||
export { default as ConfigurationPackagesPanel } from "./features/admin/ConfigurationPackagesPanel";
|
||||
export {
|
||||
configurationReferenceSelectors,
|
||||
createChangeRequestReferenceProvider,
|
||||
createTenantReferenceProvider
|
||||
} from "./features/admin/configurationReferenceProviders";
|
||||
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
||||
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement, lazy } from "react";
|
||||
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import { configurationReferenceSelectors } from "./features/admin/configurationReferenceProviders";
|
||||
|
||||
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
|
||||
const ConfigurationChangesPanel = lazy(() => import("./features/admin/ConfigurationChangesPanel"));
|
||||
@@ -64,6 +65,7 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
allOf: ["system:settings:read"],
|
||||
render: ({ settings, auth }) => createElement(ConfigurationPackagesPanel, {
|
||||
settings,
|
||||
auth,
|
||||
canWrite: hasScope(auth, "system:settings:write")
|
||||
})
|
||||
},
|
||||
@@ -127,7 +129,8 @@ export const adminModule: PlatformWebModule = {
|
||||
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "Modules", order: 85 }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
"admin.sections": adminSections,
|
||||
"admin.configurationReferences": configurationReferenceSelectors
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user