feat(idm): administer typed identity relationships
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/idm-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -10,6 +10,8 @@ function assert(condition, message) {
|
||||
|
||||
const page = source("../src/features/IdmPage.tsx");
|
||||
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
||||
const relationships = source("../src/features/TypedRelationshipsPanel.tsx");
|
||||
const api = source("../src/api/idm.ts");
|
||||
const patterns = source("../src/features/interfacePatterns.ts");
|
||||
const moduleSource = source("../src/module.ts");
|
||||
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||
@@ -26,5 +28,15 @@ assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label
|
||||
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
|
||||
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
|
||||
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
|
||||
assert(page.includes("TypedRelationshipsPanel") && page.includes("<TypedRelationshipsPanel settings={settings} auth={auth} />"), "The IDM workspace exposes typed-group and relationship administration");
|
||||
assert(relationships.includes("SearchableSelect") && relationships.includes('aria-label="Subject identity"') && relationships.includes('aria-label="Target typed group"') && relationships.includes('aria-label="Related identity"'), "Identity and group references use searchable selectors");
|
||||
assert(relationships.includes('"future" | "active" | "expired" | "revoked"') && relationships.includes("relationshipState(row)"), "Relationship lifecycle states remain visually distinct");
|
||||
assert(relationships.includes("revocationReason.trim()") && relationships.includes('helpContextId="idm.relationships.confirm-revoke"'), "Relationship revocation requires a reason and explicit governed confirmation");
|
||||
assert(relationships.includes("resolveTypedGroupMemberships") && relationships.includes('id="idm-typed-group-membership-resolution"'), "Effective group membership is inspectable through the shared resolver");
|
||||
assert(relationships.includes("sourceResourceId") && relationships.includes("sourceRevision") && relationships.includes("provenance"), "Relationship administration retains source and provenance evidence");
|
||||
assert(api.includes("/api/v1/idm/typed-groups") && api.includes("/api/v1/idm/relationships") && api.includes("/memberships?"), "The WebUI API uses the implemented relationship lifecycle endpoints");
|
||||
assert(moduleSource.includes('"idm:relationship:read"') && moduleSource.includes('"idm:relationship:write"'), "Relationship-only administrators can enter the IDM product surface");
|
||||
assert(translations.includes('"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen"') && translations.includes('"Revoked": "Widerrufen"'), "The relationship administration vocabulary has German reference translations");
|
||||
assert(!relationships.includes("window.confirm"), "Relationship administration does not use browser-native consequential confirmation");
|
||||
|
||||
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||
|
||||
+194
-2
@@ -49,6 +49,117 @@ export type IdentityListResponse = {
|
||||
identities: IdentityOption[];
|
||||
};
|
||||
|
||||
export type TypedGroupItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
group_type: string;
|
||||
description?: string | null;
|
||||
status: "active" | "inactive";
|
||||
source_provider: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
revision: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TypedGroupList = {
|
||||
groups: TypedGroupItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TypedGroupPayload = {
|
||||
key: string;
|
||||
name: string;
|
||||
group_type: string;
|
||||
description?: string | null;
|
||||
source_provider?: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TypedGroupUpdatePayload = Partial<TypedGroupPayload> & {
|
||||
base_revision: number;
|
||||
status?: "active" | "inactive";
|
||||
};
|
||||
|
||||
export type IdentityRelationshipItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
relationship_kind: string;
|
||||
subject_identity_id: string;
|
||||
target_group_id?: string | null;
|
||||
related_identity_id?: string | null;
|
||||
role?: string | null;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
status: "active" | "revoked";
|
||||
revoked_at?: string | null;
|
||||
revoked_by?: string | null;
|
||||
revocation_reason?: string | null;
|
||||
expired_event_at?: string | null;
|
||||
source_provider: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
provenance: Record<string, unknown>;
|
||||
revision: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipList = {
|
||||
relationships: IdentityRelationshipItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipPayload = {
|
||||
relationship_kind: string;
|
||||
subject_identity_id: string;
|
||||
target_group_id?: string | null;
|
||||
related_identity_id?: string | null;
|
||||
role?: string | null;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
source_provider?: string;
|
||||
source_resource_type?: string | null;
|
||||
source_resource_id?: string | null;
|
||||
source_revision?: string | null;
|
||||
properties?: Record<string, unknown>;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipUpdatePayload = Omit<
|
||||
Partial<IdentityRelationshipPayload>,
|
||||
"subject_identity_id"
|
||||
> & {
|
||||
base_revision: number;
|
||||
};
|
||||
|
||||
export type IdentityRelationshipDecisionItem = {
|
||||
relationship: IdentityRelationshipItem;
|
||||
included: boolean;
|
||||
code: string;
|
||||
explanation: string;
|
||||
identity_status?: string | null;
|
||||
};
|
||||
|
||||
export type TypedGroupMembershipResolution = {
|
||||
group: TypedGroupItem;
|
||||
effective_at: string;
|
||||
decisions: IdentityRelationshipDecisionItem[];
|
||||
identity_ids: string[];
|
||||
};
|
||||
|
||||
export type OrganizationFunctionAssignmentItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -218,12 +329,93 @@ export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayl
|
||||
return apiPatchJson(settings, "/api/v1/idm/settings", payload);
|
||||
}
|
||||
|
||||
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
|
||||
export function searchOrganizationIdentityOptions(
|
||||
settings: ApiSettings,
|
||||
query = "",
|
||||
limit = 50,
|
||||
signal?: AbortSignal
|
||||
): Promise<IdentityListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) params.set("query", trimmed);
|
||||
params.set("limit", String(limit));
|
||||
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
|
||||
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`, { signal });
|
||||
}
|
||||
|
||||
export function getTypedGroups(
|
||||
settings: ApiSettings,
|
||||
options: { query?: string; includeInactive?: boolean; limit?: number; signal?: AbortSignal } = {}
|
||||
): Promise<TypedGroupList> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.query?.trim()) params.set("query", options.query.trim());
|
||||
if (options.includeInactive) params.set("include_inactive", "true");
|
||||
params.set("limit", String(options.limit ?? 1000));
|
||||
return apiFetch<TypedGroupList>(settings, `/api/v1/idm/typed-groups?${params.toString()}`, { signal: options.signal });
|
||||
}
|
||||
|
||||
export function createTypedGroup(settings: ApiSettings, payload: TypedGroupPayload): Promise<TypedGroupItem> {
|
||||
return apiPostJson(settings, "/api/v1/idm/typed-groups", payload);
|
||||
}
|
||||
|
||||
export function patchTypedGroup(
|
||||
settings: ApiSettings,
|
||||
groupId: string,
|
||||
payload: TypedGroupUpdatePayload
|
||||
): Promise<TypedGroupItem> {
|
||||
return apiPatchJson(settings, `/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}`, payload);
|
||||
}
|
||||
|
||||
export function getIdentityRelationships(
|
||||
settings: ApiSettings,
|
||||
options: { includeRevoked?: boolean; identityId?: string; groupId?: string; relationshipKind?: string; limit?: number } = {}
|
||||
): Promise<IdentityRelationshipList> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.includeRevoked) params.set("include_revoked", "true");
|
||||
if (options.identityId) params.set("identity_id", options.identityId);
|
||||
if (options.groupId) params.set("group_id", options.groupId);
|
||||
if (options.relationshipKind) params.set("relationship_kind", options.relationshipKind);
|
||||
params.set("limit", String(options.limit ?? 1000));
|
||||
return apiFetch<IdentityRelationshipList>(settings, `/api/v1/idm/relationships?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
payload: IdentityRelationshipPayload
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPostJson(settings, "/api/v1/idm/relationships", payload);
|
||||
}
|
||||
|
||||
export function patchIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
relationshipId: string,
|
||||
payload: IdentityRelationshipUpdatePayload
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPatchJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationshipId)}`, payload);
|
||||
}
|
||||
|
||||
export function revokeIdentityRelationship(
|
||||
settings: ApiSettings,
|
||||
relationship: Pick<IdentityRelationshipItem, "id" | "revision">,
|
||||
reason: string
|
||||
): Promise<IdentityRelationshipItem> {
|
||||
return apiPostJson(settings, `/api/v1/idm/relationships/${encodeURIComponent(relationship.id)}/revoke`, {
|
||||
base_revision: relationship.revision,
|
||||
reason
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTypedGroupMemberships(
|
||||
settings: ApiSettings,
|
||||
groupId: string,
|
||||
options: { effectiveAt?: string; relationshipKinds?: string[] } = {}
|
||||
): Promise<TypedGroupMembershipResolution> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.effectiveAt) params.set("effective_at", options.effectiveAt);
|
||||
for (const kind of options.relationshipKinds ?? ["member"]) params.append("relationship_kind", kind);
|
||||
return apiFetch<TypedGroupMembershipResolution>(
|
||||
settings,
|
||||
`/api/v1/idm/typed-groups/${encodeURIComponent(groupId)}/memberships?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export function createOrganizationFunctionAssignment(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type OrganizationUnitItem
|
||||
} from "../api/idm";
|
||||
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||
import TypedRelationshipsPanel from "./TypedRelationshipsPanel";
|
||||
import {
|
||||
IDM_DOCUMENTATION,
|
||||
IDM_FIELD_DOCUMENTATION,
|
||||
@@ -277,7 +278,10 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
const appliedInitialQueryRef = useRef(false);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canReadAssignments = hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign");
|
||||
const canUseFunctionChanges = hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_request:create") || hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||
const canUseAssignmentWorkspace = canReadAssignments || canUseFunctionChanges;
|
||||
const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read");
|
||||
const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write");
|
||||
const canManageSettings = hasScope(auth, "idm:settings:write");
|
||||
@@ -336,8 +340,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
setError("");
|
||||
try {
|
||||
const [nextModel, nextAssignments, nextSettings] = await Promise.all([
|
||||
getOrganizationModel(settings),
|
||||
getOrganizationFunctionAssignments(settings),
|
||||
canUseAssignmentWorkspace ? getOrganizationModel(settings) : Promise.resolve(EMPTY_MODEL),
|
||||
canReadAssignments ? getOrganizationFunctionAssignments(settings) : Promise.resolve({ assignments: [], total: 0, page: 1, page_size: 0, pages: 1 }),
|
||||
canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null)
|
||||
]);
|
||||
setModel(nextModel);
|
||||
@@ -371,7 +375,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canReadSettings, canSearchIdentities, initialFunctionFilter, initialQuery.assignmentId, settings]);
|
||||
}, [canReadAssignments, canReadSettings, canSearchIdentities, canUseAssignmentWorkspace, initialQuery.assignmentId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
@@ -640,7 +644,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
{!canManage && (
|
||||
{canReadAssignments && !canManage && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.writeReason,
|
||||
@@ -656,7 +660,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
documentation={IDM_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
{!model.functions.length && !loading && (
|
||||
{canUseAssignmentWorkspace && !model.functions.length && !loading && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: IDM_INTERFACE_I18N.noFunctions,
|
||||
@@ -720,14 +724,18 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
{canUseFunctionChanges && (
|
||||
<FunctionAssignmentChangesPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
model={model}
|
||||
identities={identityOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||
<TypedRelationshipsPanel settings={settings} auth={auth} />
|
||||
|
||||
{canReadAssignments && <Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||
<DataGrid
|
||||
id="idm-organization-function-assignments"
|
||||
rows={assignments}
|
||||
@@ -737,7 +745,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
|
||||
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
|
||||
initialFit="container"
|
||||
/>
|
||||
</Card>
|
||||
</Card>}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
{renderAssignmentDialog()}
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
ActionToolbar,
|
||||
AdminIconButton,
|
||||
ApiError,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
DateTimeField,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
FormLayout,
|
||||
LoadingFrame,
|
||||
SearchableSelect,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
usePlatformLanguage,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
type SearchableSelectOption
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createIdentityRelationship,
|
||||
createTypedGroup,
|
||||
getIdentityRelationships,
|
||||
getTypedGroups,
|
||||
patchIdentityRelationship,
|
||||
patchTypedGroup,
|
||||
resolveTypedGroupMemberships,
|
||||
revokeIdentityRelationship,
|
||||
searchOrganizationIdentityOptions,
|
||||
type IdentityOption,
|
||||
type IdentityRelationshipDecisionItem,
|
||||
type IdentityRelationshipItem,
|
||||
type IdentityRelationshipPayload,
|
||||
type TypedGroupItem,
|
||||
type TypedGroupMembershipResolution,
|
||||
type TypedGroupPayload
|
||||
} from "../api/idm";
|
||||
import { IDM_RELATIONSHIP_DOCUMENTATION } from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
type GroupDraft = {
|
||||
key: string;
|
||||
name: string;
|
||||
groupType: string;
|
||||
description: string;
|
||||
status: "active" | "inactive";
|
||||
sourceProvider: string;
|
||||
sourceResourceType: string;
|
||||
sourceResourceId: string;
|
||||
sourceRevision: string;
|
||||
properties: string;
|
||||
provenance: string;
|
||||
};
|
||||
|
||||
type RelationshipDraft = {
|
||||
relationshipKind: string;
|
||||
subjectIdentityId: string;
|
||||
targetType: "group" | "identity";
|
||||
targetGroupId: string;
|
||||
relatedIdentityId: string;
|
||||
role: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
sourceProvider: string;
|
||||
sourceResourceType: string;
|
||||
sourceResourceId: string;
|
||||
sourceRevision: string;
|
||||
properties: string;
|
||||
provenance: string;
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
key: "",
|
||||
name: "",
|
||||
groupType: "business_group",
|
||||
description: "",
|
||||
status: "active",
|
||||
sourceProvider: "local",
|
||||
sourceResourceType: "",
|
||||
sourceResourceId: "",
|
||||
sourceRevision: "",
|
||||
properties: "{}",
|
||||
provenance: "{}"
|
||||
};
|
||||
|
||||
const EMPTY_RELATIONSHIP_DRAFT: RelationshipDraft = {
|
||||
relationshipKind: "member",
|
||||
subjectIdentityId: "",
|
||||
targetType: "group",
|
||||
targetGroupId: "",
|
||||
relatedIdentityId: "",
|
||||
role: "",
|
||||
validFrom: "",
|
||||
validUntil: "",
|
||||
sourceProvider: "local",
|
||||
sourceResourceType: "",
|
||||
sourceResourceId: "",
|
||||
sourceRevision: "",
|
||||
properties: "{}",
|
||||
provenance: "{}"
|
||||
};
|
||||
|
||||
export default function TypedRelationshipsPanel({ settings, auth }: Props) {
|
||||
const [groups, setGroups] = useState<TypedGroupItem[]>([]);
|
||||
const [relationships, setRelationships] = useState<IdentityRelationshipItem[]>([]);
|
||||
const [identities, setIdentities] = useState<IdentityOption[]>([]);
|
||||
const [showInactiveGroups, setShowInactiveGroups] = useState(false);
|
||||
const [showRevokedRelationships, setShowRevokedRelationships] = useState(false);
|
||||
const [groupEditor, setGroupEditor] = useState<TypedGroupItem | "create" | null>(null);
|
||||
const [groupDraft, setGroupDraft] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||
const [groupBaseline, setGroupBaseline] = useState<GroupDraft>({ ...EMPTY_GROUP_DRAFT });
|
||||
const [relationshipEditor, setRelationshipEditor] = useState<IdentityRelationshipItem | "create" | null>(null);
|
||||
const [relationshipDraft, setRelationshipDraft] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
const [relationshipBaseline, setRelationshipBaseline] = useState<RelationshipDraft>({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
const [revokeTarget, setRevokeTarget] = useState<IdentityRelationshipItem | null>(null);
|
||||
const [revocationReason, setRevocationReason] = useState("");
|
||||
const [membershipGroup, setMembershipGroup] = useState<TypedGroupItem | null>(null);
|
||||
const [membershipEffectiveAt, setMembershipEffectiveAt] = useState("");
|
||||
const [membershipKinds, setMembershipKinds] = useState("member");
|
||||
const [membershipResolution, setMembershipResolution] = useState<TypedGroupMembershipResolution | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [membershipLoading, setMembershipLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const appliedDeepLink = useRef(false);
|
||||
const { language } = usePlatformLanguage();
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const canRead = hasScope(auth, "idm:relationship:read") || hasScope(auth, "idm:relationship:write");
|
||||
const canWrite = hasScope(auth, "idm:relationship:write");
|
||||
const canSearchIdentities = canWrite
|
||||
|| hasScope(auth, "idm:organization_identity:read")
|
||||
|| hasScope(auth, "idm:organization_assignment:write")
|
||||
|| hasScope(auth, "admin:users:read");
|
||||
const groupById = useMemo(() => new Map(groups.map((item) => [item.id, item])), [groups]);
|
||||
const visibleGroups = useMemo(
|
||||
() => showInactiveGroups ? groups : groups.filter((item) => item.status === "active"),
|
||||
[groups, showInactiveGroups]
|
||||
);
|
||||
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||
const groupOptions = useMemo<SearchableSelectOption[]>(
|
||||
() => groups.filter((item) => item.status === "active").map(groupOption),
|
||||
[groups]
|
||||
);
|
||||
const dirty = groupEditor
|
||||
? draftKey(groupDraft) !== draftKey(groupBaseline)
|
||||
: relationshipEditor
|
||||
? draftKey(relationshipDraft) !== draftKey(relationshipBaseline)
|
||||
: false;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canRead) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [groupResponse, relationshipResponse, identityResponse] = await Promise.all([
|
||||
getTypedGroups(settings, { includeInactive: true }),
|
||||
getIdentityRelationships(settings, { includeRevoked: showRevokedRelationships }),
|
||||
canSearchIdentities
|
||||
? searchOrganizationIdentityOptions(settings, "", 100).catch(() => ({ identities: [] }))
|
||||
: Promise.resolve({ identities: [] })
|
||||
]);
|
||||
setGroups(groupResponse.groups);
|
||||
setRelationships(relationshipResponse.relationships);
|
||||
setIdentities(identityResponse.identities);
|
||||
if (!appliedDeepLink.current && typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const groupId = params.get("groupId");
|
||||
const relationshipId = params.get("relationshipId");
|
||||
const linkedGroup = groupResponse.groups.find((item) => item.id === groupId);
|
||||
const linkedRelationship = relationshipResponse.relationships.find((item) => item.id === relationshipId);
|
||||
if (linkedGroup) {
|
||||
appliedDeepLink.current = true;
|
||||
openMembership(linkedGroup);
|
||||
} else if (linkedRelationship) {
|
||||
appliedDeepLink.current = true;
|
||||
openRelationshipEditor(linkedRelationship);
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canRead, canSearchIdentities, settings, showRevokedRelationships]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadIdentityOptions = useCallback(async (
|
||||
query: string,
|
||||
options: { limit: number; signal: AbortSignal }
|
||||
): Promise<SearchableSelectOption[]> => {
|
||||
const response = await searchOrganizationIdentityOptions(settings, query, options.limit, options.signal);
|
||||
if (!options.signal.aborted) {
|
||||
setIdentities((current) => mergeIdentities(current, response.identities));
|
||||
}
|
||||
return response.identities.map(identityOption);
|
||||
}, [settings]);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: async () => groupEditor ? saveGroup() : saveRelationship(),
|
||||
onDiscard: closeEditors,
|
||||
title: "Unsaved relationship administration",
|
||||
message: "Save or discard the typed-group or relationship draft before leaving this surface."
|
||||
});
|
||||
|
||||
const groupColumns = useMemo<DataGridColumn<TypedGroupItem>[]>(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
minWidth: 210,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.name,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="idm-id">{row.key}</div></div>
|
||||
},
|
||||
{ id: "type", header: "Group type", minWidth: 170, sortable: true, filterable: true, value: (row) => row.group_type },
|
||||
{ id: "source", header: "Source", minWidth: 160, sortable: true, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||
{ id: "status", header: "Status", width: 120, sortable: true, value: (row) => row.status, render: (row) => <StatusBadge status={row.status} label={groupStatusLabel(row.status)} /> },
|
||||
{ id: "revision", header: "Revision", width: 100, sortable: true, value: (row) => row.revision },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "memberships",
|
||||
label: i18nMessage("i18n:govoplan-idm.inspect_memberships_value", { value0: row.name }),
|
||||
icon: <Eye size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.typed-groups.action.inspect-memberships",
|
||||
helpModuleId: "idm",
|
||||
onClick: () => openMembership(row)
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
label: i18nMessage("i18n:govoplan-idm.edit_group_value", { value0: row.name }),
|
||||
icon: <Pencil size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.typed-groups.action.edit",
|
||||
helpModuleId: "idm",
|
||||
disabled: !canWrite || busy,
|
||||
disabledReason: !canWrite ? "Typed-group write permission is required." : undefined,
|
||||
onClick: () => openGroupEditor(row)
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [busy, canWrite]);
|
||||
|
||||
const relationshipColumns = useMemo<DataGridColumn<IdentityRelationshipItem>[]>(() => [
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject identity",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterValue: (row) => identityLabel(identityById.get(row.subject_identity_id), row.subject_identity_id),
|
||||
render: (row) => identityDisplay(identityById.get(row.subject_identity_id), row.subject_identity_id)
|
||||
},
|
||||
{ id: "kind", header: "Relationship", minWidth: 170, sortable: true, filterable: true, value: (row) => row.relationship_kind, render: (row) => row.role ? `${row.relationship_kind} · ${row.role}` : row.relationship_kind },
|
||||
{
|
||||
id: "target",
|
||||
header: "Target",
|
||||
minWidth: 220,
|
||||
filterable: true,
|
||||
filterValue: (row) => relationshipTargetLabel(row, groupById, identityById),
|
||||
render: (row) => relationshipTarget(row, groupById, identityById)
|
||||
},
|
||||
{
|
||||
id: "effective_status",
|
||||
header: "Effective state",
|
||||
width: 145,
|
||||
sortable: true,
|
||||
value: (row) => relationshipState(row),
|
||||
render: (row) => {
|
||||
const state = relationshipState(row);
|
||||
return <StatusBadge status={stateStatus(state)} label={relationshipStateLabel(state)} />;
|
||||
}
|
||||
},
|
||||
{ id: "window", header: "Effective window", minWidth: 220, value: (row) => `${row.valid_from ?? ""} ${row.valid_until ?? ""}`, render: (row) => effectiveWindow(row, language) },
|
||||
{ id: "source", header: "Source", minWidth: 150, value: (row) => row.source_provider, render: (row) => sourceSummary(row) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "edit",
|
||||
label: "Edit relationship",
|
||||
icon: <Pencil size={16} aria-hidden="true" />,
|
||||
helpContextId: "idm.relationships.action.edit",
|
||||
helpModuleId: "idm",
|
||||
disabled: !canWrite || busy || row.status === "revoked",
|
||||
disabledReason: !canWrite ? "Relationship write permission is required." : row.status === "revoked" ? "Revoked relationships are retained as immutable evidence." : undefined,
|
||||
onClick: () => openRelationshipEditor(row)
|
||||
},
|
||||
{
|
||||
id: "revoke",
|
||||
label: "Revoke relationship",
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
helpContextId: "idm.relationships.action.revoke",
|
||||
helpModuleId: "idm",
|
||||
applicable: row.status !== "revoked",
|
||||
disabled: !canWrite || busy,
|
||||
disabledReason: !canWrite ? "Relationship write permission is required." : undefined,
|
||||
onClick: () => {
|
||||
setRevokeTarget(row);
|
||||
setRevocationReason("");
|
||||
}
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [busy, canWrite, groupById, identityById, language]);
|
||||
|
||||
const membershipColumns = useMemo<DataGridColumn<IdentityRelationshipDecisionItem>[]>(() => [
|
||||
{
|
||||
id: "identity",
|
||||
header: "Identity",
|
||||
minWidth: 220,
|
||||
value: (row) => row.relationship.subject_identity_id,
|
||||
render: (row) => identityDisplay(identityById.get(row.relationship.subject_identity_id), row.relationship.subject_identity_id)
|
||||
},
|
||||
{ id: "kind", header: "Relationship", minWidth: 150, value: (row) => row.relationship.relationship_kind },
|
||||
{ id: "decision", header: "Resolution", minWidth: 150, value: (row) => row.code, render: (row) => <StatusBadge status={row.included ? "success" : "inactive"} label={row.code} /> },
|
||||
{ id: "identity_status", header: "Identity state", minWidth: 130, value: (row) => row.identity_status ?? "", render: (row) => row.identity_status ?? "Not available" },
|
||||
{ id: "explanation", header: "Explanation", minWidth: 280, fill: true, value: (row) => row.explanation }
|
||||
], [identityById]);
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<Card title="Typed groups and identity relationships" collapsible collapseKey="idm.typed-relationships">
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "You do not have permission to view typed identity relationships.",
|
||||
requiredAction: "Ask for typed-relationship read permission.",
|
||||
actor: "An Access or tenant administrator",
|
||||
target: "Access role assignments"
|
||||
}}
|
||||
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="idm-relationship-stack" data-help-context-id="idm.relationships.page" data-help-module-id="idm">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && !error && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||
{!canWrite && (
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "You may inspect relationship evidence but not change it.",
|
||||
requiredAction: "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||
actor: "An Access or tenant administrator",
|
||||
target: "Access role assignments"
|
||||
}}
|
||||
labels={{ requiredAction: "Required action", actor: "Responsible actor", target: "Destination" }}
|
||||
documentation={IDM_RELATIONSHIP_DOCUMENTATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading typed groups and relationships">
|
||||
<Card
|
||||
title="Typed groups"
|
||||
collapsible
|
||||
collapseKey="idm.typed-groups"
|
||||
actions={(
|
||||
<ActionToolbar justify="end">
|
||||
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||
<ToggleSwitch
|
||||
label="Show inactive groups"
|
||||
checked={showInactiveGroups}
|
||||
helpContextId="idm.typed-groups.field.show-inactive"
|
||||
helpModuleId="idm"
|
||||
onChange={setShowInactiveGroups}
|
||||
/>
|
||||
<Button helpContextId="idm.typed-groups.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||
</Button>
|
||||
<AdminIconButton
|
||||
label="Create typed group"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
variant="primary"
|
||||
helpContextId="idm.typed-groups.action.create"
|
||||
helpModuleId="idm"
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite ? "Typed-group write permission is required." : undefined}
|
||||
onClick={() => openGroupEditor("create")}
|
||||
/>
|
||||
</ActionToolbar>
|
||||
)}
|
||||
>
|
||||
<DataGrid id="idm-typed-groups" rows={visibleGroups} columns={groupColumns} getRowKey={(row) => row.id} emptyText="No typed groups found." initialFit="container" />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Effective identity relationships"
|
||||
collapsible
|
||||
collapseKey="idm.identity-relationships"
|
||||
actions={(
|
||||
<ActionToolbar justify="end">
|
||||
<DocumentationHelpLink reference={IDM_RELATIONSHIP_DOCUMENTATION} />
|
||||
<ToggleSwitch
|
||||
label="Show revoked relationships"
|
||||
checked={showRevokedRelationships}
|
||||
helpContextId="idm.relationships.field.show-revoked"
|
||||
helpModuleId="idm"
|
||||
onChange={setShowRevokedRelationships}
|
||||
/>
|
||||
<Button helpContextId="idm.relationships.action.reload" helpModuleId="idm" onClick={() => void load()} disabled={loading || busy}>
|
||||
<RefreshCw size={16} aria-hidden="true" /> Reload
|
||||
</Button>
|
||||
<AdminIconButton
|
||||
label="Create relationship"
|
||||
icon={<Plus size={16} aria-hidden="true" />}
|
||||
variant="primary"
|
||||
helpContextId="idm.relationships.action.create"
|
||||
helpModuleId="idm"
|
||||
disabled={!canWrite || busy || groups.every((item) => item.status !== "active")}
|
||||
disabledReason={!canWrite ? "Relationship write permission is required." : groups.every((item) => item.status !== "active") ? "Create an active typed group first." : undefined}
|
||||
onClick={() => openRelationshipEditor("create")}
|
||||
/>
|
||||
</ActionToolbar>
|
||||
)}
|
||||
>
|
||||
<DataGrid id="idm-identity-relationships" rows={relationships} columns={relationshipColumns} getRowKey={(row) => row.id} emptyText="No identity relationships found." initialFit="container" />
|
||||
<p className="idm-muted idm-card-note">Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.</p>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
{renderGroupEditor()}
|
||||
{renderRelationshipEditor()}
|
||||
{renderRevokeDialog()}
|
||||
{renderMembershipDialog()}
|
||||
</div>
|
||||
);
|
||||
|
||||
function openGroupEditor(item: TypedGroupItem | "create") {
|
||||
const next = item === "create" ? { ...EMPTY_GROUP_DRAFT } : groupDraftFrom(item);
|
||||
setGroupEditor(item);
|
||||
setGroupDraft(next);
|
||||
setGroupBaseline(next);
|
||||
setRelationshipEditor(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openRelationshipEditor(item: IdentityRelationshipItem | "create") {
|
||||
const next = item === "create" ? { ...EMPTY_RELATIONSHIP_DRAFT } : relationshipDraftFrom(item);
|
||||
setRelationshipEditor(item);
|
||||
setRelationshipDraft(next);
|
||||
setRelationshipBaseline(next);
|
||||
setGroupEditor(null);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditors() {
|
||||
setGroupEditor(null);
|
||||
setRelationshipEditor(null);
|
||||
setGroupDraft({ ...EMPTY_GROUP_DRAFT });
|
||||
setGroupBaseline({ ...EMPTY_GROUP_DRAFT });
|
||||
setRelationshipDraft({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
setRelationshipBaseline({ ...EMPTY_RELATIONSHIP_DRAFT });
|
||||
}
|
||||
|
||||
function requestCloseEditors() {
|
||||
if (busy) return;
|
||||
if (dirty) requestDiscard(closeEditors);
|
||||
else closeEditors();
|
||||
}
|
||||
|
||||
async function saveGroup(): Promise<boolean> {
|
||||
if (!groupEditor || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = groupPayload(groupDraft);
|
||||
if (groupEditor === "create") {
|
||||
await createTypedGroup(settings, payload);
|
||||
setSuccess("Typed group created.");
|
||||
} else {
|
||||
await patchTypedGroup(settings, groupEditor.id, {
|
||||
...payload,
|
||||
base_revision: groupEditor.revision,
|
||||
status: groupDraft.status
|
||||
});
|
||||
setSuccess("Typed group updated.");
|
||||
}
|
||||
closeEditors();
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelationship(): Promise<boolean> {
|
||||
if (!relationshipEditor || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = relationshipPayload(relationshipDraft);
|
||||
if (relationshipEditor === "create") {
|
||||
await createIdentityRelationship(settings, payload);
|
||||
setSuccess("Identity relationship created.");
|
||||
} else {
|
||||
const { subject_identity_id: _subject, ...update } = payload;
|
||||
await patchIdentityRelationship(settings, relationshipEditor.id, {
|
||||
...update,
|
||||
base_revision: relationshipEditor.revision
|
||||
});
|
||||
setSuccess("Identity relationship updated.");
|
||||
}
|
||||
closeEditors();
|
||||
await load();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeRelationship(): Promise<void> {
|
||||
if (!revokeTarget || !revocationReason.trim() || !canWrite) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeIdentityRelationship(settings, revokeTarget, revocationReason.trim());
|
||||
setSuccess("Identity relationship revoked. Effective membership and downstream business resolution stop immediately.");
|
||||
setRevokeTarget(null);
|
||||
setRevocationReason("");
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openMembership(group: TypedGroupItem) {
|
||||
setMembershipGroup(group);
|
||||
setMembershipEffectiveAt("");
|
||||
setMembershipKinds("member");
|
||||
setMembershipResolution(null);
|
||||
void loadMembership(group, "", "member");
|
||||
}
|
||||
|
||||
async function loadMembership(group = membershipGroup, effectiveAt = membershipEffectiveAt, kinds = membershipKinds): Promise<void> {
|
||||
if (!group) return;
|
||||
setMembershipLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const resolution = await resolveTypedGroupMemberships(settings, group.id, {
|
||||
effectiveAt: optionalDateTime(effectiveAt) ?? undefined,
|
||||
relationshipKinds: kinds.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
});
|
||||
setMembershipResolution(resolution);
|
||||
} catch (caught) {
|
||||
setError(apiErrorMessage(caught));
|
||||
} finally {
|
||||
setMembershipLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupEditor() {
|
||||
const formId = "idm-typed-group-editor";
|
||||
const editing = groupEditor && groupEditor !== "create" ? groupEditor : null;
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(groupEditor)}
|
||||
title={editing ? "Edit typed group" : "Create typed group"}
|
||||
helpContextId="idm.typed-groups.editor"
|
||||
helpModuleId="idm"
|
||||
onClose={requestCloseEditors}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.typed-groups.action.save" helpModuleId="idm" disabled={!canWrite || busy || !groupDraft.key.trim() || !groupDraft.name.trim() || !groupDraft.groupType.trim()}>{busy ? "Saving..." : "Save group"}</Button></>}
|
||||
>
|
||||
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveGroup(); }}>
|
||||
<FormField label="Key" helpContextId="idm.typed-groups.field.key" helpModuleId="idm"><input required value={groupDraft.key} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, key: event.target.value })} /></FormField>
|
||||
<FormField label="Name" helpContextId="idm.typed-groups.field.name" helpModuleId="idm"><input required value={groupDraft.name} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Group type" helpContextId="idm.typed-groups.field.type" helpModuleId="idm"><input required value={groupDraft.groupType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, groupType: event.target.value })} /></FormField>
|
||||
{editing && <FormField label="Status" helpContextId="idm.typed-groups.field.status" helpModuleId="idm"><select value={groupDraft.status} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, status: event.target.value as GroupDraft["status"] })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>}
|
||||
<FormField label="Description" helpContextId="idm.typed-groups.field.description" helpModuleId="idm" className="wide"><textarea rows={3} value={groupDraft.description} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, description: event.target.value })} /></FormField>
|
||||
<FormField label="Source provider" helpContextId="idm.typed-groups.field.source-provider" helpModuleId="idm"><input required value={groupDraft.sourceProvider} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceProvider: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource type" helpContextId="idm.typed-groups.field.source-resource-type" helpModuleId="idm"><input value={groupDraft.sourceResourceType} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource ID" helpContextId="idm.typed-groups.field.source-resource-id" helpModuleId="idm"><input value={groupDraft.sourceResourceId} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||
<FormField label="Source revision" helpContextId="idm.typed-groups.field.source-revision" helpModuleId="idm"><input value={groupDraft.sourceRevision} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, sourceRevision: event.target.value })} /></FormField>
|
||||
<FormField label="Properties (JSON object)" helpContextId="idm.typed-groups.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.properties} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, properties: event.target.value })} /></FormField>
|
||||
<FormField label="Provenance (JSON object)" helpContextId="idm.typed-groups.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={groupDraft.provenance} disabled={busy} onChange={(event) => setGroupDraft({ ...groupDraft, provenance: event.target.value })} /></FormField>
|
||||
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.group_revision_help", { value0: editing.revision })}</p>}
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRelationshipEditor() {
|
||||
const formId = "idm-identity-relationship-editor";
|
||||
const editing = relationshipEditor && relationshipEditor !== "create" ? relationshipEditor : null;
|
||||
const subjectOption = selectedIdentityOption(relationshipDraft.subjectIdentityId, identityById);
|
||||
const relatedOption = selectedIdentityOption(relationshipDraft.relatedIdentityId, identityById);
|
||||
const targetGroupOption = relationshipDraft.targetGroupId ? groupOption(groupById.get(relationshipDraft.targetGroupId) ?? fallbackGroup(relationshipDraft.targetGroupId)) : null;
|
||||
const complete = Boolean(
|
||||
relationshipDraft.relationshipKind.trim()
|
||||
&& relationshipDraft.subjectIdentityId
|
||||
&& (relationshipDraft.targetType === "group" ? relationshipDraft.targetGroupId : relationshipDraft.relatedIdentityId)
|
||||
);
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(relationshipEditor)}
|
||||
title={editing ? "Edit identity relationship" : "Create identity relationship"}
|
||||
helpContextId="idm.relationships.editor"
|
||||
helpModuleId="idm"
|
||||
onClose={requestCloseEditors}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={requestCloseEditors} disabled={busy}>Cancel</Button><Button type="submit" form={formId} variant="primary" helpContextId="idm.relationships.action.save" helpModuleId="idm" disabled={!canWrite || busy || !complete}>{busy ? "Saving..." : "Save relationship"}</Button></>}
|
||||
>
|
||||
<FormLayout id={formId} columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void saveRelationship(); }}>
|
||||
<FormField label="Relationship kind" helpContextId="idm.relationships.field.kind" helpModuleId="idm"><input required value={relationshipDraft.relationshipKind} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, relationshipKind: event.target.value })} /></FormField>
|
||||
<FormField label="Role" helpContextId="idm.relationships.field.role" helpModuleId="idm"><input value={relationshipDraft.role} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, role: event.target.value })} /></FormField>
|
||||
<FormField label="Subject identity" helpContextId="idm.relationships.field.subject-identity" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.subjectIdentityId}
|
||||
selectedOption={subjectOption}
|
||||
loadOptions={loadIdentityOptions}
|
||||
aria-label="Subject identity"
|
||||
placeholder="Search identities"
|
||||
minQueryLength={0}
|
||||
required
|
||||
disabled={busy || Boolean(editing)}
|
||||
helpContextId="idm.relationships.field.subject-identity"
|
||||
helpModuleId="idm"
|
||||
onChange={(subjectIdentityId) => setRelationshipDraft({ ...relationshipDraft, subjectIdentityId })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Target type" helpContextId="idm.relationships.field.target-type" helpModuleId="idm"><select value={relationshipDraft.targetType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, targetType: event.target.value as RelationshipDraft["targetType"], targetGroupId: "", relatedIdentityId: "" })}><option value="group">Typed group</option><option value="identity">Related identity</option></select></FormField>
|
||||
{relationshipDraft.targetType === "group" ? (
|
||||
<FormField label="Target group" helpContextId="idm.relationships.field.target-group" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.targetGroupId}
|
||||
selectedOption={targetGroupOption}
|
||||
options={groupOptions}
|
||||
aria-label="Target typed group"
|
||||
placeholder="Search typed groups"
|
||||
required
|
||||
disabled={busy}
|
||||
helpContextId="idm.relationships.field.target-group"
|
||||
helpModuleId="idm"
|
||||
onChange={(targetGroupId) => setRelationshipDraft({ ...relationshipDraft, targetGroupId })}
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<FormField label="Related identity" helpContextId="idm.relationships.field.related-identity" helpModuleId="idm">
|
||||
<SearchableSelect
|
||||
value={relationshipDraft.relatedIdentityId}
|
||||
selectedOption={relatedOption}
|
||||
loadOptions={loadIdentityOptions}
|
||||
aria-label="Related identity"
|
||||
placeholder="Search identities"
|
||||
minQueryLength={0}
|
||||
required
|
||||
disabled={busy}
|
||||
helpContextId="idm.relationships.field.related-identity"
|
||||
helpModuleId="idm"
|
||||
onChange={(relatedIdentityId) => setRelationshipDraft({ ...relationshipDraft, relatedIdentityId })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Valid from" helpContextId="idm.relationships.field.valid-from" helpModuleId="idm"><DateTimeField value={relationshipDraft.validFrom} disabled={busy} helpContextId="idm.relationships.field.valid-from" helpModuleId="idm" onChange={(validFrom) => setRelationshipDraft({ ...relationshipDraft, validFrom })} /></FormField>
|
||||
<FormField label="Valid until" helpContextId="idm.relationships.field.valid-until" helpModuleId="idm"><DateTimeField value={relationshipDraft.validUntil} disabled={busy} helpContextId="idm.relationships.field.valid-until" helpModuleId="idm" onChange={(validUntil) => setRelationshipDraft({ ...relationshipDraft, validUntil })} /></FormField>
|
||||
<FormField label="Source provider" helpContextId="idm.relationships.field.source-provider" helpModuleId="idm"><input required value={relationshipDraft.sourceProvider} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceProvider: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource type" helpContextId="idm.relationships.field.source-resource-type" helpModuleId="idm"><input value={relationshipDraft.sourceResourceType} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceType: event.target.value })} /></FormField>
|
||||
<FormField label="Source resource ID" helpContextId="idm.relationships.field.source-resource-id" helpModuleId="idm"><input value={relationshipDraft.sourceResourceId} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceResourceId: event.target.value })} /></FormField>
|
||||
<FormField label="Source revision" helpContextId="idm.relationships.field.source-revision" helpModuleId="idm"><input value={relationshipDraft.sourceRevision} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, sourceRevision: event.target.value })} /></FormField>
|
||||
<FormField label="Properties (JSON object)" helpContextId="idm.relationships.field.properties" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.properties} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, properties: event.target.value })} /></FormField>
|
||||
<FormField label="Provenance (JSON object)" helpContextId="idm.relationships.field.provenance" helpModuleId="idm" className="wide"><textarea rows={4} value={relationshipDraft.provenance} disabled={busy} onChange={(event) => setRelationshipDraft({ ...relationshipDraft, provenance: event.target.value })} /></FormField>
|
||||
<p className="idm-muted wide">Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.</p>
|
||||
{editing && <p className="idm-muted wide">{i18nMessage("i18n:govoplan-idm.relationship_revision_help", { value0: editing.revision })}</p>}
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRevokeDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="large"
|
||||
open={Boolean(revokeTarget)}
|
||||
title="Revoke identity relationship"
|
||||
helpContextId="idm.relationships.confirm-revoke"
|
||||
helpModuleId="idm"
|
||||
onClose={() => {
|
||||
if (busy) return;
|
||||
const close = () => { setRevokeTarget(null); setRevocationReason(""); };
|
||||
if (revocationReason.trim()) requestDiscard(close);
|
||||
else close();
|
||||
}}
|
||||
closeDisabled={busy}
|
||||
className=""
|
||||
footer={<><Button onClick={() => { setRevokeTarget(null); setRevocationReason(""); }} disabled={busy}>Cancel</Button><Button variant="danger" helpContextId="idm.relationships.action.confirm-revoke" helpModuleId="idm" disabled={!canWrite || busy || !revocationReason.trim()} onClick={() => void revokeRelationship()}>{busy ? "Revoking..." : "Revoke relationship"}</Button></>}
|
||||
>
|
||||
<p>Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.</p>
|
||||
<FormField label="Revocation reason" helpContextId="idm.relationships.field.revocation-reason" helpModuleId="idm"><textarea rows={4} required value={revocationReason} disabled={busy} onChange={(event) => setRevocationReason(event.target.value)} /></FormField>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMembershipDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
variant="administration"
|
||||
size="wide"
|
||||
open={Boolean(membershipGroup)}
|
||||
title={membershipGroup ? i18nMessage("i18n:govoplan-idm.effective_memberships_value", { value0: membershipGroup.name }) : "Effective memberships"}
|
||||
helpContextId="idm.typed-groups.membership-resolution"
|
||||
helpModuleId="idm"
|
||||
onClose={() => !membershipLoading && setMembershipGroup(null)}
|
||||
closeDisabled={membershipLoading}
|
||||
className=""
|
||||
footer={<Button variant="primary" onClick={() => setMembershipGroup(null)} disabled={membershipLoading}>Close</Button>}
|
||||
>
|
||||
<FormLayout columns={2} gap="small" collapseAt="workspace" className="" onSubmit={(event) => { event.preventDefault(); void loadMembership(); }}>
|
||||
<FormField label="Effective at" helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm"><DateTimeField value={membershipEffectiveAt} disabled={membershipLoading} helpContextId="idm.typed-groups.field.membership-effective-at" helpModuleId="idm" onChange={setMembershipEffectiveAt} /></FormField>
|
||||
<FormField label="Relationship kinds" helpContextId="idm.typed-groups.field.membership-kinds" helpModuleId="idm"><input value={membershipKinds} disabled={membershipLoading} placeholder="member" onChange={(event) => setMembershipKinds(event.target.value)} /></FormField>
|
||||
<div className="wide button-row compact-actions"><Button type="submit" helpContextId="idm.typed-groups.action.resolve-memberships" helpModuleId="idm" disabled={membershipLoading || !membershipKinds.trim()}><RefreshCw size={16} aria-hidden="true" /> Resolve memberships</Button></div>
|
||||
</FormLayout>
|
||||
<LoadingFrame loading={membershipLoading} label="Resolving effective memberships">
|
||||
{membershipResolution && (
|
||||
<>
|
||||
<p className="idm-muted">{i18nMessage("i18n:govoplan-idm.memberships_resolved_summary", { value0: new Date(membershipResolution.effective_at).toLocaleString(language), value1: membershipResolution.identity_ids.length })}</p>
|
||||
<DataGrid id="idm-typed-group-membership-resolution" rows={membershipResolution.decisions} columns={membershipColumns} getRowKey={(row) => row.relationship.id} emptyText="No membership relationships were evaluated." initialFit="container" />
|
||||
</>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function groupDraftFrom(item: TypedGroupItem): GroupDraft {
|
||||
return {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
groupType: item.group_type,
|
||||
description: item.description ?? "",
|
||||
status: item.status,
|
||||
sourceProvider: item.source_provider,
|
||||
sourceResourceType: item.source_resource_type ?? "",
|
||||
sourceResourceId: item.source_resource_id ?? "",
|
||||
sourceRevision: item.source_revision ?? "",
|
||||
properties: prettyJson(item.properties),
|
||||
provenance: prettyJson(item.provenance)
|
||||
};
|
||||
}
|
||||
|
||||
function groupPayload(draft: GroupDraft): TypedGroupPayload {
|
||||
return {
|
||||
key: draft.key.trim(),
|
||||
name: draft.name.trim(),
|
||||
group_type: draft.groupType.trim(),
|
||||
description: optionalText(draft.description),
|
||||
source_provider: draft.sourceProvider.trim(),
|
||||
source_resource_type: optionalText(draft.sourceResourceType),
|
||||
source_resource_id: optionalText(draft.sourceResourceId),
|
||||
source_revision: optionalText(draft.sourceRevision),
|
||||
properties: parseJsonObject(draft.properties, "Properties"),
|
||||
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipDraftFrom(item: IdentityRelationshipItem): RelationshipDraft {
|
||||
return {
|
||||
relationshipKind: item.relationship_kind,
|
||||
subjectIdentityId: item.subject_identity_id,
|
||||
targetType: item.target_group_id ? "group" : "identity",
|
||||
targetGroupId: item.target_group_id ?? "",
|
||||
relatedIdentityId: item.related_identity_id ?? "",
|
||||
role: item.role ?? "",
|
||||
validFrom: item.valid_from ?? "",
|
||||
validUntil: item.valid_until ?? "",
|
||||
sourceProvider: item.source_provider,
|
||||
sourceResourceType: item.source_resource_type ?? "",
|
||||
sourceResourceId: item.source_resource_id ?? "",
|
||||
sourceRevision: item.source_revision ?? "",
|
||||
properties: prettyJson(item.properties),
|
||||
provenance: prettyJson(item.provenance)
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipPayload(draft: RelationshipDraft): IdentityRelationshipPayload {
|
||||
return {
|
||||
relationship_kind: draft.relationshipKind.trim(),
|
||||
subject_identity_id: draft.subjectIdentityId,
|
||||
target_group_id: draft.targetType === "group" ? draft.targetGroupId || null : null,
|
||||
related_identity_id: draft.targetType === "identity" ? draft.relatedIdentityId || null : null,
|
||||
role: optionalText(draft.role),
|
||||
valid_from: optionalDateTime(draft.validFrom),
|
||||
valid_until: optionalDateTime(draft.validUntil),
|
||||
source_provider: draft.sourceProvider.trim(),
|
||||
source_resource_type: optionalText(draft.sourceResourceType),
|
||||
source_resource_id: optionalText(draft.sourceResourceId),
|
||||
source_revision: optionalText(draft.sourceRevision),
|
||||
properties: parseJsonObject(draft.properties, "Properties"),
|
||||
provenance: parseJsonObject(draft.provenance, "Provenance")
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipState(item: IdentityRelationshipItem): "future" | "active" | "expired" | "revoked" {
|
||||
if (item.status === "revoked") return "revoked";
|
||||
const now = Date.now();
|
||||
if (item.valid_from && new Date(item.valid_from).getTime() > now) return "future";
|
||||
if (item.valid_until && new Date(item.valid_until).getTime() <= now) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function stateStatus(state: ReturnType<typeof relationshipState>): string {
|
||||
if (state === "future") return "pending";
|
||||
return state;
|
||||
}
|
||||
|
||||
function relationshipStateLabel(state: ReturnType<typeof relationshipState>): string {
|
||||
return state === "future" ? "Future" : state === "active" ? "Active" : state === "expired" ? "Expired" : "Revoked";
|
||||
}
|
||||
|
||||
function groupStatusLabel(status: TypedGroupItem["status"]): string {
|
||||
return status === "active" ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function identityOption(item: IdentityOption): SearchableSelectOption {
|
||||
const label = identityLabel(item, item.id);
|
||||
return { value: item.id, label, description: item.external_subject ?? item.id, searchText: `${item.id} ${item.account_ids.join(" ")}` };
|
||||
}
|
||||
|
||||
function selectedIdentityOption(id: string, identityById: ReadonlyMap<string, IdentityOption>): SearchableSelectOption | null {
|
||||
if (!id) return null;
|
||||
const item = identityById.get(id);
|
||||
return item ? identityOption(item) : { value: id, label: id, description: "Identity reference" };
|
||||
}
|
||||
|
||||
function identityLabel(item: IdentityOption | undefined, fallback: string): string {
|
||||
return item?.display_name || item?.external_subject || fallback;
|
||||
}
|
||||
|
||||
function identityDisplay(item: IdentityOption | undefined, fallback: string): JSX.Element {
|
||||
return <div><strong>{identityLabel(item, fallback)}</strong><div className="idm-id">{fallback}</div></div>;
|
||||
}
|
||||
|
||||
function groupOption(item: TypedGroupItem): SearchableSelectOption {
|
||||
return { value: item.id, label: item.name, description: `${item.group_type} · ${item.key}`, searchText: `${item.id} ${item.key}` };
|
||||
}
|
||||
|
||||
function fallbackGroup(id: string): TypedGroupItem {
|
||||
return { id, tenant_id: "", key: id, name: id, group_type: "unknown", status: "inactive", source_provider: "unknown", properties: {}, provenance: {}, revision: 1 };
|
||||
}
|
||||
|
||||
function sourceSummary(item: Pick<TypedGroupItem, "source_provider" | "source_revision">): JSX.Element {
|
||||
return <div><span>{item.source_provider}</span>{item.source_revision && <div className="idm-id">{item.source_revision}</div>}</div>;
|
||||
}
|
||||
|
||||
function relationshipTargetLabel(
|
||||
item: IdentityRelationshipItem,
|
||||
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||
identityById: ReadonlyMap<string, IdentityOption>
|
||||
): string {
|
||||
if (item.target_group_id) return groupById.get(item.target_group_id)?.name ?? item.target_group_id;
|
||||
return identityLabel(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||
}
|
||||
|
||||
function relationshipTarget(
|
||||
item: IdentityRelationshipItem,
|
||||
groupById: ReadonlyMap<string, TypedGroupItem>,
|
||||
identityById: ReadonlyMap<string, IdentityOption>
|
||||
): JSX.Element {
|
||||
if (item.target_group_id) {
|
||||
const group = groupById.get(item.target_group_id);
|
||||
return <div><strong>{group?.name ?? item.target_group_id}</strong><div className="idm-id">{group ? `${group.group_type} · ${group.key}` : item.target_group_id}</div></div>;
|
||||
}
|
||||
return identityDisplay(identityById.get(item.related_identity_id ?? ""), item.related_identity_id ?? "");
|
||||
}
|
||||
|
||||
function effectiveWindow(item: IdentityRelationshipItem, language: string): JSX.Element {
|
||||
return <div><div>{item.valid_from ? new Date(item.valid_from).toLocaleString(language) : "No start limit"}</div><div className="idm-muted">{item.valid_until ? new Date(item.valid_until).toLocaleString(language) : "No end limit"}</div></div>;
|
||||
}
|
||||
|
||||
function mergeIdentities(current: IdentityOption[], incoming: IdentityOption[]): IdentityOption[] {
|
||||
const merged = new Map(current.map((item) => [item.id, item]));
|
||||
for (const item of incoming) merged.set(item.id, item);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function optionalText(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function optionalDateTime(value: string): string | null {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) throw new Error("Enter a valid date and time.");
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value || "{}");
|
||||
} catch {
|
||||
throw new Error(`${label} must contain valid JSON.`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function prettyJson(value: Record<string, unknown>): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function draftKey(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||
if (typeof parsed.detail === "string") return parsed.detail;
|
||||
if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message;
|
||||
} catch {
|
||||
// Fall back to the transport message.
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export const IDM_FIELD_DOCUMENTATION = {
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_RELATIONSHIP_DOCUMENTATION = {
|
||||
topicId: "idm.reference.typed-relationships",
|
||||
documentationType: "admin"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const IDM_INTERFACE_I18N = {
|
||||
loading: "i18n:govoplan-idm.loading_reason",
|
||||
busy: "i18n:govoplan-idm.busy_reason",
|
||||
@@ -45,4 +50,3 @@ export function idmDisabledReason(
|
||||
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.governance.b989a277": "governance",
|
||||
"i18n:govoplan-idm.identity.544a8347": "Identity",
|
||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Manage organization-function assignments, typed business groups, and effective identity relationships. IDM records institutional facts; Access evaluates application authority separately.",
|
||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.",
|
||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identity search",
|
||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance",
|
||||
@@ -151,7 +151,108 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"History": "History",
|
||||
"Confirm function decision": "Confirm function decision",
|
||||
"Confirm": "Confirm",
|
||||
"None": "None"
|
||||
"None": "None",
|
||||
"i18n:govoplan-idm.edit_group_value": "Edit {value0}",
|
||||
"i18n:govoplan-idm.inspect_memberships_value": "Inspect memberships for {value0}",
|
||||
"i18n:govoplan-idm.effective_memberships_value": "Effective memberships: {value0}",
|
||||
"i18n:govoplan-idm.memberships_resolved_summary": "Resolved at {value0}. {value1} identities are effective; excluded decisions remain visible for explanation.",
|
||||
"i18n:govoplan-idm.group_revision_help": "Revision {value0} is used for optimistic concurrency. If another administrator saves first, reload before applying your change.",
|
||||
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} is used for optimistic concurrency. The subject identity is immutable; replace the relationship if the subject is wrong.",
|
||||
"Access role assignments": "Access role assignments",
|
||||
"Active": "Active",
|
||||
"An Access or tenant administrator": "An Access or tenant administrator",
|
||||
"Ask for typed-relationship read permission.": "Ask for typed-relationship read permission.",
|
||||
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Ask for typed-relationship write permission before creating, editing, or revoking records.",
|
||||
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.",
|
||||
"Create an active typed group first.": "Create an active typed group first.",
|
||||
"Create identity relationship": "Create identity relationship",
|
||||
"Create relationship": "Create relationship",
|
||||
"Create typed group": "Create typed group",
|
||||
"Description": "Description",
|
||||
"Destination": "Destination",
|
||||
"Edit identity relationship": "Edit identity relationship",
|
||||
"Edit relationship": "Edit relationship",
|
||||
"Edit typed group": "Edit typed group",
|
||||
"Effective at": "Effective at",
|
||||
"Effective identity relationships": "Effective identity relationships",
|
||||
"Effective memberships": "Effective memberships",
|
||||
"Effective state": "Effective state",
|
||||
"Effective window": "Effective window",
|
||||
"Enter a valid date and time.": "Enter a valid date and time.",
|
||||
"Expired": "Expired",
|
||||
"Future": "Future",
|
||||
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.",
|
||||
"Group type": "Group type",
|
||||
"Identity": "Identity",
|
||||
"Identity reference": "Identity reference",
|
||||
"Identity relationship created.": "Identity relationship created.",
|
||||
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identity relationship revoked. Effective membership and downstream business resolution stop immediately.",
|
||||
"Identity relationship updated.": "Identity relationship updated.",
|
||||
"Identity state": "Identity state",
|
||||
"Inactive": "Inactive",
|
||||
"Key": "Key",
|
||||
"Loading typed groups and relationships": "Loading typed groups and relationships",
|
||||
"Name": "Name",
|
||||
"No end limit": "No end limit",
|
||||
"No identity relationships found.": "No identity relationships found.",
|
||||
"No membership relationships were evaluated.": "No membership relationships were evaluated.",
|
||||
"No start limit": "No start limit",
|
||||
"No typed groups found.": "No typed groups found.",
|
||||
"Not available": "Not available",
|
||||
"Properties (JSON object)": "Properties (JSON object)",
|
||||
"Properties must be a JSON object.": "Properties must be a JSON object.",
|
||||
"Properties must contain valid JSON.": "Properties must contain valid JSON.",
|
||||
"Provenance (JSON object)": "Provenance (JSON object)",
|
||||
"Provenance must be a JSON object.": "Provenance must be a JSON object.",
|
||||
"Provenance must contain valid JSON.": "Provenance must contain valid JSON.",
|
||||
"Related identity": "Related identity",
|
||||
"Reload": "Reload",
|
||||
"Relationship": "Relationship",
|
||||
"Relationship kind": "Relationship kind",
|
||||
"Relationship kinds": "Relationship kinds",
|
||||
"Relationship write permission is required.": "Relationship write permission is required.",
|
||||
"Required action": "Required action",
|
||||
"Resolution": "Resolution",
|
||||
"Resolve memberships": "Resolve memberships",
|
||||
"Resolving effective memberships": "Resolving effective memberships",
|
||||
"Responsible actor": "Responsible actor",
|
||||
"Revision": "Revision",
|
||||
"Revocation reason": "Revocation reason",
|
||||
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.",
|
||||
"Revoke identity relationship": "Revoke identity relationship",
|
||||
"Revoke relationship": "Revoke relationship",
|
||||
"Revoked": "Revoked",
|
||||
"Revoked relationships are retained as immutable evidence.": "Revoked relationships are retained as immutable evidence.",
|
||||
"Revoking...": "Revoking...",
|
||||
"Role": "Role",
|
||||
"Save group": "Save group",
|
||||
"Save or discard the typed-group or relationship draft before leaving this surface.": "Save or discard the typed-group or relationship draft before leaving this surface.",
|
||||
"Save relationship": "Save relationship",
|
||||
"Saving...": "Saving...",
|
||||
"Search identities": "Search identities",
|
||||
"Search typed groups": "Search typed groups",
|
||||
"Show inactive groups": "Show inactive groups",
|
||||
"Show revoked relationships": "Show revoked relationships",
|
||||
"Source": "Source",
|
||||
"Source provider": "Source provider",
|
||||
"Source resource ID": "Source resource ID",
|
||||
"Source resource type": "Source resource type",
|
||||
"Source revision": "Source revision",
|
||||
"Status": "Status",
|
||||
"Subject identity": "Subject identity",
|
||||
"Target": "Target",
|
||||
"Target group": "Target group",
|
||||
"Target type": "Target type",
|
||||
"Target typed group": "Target typed group",
|
||||
"Typed group": "Typed group",
|
||||
"Typed group created.": "Typed group created.",
|
||||
"Typed group updated.": "Typed group updated.",
|
||||
"Typed groups": "Typed groups",
|
||||
"Typed groups and identity relationships": "Typed groups and identity relationships",
|
||||
"Typed-group write permission is required.": "Typed-group write permission is required.",
|
||||
"Unsaved relationship administration": "Unsaved relationship administration",
|
||||
"You do not have permission to view typed identity relationships.": "You do not have permission to view typed identity relationships.",
|
||||
"You may inspect relationship evidence but not change it.": "You may inspect relationship evidence but not change it."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-idm.account.2b2936f8": "Konto",
|
||||
@@ -183,7 +284,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-idm.governance.b989a277": "Governance",
|
||||
"i18n:govoplan-idm.identity.544a8347": "Identität",
|
||||
"i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.",
|
||||
"i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verwalten Sie Organisationsfunktionszuordnungen, typisierte Fachgruppen und zeitlich wirksame Identitätsbeziehungen. IDM erfasst institutionelle Tatsachen; Access bewertet Anwendungsbefugnisse getrennt.",
|
||||
"i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.",
|
||||
"i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche",
|
||||
"i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance",
|
||||
@@ -303,6 +404,107 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"History": "Verlauf",
|
||||
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
||||
"Confirm": "Bestätigen",
|
||||
"None": "Keine"
|
||||
"None": "Keine",
|
||||
"i18n:govoplan-idm.edit_group_value": "{value0} bearbeiten",
|
||||
"i18n:govoplan-idm.inspect_memberships_value": "Mitgliedschaften von {value0} prüfen",
|
||||
"i18n:govoplan-idm.effective_memberships_value": "Wirksame Mitgliedschaften: {value0}",
|
||||
"i18n:govoplan-idm.memberships_resolved_summary": "Aufgelöst zum Zeitpunkt {value0}. {value1} Identitäten sind wirksam; ausgeschlossene Entscheidungen bleiben zur Erläuterung sichtbar.",
|
||||
"i18n:govoplan-idm.group_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Falls eine andere Administration zuerst speichert, laden Sie vor Ihrer Änderung neu.",
|
||||
"i18n:govoplan-idm.relationship_revision_help": "Revision {value0} schützt vor dem Überschreiben paralleler Änderungen. Die Ausgangsidentität ist unveränderbar; ersetzen Sie die Beziehung, wenn sie falsch ist.",
|
||||
"Access role assignments": "Zuweisungen von Zugriffsrollen",
|
||||
"Active": "Aktiv",
|
||||
"An Access or tenant administrator": "Access- oder Mandantenadministration",
|
||||
"Ask for typed-relationship read permission.": "Fordern Sie die Leseberechtigung für typisierte Beziehungen an.",
|
||||
"Ask for typed-relationship write permission before creating, editing, or revoking records.": "Fordern Sie vor dem Erstellen, Bearbeiten oder Widerrufen die Schreibberechtigung für typisierte Beziehungen an.",
|
||||
"Business membership is an institutional fact. It does not grant application permissions; Access evaluates authority separately.": "Eine fachliche Mitgliedschaft ist eine institutionelle Tatsache. Sie erteilt keine Anwendungsberechtigungen; Access bewertet Befugnisse getrennt.",
|
||||
"Create an active typed group first.": "Erstellen Sie zuerst eine aktive typisierte Gruppe.",
|
||||
"Create identity relationship": "Identitätsbeziehung erstellen",
|
||||
"Create relationship": "Beziehung erstellen",
|
||||
"Create typed group": "Typisierte Gruppe erstellen",
|
||||
"Description": "Beschreibung",
|
||||
"Destination": "Ziel",
|
||||
"Edit identity relationship": "Identitätsbeziehung bearbeiten",
|
||||
"Edit relationship": "Beziehung bearbeiten",
|
||||
"Edit typed group": "Typisierte Gruppe bearbeiten",
|
||||
"Effective at": "Wirksam zum Zeitpunkt",
|
||||
"Effective identity relationships": "Wirksame Identitätsbeziehungen",
|
||||
"Effective memberships": "Wirksame Mitgliedschaften",
|
||||
"Effective state": "Wirksamkeitsstatus",
|
||||
"Effective window": "Wirksamkeitszeitraum",
|
||||
"Enter a valid date and time.": "Geben Sie ein gültiges Datum mit Uhrzeit ein.",
|
||||
"Expired": "Abgelaufen",
|
||||
"Future": "Zukünftig",
|
||||
"Future dates schedule the fact without granting current membership. Expiry and revocation remove it from effective resolution while retaining source and decision evidence. Membership never grants Access permissions by itself.": "Ein zukünftiger Beginn plant die Tatsache, ohne eine aktuelle Mitgliedschaft zu erzeugen. Ablauf und Widerruf entfernen sie aus der wirksamen Auflösung, während Quellen- und Entscheidungsnachweise erhalten bleiben. Eine Mitgliedschaft erteilt niemals selbstständig Access-Berechtigungen.",
|
||||
"Group type": "Gruppentyp",
|
||||
"Identity": "Identität",
|
||||
"Identity reference": "Identitätsreferenz",
|
||||
"Identity relationship created.": "Identitätsbeziehung erstellt.",
|
||||
"Identity relationship revoked. Effective membership and downstream business resolution stop immediately.": "Identitätsbeziehung widerrufen. Wirksame Mitgliedschaft und nachgelagerte fachliche Auflösungen enden sofort.",
|
||||
"Identity relationship updated.": "Identitätsbeziehung aktualisiert.",
|
||||
"Identity state": "Identitätsstatus",
|
||||
"Inactive": "Inaktiv",
|
||||
"Key": "Schlüssel",
|
||||
"Loading typed groups and relationships": "Typisierte Gruppen und Beziehungen werden geladen",
|
||||
"Name": "Name",
|
||||
"No end limit": "Kein Endzeitpunkt",
|
||||
"No identity relationships found.": "Keine Identitätsbeziehungen gefunden.",
|
||||
"No membership relationships were evaluated.": "Es wurden keine Mitgliedschaftsbeziehungen ausgewertet.",
|
||||
"No start limit": "Kein Startzeitpunkt",
|
||||
"No typed groups found.": "Keine typisierten Gruppen gefunden.",
|
||||
"Not available": "Nicht verfügbar",
|
||||
"Properties (JSON object)": "Eigenschaften (JSON-Objekt)",
|
||||
"Properties must be a JSON object.": "Eigenschaften müssen ein JSON-Objekt sein.",
|
||||
"Properties must contain valid JSON.": "Eigenschaften müssen gültiges JSON enthalten.",
|
||||
"Provenance (JSON object)": "Herkunftsnachweis (JSON-Objekt)",
|
||||
"Provenance must be a JSON object.": "Der Herkunftsnachweis muss ein JSON-Objekt sein.",
|
||||
"Provenance must contain valid JSON.": "Der Herkunftsnachweis muss gültiges JSON enthalten.",
|
||||
"Related identity": "Verknüpfte Identität",
|
||||
"Reload": "Neu laden",
|
||||
"Relationship": "Beziehung",
|
||||
"Relationship kind": "Beziehungsart",
|
||||
"Relationship kinds": "Beziehungsarten",
|
||||
"Relationship write permission is required.": "Die Schreibberechtigung für Beziehungen ist erforderlich.",
|
||||
"Required action": "Erforderliche Aktion",
|
||||
"Resolution": "Auflösung",
|
||||
"Resolve memberships": "Mitgliedschaften auflösen",
|
||||
"Resolving effective memberships": "Wirksame Mitgliedschaften werden aufgelöst",
|
||||
"Responsible actor": "Verantwortliche Stelle",
|
||||
"Revision": "Revision",
|
||||
"Revocation reason": "Widerrufsgrund",
|
||||
"Revocation takes effect immediately for membership resolution and downstream business consumers. The record, actor, time, source, and reason remain as evidence; a revoked relationship cannot be edited or reactivated.": "Der Widerruf wirkt sofort auf die Mitgliedschaftsauflösung und nachgelagerte fachliche Verbraucher. Datensatz, handelnde Person, Zeitpunkt, Quelle und Grund bleiben als Nachweis erhalten; eine widerrufene Beziehung kann weder bearbeitet noch reaktiviert werden.",
|
||||
"Revoke identity relationship": "Identitätsbeziehung widerrufen",
|
||||
"Revoke relationship": "Beziehung widerrufen",
|
||||
"Revoked": "Widerrufen",
|
||||
"Revoked relationships are retained as immutable evidence.": "Widerrufene Beziehungen bleiben als unveränderbarer Nachweis erhalten.",
|
||||
"Revoking...": "Wird widerrufen …",
|
||||
"Role": "Rolle",
|
||||
"Save group": "Gruppe speichern",
|
||||
"Save or discard the typed-group or relationship draft before leaving this surface.": "Speichern oder verwerfen Sie den Entwurf der typisierten Gruppe oder Beziehung, bevor Sie diesen Bereich verlassen.",
|
||||
"Save relationship": "Beziehung speichern",
|
||||
"Saving...": "Wird gespeichert …",
|
||||
"Search identities": "Identitäten durchsuchen",
|
||||
"Search typed groups": "Typisierte Gruppen durchsuchen",
|
||||
"Show inactive groups": "Inaktive Gruppen anzeigen",
|
||||
"Show revoked relationships": "Widerrufene Beziehungen anzeigen",
|
||||
"Source": "Quelle",
|
||||
"Source provider": "Quellanbieter",
|
||||
"Source resource ID": "Quellressourcen-ID",
|
||||
"Source resource type": "Quellressourcentyp",
|
||||
"Source revision": "Quellrevision",
|
||||
"Status": "Status",
|
||||
"Subject identity": "Ausgangsidentität",
|
||||
"Target": "Ziel",
|
||||
"Target group": "Zielgruppe",
|
||||
"Target type": "Zielart",
|
||||
"Target typed group": "Typisierte Zielgruppe",
|
||||
"Typed group": "Typisierte Gruppe",
|
||||
"Typed group created.": "Typisierte Gruppe erstellt.",
|
||||
"Typed group updated.": "Typisierte Gruppe aktualisiert.",
|
||||
"Typed groups": "Typisierte Gruppen",
|
||||
"Typed groups and identity relationships": "Typisierte Gruppen und Identitätsbeziehungen",
|
||||
"Typed-group write permission is required.": "Die Schreibberechtigung für typisierte Gruppen ist erforderlich.",
|
||||
"Unsaved relationship administration": "Ungespeicherte Beziehungsverwaltung",
|
||||
"You do not have permission to view typed identity relationships.": "Sie haben keine Berechtigung, typisierte Identitätsbeziehungen anzuzeigen.",
|
||||
"You may inspect relationship evidence but not change it.": "Sie können Beziehungsnachweise prüfen, aber nicht ändern."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ const IdmPage = lazy(() => import("./features/IdmPage"));
|
||||
const idmReadScopes = [
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:relationship:read",
|
||||
"idm:relationship:write",
|
||||
"organizations:function:assign"
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.idm-relationship-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.idm-card-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.idm-check-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
Reference in New Issue
Block a user