Implement governed distribution lists

This commit is contained in:
2026-07-31 21:00:06 +02:00
parent 5d74f061b0
commit 8c54a5d6c9
26 changed files with 7280 additions and 40 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/dist-lists-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/dist-lists.css": "./src/styles/dist-lists.css"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+309
View File
@@ -0,0 +1,309 @@
import {
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type DefinitionKind = "static" | "parameterized" | "dynamic" | "template";
export type EntryMode = "include" | "exclude" | "override";
export type EntryKind =
| "address_contact"
| "address_list"
| "address_email"
| "raw_email"
| "raw_postal_address"
| "internal_mail"
| "portal"
| "idm_identity"
| "idm_group"
| "organization_unit"
| "function"
| "effective_function_incumbent"
| "dataflow_result"
| "distribution_list";
export type DistributionChannel = "email" | "postal" | "internal_mail" | "portal";
export type DistributionOutcome =
| "usable"
| "unresolved"
| "invalid"
| "suppressed"
| "ambiguous"
| "duplicate"
| "policy_blocked"
| "provider_unavailable"
| "stale";
export type SourceReference = {
provider: string;
resource_type: string;
resource_id: string;
revision?: string | null;
fingerprint?: string | null;
label?: string | null;
metadata: Record<string, unknown>;
};
export type DistributionParameter = {
key: string;
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "string_list";
label?: string | null;
required: boolean;
default?: unknown;
allowed_values: unknown[];
minimum?: number | null;
maximum?: number | null;
pattern?: string | null;
description?: string | null;
};
export type DistributionListEntryInput = {
entry_key?: string | null;
kind: EntryKind;
mode: EntryMode;
source: SourceReference;
label?: string | null;
purpose?: string | null;
requested_channels: DistributionChannel[];
effective_from?: string | null;
effective_until?: string | null;
configuration: Record<string, unknown>;
};
export type DistributionListEntry = DistributionListEntryInput & {
id: string;
entry_key: string;
order: number;
};
export type DistributionListRevision = {
id: string;
revision: number;
definition_kind: DefinitionKind;
definition_hash: string;
parameters: DistributionParameter[];
constraints: Record<string, unknown>;
source_fingerprints: Array<Record<string, unknown>>;
entries: DistributionListEntry[];
created_by_account_id?: string | null;
created_at: string;
};
export type DistributionList = {
id: string;
tenant_id: string;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
name: string;
description?: string | null;
status: string;
current_revision: number;
resource_revision: number;
etag: string;
metadata: Record<string, unknown>;
deleted_at?: string | null;
created_at: string;
updated_at: string;
revision: DistributionListRevision;
};
export type DistributionListPayload = {
name: string;
description?: string | null;
scope_type: "tenant" | "group" | "user";
scope_id?: string | null;
definition_kind: DefinitionKind;
parameters: DistributionParameter[];
constraints: Record<string, unknown>;
entries: DistributionListEntryInput[];
metadata: Record<string, unknown>;
};
export type DistributionExplanation = {
code: string;
message: string;
severity: "info" | "warning" | "error";
provider?: string | null;
source?: SourceReference | null;
provenance: Record<string, unknown>;
};
export type DistributionChannelCandidate = {
channel: DistributionChannel;
target: string;
target_key: string;
status: DistributionOutcome;
contact_point_id?: string | null;
locale?: string | null;
preferred: boolean;
reason_code?: string | null;
explanation?: string | null;
source?: SourceReference | null;
decision_provenance: Record<string, unknown>;
};
export type DistributionRecipient = {
recipient_key: string;
display_name: string;
status: DistributionOutcome;
channels: DistributionChannelCandidate[];
identity_id?: string | null;
account_id?: string | null;
contact_id?: string | null;
organization_unit_id?: string | null;
function_id?: string | null;
source_entry_ids: string[];
explanations: DistributionExplanation[];
attributes: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type ProviderEvidence = {
provider: string;
source: SourceReference;
actual_revision?: string | null;
actual_fingerprint?: string | null;
stale: boolean;
generated_at?: string | null;
details: Record<string, unknown>;
};
export type ExpansionResult = {
list_id: string;
revision_id: string;
revision: number;
definition_hash: string;
recipients: DistributionRecipient[];
excluded: DistributionRecipient[];
diagnostics: DistributionExplanation[];
provider_evidence: ProviderEvidence[];
expansion_hash: string;
generated_at: string;
snapshot_id?: string | null;
stale: boolean;
truncated: boolean;
};
export type ExpansionPayload = {
revision?: number | null;
effective_at?: string | null;
purpose?: string | null;
requested_channels?: DistributionChannel[];
parameters?: Record<string, unknown>;
preview?: boolean;
freeze?: boolean;
idempotency_key?: string | null;
max_entries?: number;
max_results?: number;
max_depth?: number;
max_provider_results?: number;
};
export type DistributionSnapshot = {
id: string;
list_id: string;
revision_id: string;
revision: number;
expansion_hash: string;
effective_at: string;
recipient_count: number;
excluded_count: number;
stale: boolean;
truncated: boolean;
created_at: string;
};
export type ProviderOption = {
key: string;
kind: EntryKind;
label: string;
description?: string | null;
provider: string;
source: SourceReference;
available: boolean;
reason?: string | null;
metadata: Record<string, unknown>;
};
export type ProviderCatalogue = {
items: ProviderOption[];
unavailable_providers: DistributionExplanation[];
};
export async function listDistributionLists(
settings: ApiSettings,
query = ""
): Promise<DistributionList[]> {
const result = await apiFetch<{ items: DistributionList[] }>(
settings,
apiPath("/api/v1/dist-lists/lists", { query, limit: 500 })
);
return result.items;
}
export function createDistributionList(
settings: ApiSettings,
payload: DistributionListPayload
): Promise<DistributionList> {
return apiFetch(settings, "/api/v1/dist-lists/lists", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateDistributionList(
settings: ApiSettings,
item: DistributionList,
payload: DistributionListPayload
): Promise<DistributionList> {
return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(item.id)}`, {
method: "PUT",
headers: { "If-Match": item.etag },
body: JSON.stringify({ ...payload, base_revision: item.resource_revision })
});
}
export function deleteDistributionList(
settings: ApiSettings,
item: DistributionList
): Promise<void> {
return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(item.id)}`, {
method: "DELETE",
headers: { "If-Match": item.etag },
body: JSON.stringify({ base_revision: item.resource_revision })
});
}
export function expandDistributionList(
settings: ApiSettings,
listId: string,
payload: ExpansionPayload
): Promise<ExpansionResult> {
return apiFetch(settings, `/api/v1/dist-lists/lists/${encodeURIComponent(listId)}/expand`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listDistributionSnapshots(
settings: ApiSettings,
listId: string
): Promise<DistributionSnapshot[]> {
const result = await apiFetch<{ items: DistributionSnapshot[] }>(
settings,
`/api/v1/dist-lists/lists/${encodeURIComponent(listId)}/snapshots`
);
return result.items;
}
export function listProviderOptions(
settings: ApiSettings,
query = "",
limit = 50,
signal?: AbortSignal
): Promise<ProviderCatalogue> {
return apiFetch(
settings,
apiPath("/api/v1/dist-lists/providers", { query, limit }),
{ signal }
);
}
@@ -0,0 +1,74 @@
import { useCallback, useMemo, useRef } from "react";
import {
FormField,
SearchableSelect,
type ApiSettings,
type SearchableSelectOption
} from "@govoplan/core-webui";
import {
listDistributionLists,
type DistributionList
} from "../api/distLists";
export type DistributionListPickerProps = {
settings: ApiSettings;
value: string;
onChange: (listId: string, item: DistributionList | null) => void;
label?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
selected?: DistributionList | null;
};
export default function DistributionListPicker({
settings,
value,
onChange,
label = "Distribution list",
placeholder = "Search distribution lists",
disabled = false,
required = false,
selected = null
}: DistributionListPickerProps) {
const cache = useRef(new Map<string, DistributionList>());
if (selected) cache.current.set(selected.id, selected);
const selectedOption = useMemo<SearchableSelectOption | null>(() => {
const item = selected ?? cache.current.get(value);
if (!item) return value ? { value, label: value } : null;
return optionFor(item);
}, [selected, value]);
const loadOptions = useCallback(async (query: string) => {
const items = await listDistributionLists(settings, query);
for (const item of items) cache.current.set(item.id, item);
return items.map(optionFor);
}, [settings]);
return (
<FormField label={label}>
<SearchableSelect
value={value}
selectedOption={selectedOption}
loadOptions={loadOptions}
onChange={(nextValue) =>
onChange(nextValue, cache.current.get(nextValue) ?? null)}
placeholder={placeholder}
searchPlaceholder="Search by list name"
emptyText="No matching distribution lists."
disabled={disabled}
required={required}
/>
</FormField>
);
}
function optionFor(item: DistributionList): SearchableSelectOption {
return {
value: item.id,
label: item.name,
description: `${item.revision.entries.length} entries · revision ${item.current_revision}`,
searchText: `${item.description ?? ""} ${item.scope_type}`
};
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export { default } from "./module";
export * from "./module";
export * from "./api/distLists";
export { default as DistributionListPicker } from "./components/DistributionListPicker";
export type { DistributionListPickerProps } from "./components/DistributionListPicker";
export { default as DistributionListsPage } from "./features/distributionLists/DistributionListsPage";
+47
View File
@@ -0,0 +1,47 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/dist-lists.css";
const DistributionListsPage = lazy(
() => import("./features/distributionLists/DistributionListsPage")
);
const readScopes = [
"dist_lists:list:read",
"dist_lists:list:write",
"dist_lists:list:admin"
];
export const distributionListsModule: PlatformWebModule = {
id: "dist_lists",
label: "Distribution Lists",
version: "0.1.14",
optionalDependencies: [
"addresses",
"identity",
"idm",
"organizations",
"dataflow",
"policy"
],
navItems: [
{
to: "/distribution-lists",
label: "Distribution Lists",
iconName: "list-tree",
anyOf: readScopes,
order: 74
}
],
routes: [
{
path: "/distribution-lists",
anyOf: readScopes,
order: 74,
render: ({ settings, auth }) =>
createElement(DistributionListsPage, { settings, auth })
}
]
};
export default distributionListsModule;
+552
View File
@@ -0,0 +1,552 @@
.dist-lists-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.dist-lists-page *,
.dist-lists-page *::before,
.dist-lists-page *::after {
box-sizing: border-box;
}
.dist-lists-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-sidebar,
.dist-lists-workspace,
.dist-lists-list-frame,
.dist-lists-content-frame,
.dist-lists-content {
min-width: 0;
min-height: 0;
}
.dist-lists-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.dist-lists-sidebar-toolbar,
.dist-lists-workspace-toolbar,
.dist-lists-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.dist-lists-sidebar-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.dist-lists-sidebar-toolbar > span,
.dist-lists-toolbar-actions,
.dist-lists-row-actions,
.dist-lists-preview-actions,
.dist-lists-explanation-summary {
display: flex;
align-items: center;
gap: 7px;
}
.dist-lists-search {
padding: 9px;
border-bottom: var(--border-line);
background: var(--panel);
}
.dist-lists-search input {
width: 100%;
min-height: 34px;
padding: 7px 9px;
}
.dist-lists-list-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dist-lists-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.dist-lists-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
padding: 8px 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
}
.dist-lists-list > button:hover,
.dist-lists-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.dist-lists-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.dist-lists-list > button > span:first-child {
min-width: 0;
}
.dist-lists-list strong,
.dist-lists-list small,
.dist-lists-current-title strong,
.dist-lists-current-title small,
.dist-lists-source-cell strong,
.dist-lists-source-cell small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-list strong,
.dist-lists-current-title strong,
.dist-lists-source-cell strong {
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-list small,
.dist-lists-current-title small,
.dist-lists-source-cell small {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-workspace {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.dist-lists-workspace-toolbar {
min-height: 58px;
padding: 8px 10px 8px 14px;
}
.dist-lists-current-title {
display: block;
min-width: 0;
flex: 1 1 auto;
}
.dist-lists-toolbar-actions {
flex: 0 0 auto;
}
.dist-lists-toolbar-actions .btn {
white-space: nowrap;
}
.dist-lists-alerts {
flex: 0 0 auto;
padding: 0 12px;
}
.dist-lists-alerts:empty {
display: none;
}
.dist-lists-alerts .alert {
margin: 10px 0 0;
}
.dist-lists-content-frame {
flex: 1 1 auto;
overflow: hidden;
}
.dist-lists-content {
height: 100%;
overflow: auto;
padding: 14px;
}
.dist-lists-empty,
.dist-lists-inline-empty {
display: grid;
place-items: center;
min-height: 110px;
padding: 18px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.dist-lists-definition-fields {
display: grid;
grid-template-columns: minmax(220px, 1.4fr) minmax(160px, 0.7fr) minmax(160px, 0.7fr);
gap: 12px;
margin-bottom: 14px;
}
.dist-lists-definition-fields .form-field:last-child {
grid-column: 1 / -1;
}
.dist-lists-definition-fields input,
.dist-lists-definition-fields select,
.dist-lists-definition-fields textarea,
.dist-lists-dialog-form input,
.dist-lists-dialog-form select,
.dist-lists-dialog-form textarea,
.dist-lists-preview-inputs input,
.dist-lists-parameter-row input,
.dist-lists-parameter-row select {
width: 100%;
}
.dist-lists-definition-fields textarea {
resize: vertical;
}
.dist-lists-section {
min-width: 0;
margin-bottom: 14px;
border: var(--border-line);
background: var(--panel);
}
.dist-lists-entry-section {
min-height: 260px;
}
.dist-lists-section-heading {
min-height: 44px;
padding: 7px 10px;
}
.dist-lists-section-heading > span {
min-width: 0;
}
.dist-lists-section-heading strong,
.dist-lists-section-heading small {
display: block;
}
.dist-lists-section-heading strong {
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-section-heading small {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-parameter-list {
padding: 8px;
}
.dist-lists-parameter-row {
display: grid;
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1.2fr) minmax(120px, 0.8fr) minmax(130px, 1fr) auto 36px;
align-items: center;
gap: 7px;
padding: 7px;
border-bottom: var(--border-line);
}
.dist-lists-parameter-row:last-child {
border-bottom: 0;
}
.dist-lists-grid {
min-width: 0;
}
.dist-lists-row-actions {
justify-content: flex-end;
}
.dist-lists-preview-controls {
display: flex;
align-items: end;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: var(--border-line);
}
.dist-lists-preview-inputs {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr));
gap: 10px;
flex: 1 1 auto;
}
.dist-lists-preview-actions {
flex: 0 0 auto;
padding-bottom: 1px;
}
.dist-lists-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.dist-lists-metrics > span {
min-height: 62px;
padding: 9px 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.dist-lists-metrics small,
.dist-lists-metrics strong {
display: block;
}
.dist-lists-metrics small {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
}
.dist-lists-metrics strong {
margin-top: 6px;
color: var(--text-strong);
font-size: 15px;
text-transform: capitalize;
}
.dist-lists-content > .alert {
margin: 8px 0;
}
.dist-lists-result-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 0 8px;
}
.dist-lists-result-heading code,
.dist-lists-explanation-summary code {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-candidates {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-lists-dialog-form {
display: grid;
gap: 12px;
}
.dist-lists-entry-dialog {
width: min(780px, calc(100vw - 32px));
max-width: 780px;
}
.dist-lists-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.dist-lists-provider-notices {
display: grid;
max-height: 150px;
overflow: auto;
}
.dist-lists-provider-notices .alert {
margin: 4px 0;
}
.dist-lists-channel-fieldset {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 10px;
border: var(--border-line);
}
.dist-lists-channel-fieldset legend {
padding: 0 5px;
color: var(--muted);
font-size: 11px;
}
.dist-lists-explanation-dialog {
width: min(760px, calc(100vw - 32px));
max-width: 760px;
}
.dist-lists-explanation-content {
display: grid;
gap: 9px;
}
.dist-lists-explanation-content h3 {
margin: 8px 0 0;
color: var(--text-strong);
font-size: 13px;
}
.dist-lists-decision {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px 10px;
padding: 10px;
border: var(--border-line);
border-left: 3px solid var(--accent);
background: var(--panel-soft);
}
.dist-lists-decision span strong,
.dist-lists-decision span small {
display: block;
}
.dist-lists-decision span small {
margin-top: 3px;
color: var(--muted);
}
.dist-lists-decision p,
.dist-lists-decision pre {
grid-column: 1 / -1;
margin: 0;
}
.dist-lists-explanation-content pre {
max-height: 210px;
margin: 0;
padding: 9px;
overflow: auto;
border: var(--border-line);
background: var(--panel-soft);
color: var(--text);
font-size: 11px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1040px) {
.dist-lists-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
}
.dist-lists-workspace-toolbar {
align-items: flex-start;
}
.dist-lists-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
}
.dist-lists-definition-fields,
.dist-lists-preview-inputs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dist-lists-parameter-row {
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
}
@media (max-width: 760px) {
.dist-lists-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.dist-lists-shell {
display: flex;
min-height: calc(100vh - 115px);
overflow: visible;
flex-direction: column;
}
.dist-lists-sidebar {
min-height: 230px;
max-height: 36vh;
border-right: 0;
border-bottom: var(--border-line);
}
.dist-lists-workspace {
min-height: 560px;
}
.dist-lists-workspace-toolbar,
.dist-lists-preview-controls {
align-items: stretch;
flex-direction: column;
}
.dist-lists-toolbar-actions,
.dist-lists-preview-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.dist-lists-definition-fields,
.dist-lists-preview-inputs,
.dist-lists-form-grid,
.dist-lists-metrics,
.dist-lists-channel-fieldset,
.dist-lists-parameter-row {
grid-template-columns: minmax(0, 1fr);
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference path="../../../govoplan-core/webui/src/vite-env.d.ts" />
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
}
},
"include": ["src"]
}