Release v0.1.7
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.6",
|
||||
"govoplan-core>=0.1.7",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -600,7 +600,7 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.6",
|
||||
version="0.1.7",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -205,7 +205,6 @@ export type PrivacyRetentionPolicyFieldKey =
|
||||
| "audit_detail_level";
|
||||
|
||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||
|
||||
export type PrivacyRetentionPolicy = {
|
||||
store_raw_campaign_json: boolean;
|
||||
@@ -218,29 +217,6 @@ export type PrivacyRetentionPolicy = {
|
||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
||||
};
|
||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
policy: PrivacyRetentionPolicyPatch;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
@@ -276,16 +252,6 @@ export type TenantSettingsDeltaSections = Partial<{
|
||||
settings: Pick<TenantSettingsItem, "settings">["settings"];
|
||||
}>;
|
||||
|
||||
export type RetentionRunResponse = {
|
||||
result: {
|
||||
dry_run: boolean;
|
||||
policy: PrivacyRetentionPolicy;
|
||||
cutoffs: Record<string, string | null>;
|
||||
effective_policy_scope?: string;
|
||||
counts: Record<string, Record<string, number>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
@@ -329,18 +295,6 @@ export type ExternalFunctionRoleMappingItem = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AuditAdminItem = {
|
||||
id: string;
|
||||
scope: "tenant" | "system";
|
||||
tenant_id?: string | null;
|
||||
actor_email?: string | null;
|
||||
action: string;
|
||||
object_type?: string | null;
|
||||
object_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type DeltaResponseFields = {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
@@ -361,15 +315,6 @@ export type TenantSettingsDeltaResponse = {
|
||||
sections: TenantSettingsDeltaSections;
|
||||
changed_sections: string[];
|
||||
} & DeltaResponseFields;
|
||||
export type AuditAdminDeltaResponse = {
|
||||
items: AuditAdminItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
} & DeltaResponseFields;
|
||||
|
||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
@@ -671,58 +616,6 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export type AuditQueryOptions = {
|
||||
tenantId?: string | null;
|
||||
allTenants?: boolean;
|
||||
scope?: "tenant" | "system";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
|
||||
sortDirection?: "asc" | "desc";
|
||||
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
|
||||
};
|
||||
|
||||
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number; cursor?: string | null; next_cursor?: string | null }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
if (options.allTenants) params.set("all_tenants", "true");
|
||||
if (options.scope) params.set("scope", options.scope);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.offset) params.set("offset", String(options.offset));
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
||||
for (const [column, value] of Object.entries(options.filters ?? {})) {
|
||||
if (value?.trim()) params.set(`filter_${column}`, value);
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise<AuditAdminDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
if (options.allTenants) params.set("all_tenants", "true");
|
||||
if (options.scope) params.set("scope", options.scope);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
||||
if (options.since) params.set("since", options.since);
|
||||
for (const [column, value] of Object.entries(options.filters ?? {})) {
|
||||
if (value?.trim()) params.set(`filter_${column}`, value);
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/audit/delta${suffix}`);
|
||||
}
|
||||
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
@@ -759,24 +652,6 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
|
||||
}
|
||||
|
||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
|
||||
}
|
||||
|
||||
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
|
||||
type AuditSortBy = "time" | "actor" | "action" | "object" | "tenant";
|
||||
|
||||
const DEFAULT_QUERY: DataGridQueryState = {
|
||||
sort: { columnId: "time", direction: "desc" },
|
||||
filters: {}
|
||||
};
|
||||
|
||||
export default function AdminAuditPanel({
|
||||
settings,
|
||||
auth,
|
||||
systemMode = false
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;systemMode?: boolean;}) {
|
||||
const [items, setItems] = useState<AuditAdminItem[]>([]);
|
||||
const itemsRef = useRef<AuditAdminItem[]>([]);
|
||||
const pageItemsRef = useRef<Record<string, AuditAdminItem[]>>({});
|
||||
const pageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
|
||||
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const sortColumn = query.sort?.columnId;
|
||||
const sortBy = sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn) ?
|
||||
sortColumn as AuditSortBy :
|
||||
"time";
|
||||
const sortDirection = query.sort?.direction ?? "desc";
|
||||
const filters = query.filters;
|
||||
const pageCursor = page === 1 ? null : pageCursorsRef.current[page];
|
||||
const deltaMode = page === 1 || pageCursor !== undefined;
|
||||
const requestOptions = {
|
||||
scope: systemMode ? "system" : "tenant",
|
||||
page,
|
||||
pageSize,
|
||||
cursor: pageCursor,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
filters
|
||||
};
|
||||
const deltaKey = `access:audit:${systemMode ? "system" : "tenant"}:${tenantId}:${pageSize}:${page}:${pageCursor ?? "root"}:${JSON.stringify({ sortBy, sortDirection, filters })}`;
|
||||
const response = deltaMode
|
||||
? await fetchAdminAuditDelta(settings, { ...requestOptions, since: getDeltaWatermark(deltaKey) })
|
||||
: await fetchAdminAudit(settings, requestOptions);
|
||||
const baseItems = pageItemsRef.current[deltaKey] ?? [];
|
||||
const nextItems = "full" in response && !response.full
|
||||
? mergeDeltaRows(baseItems, response.items, response.deleted, (item) => item.id, { sort: compareAuditEvents(sortBy, sortDirection) }).slice(0, pageSize)
|
||||
: response.items;
|
||||
pageItemsRef.current[deltaKey] = nextItems;
|
||||
itemsRef.current = nextItems;
|
||||
setItems(nextItems);
|
||||
setTotal(response.total);
|
||||
if (!deltaMode && response.page !== page) setPage(response.page);
|
||||
if (response.cursor !== undefined) pageCursorsRef.current[page] = response.cursor ?? null;
|
||||
if (response.next_cursor !== undefined) {
|
||||
if (response.next_cursor) pageCursorsRef.current[page + 1] = response.next_cursor;
|
||||
else delete pageCursorsRef.current[page + 1];
|
||||
}
|
||||
if ("full" in response && !response.full && page === 1 && (response.items.length > 0 || response.deleted.length > 0)) {
|
||||
pageCursorsRef.current = { 1: null };
|
||||
}
|
||||
if ("watermark" in response) setDeltaWatermark(deltaKey, response.watermark);
|
||||
if (!deltaMode) resetDeltaWatermark(deltaKey);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
itemsRef.current = [];
|
||||
pageItemsRef.current = {};
|
||||
pageCursorsRef.current = { 1: null };
|
||||
resetDeltaWatermark();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, pageSize, query, resetDeltaWatermark]);
|
||||
|
||||
useEffect(() => {void load();}, [load]);
|
||||
|
||||
const handleQueryChange = useCallback((next: DataGridQueryState) => {
|
||||
setQuery((current) => {
|
||||
if (JSON.stringify(current) === JSON.stringify(next)) return current;
|
||||
setPage(1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
||||
{ id: "time", header: "i18n:govoplan-access.time.6c82e6dd", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||
{ id: "actor", header: "i18n:govoplan-access.actor.cbd19b5c", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "i18n:govoplan-access.system.bc0792d8" },
|
||||
{ id: "action", header: "i18n:govoplan-access.action.97c89a4d", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||
{ id: "object", header: "i18n:govoplan-access.object.2883f191", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
|
||||
...(systemMode ? [{ id: "tenant", header: "i18n:govoplan-access.tenant_context.b401a2ad", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="i18n:govoplan-access.inspect_audit_event.5776c1b2" icon={<Search />} onClick={() => setSelected(row)} /></div> }],
|
||||
[systemMode]);
|
||||
|
||||
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const lastShown = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title={systemMode ? "i18n:govoplan-access.system_audit.69c6b424" : "i18n:govoplan-access.tenant_audit.492b9138"}
|
||||
description={systemMode ? i18nMessage("i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1", { value0:
|
||||
firstShown, value1: lastShown, value2: total }) : i18nMessage("i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff", { value0:
|
||||
firstShown, value1: lastShown, value2: total })}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>}>
|
||||
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
|
||||
rows={items}
|
||||
columns={columns}
|
||||
initialFit="container" getRowKey={(row) => row.id}
|
||||
emptyText="i18n:govoplan-access.no_administrative_audit_records_found.8d128767"
|
||||
className="admin-audit-grid"
|
||||
initialSort={{ columnId: "time", direction: "desc" }}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
pageSize,
|
||||
totalRows: total,
|
||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||
disabled: loading,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: (next) => {setPageSize(next);setPage(1);}
|
||||
}}
|
||||
onQueryChange={handleQueryChange} />
|
||||
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
<Dialog open={Boolean(selected)} title="i18n:govoplan-access.audit_event_details.3749b52d" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{selected && <><dl className="admin-details-grid"><div><dt>i18n:govoplan-access.scope.4651a34e</dt><dd>{selected.scope}</dd></div><div><dt>i18n:govoplan-access.action.97c89a4d</dt><dd>{selected.action}</dd></div><div><dt>i18n:govoplan-access.actor.cbd19b5c</dt><dd>{selected.actor_email || "i18n:govoplan-access.system.bc0792d8"}</dd></div><div><dt>i18n:govoplan-access.object.2883f191</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>i18n:govoplan-access.tenant_context.b401a2ad</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>i18n:govoplan-access.time.6c82e6dd</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
|
||||
</Dialog>
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function compareAuditEvents(sortBy: AuditSortBy, sortDirection: "asc" | "desc"): (left: AuditAdminItem, right: AuditAdminItem) => number {
|
||||
return (left, right) => {
|
||||
const primary = compareAuditValues(auditSortValue(left, sortBy), auditSortValue(right, sortBy));
|
||||
const directed = sortDirection === "asc" ? primary : -primary;
|
||||
return directed || right.id.localeCompare(left.id);
|
||||
};
|
||||
}
|
||||
|
||||
function auditSortValue(item: AuditAdminItem, sortBy: AuditSortBy): string | number {
|
||||
if (sortBy === "time") return new Date(item.created_at).getTime();
|
||||
if (sortBy === "actor") return item.actor_email || "System";
|
||||
if (sortBy === "action") return item.action;
|
||||
if (sortBy === "object") return `${item.object_type || ""} ${item.object_id || ""}`;
|
||||
return item.tenant_id || "";
|
||||
}
|
||||
|
||||
function compareAuditValues(left: string | number, right: string | number): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return String(left).localeCompare(String(right));
|
||||
}
|
||||
@@ -22,11 +22,9 @@ import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import AdminAuditPanel from "./AdminAuditPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||
import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
|
||||
type AdminSection = string;
|
||||
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
@@ -37,7 +35,6 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"system-configuration-changes",
|
||||
"system-configuration-packages",
|
||||
"system-modules",
|
||||
"system-audit",
|
||||
"system-tenants",
|
||||
"system-roles",
|
||||
"system-role-templates",
|
||||
@@ -45,7 +42,6 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"system-retention",
|
||||
"tenant-settings",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
@@ -54,14 +50,10 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-api-keys",
|
||||
"tenant-retention",
|
||||
"tenant-audit",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-group-retention",
|
||||
"tenant-user-file-connectors",
|
||||
"tenant-user-mail-servers",
|
||||
"tenant-user-retention"
|
||||
"tenant-user-mail-servers"
|
||||
]);
|
||||
|
||||
export default function AdminPage({
|
||||
@@ -79,8 +71,6 @@ export default function AdminPage({
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const auditAvailable = usePlatformModuleInstalled("audit");
|
||||
const policyAvailable = usePlatformModuleInstalled("policy");
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
@@ -102,13 +92,11 @@ export default function AdminPage({
|
||||
if (canUseContributedSection(auth, section)) sections.add(section.id);
|
||||
}
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
if (policyAvailable) sections.add("system-retention");
|
||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
@@ -123,15 +111,9 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (policyAvailable && hasScope(auth, "admin:policies:read")) {
|
||||
sections.add("tenant-retention");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
|
||||
return sections;
|
||||
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, policyAvailable]);
|
||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
@@ -176,7 +158,6 @@ export default function AdminPage({
|
||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||
visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
|
||||
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
||||
visibleNavItem(available, "system-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
|
||||
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -192,7 +173,6 @@ export default function AdminPage({
|
||||
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
||||
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
||||
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
||||
visibleNavItem(available, "system-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
|
||||
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
||||
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
||||
])
|
||||
@@ -209,9 +189,7 @@ export default function AdminPage({
|
||||
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
||||
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
|
||||
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 80),
|
||||
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
|
||||
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 100),
|
||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -222,7 +200,6 @@ export default function AdminPage({
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-group-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
|
||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -233,7 +210,6 @@ export default function AdminPage({
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-user-retention", "i18n:govoplan-access.retention.c7199d9e", 30),
|
||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -248,7 +224,6 @@ export default function AdminPage({
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
{contributedSection && contributedSection.render(contributionContext)}
|
||||
{!contributedSection && active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />}
|
||||
{!contributedSection && active === "system-mail-servers" && (
|
||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||
)}
|
||||
@@ -267,7 +242,6 @@ export default function AdminPage({
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />}
|
||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
@@ -278,11 +252,7 @@ export default function AdminPage({
|
||||
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, runRetentionPolicy, type GroupSummary, type PrivacyRetentionPolicyScope, type RetentionRunResponse, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;policyTitle: string;policyDescription: string;}> = {
|
||||
system: {
|
||||
title: "i18n:govoplan-access.system_retention.4191e7f7",
|
||||
description: "i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224",
|
||||
policyTitle: "i18n:govoplan-access.system_retention_policy.7027f6ba",
|
||||
policyDescription: "i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13"
|
||||
},
|
||||
tenant: {
|
||||
title: "i18n:govoplan-access.tenant_retention.95b35db0",
|
||||
description: "i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c",
|
||||
policyTitle: "i18n:govoplan-access.tenant_retention_policy.f10893d7",
|
||||
policyDescription: "i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77"
|
||||
},
|
||||
user: {
|
||||
title: "i18n:govoplan-access.user_retention.f0966bbf",
|
||||
description: "i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b",
|
||||
targetLabel: "i18n:govoplan-access.user.9f8a2389",
|
||||
policyTitle: "i18n:govoplan-access.user_retention_policy.2776f485",
|
||||
policyDescription: "i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0"
|
||||
},
|
||||
group: {
|
||||
title: "i18n:govoplan-access.group_retention.57cdcdaa",
|
||||
description: "i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04",
|
||||
targetLabel: "i18n:govoplan-access.group.171a0606",
|
||||
policyTitle: "i18n:govoplan-access.group_retention_policy.ad941c0b",
|
||||
policyDescription: "i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f"
|
||||
}
|
||||
};
|
||||
|
||||
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||
const [targetError, setTargetError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [runError, setRunError] = useState("");
|
||||
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
|
||||
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await loadDeltaRows(usersRef.current, "access:retention-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
|
||||
usersRef.current = users;
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await loadDeltaRows(groupsRef.current, "access:retention-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
|
||||
groupsRef.current = groups;
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetention(dryRun: boolean) {
|
||||
setBusy(true);
|
||||
setRunError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const response = await runRetentionPolicy(settings, dryRun);
|
||||
setRetentionResult(response);
|
||||
setSuccess(dryRun ? "i18n:govoplan-access.retention_dry_run_completed.91895aee" : "i18n:govoplan-access.retention_policy_applied.7fa4e050");
|
||||
setConfirmRetentionRun(false);
|
||||
} catch (err) {setRunError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
||||
<RetentionPolicyScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
title={labels.policyTitle}
|
||||
description={labels.policyDescription}
|
||||
canWrite={canWrite} />
|
||||
|
||||
{scopeType === "system" &&
|
||||
<div className="retention-run-card">
|
||||
<Card title="i18n:govoplan-access.retention_execution.84b7105d">
|
||||
<p className="muted small-note">i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c</p>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>i18n:govoplan-access.dry_run.485a3d15</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>i18n:govoplan-access.apply_retention.5b991811</Button>
|
||||
</div>
|
||||
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
</AdminPageLayout>
|
||||
<ConfirmDialog
|
||||
open={confirmRetentionRun}
|
||||
title="i18n:govoplan-access.apply_retention_policy.9e5d32b4"
|
||||
message="i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715"
|
||||
confirmLabel="i18n:govoplan-access.apply_retention.5b991811"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmRetentionRun(false)}
|
||||
onConfirm={() => void runRetention(false)} />
|
||||
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
Reference in New Issue
Block a user