From 118e96db438445a0edd3f6b34e371e7c972d63e5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 04:21:34 +0200 Subject: [PATCH] feat: own tenant administration WebUI --- README.md | 10 +- package.json | 30 ++ src/govoplan_tenancy/backend/manifest.py | 23 +- webui/package.json | 29 ++ .../scripts/test-tenancy-admin-structure.mjs | 48 +++ webui/src/api/tenancy.ts | 162 ++++++++++ .../features/admin/TenantSettingsPanel.tsx | 166 ++++++++++ webui/src/features/admin/TenantsPanel.tsx | 295 ++++++++++++++++++ webui/src/features/admin/utils/deltaRows.ts | 38 +++ webui/src/i18n/generatedTranslations.ts | 150 +++++++++ webui/src/index.ts | 9 + webui/src/module.ts | 83 +++++ 12 files changed, 1040 insertions(+), 3 deletions(-) create mode 100644 package.json create mode 100644 webui/package.json create mode 100644 webui/scripts/test-tenancy-admin-structure.mjs create mode 100644 webui/src/api/tenancy.ts create mode 100644 webui/src/features/admin/TenantSettingsPanel.tsx create mode 100644 webui/src/features/admin/TenantsPanel.tsx create mode 100644 webui/src/features/admin/utils/deltaRows.ts create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts diff --git a/README.md b/README.md index dc0616b..d82d31d 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,17 @@ `govoplan-tenancy` owns tenant lifecycle, tenant administration API route -contributions, and the `tenancy.tenantResolver` capability during the GovOPlaN -module split. +contributions, the `tenancy.tenantResolver` capability, and the tenant registry +and tenant settings WebUI panels during the GovOPlaN module split. `govoplan-access` no longer hard-depends on this module. Access can run in the single-scope compatibility mode used by the core/access baseline; installing tenancy adds explicit tenant management and resolver behavior. The shared scope storage table is core-owned as `core_scopes`; tenancy provides lifecycle and administration behavior over those rows rather than owning the table. + +The `@govoplan/tenancy-webui` package contributes `system-tenants` and +`tenant-settings` through the shared `admin.sections` capability. The Access +module owns the `/admin` shell but does not import these panels. Historical +`access.admin.*` surface identifiers remain stable so existing saved Views keep +working after the ownership move. diff --git a/package.json b/package.json new file mode 100644 index 0000000..49486d2 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@govoplan/tenancy-webui", + "version": "0.1.8", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + } + }, + "files": [ + "webui/src", + "README.md" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index 31984a4..6b1b98d 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -6,7 +6,8 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_TENANCY_TENANT_RESOLVER, ) -from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.modules import FrontendModule, ModuleContext, ModuleManifest +from govoplan_core.core.views import ViewSurface def _tenant_resolver(context: ModuleContext): @@ -40,6 +41,26 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver, }, + frontend=FrontendModule( + module_id="tenancy", + package_name="@govoplan/tenancy-webui", + view_surfaces=( + ViewSurface( + id="tenancy.admin.system-tenants", + module_id="tenancy", + kind="section", + label="System tenants", + order=10, + ), + ViewSurface( + id="tenancy.admin.tenant-settings", + module_id="tenancy", + kind="section", + label="Tenant settings", + order=90, + ), + ), + ), ) diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..f072bde --- /dev/null +++ b/webui/package.json @@ -0,0 +1,29 @@ +{ + "name": "@govoplan/tenancy-webui", + "version": "0.1.8", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test:tenancy-admin": "node scripts/test-tenancy-admin-structure.mjs" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/scripts/test-tenancy-admin-structure.mjs b/webui/scripts/test-tenancy-admin-structure.mjs new file mode 100644 index 0000000..cf875e4 --- /dev/null +++ b/webui/scripts/test-tenancy-admin-structure.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from "node:fs"; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const moduleSource = readFileSync( + new URL("../src/module.ts", import.meta.url), + "utf8" +); +const tenantsSource = readFileSync( + new URL("../src/features/admin/TenantsPanel.tsx", import.meta.url), + "utf8" +); +const settingsSource = readFileSync( + new URL("../src/features/admin/TenantSettingsPanel.tsx", import.meta.url), + "utf8" +); + +assert( + moduleSource.includes('"admin.sections": adminSections'), + "Tenancy contributes its panels through admin.sections" +); +assert( + moduleSource.includes('id: "system-tenants"'), + "Tenancy contributes the system tenant registry section" +); +assert( + moduleSource.includes('id: "tenant-settings"'), + "Tenancy contributes the active tenant settings section" +); +assert( + moduleSource.includes('surfaceId: "tenancy.admin.system-tenants"') && + moduleSource.includes('surfaceId: "tenancy.admin.tenant-settings"'), + "Tenancy owns the view-surface namespace for both admin sections" +); +assert( + !moduleSource.includes("@govoplan/access-webui"), + "Tenancy does not import the optional Access WebUI package" +); +assert( + tenantsSource.includes("/api/tenancy"), + "The tenant registry panel consumes the tenancy-owned API client" +); +assert( + settingsSource.includes("/api/tenancy"), + "The tenant settings panel consumes the tenancy-owned API client" +); diff --git a/webui/src/api/tenancy.ts b/webui/src/api/tenancy.ts new file mode 100644 index 0000000..903d594 --- /dev/null +++ b/webui/src/api/tenancy.ts @@ -0,0 +1,162 @@ +import type { + ApiSettings, + DeltaDeletedItem, + PrivacyRetentionPolicy, + TenantAdminItem +} from "@govoplan/core-webui"; +import { + apiFetch, + apiGetList, + apiQuery +} from "@govoplan/core-webui"; + +export type TenantOwnerCandidate = { + account_id: string; + email: string; + display_name?: string | null; +}; + +export type LanguagePackage = { + code: string; + label: string; + native_label?: string | null; +}; + +export type SystemSettingsItem = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy: PrivacyRetentionPolicy; + available_languages?: LanguagePackage[]; + enabled_language_codes?: string[]; + settings: Record; +}; + +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; +}; + +export type TenantSettingsDeltaSections = Partial<{ + identity: Pick; + locale: Pick; + languages: Pick< + TenantSettingsItem, + "available_languages" | "system_enabled_language_codes" | "enabled_language_codes" + >; + settings: TenantSettingsItem["settings"]; +}>; + +type DeltaResponseFields = { + deleted: DeltaDeletedItem[]; + watermark?: string | null; + has_more: boolean; + full: boolean; +}; + +export type TenantListDeltaResponse = { + tenants: TenantAdminItem[]; +} & DeltaResponseFields; + +export type TenantSettingsDeltaResponse = { + item?: TenantSettingsItem | null; + sections: TenantSettingsDeltaSections; + changed_sections: string[]; +} & DeltaResponseFields; + +export function fetchTenantsDelta( + settings: ApiSettings, + options: { since?: string | null; limit?: number } = {} +): Promise { + return apiFetch( + settings, + `/api/v1/admin/tenants/delta${apiQuery(options)}` + ); +} + +export async function fetchTenantOwnerCandidates( + settings: ApiSettings +): Promise { + return apiGetList( + settings, + "/api/v1/admin/tenants/owner-candidates", + "accounts" + ); +} + +export function createTenant( + settings: ApiSettings, + payload: { + slug: string; + name: string; + owner_account_id?: string | null; + description?: string | null; + default_locale?: string; + settings?: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + } +): Promise { + return apiFetch(settings, "/api/v1/admin/tenants", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function updateTenant( + settings: ApiSettings, + tenantId: string, + payload: Partial<{ + name: string; + description: string | null; + default_locale: string; + settings: Record; + allow_custom_groups: boolean | null; + allow_custom_roles: boolean | null; + allow_api_keys: boolean | null; + is_active: boolean; + }> +): Promise { + return apiFetch( + settings, + `/api/v1/admin/tenants/${encodeURIComponent(tenantId)}`, + { method: "PATCH", body: JSON.stringify(payload) } + ); +} + +export function fetchTenantSettingsDelta( + settings: ApiSettings, + options: { since?: string | null; limit?: number } = {} +): Promise { + return apiFetch( + settings, + `/api/v1/admin/tenant/settings/delta${apiQuery(options)}` + ); +} + +export function updateTenantSettings( + settings: ApiSettings, + payload: { + default_locale: string; + enabled_language_codes?: string[] | null; + } +): Promise { + return apiFetch(settings, "/api/v1/admin/tenant/settings", { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function fetchSystemSettings( + settings: ApiSettings +): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings"); +} diff --git a/webui/src/features/admin/TenantSettingsPanel.tsx b/webui/src/features/admin/TenantSettingsPanel.tsx new file mode 100644 index 0000000..910ac09 --- /dev/null +++ b/webui/src/features/admin/TenantSettingsPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +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 { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/tenancy"; +import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; + +const DELTA_KEY = "tenancy: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: {} +}; + +export default function TenantSettingsPanel({ + settings, + canWrite, + onAuthRefresh + + + + +}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise;}) { + const [draft, setDraft] = useState(fallback); + const [savedDraft, setSavedDraft] = useState(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 { + 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 { + setLoading(false); + } + } + + useEffect(() => { + resetDeltaWatermark(DELTA_KEY); + void load(); + }, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]); + + async function save(): Promise { + setBusy(true); + setError(""); + setSuccess(""); + try { + const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes }); + setDraft(saved); + setSavedDraft(saved); + resetDeltaWatermark(DELTA_KEY); + setSuccess("i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681"); + await onAuthRefresh(); + return true; + } catch (err) { + setError(adminErrorMessage(err)); + return false; + } finally { + setBusy(false); + } + } + + function setEnabledLanguages(selected: string[]) { + const enabled = new Set(selected); + 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 ( + }> + +
+ + + + + { + const language = draft.available_languages.find((item) => item.code === code); + return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale }; + })} + selected={draft.enabled_language_codes} + onChange={setEnabledLanguages} + /> +

i18n:govoplan-tenancy.tenant_languages_help

+
+
i18n:govoplan-tenancy.tenant.3ca93c78
{draft.name || "-"}
+
i18n:govoplan-tenancy.slug.094da9b9
{draft.slug || "-"}
+
i18n:govoplan-tenancy.available.7c62a142
{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}
+
+
+
+
); + +} + +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 } : {}) + }; +} diff --git a/webui/src/features/admin/TenantsPanel.tsx b/webui/src/features/admin/TenantsPanel.tsx new file mode 100644 index 0000000..bc203c7 --- /dev/null +++ b/webui/src/features/admin/TenantsPanel.tsx @@ -0,0 +1,295 @@ +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, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantOwnerCandidate } from "../../api/tenancy"; +import type { TenantAdminItem } from "@govoplan/core-webui"; +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, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui"; +import { loadDeltaRows } from "./utils/deltaRows"; + +type OverrideValue = "inherit" | "allow" | "deny"; +type TenantDraft = { + slug: string; + name: string; + ownerAccountId: string; + description: string; + defaultLocale: string; + isActive: boolean; + customGroups: OverrideValue; + customRoles: OverrideValue; + apiKeys: OverrideValue; +}; + +const emptyDraft: TenantDraft = { + slug: "", + name: "", + ownerAccountId: "", + description: "", + defaultLocale: "en", + isActive: true, + customGroups: "inherit", + customRoles: "inherit", + apiKeys: "inherit" +}; + +function fromOverride(value?: boolean | null): OverrideValue { + if (value === true) return "allow"; + if (value === false) return "deny"; + return "inherit"; +} + +function toOverride(value: OverrideValue): boolean | null { + if (value === "allow") return true; + if (value === "deny") return false; + return null; +} + +export default function TenantsPanel({ + settings, + auth, + canCreate, + canUpdate, + canSuspend, + onAuthRefresh + + + + + + + +}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise;}) { + const [tenants, setTenants] = useState([]); + const [systemSettings, setSystemSettings] = useState(null); + const [ownerCandidates, setOwnerCandidates] = useState([]); + const tenantsRef = useRef([]); + const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); + const [editing, setEditing] = useState(null); + const [viewing, setViewing] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft)); + const [confirmSuspend, setConfirmSuspend] = useState(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([ + 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); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + tenantsRef.current = []; + resetDeltaWatermark(); + void load(); + }, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]); + + function openCreate() { + const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id }; + setDraft(nextDraft); + setSavedDraftKey(draftKey(nextDraft)); + setEditing("new"); + setError(""); + } + + function openEdit(tenant: TenantAdminItem) { + const nextDraft = { + slug: tenant.slug, + name: tenant.name, + ownerAccountId: "", + description: tenant.description || "", + defaultLocale: tenant.default_locale || "en", + isActive: tenant.is_active, + 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(""); + } + + function closeEditor() { + setEditing(null); + setDraft(emptyDraft); + setSavedDraftKey(draftKey(emptyDraft)); + } + + async function save(): Promise { + setBusy(true); + setError(""); + try { + const governance = { + allow_custom_groups: toOverride(draft.customGroups), + allow_custom_roles: toOverride(draft.customRoles), + allow_api_keys: toOverride(draft.apiKeys) + }; + if (editing === "new") { + const created = await createTenant(settings, { + slug: draft.slug, + name: draft.name, + owner_account_id: draft.ownerAccountId || null, + description: draft.description || null, + default_locale: draft.defaultLocale, + settings: {}, + ...governance + }); + const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId); + setSuccess(i18nMessage("i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-tenancy.the_selected_account.1211bfb9" })); + await onAuthRefresh(); + } else if (editing) { + const payload: Parameters[2] = {}; + if (canUpdate) { + payload.name = draft.name; + payload.description = draft.description || null; + payload.default_locale = draft.defaultLocale; + payload.allow_custom_groups = governance.allow_custom_groups; + payload.allow_custom_roles = governance.allow_custom_roles; + payload.allow_api_keys = governance.allow_api_keys; + } + if (canSuspend) payload.is_active = draft.isActive; + await updateTenant(settings, editing.id, payload); + setSuccess(i18nMessage("i18n:govoplan-tenancy.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); + } + } + + async function suspend() { + if (!confirmSuspend) return; + setBusy(true); + setError(""); + try { + await updateTenant(settings, confirmSuspend.id, { is_active: false }); + setSuccess(i18nMessage("i18n:govoplan-tenancy.value_suspended.31731a28", { value0: confirmSuspend.name })); + setConfirmSuspend(null); + await onAuthRefresh(); + await load(); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setBusy(false); + } + } + + const activeTenantId = (auth.active_tenant ?? auth.tenant).id; + const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false; + const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false; + const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false; + const systemDeniedGovernance = [ + systemAllowsCustomGroups ? "" : "i18n:govoplan-tenancy.custom_groups.453a605c", + systemAllowsCustomRoles ? "" : "i18n:govoplan-tenancy.custom_roles.d48dc976", + systemAllowsApiKeys ? "" : "i18n:govoplan-tenancy.api_keys.94fcf3c2"]. + filter(Boolean).join(", "); + const columns = useMemo[]>(() => [ + { id: "name", header: "i18n:govoplan-tenancy.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) =>
{row.name}
{row.slug}
}, + { id: "users", header: "i18n:govoplan-tenancy.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-tenancy.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 }, + { id: "campaigns", header: "i18n:govoplan-tenancy.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 }, + { id: "files", header: "i18n:govoplan-tenancy.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 }, + { id: "locale", header: "i18n:govoplan-tenancy.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-tenancy.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => }, + { id: "actions", header: "i18n:govoplan-tenancy.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => , onClick: () => setViewing(row) }, + { id: "edit", label: i18nMessage("i18n:govoplan-tenancy.edit_value.fad75899", { value0: row.name }), icon: , disabled: !canUpdate, onClick: () => openEdit(row) }, + { id: "suspend", label: i18nMessage("i18n:govoplan-tenancy.suspend_value.03a74b32", { value0: row.name }), icon: , variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) } + ]} /> }], + [activeTenantId, canSuspend, canUpdate]); + + return ( + <> + } variant="primary" onClick={openCreate} disabled={!canCreate} />}> + +
row.id} emptyText="i18n:govoplan-tenancy.no_tenants_found.72d04cf4" />
+
+ + !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<>}> +
+ setDraft({ ...draft, name: event.target.value })} /> + setDraft({ ...draft, slug: event.target.value })} /> + {editing === "new" && } + setDraft({ ...draft, defaultLocale: event.target.value })} /> + {editing !== "new" && } +