feat: add canonical identity administration
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@govoplan/identity-webui",
|
||||
"version": "0.1.18",
|
||||
"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/identity.css": "./src/styles/identity.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:identity-admin-ui": "node tests/identity-admin-ui-structure.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type IdentityAccountLink = {
|
||||
id: string;
|
||||
identity_id: string;
|
||||
account_id: string;
|
||||
is_primary: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityItem = {
|
||||
id: string;
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
primary_account_id?: string | null;
|
||||
account_ids: string[];
|
||||
account_links: IdentityAccountLink[];
|
||||
status: "active" | "inactive";
|
||||
is_active: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
management_scope: "system";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityDraft = {
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export async function listIdentities(
|
||||
settings: ApiSettings,
|
||||
query = "",
|
||||
includeInactive = true
|
||||
): Promise<IdentityItem[]> {
|
||||
const result = await apiFetch<{ identities: IdentityItem[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/identity/identities", {
|
||||
query: query.trim() || undefined,
|
||||
include_inactive: includeInactive,
|
||||
limit: 500
|
||||
})
|
||||
);
|
||||
return result.identities;
|
||||
}
|
||||
|
||||
export function createIdentity(
|
||||
settings: ApiSettings,
|
||||
payload: IdentityDraft
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(settings, "/api/v1/identity/identities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateIdentity(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: Partial<IdentityDraft>
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setIdentityActive(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
active: boolean,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/${active ? "activate" : "deactivate"}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason.trim() || null })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function addIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: {
|
||||
account_id: string;
|
||||
source: string;
|
||||
make_primary: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
is_primary: true,
|
||||
reason: reason.trim() || null
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string
|
||||
): Promise<void> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import {
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
FilterBar,
|
||||
FormField,
|
||||
FormGrid,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageActionBar,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
WorkspaceLayout,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
addIdentityAccountLink,
|
||||
createIdentity,
|
||||
listIdentities,
|
||||
promoteIdentityAccountLink,
|
||||
removeIdentityAccountLink,
|
||||
setIdentityActive,
|
||||
updateIdentity,
|
||||
type IdentityAccountLink,
|
||||
type IdentityDraft,
|
||||
type IdentityItem
|
||||
} from "../api/identities";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
type LinkDraft = {
|
||||
accountId: string;
|
||||
source: string;
|
||||
makePrimary: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: IdentityDraft = {
|
||||
display_name: "",
|
||||
external_subject: "",
|
||||
source: "local"
|
||||
};
|
||||
|
||||
const EMPTY_LINK: LinkDraft = {
|
||||
accountId: "",
|
||||
source: "local",
|
||||
makePrimary: false,
|
||||
reason: ""
|
||||
};
|
||||
|
||||
export default function IdentityAdminPage({ settings, auth }: Props) {
|
||||
const [items, setItems] = useState<IdentityItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [savedKey, setSavedKey] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkDraft, setLinkDraft] = useState<LinkDraft>(EMPTY_LINK);
|
||||
const [lifecycleOpen, setLifecycleOpen] = useState(false);
|
||||
const [lifecycleReason, setLifecycleReason] = useState("");
|
||||
const [removeLink, setRemoveLink] = useState<IdentityAccountLink | null>(null);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
const canWrite = hasScope(auth, "identity:identity:admin")
|
||||
|| hasScope(auth, "system:accounts:update")
|
||||
|| hasScope(auth, "access:account:update");
|
||||
const canManageLinks = hasScope(auth, "identity:account_link:admin")
|
||||
|| canWrite;
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
||||
|
||||
const applyIdentity = useCallback((item: IdentityItem | null) => {
|
||||
const next = item ? draftFromIdentity(item) : EMPTY_DRAFT;
|
||||
setDraft(next);
|
||||
setSavedKey(item ? draftKey(next) : "");
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await listIdentities(settings, "", true);
|
||||
setItems(next);
|
||||
const nextId = preferredId && next.some((item) => item.id === preferredId)
|
||||
? preferredId
|
||||
: next.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: next[0]?.id ?? "";
|
||||
setSelectedId(nextId);
|
||||
applyIdentity(next.find((item) => item.id === nextId) ?? null);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyIdentity, selectedId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (!showInactive && !item.is_active) return false;
|
||||
if (!needle) return true;
|
||||
return `${item.display_name ?? ""} ${item.external_subject ?? ""} ${item.id} ${item.account_ids.join(" ")}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle);
|
||||
});
|
||||
}, [items, search, showInactive]);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!selected || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateIdentity(settings, selected.id, draft);
|
||||
setSuccess("Identity saved.");
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => applyIdentity(selected),
|
||||
title: "Unsaved identity changes",
|
||||
message: "Save or discard the current identity changes before continuing."
|
||||
});
|
||||
|
||||
const selectIdentity = (item: IdentityItem) => {
|
||||
if (item.id === selectedId) return;
|
||||
requestDiscard(() => {
|
||||
setSelectedId(item.id);
|
||||
applyIdentity(item);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
});
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
if (!createDraft.display_name?.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createIdentity(settings, createDraft);
|
||||
setCreateOpen(false);
|
||||
setCreateDraft(EMPTY_DRAFT);
|
||||
setSuccess("Identity created.");
|
||||
await reload(created.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLink = async () => {
|
||||
if (!selected || !linkDraft.accountId.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await addIdentityAccountLink(settings, selected.id, {
|
||||
account_id: linkDraft.accountId.trim(),
|
||||
source: linkDraft.source.trim() || "local",
|
||||
make_primary: linkDraft.makePrimary,
|
||||
reason: linkDraft.reason.trim() || null
|
||||
});
|
||||
setLinkOpen(false);
|
||||
setLinkDraft(EMPTY_LINK);
|
||||
setSuccess("Account link added.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const promote = async (link: IdentityAccountLink) => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await promoteIdentityAccountLink(
|
||||
settings,
|
||||
selected.id,
|
||||
link.id,
|
||||
"Promoted through Identity administration"
|
||||
);
|
||||
setSuccess("Primary account changed.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selected || !removeLink || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await removeIdentityAccountLink(settings, selected.id, removeLink.id);
|
||||
setRemoveLink(null);
|
||||
setSuccess("Account link removed.");
|
||||
await reload(selected.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyLifecycle = async () => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setIdentityActive(
|
||||
settings,
|
||||
selected.id,
|
||||
!selected.is_active,
|
||||
lifecycleReason
|
||||
);
|
||||
setLifecycleOpen(false);
|
||||
setLifecycleReason("");
|
||||
setSuccess(updated.is_active ? "Identity reactivated." : "Identity deactivated.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const linkColumns = useMemo<DataGridColumn<IdentityAccountLink>[]>(() => [
|
||||
{
|
||||
id: "account",
|
||||
header: "Account ID",
|
||||
width: "1fr",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.account_id,
|
||||
render: (row) => <code>{row.account_id}</code>
|
||||
},
|
||||
{
|
||||
id: "primary",
|
||||
header: "Role",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
value: (row) => row.is_primary ? "primary" : "linked",
|
||||
render: (row) => (
|
||||
<StatusBadge
|
||||
status={row.is_primary ? "active" : "neutral"}
|
||||
label={row.is_primary ? "Primary" : "Linked"}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
width: 180,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.source
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Linked",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
value: (row) => row.created_at,
|
||||
render: (row) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 100,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<TableActionGroup actions={[
|
||||
{
|
||||
id: "promote",
|
||||
label: "Promote to primary",
|
||||
icon: <Star aria-hidden="true" />,
|
||||
applicable: !row.is_primary,
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void promote(row)
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: "Remove account link",
|
||||
icon: <Trash2 aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: row.is_primary && (selected?.account_links.length ?? 0) > 1
|
||||
? "Promote another account before removing the primary link."
|
||||
: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => setRemoveLink(row)
|
||||
}
|
||||
]} />
|
||||
)
|
||||
}
|
||||
], [busy, canManageLinks, selected?.account_links.length]);
|
||||
|
||||
const actionBar = (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void reload(selectedId),
|
||||
loading: loading
|
||||
}}
|
||||
primaryActions={
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> New identity
|
||||
</Button>
|
||||
}
|
||||
destructiveActions={selected ? (
|
||||
<Button
|
||||
variant={selected.is_active ? "danger" : "secondary"}
|
||||
onClick={() => setLifecycleOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
{selected.is_active
|
||||
? <><UserMinus aria-hidden="true" /> Deactivate</>
|
||||
: <><UserCheck aria-hidden="true" /> Reactivate</>}
|
||||
</Button>
|
||||
) : null}
|
||||
discardAction={{
|
||||
label: "Discard changes",
|
||||
disabled: !selected,
|
||||
onClick: () => applyIdentity(selected)
|
||||
}}
|
||||
saveAction={{
|
||||
label: "Save",
|
||||
disabled: !selected || !canWrite || busy,
|
||||
disabledReason: !canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void save()
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
archetype="workspace"
|
||||
title="Identity directory"
|
||||
description="Manage canonical system identities and their opaque platform-account links."
|
||||
loading={loading && !items.length}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={actionBar}
|
||||
className="identity-admin-page"
|
||||
helpContextId="identity.admin.directory"
|
||||
>
|
||||
<p className="muted identity-admin-scope-note">
|
||||
<strong>Management scope:</strong> {selected?.management_scope ?? "system"}.
|
||||
{" "}The active tenant is the
|
||||
actor context only; Identity does not grant account access or suspend authentication.
|
||||
</p>
|
||||
|
||||
<MetricGrid columns={3} density="compact" minimum="compact">
|
||||
<MetricCard label="Identities" value={items.length} />
|
||||
<MetricCard
|
||||
label="Active"
|
||||
value={items.filter((item) => item.is_active).length}
|
||||
tone="good"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Without account"
|
||||
value={items.filter((item) => !item.account_links.length).length}
|
||||
tone="warning"
|
||||
/>
|
||||
</MetricGrid>
|
||||
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Identities"
|
||||
contentLabel="Identity details"
|
||||
contentClassName="identity-admin-workspace"
|
||||
primary={<div className="identity-admin-list">
|
||||
<FilterBar surface="panel">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search identities or account IDs"
|
||||
aria-label="Search identities"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show inactive"
|
||||
checked={showInactive}
|
||||
onChange={setShowInactive}
|
||||
/>
|
||||
</FilterBar>
|
||||
<SelectionList variant="navigation" label="Identities">
|
||||
{visibleItems.map((item) => (
|
||||
<SelectionListItem
|
||||
key={item.id}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => selectIdentity(item)}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
title={item.display_name || item.id}
|
||||
description={item.primary_account_id || "No account linked"}
|
||||
/>
|
||||
<StatusBadge status={item.status} />
|
||||
</SelectionListItem>
|
||||
))}
|
||||
{!visibleItems.length
|
||||
? <StatePanel size="compact" description="No matching identities." />
|
||||
: null}
|
||||
</SelectionList>
|
||||
</div>}
|
||||
>
|
||||
{!selected ? (
|
||||
<StatePanel
|
||||
size="fill"
|
||||
title="Identity directory"
|
||||
description="Create or select an identity to inspect it."
|
||||
/>
|
||||
) : (
|
||||
<div className="identity-admin-detail">
|
||||
<Card
|
||||
title={selected.display_name || selected.id}
|
||||
>
|
||||
<p className="muted">System identity · {selected.status}</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={draft.display_name ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={draft.external_subject ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={draft.source}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Identity ID">
|
||||
<input value={selected.id} readOnly />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Account links"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => setLinkOpen(true)}
|
||||
disabled={!canManageLinks || busy}
|
||||
disabledReason={!canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> Add account link
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="muted">
|
||||
One opaque account reference is primary. Authentication and
|
||||
account lookup remain owned by Access.
|
||||
</p>
|
||||
<DataGrid
|
||||
id="identity-account-links"
|
||||
rows={selected.account_links}
|
||||
columns={linkColumns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="No platform accounts are linked."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Accepted normalized facts">
|
||||
<pre className="identity-admin-settings">
|
||||
{JSON.stringify(selected.settings, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceLayout>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Create identity"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void create()}
|
||||
disabled={busy || !createDraft.display_name?.trim()}
|
||||
>
|
||||
Create identity
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={createDraft.display_name ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={createDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={createDraft.external_subject ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={linkOpen}
|
||||
title="Add account link"
|
||||
onClose={() => !busy && setLinkOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLinkOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void addLink()}
|
||||
disabled={busy || !linkDraft.accountId.trim()}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Account ID">
|
||||
<input
|
||||
value={linkDraft.accountId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
accountId: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={linkDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch
|
||||
label="Make primary"
|
||||
checked={linkDraft.makePrimary}
|
||||
onChange={(makePrimary) => setLinkDraft({ ...linkDraft, makePrimary })}
|
||||
/>
|
||||
<FormField label="Reason">
|
||||
<input
|
||||
value={linkDraft.reason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
reason: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted">
|
||||
The first account link becomes primary automatically. Identity stores
|
||||
only the account reference and provenance.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={lifecycleOpen}
|
||||
title={selected?.is_active ? "Deactivate identity" : "Reactivate identity"}
|
||||
onClose={() => !busy && setLifecycleOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLifecycleOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant={selected?.is_active ? "danger" : "primary"}
|
||||
onClick={() => void applyLifecycle()}
|
||||
disabled={busy}
|
||||
>
|
||||
{selected?.is_active ? "Deactivate" : "Reactivate"}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
{selected?.is_active
|
||||
? "Deactivation hides the identity from ordinary directory search. It does not suspend authentication, erase links, or revoke permissions."
|
||||
: "Reactivation restores the identity to ordinary directory search."}
|
||||
</p>
|
||||
<FormField label="Reason">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={lifecycleReason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLifecycleReason(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(removeLink)}
|
||||
title="Remove account link"
|
||||
onClose={() => !busy && setRemoveLink(null)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setRemoveLink(null)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => void remove()} disabled={busy}>
|
||||
Remove link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
Remove account <strong>{removeLink?.account_id}</strong> from this
|
||||
identity? The account itself is not deleted.
|
||||
</p>
|
||||
</Dialog>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromIdentity(item: IdentityItem): IdentityDraft {
|
||||
return {
|
||||
display_name: item.display_name ?? "",
|
||||
external_subject: item.external_subject ?? "",
|
||||
source: item.source
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(value: IdentityDraft): string {
|
||||
return JSON.stringify({
|
||||
display_name: value.display_name?.trim() || null,
|
||||
external_subject: value.external_subject?.trim() || null,
|
||||
source: value.source.trim()
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default, identityModule } from "./module";
|
||||
export * from "./api/identities";
|
||||
export { default as IdentityAdminPage } from "./features/IdentityAdminPage";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/identity.css";
|
||||
|
||||
const IdentityAdminPage = lazy(() => import("./features/IdentityAdminPage"));
|
||||
|
||||
const readScopes = [
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
"system:accounts:read"
|
||||
];
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "system-identities",
|
||||
moduleId: "identity",
|
||||
kind: "management",
|
||||
surfaceId: "identity.admin.directory",
|
||||
label: "Identity directory",
|
||||
group: "SYSTEM",
|
||||
order: 30,
|
||||
anyOf: readScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(IdentityAdminPage, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const identityModule: PlatformWebModule = {
|
||||
id: "identity",
|
||||
label: "Identity",
|
||||
version: "0.1.18",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "idm"],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "identity.admin.directory",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity directory",
|
||||
order: 30
|
||||
},
|
||||
{
|
||||
id: "identity.admin.account-links",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity account links",
|
||||
parentId: "identity.admin.directory",
|
||||
order: 20
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default identityModule;
|
||||
@@ -0,0 +1,25 @@
|
||||
.identity-admin-page .identity-admin-workspace {
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-scope-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-settings {
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||
const page = readFileSync("src/features/IdentityAdminPage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/identities.ts", "utf8");
|
||||
|
||||
assert.match(moduleSource, /"admin.sections": adminSections/);
|
||||
assert.match(moduleSource, /identity\.admin\.directory/);
|
||||
assert.match(page, /<AdminPageLayout/);
|
||||
assert.match(page, /<PageActionBar/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /saveAction=/);
|
||||
assert.match(page, /useUnsavedDraftGuard/);
|
||||
assert.match(page, /management_scope/);
|
||||
assert.match(page, /Promote/);
|
||||
assert.match(api, /account-links/);
|
||||
assert.match(api, /include_inactive/);
|
||||
|
||||
console.log("Identity administration UI structural contract passed.");
|
||||
Reference in New Issue
Block a user