feat: add access module boundary migrations

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 37828fe340
commit 04681f1d75
41 changed files with 7229 additions and 738 deletions

View File

@@ -1,4 +1,4 @@
import type { ApiSettings } from "@govoplan/core-webui";
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui";
import { apiFetch } from "@govoplan/core-webui";
export type PermissionItem = {
@@ -171,17 +171,35 @@ export type SystemSettingsItem = {
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
available_languages?: LanguagePackage[];
enabled_language_codes?: string[];
settings: Record<string, unknown>;
};
export type LanguagePackage = {
code: string;
label: string;
native_label?: string | null;
};
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
settings: Record<string, unknown>;
};
export type TenantSettingsDeltaSections = Partial<{
identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
locale: Pick<TenantSettingsItem, "default_locale">;
languages: Pick<TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes">;
settings: Pick<TenantSettingsItem, "settings">["settings"];
}>;
export type RetentionRunResponse = {
result: {
dry_run: boolean;
@@ -236,6 +254,42 @@ export type AuditAdminItem = {
created_at: string;
};
type DeltaResponseFields = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
export type TenantSettingsDeltaResponse = {
item?: TenantSettingsItem | null;
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();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params.toString() ? `?${params.toString()}` : "";
}
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
@@ -252,6 +306,11 @@ export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminIt
return response.tenants;
}
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
@@ -289,7 +348,12 @@ export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettin
return apiFetch(settings, "/api/v1/admin/tenant/settings");
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantSettingsDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenant/settings/delta${suffix}`);
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
@@ -298,6 +362,11 @@ export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]
return response.users;
}
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/users/delta${suffix}`);
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
@@ -324,6 +393,11 @@ export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]
return response.groups;
}
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/groups/delta${suffix}`);
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -350,6 +424,11 @@ export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]>
return response.roles;
}
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/roles/delta${suffix}`);
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -377,6 +456,11 @@ export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSumma
return response.roles;
}
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/roles/delta${suffix}`);
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
@@ -403,6 +487,11 @@ export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ acco
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function fetchSystemAccountsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<SystemAccountListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/accounts/delta${suffix}`);
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
@@ -429,6 +518,15 @@ export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false
return response.api_keys;
}
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
@@ -450,12 +548,13 @@ export type AuditQueryOptions = {
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 }> {
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");
@@ -464,6 +563,7 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery
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 ?? {})) {
@@ -473,6 +573,24 @@ export async function fetchAdminAudit(settings: ApiSettings, options: AuditQuery
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;
@@ -502,6 +620,8 @@ export type SystemSettingsUpdatePayload = {
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
available_languages?: LanguagePackage[] | null;
enabled_language_codes?: string[] | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
@@ -532,6 +652,11 @@ export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "gr
return response.templates;
}
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/system/governance-templates/delta${suffix}`);
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin";
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 } 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" },
@@ -16,12 +18,16 @@ export default function AdminAuditPanel({
settings,
auth,
systemMode = false
}: {
settings: ApiSettings;
auth: AuthInfo;
systemMode?: boolean;
}) {
}: {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);
@@ -37,27 +43,60 @@ export default function AdminAuditPanel({
setError("");
try {
const sortColumn = query.sort?.columnId;
const response = await fetchAdminAudit(settings, {
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,
sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
? sortColumn as "time" | "actor" | "action" | "object" | "tenant"
: "time",
sortDirection: query.sort?.direction ?? "desc",
filters: query.filters
});
setItems(response.items);
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 (response.page !== page) setPage(response.page);
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]);
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => { void load(); }, [load]);
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) => {
@@ -68,13 +107,13 @@ export default function AdminAuditPanel({
}, []);
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
{ id: "time", header: "Time", 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: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
{ id: "object", header: "Object", 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: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
], [systemMode]);
{ 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);
@@ -82,21 +121,21 @@ export default function AdminAuditPanel({
return (
<>
<AdminPageLayout
title={systemMode ? "System audit" : "Tenant audit"}
description={systemMode
? `System-level administrative history. Showing ${firstShown}${lastShown} of ${total} records.`
: `Tenant-level administrative history for the active tenant. Showing ${firstShown}${lastShown} of ${total} records.`}
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}>Reload</Button>}
>
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="No administrative audit records found."
emptyText="i18n:govoplan-access.no_administrative_audit_records_found.8d128767"
className="admin-audit-grid"
initialSort={{ columnId: "time", direction: "desc" }}
pagination={{
@@ -107,15 +146,36 @@ export default function AdminAuditPanel({
pageSizeOptions: [10, 25, 50, 100, 250],
disabled: loading,
onPageChange: setPage,
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
onPageSizeChange: (next) => {setPageSize(next);setPage(1);}
}}
onQueryChange={handleQueryChange}
/>
onQueryChange={handleQueryChange} />
</div>
</AdminPageLayout>
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
{selected && <><dl className="admin-details-grid"><div><dt>Scope</dt><dd>{selected.scope}</dd></div><div><dt>Action</dt><dd>{selected.action}</dd></div><div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div><div><dt>Object</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>Tenant context</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
<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));
}

View File

@@ -1,6 +1,13 @@
import { useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui";
import type {
AdminSectionContribution,
AdminSectionsUiCapability,
ApiSettings,
AuthInfo,
FilesConnectorsUiCapability,
MailProfilesUiCapability
} from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
@@ -14,6 +21,7 @@ import GroupsPanel from "./GroupsPanel";
import RolesPanel from "./RolesPanel";
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";
@@ -21,6 +29,38 @@ import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCap
type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
const handledAdminSectionIds = new Set<string>([
"overview",
"system-settings",
"system-configuration-changes",
"system-configuration-packages",
"system-modules",
"system-audit",
"system-tenants",
"system-roles",
"system-role-templates",
"system-groups",
"system-users",
"system-file-connectors",
"system-mail-servers",
"system-retention",
"tenant-settings",
"tenant-roles",
"tenant-groups",
"tenant-users",
"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"
]);
export default function AdminPage({
settings,
auth,
@@ -31,16 +71,19 @@ export default function AdminPage({
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
}) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const auditAvailable = usePlatformModuleInstalled("audit");
const policyAvailable = usePlatformModuleInstalled("policy");
const tenancyAvailable = usePlatformModuleInstalled("tenancy");
const contributedSections = useMemo(() => (
adminSectionCapabilities
.flatMap((capability) => capability.sections)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
), [adminSectionCapabilities]);
const contributedSections = useMemo(
() =>
adminSectionCapabilities
.flatMap((capability) => capability.sections)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[adminSectionCapabilities]
);
const contributionById = useMemo(() => {
const mapped = new Map<string, AdminSectionContribution>();
for (const section of contributedSections) {
@@ -58,7 +101,7 @@ export default function AdminPage({
if (policyAvailable) sections.add("system-retention");
if (mailProfilesAvailable) sections.add("system-mail-servers");
}
if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
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");
@@ -71,6 +114,10 @@ export default function AdminPage({
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
}
if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) {
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");
@@ -79,7 +126,7 @@ export default function AdminPage({
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, mailProfilesAvailable, policyAvailable, tenancyAvailable]);
}, [auth, auditAvailable, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, policyAvailable]);
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -105,50 +152,85 @@ export default function AdminPage({
}, [settings.accessToken, settings.apiBaseUrl]);
if (!hasAnyScope(auth, adminReadScopes)) {
return <div className="content-pad"><Card title="Administration unavailable"><p>Your current roles do not grant administrative access.</p></Card></div>;
return (
<div className="content-pad">
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
</Card>
</div>
);
}
const rootItems = contributedNavItems(contributedSections, available, "ROOT");
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
{ items: asSubnavItems(rootItems) },
{
title: "SYSTEM",
items: asSubnavItems(sortNavItems([
...contributedNavItems(contributedSections, available, "SYSTEM"),
visibleNavItem(available, "system-tenants", "Tenants", 20),
visibleNavItem(available, "system-roles", "System roles", 30),
visibleNavItem(available, "system-users", "Users", 60),
visibleNavItem(available, "system-mail-servers", "Mail servers", 70),
visibleNavItem(available, "system-retention", "Retention", 80),
visibleNavItem(available, "system-audit", "Audit", 90)
]))
title: "ADMINISTRATION",
items: asSubnavItems(
sortNavItems([
...contributedNavItems(contributedSections, available, "ROOT"),
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
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)
])
)
},
{
title: "GLOBAL",
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
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)
])
)
},
{
title: "TENANT",
items: [
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
]
items: asSubnavItems(
sortNavItems([
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 20),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 30),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 40),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 50),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 60),
visibleNavItem(available, "tenant-retention", "i18n:govoplan-access.retention.c7199d9e", 70),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 80),
visibleNavItem(available, "tenant-audit", "i18n:govoplan-access.audit.fa1703dd", 90),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
])
)
},
{
title: "GROUP",
items: [
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
]
items: asSubnavItems(
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)
])
)
},
{
title: "USER",
items: [
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
]
items: asSubnavItems(
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)
])
)
}
].filter((group) => group.items.length > 0);
const contributedSection = contributionById.get(active);
@@ -161,22 +243,24 @@ export default function AdminPage({
<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")} />}
{!contributedSection && active === "system-tenants" && <TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "system-users" && <SystemUsersPanel
settings={settings}
canCreate={hasScope(auth, "system:accounts:create")}
canUpdate={hasScope(auth, "system:accounts:update")}
canSuspend={hasScope(auth, "system:accounts:suspend")}
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
onAuthRefresh={refreshAuth}
/>}
{!contributedSection && active === "system-roles" && <SystemRolesPanel
settings={settings}
canWrite={hasScope(auth, "system:roles:write")}
onAuthRefresh={refreshAuth}
/>}
{!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")} />
)}
{!contributedSection && active === "system-tenants" && (
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
)}
{!contributedSection && active === "system-users" && (
<SystemUsersPanel
settings={settings}
canCreate={hasScope(auth, "system:accounts:create")}
canUpdate={hasScope(auth, "system:accounts:update")}
canSuspend={hasScope(auth, "system:accounts:suspend")}
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
onAuthRefresh={refreshAuth}
/>
)}
{!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} />}
@@ -185,6 +269,8 @@ export default function AdminPage({
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" 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-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")} />}
@@ -202,9 +288,14 @@ function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribut
return true;
}
function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet<string>, group: string): OrderedAdminNavItem[] {
function contributedNavItems(
sections: AdminSectionContribution[],
available: ReadonlySet<string>,
group: string,
excludedIds: ReadonlySet<string> = new Set()
): OrderedAdminNavItem[] {
return sections
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id))
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
}

View File

@@ -1,80 +1,139 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DateTimeField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { scopeGrants } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) {
function defaultDraft(userId: string) {
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
}
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canRevoke: boolean;}) {
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const keysRef = useRef<ApiKeyAdminItem[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [showRevoked, setShowRevoked] = useState(false);
const [creating, setCreating] = useState(false);
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" });
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id)));
const [secret, setSecret] = useState<{name: string;value: string;} | null>(null);
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = creating && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeCreate
});
async function load() {
setLoading(true);
setError("");
try {
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]);
const keyScope = showRevoked ? "all" : "active";
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([
loadDeltaRows(
keysRef.current,
`access:api-keys:${keyScope}`,
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchApiKeysDelta(settings, showRevoked, { since }),
(response) => response.api_keys,
(key) => key.id,
"access_api_key",
sortApiKeys
),
loadDeltaRows(
usersRef.current,
"access:api-keys:users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
sortUsers
),
fetchPermissionCatalog(settings)
]);
keysRef.current = nextKeys;
usersRef.current = nextUsers;
setKeys(nextKeys);
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]);
useEffect(() => {
keysRef.current = [];
usersRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked, resetDeltaWatermark]);
const selectedUser = users.find((user) => user.id === draft.userId);
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
{ id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}</div></div> },
{ id: "owner", header: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
{ id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ id: "last_used", header: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
{ id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled />
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
</div> }
], [canRevoke]);
{ id: "name", header: "i18n:govoplan-access.name.709a2322", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}</div></div> },
{ id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
{ id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
</div> }],
[canRevoke]);
function openCreate() {
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" });
const nextDraft = defaultDraft(defaultUser?.id || "");
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setCreating(true);
setError("");
}
async function save() {
function closeCreate() {
setCreating(false);
const nextDraft = defaultDraft(auth.user.id);
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
setSecret({ name: created.name, value: created.secret });
setCreating(false);
setSuccess(`API key ${created.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function revoke() {
@@ -83,42 +142,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
setError("");
try {
await revokeApiKey(settings, revoking.id);
setSuccess(`API key ${revoking.name} revoked.`);
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
setRevoking(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
return (
<>
<AdminPageLayout title="Tenant API keys" description="Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No API keys found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
</AdminPageLayout>
<Dialog open={creating} title="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}>
<Dialog open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Owner"><select value={draft.userId} onChange={(event) => { const userId = event.target.value; const user = users.find((item) => item.id === userId); const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope)); setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) }); }}><option value="">Select user</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} {user.email}</option>)}</select></FormField>
<FormField label="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.owner.89ff3122"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} {user.email}</option>)}</select></FormField>
<FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
</div>
<div className="form-field"><span className="form-label">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope}${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></div>
<div className="form-field"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
</Dialog>
<Dialog open={Boolean(viewing)} title="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}</dd></div>
<div><dt>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
<div><dt>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
</dl><h3>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}</dd></div>
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<Dialog open={Boolean(secret)} title="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>}
<Dialog open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
</Dialog>
<ConfirmDialog open={Boolean(revoking)} title="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
</>
);
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
</>);
}
function draftKey(draft: ReturnType<typeof defaultDraft>): string {
return JSON.stringify(draft);
}
function sortApiKeys(left: ApiKeyAdminItem, right: ApiKeyAdminItem): number {
return left.name.localeCompare(right.name) || left.prefix.localeCompare(right.prefix);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return left.email.localeCompare(right.email);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
import { AdminPageLayout, Card, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
scopeType: Extract<FileConnectorScope, "user" | "group">;
canWrite: boolean;
};
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel: string;panelTitle: string;}> = {
user: {
title: "i18n:govoplan-access.user_file_connections.996444a0",
description: "i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
panelTitle: "i18n:govoplan-access.user_file_connections.996444a0"
},
group: {
title: "i18n:govoplan-access.group_file_connections.73985bda",
description: "i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc",
targetLabel: "i18n:govoplan-access.group.171a0606",
panelTitle: "i18n:govoplan-access.group_file_connections.73985bda"
}
};
export default function FileConnectorsPanel({ settings, scopeType, canWrite }: Props) {
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const [targets, setTargets] = useState<FileConnectorTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(Boolean(FileConnectorScopeManager));
const [targetError, setTargetError] = useState("");
useEffect(() => {
if (!FileConnectorScopeManager) {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, FileConnectorScopeManager, resetDeltaWatermark]);
async function loadTargets() {
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows(usersRef.current, "access:file-connector-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:file-connector-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);
}
}
const labels = copy[scopeType];
if (!FileConnectorScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="i18n:govoplan-access.files_module_unavailable.0ee90db1">
<p className="muted">i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153</p>
</Card>
</AdminPageLayout>);
}
return (
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
<FileConnectorScopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={labels.targetLabel}
title={labels.panelTitle}
canWrite={canWrite} />
</AdminPageLayout>);
}
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);
}

View File

@@ -1,65 +1,99 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: {
settings: ApiSettings;
auth: AuthInfo;
canDefine: boolean;
canManageMembers: boolean;
canAssignRoles: boolean;
onAuthRefresh: () => Promise<void>;
}) {
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh
}: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [groups, setGroups] = useState<GroupSummary[]>([]);
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
const [viewing, setViewing] = useState<GroupSummary | null>(null);
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]);
const [nextGroups, nextUsers, nextRoles] = await Promise.all([
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
);
groupsRef.current = nextGroups;
usersRef.current = nextUsers;
rolesRef.current = nextRoles;
setGroups(nextGroups);
setUsers(nextUsers);
setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
groupsRef.current = [];
usersRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(group: GroupSummary) {
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) });
const nextDraft = { slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(group);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
setSuccess(`Group ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name }));
} else if (editing) {
const managed = Boolean(editing.system_template_id);
await updateGroup(settings, editing.id, {
@@ -68,13 +102,14 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
});
setSuccess(`Group ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name }));
}
setEditing(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -83,57 +118,75 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
setError("");
try {
await updateGroup(settings, deactivating.id, { is_active: false });
setSuccess(`Group ${deactivating.name} deactivated.`);
setSuccess(i18nMessage("i18n:govoplan-access.group_value_deactivated.8512bf9c", { value0: deactivating.name }));
setDeactivating(null);
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
{ id: "group", header: "Group", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
{ id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
{ id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
</div> }
], [canAssignRoles, canDefine, canManageMembers]);
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
</div> }],
[canAssignRoles, canDefine, canManageMembers]);
return (
<>
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No groups found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}>
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>}
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_required))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<div className="admin-assignment-grid">
<div><span className="form-label">Members</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="No tenant users exist." /></div>
<div><span className="form-label">Inherited roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
</div>
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p>
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
</Dialog>
<Dialog open={Boolean(viewing)} title="Group details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid">
<div><dt>Group</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</dd></div>
<div><dt>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<div><dt>i18n:govoplan-access.group.171a0606</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.management.63cecca6</dt><dd>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</dd></div>
<div><dt>i18n:govoplan-access.members.1cb449c1</dt><dd>{viewing.member_count}</dd></div><div><dt>i18n:govoplan-access.roles.47dcc27d</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
</dl>}
</Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>
);
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers } from "../../api/admin";
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
import { Card } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { usePlatformUiCapability } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
@@ -13,32 +14,32 @@ type Props = {
canWritePolicy: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; profileTitle: string; policyTitle: string }> = {
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;profileTitle: string;policyTitle: string;}> = {
system: {
title: "System mail profiles",
description: "Instance-level mail server profiles and policy limits inherited by every tenant.",
profileTitle: "System profiles",
policyTitle: "System mail profile policy"
title: "i18n:govoplan-access.system_mail_profiles.5af3eb64",
description: "i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7",
profileTitle: "i18n:govoplan-access.system_profiles.98adae54",
policyTitle: "i18n:govoplan-access.system_mail_profile_policy.7edd7fcf"
},
tenant: {
title: "Tenant mail profiles",
description: "Tenant-level mail server profiles and policy limits for the active tenant.",
profileTitle: "Tenant profiles",
policyTitle: "Tenant mail profile policy"
title: "i18n:govoplan-access.tenant_mail_profiles.5132a623",
description: "i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f",
profileTitle: "i18n:govoplan-access.tenant_profiles.4d7281ce",
policyTitle: "i18n:govoplan-access.tenant_mail_profile_policy.239298a1"
},
user: {
title: "User mail profiles",
description: "User-scoped profiles and policy limits for campaign owners in the active tenant.",
targetLabel: "User",
profileTitle: "User profiles",
policyTitle: "User mail profile policy"
title: "i18n:govoplan-access.user_mail_profiles.f54a845a",
description: "i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf",
targetLabel: "i18n:govoplan-access.user.9f8a2389",
profileTitle: "i18n:govoplan-access.user_profiles.57730285",
policyTitle: "i18n:govoplan-access.user_mail_profile_policy.529e035b"
},
group: {
title: "Group mail profiles",
description: "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
profileTitle: "Group profiles",
policyTitle: "Group mail profile policy"
title: "i18n:govoplan-access.group_mail_profiles.ebf1b5ba",
description: "i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66",
targetLabel: "i18n:govoplan-access.group.171a0606",
profileTitle: "i18n:govoplan-access.group_profiles.74568838",
policyTitle: "i18n:govoplan-access.group_mail_profile_policy.d98ef5a2"
}
};
@@ -46,6 +47,9 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
const [targetError, setTargetError] = useState("");
@@ -56,8 +60,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError("");
return;
}
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
@@ -70,14 +77,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
setTargetError("");
try {
if (scopeType === "user") {
const users = await fetchUsers(settings);
const users = await loadDeltaRows(usersRef.current, "access:mail-profile-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 fetchGroups(settings);
const groups = await loadDeltaRows(groupsRef.current, "access:mail-profile-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,
@@ -97,11 +106,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
if (!MailProfileScopeManager) {
return (
<AdminPageLayout title={labels.title} description={labels.description}>
<Card title="Mail module unavailable">
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
<Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
<p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
</Card>
</AdminPageLayout>
);
</AdminPageLayout>);
}
return (
@@ -115,8 +124,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
policyTitle={labels.policyTitle}
canWriteProfiles={canWriteProfiles}
canManageCredentials={canManageCredentials}
canWritePolicy={canWritePolicy}
/>
</AdminPageLayout>
);
canWritePolicy={canWritePolicy} />
</AdminPageLayout>);
}
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);
}

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchGroups, fetchUsers, runRetentionPolicy, type PrivacyRetentionPolicyScope, type RetentionRunResponse } from "../../api/admin";
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 } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type Props = {
settings: ApiSettings;
@@ -13,37 +14,40 @@ type Props = {
canWrite: boolean;
};
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;policyTitle: string;policyDescription: string;}> = {
system: {
title: "System retention",
description: "Instance-wide privacy retention policy and lower-level override permissions.",
policyTitle: "System retention policy",
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
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: "Tenant retention",
description: "Tenant-level privacy and retention limits for the active tenant.",
policyTitle: "Tenant retention policy",
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
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: "User retention",
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
targetLabel: "User",
policyTitle: "User retention policy",
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
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: "Group retention",
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
targetLabel: "Group",
policyTitle: "Group retention policy",
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
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);
@@ -52,7 +56,12 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
@@ -65,14 +74,16 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
setTargetError("");
try {
if (scopeType === "user") {
const users = await fetchUsers(settings);
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 fetchGroups(settings);
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,
@@ -94,10 +105,10 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
try {
const response = await runRetentionPolicy(settings, dryRun);
setRetentionResult(response);
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
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); }
} catch (err) {setRunError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const labels = copy[scopeType];
@@ -112,31 +123,39 @@ export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }
targetLabel={labels.targetLabel}
title={labels.policyTitle}
description={labels.policyDescription}
canWrite={canWrite}
/>
{scopeType === "system" && (
<div className="retention-run-card">
<Card title="Retention execution">
<p className="muted small-note">Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.</p>
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}>Dry run</Button>
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
<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="Apply retention policy"
message="This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed."
confirmLabel="Apply retention"
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)}
/>
</>
);
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);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
@@ -9,34 +9,53 @@ import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { hasTenantWildcard } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: { settings: ApiSettings; auth: AuthInfo; canDefine: boolean; onAuthRefresh: () => Promise<void> }) {
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]);
const [nextRoles, nextPermissions] = await Promise.all([
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles),
fetchPermissionCatalog(settings)]
);
rolesRef.current = nextRoles;
setRoles(nextRoles);
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
const permissionGroups = useMemo(() => {
const groups = new Map<string, PermissionItem[]>();
@@ -44,35 +63,44 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
return Array.from(groups.entries());
}, [permissions]);
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
function openEdit(role: RoleSummary) {
if (role.is_builtin || role.system_template_id) return;
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable });
const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(role);
setError("");
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function togglePermission(scope: string, checked: boolean) {
const next = new Set(draft.permissions);
if (checked) next.add(scope); else next.delete(scope);
if (checked) next.add(scope);else next.delete(scope);
setDraft({ ...draft, permissions: Array.from(next) });
}
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
setSuccess(`Role ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
} else if (editing) {
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
setSuccess(`Role ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
}
setEditing(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function remove() {
@@ -81,51 +109,61 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
setError("");
try {
await deleteRole(settings, deleting.id);
setSuccess(`Role ${deleting.name} deleted.`);
setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
setDeleting(null);
await onAuthRefresh();
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{ id: "role", header: "Role", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
{ id: "permissions", header: "Permissions", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
{ id: "assignments", header: "Assignments", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
{ id: "type", header: "Type", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
</div> }
], [canDefine, permissions]);
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
</div> }],
[canDefine, permissions]);
return (
<>
<AdminPageLayout title="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No roles found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit role"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>}
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
</div>
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
</Dialog>
<Dialog open={Boolean(viewing)} title="Role details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div>
<div><dt>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</dd></div>
</dl><h3>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
<div><dt>i18n:govoplan-access.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div>
<div><dt>i18n:govoplan-access.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div>
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -1,22 +1,23 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import {
createSystemRole,
deleteSystemRole,
fetchPermissionCatalog,
fetchSystemRoles,
fetchSystemRolesDelta,
updateSystemRole,
type PermissionItem,
type RoleSummary
} from "../../api/admin";
type RoleSummary } from
"../../api/admin";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
slug: "",
@@ -30,30 +31,41 @@ export default function SystemRolesPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
const [viewing, setViewing] = useState<RoleSummary | null>(null);
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextRoles, catalogue] = await Promise.all([
fetchSystemRoles(settings),
fetchPermissionCatalog(settings)
]);
loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
fetchPermissionCatalog(settings)]
);
rolesRef.current = nextRoles;
setRoles(nextRoles);
setPermissions(catalogue.filter((item) => item.level === "system"));
} catch (err) {
@@ -63,25 +75,38 @@ export default function SystemRolesPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
}
function openEdit(role: RoleSummary) {
setDraft({
const nextDraft = {
slug: role.slug,
name: role.name,
description: role.description || "",
permissions: role.permissions,
isAssignable: role.is_assignable
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(role);
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -92,7 +117,7 @@ export default function SystemRolesPanel({
description: draft.description || null,
permissions: draft.permissions
});
setSuccess(`System role ${draft.name} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
} else if (editing) {
await updateSystemRole(settings, editing.id, {
name: draft.name,
@@ -100,13 +125,15 @@ export default function SystemRolesPanel({
permissions: draft.permissions,
is_assignable: draft.isAssignable
});
setSuccess(`System role ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
}
setEditing(null);
await load();
await onAuthRefresh();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
@@ -118,7 +145,7 @@ export default function SystemRolesPanel({
setError("");
try {
await deleteSystemRole(settings, deleting.id);
setSuccess(`System role ${deleting.name} deleted.`);
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
setDeleting(null);
await load();
await onAuthRefresh();
@@ -130,125 +157,133 @@ export default function SystemRolesPanel({
}
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
{
id: "role",
header: "System role",
width: 240,
minWidth: 180,
maxWidth: 380,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "description",
header: "Description",
width: 360,
fill: true,
minWidth: 220,
maxWidth: 640,
resizable: true,
sortable: true,
filterable: true,
value: (row) => row.description || "",
render: (row) => row.description || "—"
},
{
id: "permissions",
header: "Permissions",
width: 120,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.effective_permission_count,
render: (row) => String(row.effective_permission_count)
},
{
id: "assignable",
header: "Assignable",
width: 120,
resizable: false,
sortable: true,
filterable: true,
value: (row) => row.is_assignable ? "yes" : "no",
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "Yes" : "No"} />
},
{
id: "assignments",
header: "Accounts",
width: 110,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.user_assignments
},
{
id: "actions",
header: "Actions",
width: 150,
sticky: "end",
resizable: false,
align: "right",
render: (row) => {
const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
{
id: "role",
header: "i18n:govoplan-access.system_role.91762640",
width: 240,
minWidth: 180,
maxWidth: 380,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "description",
header: "i18n:govoplan-access.description.55f8ebc8",
width: 360,
fill: true,
minWidth: 220,
maxWidth: 640,
resizable: true,
sortable: true,
filterable: true,
value: (row) => row.description || "",
render: (row) => row.description || "—"
},
{
id: "permissions",
header: "i18n:govoplan-access.permissions.d06d5557",
width: 120,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.effective_permission_count,
render: (row) => String(row.effective_permission_count)
},
{
id: "assignable",
header: "i18n:govoplan-access.assignable.a88debc5",
width: 120,
resizable: false,
sortable: true,
filterable: true,
value: (row) => row.is_assignable ? "yes" : "no",
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"} />
},
{
id: "assignments",
header: "i18n:govoplan-access.accounts.36bae316",
width: 110,
resizable: false,
sortable: true,
filterable: true,
filterType: "integer",
value: (row) => row.user_assignments
},
{
id: "actions",
header: "i18n:govoplan-access.actions.c3cd636a",
width: 150,
sticky: "end",
resizable: false,
align: "right",
render: (row) => {
const protectedOwner = row.slug === "system_owner";
return <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
</div>;
}
}
], [canWrite]);
}],
[canWrite]);
return (
<>
<AdminPageLayout
title="System roles"
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users."
title="i18n:govoplan-access.system_roles.a9461aa6"
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
<div className="admin-table-surface">
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No system roles found." />
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? "Create system role" : "Edit system role"}
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}
>
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<div className="form-field">
<span className="form-label">System permissions</span>
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
<AdminSelectionList
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
selected={draft.permissions}
onChange={(next) => setDraft({ ...draft, permissions: next })}
/>
<p className="muted small-note">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p>
onChange={(next) => setDraft({ ...draft, permissions: next })} />
<p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
</div>
</Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
@@ -10,7 +10,7 @@ import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import {
createSystemAccount,
fetchSystemAccounts,
fetchSystemAccountsDelta,
fetchTenants,
updateSystemAccount,
updateSystemAccountRoles,
@@ -20,7 +20,7 @@ import {
type SystemMembershipDraft,
type TenantAdminItem
} from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const emptyDraft = {
email: "",
@@ -40,49 +40,80 @@ export default function SystemUsersPanel({
canAssignRoles,
canManageMemberships,
onAuthRefresh
}: {
settings: ApiSettings;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
canAssignRoles: boolean;
canManageMemberships: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const accountsRef = useRef<SystemAccountItem[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | null>(null);
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]);
setAccounts(access.accounts);
setRoles(access.roles);
let nextWatermark = getDeltaWatermark("access:system-accounts");
let nextAccounts = accountsRef.current;
let nextRoles = rolesRef.current;
let hasMore = false;
do {
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark("access:system-accounts", nextWatermark);
const nextTenants = await fetchTenants(settings);
accountsRef.current = nextAccounts;
rolesRef.current = nextRoles;
setAccounts(nextAccounts);
setRoles(nextRoles);
setTenants(nextTenants);
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
accountsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
}
function openEdit(item: SystemAccountItem) {
setDraft({
const nextDraft = {
email: item.email,
displayName: item.display_name || "",
password: "",
@@ -97,10 +128,18 @@ export default function SystemUsersPanel({
is_owner: membership.is_owner,
is_last_active_owner: membership.is_last_active_owner
}))
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(item);
}
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
function membership(tenantId: string) {
return draft.memberships.find((item) => item.tenant_id === tenantId);
}
@@ -108,13 +147,13 @@ export default function SystemUsersPanel({
function setMembership(tenantId: string, enabled: boolean) {
setDraft((current) => ({
...current,
memberships: enabled
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }]
: current.memberships.filter((item) => item.tenant_id !== tenantId)
memberships: enabled ?
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
current.memberships.filter((item) => item.tenant_id !== tenantId)
}));
}
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -129,21 +168,22 @@ export default function SystemUsersPanel({
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
});
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
setSuccess(`Global account ${response.account.email} created.`);
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
} else if (editing) {
const accountChanges: { display_name?: string | null; is_active?: boolean } = {};
const accountChanges: {display_name?: string | null;is_active?: boolean;} = {};
if (canUpdate) accountChanges.display_name = draft.displayName || null;
if (canSuspend) accountChanges.is_active = draft.isActive;
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
setSuccess(`Global account ${editing.email} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
}
setEditing(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -152,73 +192,85 @@ export default function SystemUsersPanel({
setError("");
try {
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
setSuccess(`${deactivating.email} deactivated.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
setDeactivating(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
{ id: "account", header: "Account", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
{ id: "tenants", header: "Tenant memberships", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
{ id: "roles", header: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
</div> }
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
{ id: "account", header: "i18n:govoplan-access.account.85dfa32c", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
{ id: "tenants", header: "i18n:govoplan-access.tenant_memberships.451de736", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
</div> }],
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout
title="Central users"
description="Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here."
title="i18n:govoplan-access.central_users.91ac1b51"
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
>
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="No global accounts found." /></div>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && (
<FormField label="Initial password">
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} />
</FormField>
)}
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
}
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
</div>
<div className="admin-assignment-grid">
<div><span className="form-label">System roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
<div><span className="form-label">Tenant memberships</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
</div>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.</p>}
<p className="muted small-note">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</p>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
</Dialog>
<Dialog open={Boolean(viewing)} title="Global account details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
</Dialog>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
</Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>
);
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_global_account.66d92736" message={i18nMessage("i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_account.fd9fd676" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortSystemAccounts(left: SystemAccountItem, right: SystemAccountItem): number {
return left.email.localeCompare(right.email);
}
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
return left.name.localeCompare(right.name);
}

View File

@@ -3,14 +3,22 @@ import type { ApiSettings } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
const DELTA_KEY = "access:tenant-settings";
const fallback: TenantSettingsItem = {
id: "",
slug: "",
name: "",
default_locale: "en",
available_languages: [
{ code: "en", label: "English", native_label: "English" },
{ code: "de", label: "German", native_label: "Deutsch" }
],
system_enabled_language_codes: ["en", "de"],
enabled_language_codes: ["en", "de"],
settings: {}
};
@@ -18,23 +26,42 @@ export default function TenantSettingsPanel({
settings,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => setDraft(savedDraft)
});
async function load() {
setLoading(true);
setError("");
setSuccess("");
try {
setDraft(await fetchTenantSettings(settings));
const wasDirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
const loaded = await fetchTenantSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
setDeltaWatermark(DELTA_KEY, loaded.watermark);
if (loaded.full && loaded.item) {
setSavedDraft(loaded.item);
if (!wasDirty) setDraft(loaded.item);
} else if (loaded.changed_sections.length) {
setSavedDraft((current) => applyTenantSettingsSections(current, loaded.sections));
if (!wasDirty) setDraft((current) => applyTenantSettingsSections(current, loaded.sections));
}
} catch (err) {
setError(adminErrorMessage(err));
} finally {
@@ -42,44 +69,107 @@ export default function TenantSettingsPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
resetDeltaWatermark(DELTA_KEY);
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
async function save() {
async function save(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
setDraft(saved);
setSuccess("Tenant general settings saved.");
setSavedDraft(saved);
resetDeltaWatermark(DELTA_KEY);
setSuccess("i18n:govoplan-access.tenant_general_settings_saved.485e7681");
await onAuthRefresh();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
function toggleLanguage(code: string, checked: boolean) {
const enabled = new Set(draft.enabled_language_codes);
if (checked) enabled.add(code);
else enabled.delete(code);
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
}
return (
<AdminPageLayout
title="Tenant general settings"
description="Settings for the active tenant context."
title="i18n:govoplan-access.tenant_general_settings.db1c3ba8"
description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-access.saving.ae7e8875" : "i18n:govoplan-access.save_general_settings.5c90f8c4"}</Button></>}>
<div className="admin-settings-form">
<Card title="Locale">
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
<Card title="i18n:govoplan-access.locale.8970f0e6">
<FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
<select value={draft.default_locale} disabled={!canWrite || busy || defaultLocaleOptions.length === 0} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}>
{defaultLocaleOptions.map((code) => {
const language = draft.available_languages.find((item) => item.code === code);
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
})}
</select>
</FormField>
<div className="settings-list">
{draft.system_enabled_language_codes.map((code) => {
const language = draft.available_languages.find((item) => item.code === code);
return (
<label className="admin-inline-check" key={code}>
<input
type="checkbox"
checked={draft.enabled_language_codes.includes(code)}
disabled={!canWrite || busy || code === draft.default_locale}
onChange={(event) => toggleLanguage(code, event.target.checked)} />
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
</label>
);
})}
</div>
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
<dl className="detail-list">
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
<div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
<div><dt>i18n:govoplan-access.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
</dl>
</Card>
</div>
</AdminPageLayout>
);
</AdminPageLayout>);
}
function languageOptionLabel(language: {code: string;label: string;native_label?: string | null}): string {
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
}
function localeOptions(current: string, enabled: string[]): string[] {
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
}
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
return JSON.stringify({
default_locale: item.default_locale,
enabled_language_codes: item.enabled_language_codes
});
}
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
return {
...item,
...(sections.identity ?? {}),
...(sections.locale ?? {}),
...(sections.languages ?? {}),
...(sections.settings ? { settings: sections.settings } : {})
};
}

View File

@@ -1,14 +1,15 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
type OverrideValue = "inherit" | "allow" | "deny";
type TenantDraft = {
@@ -54,35 +55,46 @@ export default function TenantsPanel({
canUpdate,
canSuspend,
onAuthRefresh
}: {
settings: ApiSettings;
auth: AuthInfo;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
onAuthRefresh: () => Promise<void>;
}) {
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
const tenantsRef = useRef<TenantAdminItem[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
fetchTenants(settings),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null)
]);
loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
fetchSystemSettings(settings).catch(() => null)]
);
tenantsRef.current = nextTenants;
setTenants(nextTenants);
setOwnerCandidates(nextOwnerCandidates);
setSystemSettings(nextSystemSettings);
@@ -93,16 +105,22 @@ export default function TenantsPanel({
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
useEffect(() => {
tenantsRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
function openCreate() {
setDraft({ ...emptyDraft, ownerAccountId: auth.user.account_id });
const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing("new");
setError("");
}
function openEdit(tenant: TenantAdminItem) {
setDraft({
const nextDraft = {
slug: tenant.slug,
name: tenant.name,
ownerAccountId: "",
@@ -112,12 +130,20 @@ export default function TenantsPanel({
customGroups: fromOverride(tenant.allow_custom_groups),
customRoles: fromOverride(tenant.allow_custom_roles),
apiKeys: fromOverride(tenant.allow_api_keys)
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(tenant);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -137,7 +163,7 @@ export default function TenantsPanel({
...governance
});
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
setSuccess(`Tenant ${created.name} created with ${selectedOwner?.display_name || selectedOwner?.email || "the selected account"} as Owner.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-access.the_selected_account.1211bfb9" }));
await onAuthRefresh();
} else if (editing) {
const payload: Parameters<typeof updateTenant>[2] = {};
@@ -151,13 +177,15 @@ export default function TenantsPanel({
}
if (canSuspend) payload.is_active = draft.isActive;
await updateTenant(settings, editing.id, payload);
setSuccess(`Tenant ${draft.name} updated.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_updated.25b2c855", { value0: draft.name }));
await onAuthRefresh();
}
setEditing(null);
await load();
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
@@ -169,7 +197,7 @@ export default function TenantsPanel({
setError("");
try {
await updateTenant(settings, confirmSuspend.id, { is_active: false });
setSuccess(`${confirmSuspend.name} suspended.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_suspended.31731a28", { value0: confirmSuspend.name }));
setConfirmSuspend(null);
await onAuthRefresh();
await load();
@@ -185,74 +213,82 @@ export default function TenantsPanel({
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
const systemDeniedGovernance = [
systemAllowsCustomGroups ? "" : "custom groups",
systemAllowsCustomRoles ? "" : "custom roles",
systemAllowsApiKeys ? "" : "API keys"
].filter(Boolean).join(", ");
systemAllowsCustomGroups ? "" : "i18n:govoplan-access.custom_groups.453a605c",
systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
filter(Boolean).join(", ");
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
{ id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
{ id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
{ id: "groups", header: "Groups", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
{ id: "campaigns", header: "Campaigns", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
{ id: "files", header: "Files", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
{ id: "locale", header: "Locale", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
<AdminIconButton label={`Suspend ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
</div> }
], [activeTenantId, canSuspend, canUpdate]);
{ id: "name", header: "i18n:govoplan-access.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
{ id: "users", header: "i18n:govoplan-access.users.57f2b181", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
{ id: "campaigns", header: "i18n:govoplan-access.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
</div> }],
[activeTenantId, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout
title="Tenants"
description="Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended."
title="i18n:govoplan-access.tenants.1f7ae776"
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
>
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenants found." /></div>
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Create tenant" : "Edit tenant"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" && !draft.ownerAccountId)}>{busy ? "Saving…" : "Save tenant"}</Button></>}>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
{editing === "new" && <FormField label="Initial tenant owner"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? `${candidate.display_name} (${candidate.email})` : candidate.email}</option>)}</select></FormField>}
<FormField label="Default locale"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Suspended</option></select></FormField>}
<FormField label="Description"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
<h3>System governance overrides</h3>
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
<div className="admin-form-grid two-columns">
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
</div>
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
</Dialog>
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <><dl className="admin-details-grid">
<div><dt>Tenant</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Suspended"}</dd></div><div><dt>Default locale</dt><dd>{viewing.default_locale}</dd></div>
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<div><dt>Custom groups</dt><dd>{viewing.effective_governance.allow_custom_groups ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
<div><dt>Custom roles</dt><dd>{viewing.effective_governance.allow_custom_roles ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
<div><dt>API keys</dt><dd>{viewing.effective_governance.allow_api_keys ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
<div><dt>Objects</dt><dd>{viewing.counts.users ?? 0} users, {viewing.counts.groups ?? 0} groups, {viewing.counts.campaigns ?? 0} campaigns, {viewing.counts.files ?? 0} files</dd></div>
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
</Dialog>
<ConfirmDialog open={Boolean(confirmSuspend)} title="Suspend tenant" message={`Suspend ${confirmSuspend?.name}? Existing data remains retained, but its members cannot use the tenant.`} confirmLabel="Suspend tenant" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
</>
);
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
</>);
}
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
}
function draftKey(draft: TenantDraft): string {
return JSON.stringify(draft);
}
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
import { createUser, fetchGroups, fetchRoles, fetchUsers, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateUser, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
import { Button } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
@@ -10,7 +10,8 @@ import { PasswordField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
import { hasTenantWildcard } from "@govoplan/core-webui";
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { loadDeltaRows } from "./utils/deltaRows";
const emptyDraft = {
email: "",
@@ -22,51 +23,77 @@ const emptyDraft = {
roleIds: [] as string[]
};
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh }: {
settings: ApiSettings;
auth: AuthInfo;
canCreate: boolean;
canUpdate: boolean;
canSuspend: boolean;
canManageGroups: boolean;
canAssignRoles: boolean;
onAuthRefresh: () => Promise<void>;
}) {
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
const [users, setUsers] = useState<UserAdminItem[]>([]);
const [groups, setGroups] = useState<GroupSummary[]>([]);
const [roles, setRoles] = useState<RoleSummary[]>([]);
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const rolesRef = useRef<RoleSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [editing, setEditing] = useState<UserAdminItem | "new" | null>(null);
const [viewing, setViewing] = useState<UserAdminItem | null>(null);
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; password: string } | null>(null);
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: closeEditor
});
async function load() {
setLoading(true);
setError("");
try {
const [nextUsers, nextGroups, nextRoles] = await Promise.all([fetchUsers(settings), fetchGroups(settings), fetchRoles(settings)]);
const [nextUsers, nextGroups, nextRoles] = await Promise.all([
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
);
usersRef.current = nextUsers;
groupsRef.current = nextGroups;
rolesRef.current = nextRoles;
setUsers(nextUsers);
setGroups(nextGroups);
setRoles(nextRoles.filter((role) => role.is_assignable));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setLoading(false);}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
rolesRef.current = [];
resetDeltaWatermark();
void load();
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
function openCreate() {
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
setEditing("new");
setError("");
}
function openEdit(user: UserAdminItem) {
setDraft({
const nextDraft = {
email: user.email,
displayName: user.display_name || "",
password: "",
@@ -74,12 +101,20 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
isActive: user.is_active,
groupIds: user.groups.map((group) => group.id),
roleIds: user.roles.map((role) => role.id)
});
};
setDraft(nextDraft);
setSavedDraftKey(draftKey(nextDraft));
setEditing(user);
setError("");
}
async function save() {
function closeEditor() {
setEditing(null);
setDraft(emptyDraft);
setSavedDraftKey(draftKey(emptyDraft));
}
async function save(): Promise<boolean> {
setBusy(true);
setError("");
try {
@@ -93,7 +128,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
group_ids: canManageGroups ? draft.groupIds : [],
role_ids: canAssignRoles ? draft.roleIds : []
});
setSuccess(`Tenant membership created for ${response.user.email}.`);
setSuccess(i18nMessage("i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6", { value0: response.user.email }));
if (response.temporary_password) setTemporaryPassword({ email: response.user.email, password: response.temporary_password });
} else if (editing) {
const payload: Parameters<typeof updateUser>[2] = {};
@@ -102,13 +137,14 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
if (canManageGroups) payload.group_ids = draft.groupIds;
if (canAssignRoles) payload.role_ids = draft.roleIds;
await updateUser(settings, editing.id, payload);
setSuccess(`Access updated for ${editing.email}.`);
setSuccess(i18nMessage("i18n:govoplan-access.access_updated_for_value.87f22245", { value0: editing.email }));
if (editing.id === auth.user.id) await onAuthRefresh();
}
setEditing(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
return true;
} catch (err) {setError(adminErrorMessage(err));return false;} finally
{setBusy(false);}
}
async function deactivate() {
@@ -117,73 +153,91 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
setError("");
try {
await updateUser(settings, deactivating.id, { is_active: false });
setSuccess(`${deactivating.email} deactivated in this tenant.`);
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2", { value0: deactivating.email }));
if (deactivating.id === auth.user.id) await onAuthRefresh();
setDeactivating(null);
await load();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
} catch (err) {setError(adminErrorMessage(err));} finally
{setBusy(false);}
}
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
{ id: "user", header: "User", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
{ id: "groups", header: "Groups", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
{ id: "roles", header: "Direct roles", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "scope_count", header: "Permissions", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "All" : String(row.effective_scopes.length) },
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
</div> }
], [canAssignRoles, canManageGroups, canSuspend, canUpdate]);
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
{ id: "roles", header: "i18n:govoplan-access.direct_roles.c4db7156", width: 220, minWidth: 150, maxWidth: 460, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
</div> }],
[canAssignRoles, canManageGroups, canSuspend, canUpdate]);
return (
<>
<AdminPageLayout title="Tenant users" description="Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant user" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenant users found." /></div>
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
</AdminPageLayout>
<Dialog open={editing !== null} title={editing === "new" ? "Add tenant user" : "Edit tenant user"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "Saving…" : "Save user"}</Button></>}>
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
<div className="admin-form-grid two-columns">
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" && (
<FormField label="Initial password">
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
{editing === "new" &&
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
<PasswordField
value={draft.password}
placeholder="Leave empty to generate"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })}
/>
value={draft.password}
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
autoComplete="new-password"
onValueChange={(password) => setDraft({ ...draft, password })} />
</FormField>
)}
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
}
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
</div>
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.</p>}
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
<div className="admin-assignment-grid">
<div><span className="form-label">Groups</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="No groups exist yet." /></div>
<div><span className="form-label">Direct roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
</div>
<p className="muted small-note">Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.</p>
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
</Dialog>
<Dialog open={Boolean(viewing)} title="Tenant user details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
{viewing && <dl className="admin-details-grid">
<div><dt>User</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>Email</dt><dd>{viewing.email}</dd></div>
<div><dt>Membership</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Global account</dt><dd>{viewing.account_is_active ? "Active" : "Inactive"}</dd></div>
<div><dt>Groups</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>Direct roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>Effective permissions</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "All tenant permissions" : viewing.effective_scopes.join(", ") || "None"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>i18n:govoplan-access.email.84add5b2</dt><dd>{viewing.email}</dd></div>
<div><dt>i18n:govoplan-access.membership.53bc9670</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.global_account.e1b00cf5</dt><dd>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div>
<div><dt>i18n:govoplan-access.groups.ae9629f4</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>i18n:govoplan-access.direct_roles.c4db7156</dt><dd>{joinLabels(viewing.roles)}</dd></div>
<div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
</dl>}
</Dialog>
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">Transmit it through a separate secure channel.</p></>}
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
</Dialog>
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate tenant membership" message={`Deactivate ${deactivating?.email} in the active tenant? Historical ownership remains intact.`} confirmLabel="Deactivate user" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>
);
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c" message={i18nMessage("i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_user.9ddc7096" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
</>);
}
function draftKey(draft: typeof emptyDraft): string {
return JSON.stringify(draft);
}
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
}
function sortGroups(left: GroupSummary, right: GroupSummary): number {
return left.name.localeCompare(right.name);
}
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
if (builtinDelta !== 0) return builtinDelta;
return left.name.localeCompare(right.name);
}

View File

@@ -0,0 +1,33 @@
import { mergeDeltaRows, type DeltaDeletedItem } from "@govoplan/core-webui";
export type AdminDeltaResponse = {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>(
current: TItem[],
key: string,
getDeltaWatermark: (key: string) => string | null,
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
fetchDelta: (since: string | null) => Promise<TResponse>,
rowsFromResponse: (response: TResponse) => TItem[],
getKey: (item: TItem) => string,
deletedResourceType: string,
sort?: (left: TItem, right: TItem) => number
): Promise<TItem[]> {
let nextWatermark = getDeltaWatermark(key);
let merged = current;
let hasMore = false;
do {
const response = await fetchDelta(nextWatermark);
const rows = rowsFromResponse(response);
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
return merged;
}

View File

@@ -0,0 +1,630 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
"i18n:govoplan-access.access.2f81a22d": "Access",
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
"i18n:govoplan-access.account.85dfa32c": "Account",
"i18n:govoplan-access.accounts.36bae316": "Accounts",
"i18n:govoplan-access.action.97c89a4d": "Action",
"i18n:govoplan-access.actions.c3cd636a": "Actions",
"i18n:govoplan-access.active.a733b809": "Active",
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Add group",
"i18n:govoplan-access.add_role.d8d5d55c": "Add role",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
"i18n:govoplan-access.add_tenant.b8e32af0": "Add tenant",
"i18n:govoplan-access.admin.4e7afebc": "Admin",
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
"i18n:govoplan-access.changes.8aa57de6": "Changes",
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "All tenant permissions",
"i18n:govoplan-access.all.6a720856": "All",
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
"i18n:govoplan-access.api_keys.94fcf3c2": "API keys",
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
"i18n:govoplan-access.apply_retention.5b991811": "Apply retention",
"i18n:govoplan-access.assignable.a88debc5": "Assignable",
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
"i18n:govoplan-access.assignments.057d58c7": "Assignments",
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
"i18n:govoplan-access.audit.fa1703dd": "Audit",
"i18n:govoplan-access.available.ce372771": ", available",
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
"i18n:govoplan-access.built_in.20f409cc": "Built-in",
"i18n:govoplan-access.campaigns.01a23a28": "Campaigns",
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
"i18n:govoplan-access.cancel.77dfd213": "Cancel",
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
"i18n:govoplan-access.close.bbfa773e": "Close",
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Create group",
"i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Create role",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
"i18n:govoplan-access.create_tenant.4dbd55d9": "Create tenant",
"i18n:govoplan-access.created.accf40c8": "Created",
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
"i18n:govoplan-access.custom.081ae3fd": "Custom",
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Deactivate tenant membership",
"i18n:govoplan-access.deactivate_user.9ddc7096": "Deactivate user",
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Default locale",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-access.denied.63b16bd4": "Denied",
"i18n:govoplan-access.description.55f8ebc8": "Description",
"i18n:govoplan-access.direct_roles.c4db7156": "Direct roles",
"i18n:govoplan-access.display_name.c7874aaa": "Display name",
"i18n:govoplan-access.dry_run.485a3d15": "Dry run",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Edit tenant",
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Effective permissions",
"i18n:govoplan-access.email.84add5b2": "Email",
"i18n:govoplan-access.expires.a99be3da": "Expires",
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.file_connections.1e362326": "File connections",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
"i18n:govoplan-access.files.6ce6c512": "Files",
"i18n:govoplan-access.general.9239ee2c": "General",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
"i18n:govoplan-access.global_account.e1b00cf5": "Global account",
"i18n:govoplan-access.global": "Global",
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
"i18n:govoplan-access.group_assignments.e534bb56": "Group assignments",
"i18n:govoplan-access.group_details.df844ae3": "Group details",
"i18n:govoplan-access.group_file_connections.73985bda": "Group file connections",
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Group mail profiles",
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
"i18n:govoplan-access.group_retention.57cdcdaa": "Group retention",
"i18n:govoplan-access.group_templates": "Group templates",
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
"i18n:govoplan-access.group.171a0606": "Group",
"i18n:govoplan-access.group.ea5a3834": "GROUP",
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
"i18n:govoplan-access.groups.07551586": "groups,",
"i18n:govoplan-access.groups.ae9629f4": "Groups",
"i18n:govoplan-access.i_have_recorded_it.7522da18": "I have recorded it",
"i18n:govoplan-access.inactive.09af574c": "Inactive",
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
"i18n:govoplan-access.initial_password.2278be8c": "Initial password",
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Install and enable the Files module to manage file connector profiles and policies.",
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
"i18n:govoplan-access.last_login.43dab84f": "Last login",
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leave empty to generate",
"i18n:govoplan-access.locale.8970f0e6": "Locale",
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-access.maintenance.94de303b": "Maintenance",
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
"i18n:govoplan-access.management.63cecca6": "Management",
"i18n:govoplan-access.members.1cb449c1": "Members",
"i18n:govoplan-access.membership_status.b77fc732": "Membership status",
"i18n:govoplan-access.membership.53bc9670": "Membership",
"i18n:govoplan-access.modules.04e9462c": "Modules",
"i18n:govoplan-access.name.709a2322": "Name",
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records found.",
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
"i18n:govoplan-access.no_roles_found.70f7c0c9": "No roles found.",
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "No tenant users found.",
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
"i18n:govoplan-access.no.816c52fd": "No",
"i18n:govoplan-access.none.6eef6648": "None",
"i18n:govoplan-access.object.2883f191": "Object",
"i18n:govoplan-access.objects.72a83add": "Objects",
"i18n:govoplan-access.owner.89ff3122": "Owner",
"i18n:govoplan-access.packages.0a999012": "Packages",
"i18n:govoplan-access.permissions.d06d5557": "Permissions",
"i18n:govoplan-access.platform_administration": "Platform administration",
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
"i18n:govoplan-access.protected.28531336": "Protected",
"i18n:govoplan-access.reload.cce71553": "Reload",
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Require password change when account settings are implemented",
"i18n:govoplan-access.required.2e5396fd": ", required",
"i18n:govoplan-access.required.7c65879a": " · required",
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
"i18n:govoplan-access.retention.c7199d9e": "Retention",
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
"i18n:govoplan-access.role_details.a16b5d9f": "Role details",
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
"i18n:govoplan-access.role.c3f104d1": "Role",
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
"i18n:govoplan-access.roles.47dcc27d": "Roles",
"i18n:govoplan-access.root.77e7e78b": "ROOT",
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Save group",
"i18n:govoplan-access.save_role.16fe10d1": "Save role",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Save tenant",
"i18n:govoplan-access.save_user.0d071b89": "Save user",
"i18n:govoplan-access.saving.56a2285c": "Saving…",
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Scope",
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
"i18n:govoplan-access.suspended.794696a7": "Suspended",
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System mail profiles",
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
"i18n:govoplan-access.system_retention.4191e7f7": "System retention",
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
"i18n:govoplan-access.system_role.91762640": "System role",
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
"i18n:govoplan-access.system.29d43743": "SYSTEM",
"i18n:govoplan-access.system.bc0792d8": "System",
"i18n:govoplan-access.temporary_password.62d60628": "Temporary password",
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Tenant API keys",
"i18n:govoplan-access.tenant_audit.492b9138": "Tenant audit",
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
"i18n:govoplan-access.tenant_custom.4307081e": "Tenant custom",
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
"i18n:govoplan-access.tenant_groups.47e6cc05": "Tenant groups",
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
"i18n:govoplan-access.tenant_languages_help": "Tenant languages can only be selected from languages enabled by the system. Users can choose from the tenant-enabled set.",
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Tenant mail profiles",
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Tenant role templates",
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
"i18n:govoplan-access.tenant_users.cb800b38": "Tenant users",
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
"i18n:govoplan-access.tenant.3ca93c78": "Tenant",
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
"i18n:govoplan-access.tenants.1f7ae776": "Tenants",
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "This is shown once for",
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
"i18n:govoplan-access.time.6c82e6dd": "Time",
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Transmit it through a separate secure channel.",
"i18n:govoplan-access.type.3deb7456": "Type",
"i18n:govoplan-access.updated.f2f8570d": "Updated",
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
"i18n:govoplan-access.user_assignments.bc7cc801": "User assignments",
"i18n:govoplan-access.user_file_connections.996444a0": "User file connections",
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
"i18n:govoplan-access.user_mail_profiles.f54a845a": "User mail profiles",
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
"i18n:govoplan-access.user_retention.f0966bbf": "User retention",
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
"i18n:govoplan-access.user.6eb0c612": "USER",
"i18n:govoplan-access.user.9f8a2389": "User",
"i18n:govoplan-access.users.57f2b181": "Users",
"i18n:govoplan-access.users.81651889": "users,",
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-access.yes.5397e058": "Yes",
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
},
"de": {
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
"i18n:govoplan-access.access.2f81a22d": "Zugriff",
"i18n:govoplan-access.account_assignments.f5a91f2a": "Account assignments",
"i18n:govoplan-access.account_status.8dd86c6d": "Account status",
"i18n:govoplan-access.account.85dfa32c": "Account",
"i18n:govoplan-access.accounts.36bae316": "Accounts",
"i18n:govoplan-access.action.97c89a4d": "Action",
"i18n:govoplan-access.actions.c3cd636a": "Aktionen",
"i18n:govoplan-access.active.a733b809": "Aktiv",
"i18n:govoplan-access.actor.cbd19b5c": "Actor",
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
"i18n:govoplan-access.add_tenant.b8e32af0": "Mandant hinzufügen",
"i18n:govoplan-access.admin.4e7afebc": "Administration",
"i18n:govoplan-access.administration_unavailable.b86d4cb5": "Administration unavailable",
"i18n:govoplan-access.changes.8aa57de6": "Änderungen",
"i18n:govoplan-access.all_tenant_permissions.cca8b6e4": "Alle Mandantenberechtigungen",
"i18n:govoplan-access.all.6a720856": "All",
"i18n:govoplan-access.allow_when_system_allows.4c5178cb": "Allow when system allows",
"i18n:govoplan-access.allowed_scopes.d94515ff": "Allowed scopes",
"i18n:govoplan-access.allowed.77c7b490": "Allowed",
"i18n:govoplan-access.api_key_details.f70c16be": "API key details",
"i18n:govoplan-access.api_key_secret.00b16050": "API key secret",
"i18n:govoplan-access.api_key_value_created.0ccdfbb2": "API key {value0} created.",
"i18n:govoplan-access.api_key_value_revoked.b4431f0e": "API key {value0} revoked.",
"i18n:govoplan-access.api_keys.94fcf3c2": "API-Schlüssel",
"i18n:govoplan-access.apply_retention_policy.9e5d32b4": "Apply retention policy",
"i18n:govoplan-access.apply_retention.5b991811": "Aufbewahrung anwenden",
"i18n:govoplan-access.assignable.a88debc5": "Zuweisbar",
"i18n:govoplan-access.assigned_scopes.c7b09b12": "Assigned scopes",
"i18n:govoplan-access.assignments.057d58c7": "Zuweisungen",
"i18n:govoplan-access.audit_event_details.3749b52d": "Audit event details",
"i18n:govoplan-access.audit.fa1703dd": "Audit",
"i18n:govoplan-access.available.ce372771": ", available",
"i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
"i18n:govoplan-access.built_in.20f409cc": "Eingebaut",
"i18n:govoplan-access.campaigns.01a23a28": "Kampagnen",
"i18n:govoplan-access.campaigns.2282ffeb": "campaigns,",
"i18n:govoplan-access.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-access.central_users.91ac1b51": "Central users",
"i18n:govoplan-access.close.bbfa773e": "Schließen",
"i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
"i18n:govoplan-access.create_key.e028cb09": "Create key",
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
"i18n:govoplan-access.create_tenant.4dbd55d9": "Mandant erstellen",
"i18n:govoplan-access.created.accf40c8": "Erstellt",
"i18n:govoplan-access.creating.94d7d8ee": "Creating…",
"i18n:govoplan-access.custom_groups.1f7b7c8f": "Custom groups",
"i18n:govoplan-access.custom_groups.453a605c": "custom groups",
"i18n:govoplan-access.custom_roles.d48dc976": "custom roles",
"i18n:govoplan-access.custom_roles.e78ef63d": "Custom roles",
"i18n:govoplan-access.custom_tenant_groups.570ee603": "Custom tenant groups",
"i18n:govoplan-access.custom_tenant_roles.a738c37c": "Custom tenant roles",
"i18n:govoplan-access.custom.081ae3fd": "Benutzerdefiniert",
"i18n:govoplan-access.deactivate_account.fd9fd676": "Deactivate account",
"i18n:govoplan-access.deactivate_global_account.66d92736": "Deactivate global account",
"i18n:govoplan-access.deactivate_group.f1b8ceea": "Deactivate group",
"i18n:govoplan-access.deactivate_tenant_membership.bbe33c9c": "Mandantenmitgliedschaft deaktivieren",
"i18n:govoplan-access.deactivate_user.9ddc7096": "Benutzer deaktivieren",
"i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f": "Deactivate {value0}? All sessions and tenant access will stop. Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_in_the_active_tenant_historical.b91da381": "Deactivate {value0} in the active tenant? Historical ownership remains intact.",
"i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd": "Deactivate {value0}? Memberships remain stored, but role inheritance stops.",
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
"i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7": "Delete {value0}? Only unassigned tenant-defined roles can be deleted.",
"i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657": "Delete {value0}? The role must have no account assignments.",
"i18n:govoplan-access.delete_value.4d18989e": "Delete {value0}",
"i18n:govoplan-access.denied.63b16bd4": "Denied",
"i18n:govoplan-access.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-access.direct_roles.c4db7156": "Direkte Rollen",
"i18n:govoplan-access.display_name.c7874aaa": "Anzeigename",
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
"i18n:govoplan-access.edit_tenant.e2ba43f9": "Mandant bearbeiten",
"i18n:govoplan-access.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-access.effective_permissions.17c0fe8a": "Wirksame Berechtigungen",
"i18n:govoplan-access.email.84add5b2": "E-Mail",
"i18n:govoplan-access.expires.a99be3da": "Expires",
"i18n:govoplan-access.expiry.ba8f571e": "Expiry",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
"i18n:govoplan-access.files.6ce6c512": "Dateien",
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
"i18n:govoplan-access.global_account_value_updated.0de76b0e": "Global account {value0} updated.",
"i18n:govoplan-access.global_account.e1b00cf5": "Globales Konto",
"i18n:govoplan-access.global": "Global",
"i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f": "Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here.",
"i18n:govoplan-access.group_assignments.e534bb56": "Gruppenzuweisungen",
"i18n:govoplan-access.group_details.df844ae3": "Gruppendetails",
"i18n:govoplan-access.group_file_connections.73985bda": "Gruppen-Dateiverbindungen",
"i18n:govoplan-access.group_limits_may_only_narrow_inherited_system_an.32649b6f": "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override.",
"i18n:govoplan-access.group_mail_profile_policy.d98ef5a2": "Group mail profile policy",
"i18n:govoplan-access.group_mail_profiles.ebf1b5ba": "Gruppen-Mailprofile",
"i18n:govoplan-access.group_profiles.74568838": "Group profiles",
"i18n:govoplan-access.group_retention_policy.ad941c0b": "Group retention policy",
"i18n:govoplan-access.group_retention.57cdcdaa": "Gruppenaufbewahrung",
"i18n:govoplan-access.group_templates": "Gruppenvorlagen",
"i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16": "Group roles and direct roles are combined. The backend refuses a change that would leave an active tenant without an operational owner.",
"i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc": "Group-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66": "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_scoped_retention_limits_for_group_owned_ca.e8e25e04": "Group-scoped retention limits for group-owned campaigns in the active tenant.",
"i18n:govoplan-access.group_value_created.5a39a341": "Group {value0} created.",
"i18n:govoplan-access.group_value_deactivated.8512bf9c": "Group {value0} deactivated.",
"i18n:govoplan-access.group_value_updated.3d97d5b2": "Group {value0} updated.",
"i18n:govoplan-access.group.171a0606": "Gruppe",
"i18n:govoplan-access.group.ea5a3834": "GROUP",
"i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309": "Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles.",
"i18n:govoplan-access.groups.07551586": "groups,",
"i18n:govoplan-access.groups.ae9629f4": "Gruppen",
"i18n:govoplan-access.i_have_recorded_it.7522da18": "Ich habe es notiert",
"i18n:govoplan-access.inactive.09af574c": "Inaktiv",
"i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
"i18n:govoplan-access.inherit_system_setting.7f125156": "Inherit system setting",
"i18n:govoplan-access.inherited_roles.8def9f05": "Inherited roles",
"i18n:govoplan-access.initial_password.2278be8c": "Initiales Passwort",
"i18n:govoplan-access.initial_tenant_owner.682291a9": "Initial tenant owner",
"i18n:govoplan-access.inspect_audit_event.5776c1b2": "Inspect audit event",
"i18n:govoplan-access.inspect_value.9d5d1071": "Inspect {value0}",
"i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153": "Installieren und aktivieren Sie das Dateimodul, um Dateiverbindungsprofile und Richtlinien zu verwalten.",
"i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a": "Install and enable the Mail module to manage mail server profiles and profile policies.",
"i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7": "Instance-level mail server profiles and policy limits inherited by every tenant.",
"i18n:govoplan-access.instance_wide_privacy_retention_policy_and_lower.646ce224": "Instance-wide privacy retention policy and lower-level override permissions.",
"i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d": "Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users.",
"i18n:govoplan-access.is_shown_once.af2b1235": "is shown once.",
"i18n:govoplan-access.last_login.43dab84f": "Letzte Anmeldung",
"i18n:govoplan-access.last_used.f1109d3d": "Last used",
"i18n:govoplan-access.leave_empty_to_generate.e58222d8": "Leer lassen zum Generieren",
"i18n:govoplan-access.locale.8970f0e6": "Locale",
"i18n:govoplan-access.mail_module_unavailable.b4e95104": "Mail module unavailable",
"i18n:govoplan-access.mail_servers.d627326a": "Mail servers",
"i18n:govoplan-access.maintenance.94de303b": "Wartung",
"i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb": "Manage memberships, groups and direct roles in the active tenant. Global account status and cross-tenant membership are managed under System → Users.",
"i18n:govoplan-access.management.63cecca6": "Management",
"i18n:govoplan-access.members.1cb449c1": "Members",
"i18n:govoplan-access.membership_status.b77fc732": "Mitgliedschaftsstatus",
"i18n:govoplan-access.membership.53bc9670": "Mitgliedschaft",
"i18n:govoplan-access.modules.04e9462c": "Module",
"i18n:govoplan-access.name.709a2322": "Name",
"i18n:govoplan-access.no_administrative_audit_records_found.8d128767": "No administrative audit records found.",
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
"i18n:govoplan-access.no_groups_found.627ca913": "No groups found.",
"i18n:govoplan-access.no_roles_found.70f7c0c9": "Keine Rollen gefunden.",
"i18n:govoplan-access.no_system_roles_found.051cf727": "No system roles found.",
"i18n:govoplan-access.no_tenant_users_exist.96b4a88a": "No tenant users exist.",
"i18n:govoplan-access.no_tenant_users_found.74bb615f": "Keine Mandantenbenutzer gefunden.",
"i18n:govoplan-access.no_tenants_found.72d04cf4": "No tenants found.",
"i18n:govoplan-access.no.816c52fd": "Nein",
"i18n:govoplan-access.none.6eef6648": "Keine",
"i18n:govoplan-access.object.2883f191": "Object",
"i18n:govoplan-access.objects.72a83add": "Objects",
"i18n:govoplan-access.owner.89ff3122": "Eigentümer",
"i18n:govoplan-access.packages.0a999012": "Pakete",
"i18n:govoplan-access.permissions.d06d5557": "Berechtigungen",
"i18n:govoplan-access.platform_administration": "Plattformadministration",
"i18n:govoplan-access.prefix.90eceb01": "Prefix",
"i18n:govoplan-access.protected.28531336": "Protected",
"i18n:govoplan-access.reload.cce71553": "Neu laden",
"i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d": "Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.",
"i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3": "Passwortwechsel verlangen, sobald Kontoeinstellungen umgesetzt sind",
"i18n:govoplan-access.required.2e5396fd": ", required",
"i18n:govoplan-access.required.7c65879a": " · required",
"i18n:govoplan-access.retention_dry_run_completed.91895aee": "Retention dry run completed.",
"i18n:govoplan-access.retention_execution.84b7105d": "Retention execution",
"i18n:govoplan-access.retention_policy_applied.7fa4e050": "Retention policy applied.",
"i18n:govoplan-access.retention.c7199d9e": "Retention",
"i18n:govoplan-access.revoke_api_key.3160aa7e": "Revoke API key",
"i18n:govoplan-access.revoke_key.acb203e7": "Revoke key",
"i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd": "Revoke {value0}? Existing clients will immediately lose access.",
"i18n:govoplan-access.revoke_value.34640d6a": "Revoke {value0}",
"i18n:govoplan-access.revoked.85f17ac0": "Revoked",
"i18n:govoplan-access.role_details.a16b5d9f": "Rollendetails",
"i18n:govoplan-access.role_value_created.2a964899": "Role {value0} created.",
"i18n:govoplan-access.role_value_deleted.3bd819cf": "Role {value0} deleted.",
"i18n:govoplan-access.role_value_updated.ec386eb0": "Role {value0} updated.",
"i18n:govoplan-access.role.c3f104d1": "Rolle",
"i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa": "Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source.",
"i18n:govoplan-access.roles.47dcc27d": "Roles",
"i18n:govoplan-access.root.77e7e78b": "ROOT",
"i18n:govoplan-access.run_the_saved_effective_retention_policy_against.cd39a54c": "Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.",
"i18n:govoplan-access.save_account.0b761f5c": "Save account",
"i18n:govoplan-access.save_general_settings.5c90f8c4": "Save general settings",
"i18n:govoplan-access.save_group.36ca6865": "Gruppe speichern",
"i18n:govoplan-access.save_role.16fe10d1": "Rolle speichern",
"i18n:govoplan-access.save_tenant.9eb2ac74": "Mandant speichern",
"i18n:govoplan-access.save_user.0d071b89": "Benutzer speichern",
"i18n:govoplan-access.saving.56a2285c": "Saving…",
"i18n:govoplan-access.saving.ae7e8875": "Saving...",
"i18n:govoplan-access.scope.4651a34e": "Geltungsbereich",
"i18n:govoplan-access.scopes.c23540e5": "Scopes",
"i18n:govoplan-access.select_user.b8a1d9de": "Select user",
"i18n:govoplan-access.set_concrete_system_retention_values_the_allow_o.02e1fd13": "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override.",
"i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
"i18n:govoplan-access.show_revoked.b4265807": "Show revoked",
"i18n:govoplan-access.slug.094da9b9": "Slug",
"i18n:govoplan-access.status.bae7d5be": "Status",
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
"i18n:govoplan-access.suspended.794696a7": "Suspended",
"i18n:govoplan-access.system_audit.69c6b424": "System audit",
"i18n:govoplan-access.system_governance_overrides.97cdf3ce": "System governance overrides",
"i18n:govoplan-access.system_level_administrative_history_showing_valu.c8a089a1": "System-level administrative history. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.system_mail_profile_policy.7edd7fcf": "System mail profile policy",
"i18n:govoplan-access.system_mail_profiles.5af3eb64": "System-Mailprofile",
"i18n:govoplan-access.system_managed_value.eb564eb1": "System managed{value0}",
"i18n:govoplan-access.system_permissions.53ff0ab2": "System permissions",
"i18n:govoplan-access.system_profiles.98adae54": "System profiles",
"i18n:govoplan-access.system_retention_policy.7027f6ba": "System retention policy",
"i18n:govoplan-access.system_retention.4191e7f7": "Systemaufbewahrung",
"i18n:govoplan-access.system_role_details.3d6a8f15": "System role details",
"i18n:govoplan-access.system_role_value_created.9c1a6bc8": "System role {value0} created.",
"i18n:govoplan-access.system_role_value_deleted.7b18a6f8": "System role {value0} deleted.",
"i18n:govoplan-access.system_role_value_updated.3a3f8862": "System role {value0} updated.",
"i18n:govoplan-access.system_role.91762640": "System role",
"i18n:govoplan-access.system_roles.a9461aa6": "System roles",
"i18n:govoplan-access.system.29d43743": "SYSTEM",
"i18n:govoplan-access.system.bc0792d8": "System",
"i18n:govoplan-access.temporary_password.62d60628": "Temporäres Passwort",
"i18n:govoplan-access.tenant_api_keys.4b1d81f8": "Mandanten-API-Schlüssel",
"i18n:govoplan-access.tenant_audit.492b9138": "Mandanten-Audit",
"i18n:govoplan-access.tenant_context.b401a2ad": "Tenant context",
"i18n:govoplan-access.tenant_custom.4307081e": "Mandanten-spezifisch",
"i18n:govoplan-access.tenant_details.5976ba72": "Tenant details",
"i18n:govoplan-access.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
"i18n:govoplan-access.tenant_general_settings.db1c3ba8": "Tenant general settings",
"i18n:govoplan-access.tenant_groups.47e6cc05": "Mandantengruppen",
"i18n:govoplan-access.tenant_level_administrative_history_for_the_acti.2f8fbfff": "Tenant-level administrative history for the active tenant. Showing {value0}{value1} of {value2} records.",
"i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f": "Tenant-level mail server profiles and policy limits for the active tenant.",
"i18n:govoplan-access.tenant_level_privacy_and_retention_limits_for_th.a6d4108c": "Tenant-level privacy and retention limits for the active tenant.",
"i18n:govoplan-access.tenant_limits_may_only_narrow_the_system_policy_.de974a77": "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override.",
"i18n:govoplan-access.tenant_locale.8fc19914": "Tenant locale",
"i18n:govoplan-access.tenant_languages_help": "Mandantensprachen koennen nur aus den systemweit aktivierten Sprachen gewaehlt werden. Benutzer koennen aus den fuer den Mandanten aktivierten Sprachen waehlen.",
"i18n:govoplan-access.tenant_mail_profile_policy.239298a1": "Tenant mail profile policy",
"i18n:govoplan-access.tenant_mail_profiles.5132a623": "Mandanten-Mailprofile",
"i18n:govoplan-access.tenant_managed.843eed93": "Tenant managed",
"i18n:govoplan-access.tenant_membership_created_for_value.2b8eeef6": "Tenant membership created for {value0}.",
"i18n:govoplan-access.tenant_memberships.451de736": "Tenant memberships",
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
"i18n:govoplan-access.tenant_role_templates": "Mandantenrollenvorlagen",
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",
"i18n:govoplan-access.tenant_users.cb800b38": "Mandantenbenutzer",
"i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
"i18n:govoplan-access.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
"i18n:govoplan-access.tenant.3ca93c78": "Mandant",
"i18n:govoplan-access.tenant.affa34d7": "TENANT",
"i18n:govoplan-access.tenants.1f7ae776": "Mandanten",
"i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f": "The access admin route requires the platform auth-change callback.",
"i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc": "The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.",
"i18n:govoplan-access.the_secret_for.c60737ef": "The secret for",
"i18n:govoplan-access.the_selected_account.1211bfb9": "the selected account",
"i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7": "The selected user has no tenant permissions available to an API key.",
"i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f": "This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.",
"i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e": "This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.",
"i18n:govoplan-access.this_is_shown_once_for.b0f0f526": "Dies wird einmalig angezeigt für",
"i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f": "This membership is the tenant's last active operational owner. It cannot be deactivated; assign another owner before removing its owner-capable roles or groups.",
"i18n:govoplan-access.this_will_redact_or_delete_eligible_retained_dat.e8e80715": "This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed.",
"i18n:govoplan-access.time.6c82e6dd": "Time",
"i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b": "Übermitteln Sie es über einen separaten sicheren Kanal.",
"i18n:govoplan-access.type.3deb7456": "Typ",
"i18n:govoplan-access.updated.f2f8570d": "Aktualisiert",
"i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
"i18n:govoplan-access.user_assignments.bc7cc801": "Benutzerzuweisungen",
"i18n:govoplan-access.user_file_connections.996444a0": "Benutzer-Dateiverbindungen",
"i18n:govoplan-access.user_limits_may_only_narrow_inherited_system_and.b194c7c0": "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override.",
"i18n:govoplan-access.user_mail_profile_policy.529e035b": "User mail profile policy",
"i18n:govoplan-access.user_mail_profiles.f54a845a": "Benutzer-Mailprofile",
"i18n:govoplan-access.user_profiles.57730285": "User profiles",
"i18n:govoplan-access.user_retention_policy.2776f485": "User retention policy",
"i18n:govoplan-access.user_retention.f0966bbf": "Benutzeraufbewahrung",
"i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2": "User-scoped file server connections and credential policy limits in the active tenant.",
"i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf": "User-scoped profiles and policy limits for campaign owners in the active tenant.",
"i18n:govoplan-access.user_scoped_retention_limits_for_campaigns_owned.cbc9268b": "User-scoped retention limits for campaigns owned by users in the active tenant.",
"i18n:govoplan-access.user.6eb0c612": "USER",
"i18n:govoplan-access.user.9f8a2389": "Benutzer",
"i18n:govoplan-access.users.57f2b181": "Benutzer",
"i18n:govoplan-access.users.81651889": "users,",
"i18n:govoplan-access.value_deactivated_in_this_tenant.871837c2": "{value0} deactivated in this tenant.",
"i18n:govoplan-access.value_deactivated.ed3d027c": "{value0} deactivated.",
"i18n:govoplan-access.value_suspended.31731a28": "{value0} suspended.",
"i18n:govoplan-access.value_value.0e2772ed": "{value0} — {value1}",
"i18n:govoplan-access.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-access.yes.5397e058": "Ja",
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
}
};

View File

@@ -1,26 +1,33 @@
import { createElement, lazy } from "react";
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
import { adminReadScopes } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) {
throw new Error("The access admin route requires the platform auth-change callback.");
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
}
return createElement(AdminPage, { settings, auth, onAuthChange });
}
export const accessModule: PlatformWebModule = {
id: "access",
label: "Access",
label: "i18n:govoplan-access.access.2f81a22d",
version: "1.0.0",
translations,
navItems: [
{ to: "/admin", label: "Admin", iconName: "admin", anyOf: adminReadScopes, order: 900 }
],
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
routes: [
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }
]
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }]
};
export default accessModule;
export default accessModule;