feat: own tenant administration WebUI
This commit is contained in:
@@ -5,11 +5,17 @@
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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: 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<TenantListDeltaResponse> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/admin/tenants/delta${apiQuery(options)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(
|
||||
settings: ApiSettings
|
||||
): Promise<TenantOwnerCandidate[]> {
|
||||
return apiGetList<TenantOwnerCandidate, "accounts">(
|
||||
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<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}
|
||||
): Promise<TenantAdminItem> {
|
||||
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<string, unknown>;
|
||||
allow_custom_groups: boolean | null;
|
||||
allow_custom_roles: boolean | null;
|
||||
allow_api_keys: boolean | null;
|
||||
is_active: boolean;
|
||||
}>
|
||||
): Promise<TenantAdminItem> {
|
||||
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<TenantSettingsDeltaResponse> {
|
||||
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<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchSystemSettings(
|
||||
settings: ApiSettings
|
||||
): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||
}
|
||||
@@ -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<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 {
|
||||
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<boolean> {
|
||||
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 (
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"
|
||||
description="i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-tenancy.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-tenancy.saving.ae7e8875" : "i18n:govoplan-tenancy.save_general_settings.5c90f8c4"}</Button></>}>
|
||||
|
||||
<div className="admin-settings-form">
|
||||
<Card title="i18n:govoplan-tenancy.locale.8970f0e6">
|
||||
<FormField label="i18n:govoplan-tenancy.tenant_locale.8fc19914" help="i18n:govoplan-tenancy.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>
|
||||
<AdminSelectionList
|
||||
options={draft.system_enabled_language_codes.map((code) => {
|
||||
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}
|
||||
/>
|
||||
<p className="muted small-note">i18n:govoplan-tenancy.tenant_languages_help</p>
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-tenancy.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</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 } : {})
|
||||
};
|
||||
}
|
||||
@@ -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<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([
|
||||
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<boolean> {
|
||||
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<typeof updateTenant>[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<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ 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) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ 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) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-tenancy.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-tenancy.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-tenancy.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
|
||||
{ id: "suspend", label: i18nMessage("i18n:govoplan-tenancy.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
|
||||
]} /> }],
|
||||
[activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-tenancy.tenants.1f7ae776"
|
||||
description="i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-tenancy.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-tenancy.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-tenancy.no_tenants_found.72d04cf4" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-tenancy.create_tenant.4dbd55d9" : "i18n:govoplan-tenancy.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-tenancy.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-tenancy.saving.56a2285c" : "i18n:govoplan-tenancy.save_tenant.9eb2ac74"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-tenancy.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-tenancy.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-tenancy.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-tenancy.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="i18n:govoplan-tenancy.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-tenancy.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-tenancy.active.a733b809</option><option value="inactive">i18n:govoplan-tenancy.suspended.794696a7</option></select></FormField>}
|
||||
<FormField label="i18n:govoplan-tenancy.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<h3>i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-tenancy.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-tenancy.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-tenancy.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-tenancy.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-tenancy.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-tenancy.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-tenancy.active.a733b809" : "i18n:govoplan-tenancy.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-tenancy.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-tenancy.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>i18n:govoplan-tenancy.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-tenancy.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-tenancy.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-tenancy.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-tenancy.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-tenancy.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">i18n:govoplan-tenancy.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-tenancy.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);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
merged = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
|
||||
: rows
|
||||
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(key, nextWatermark);
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-tenancy.actions.c3cd636a": "Actions",
|
||||
"i18n:govoplan-tenancy.active.a733b809": "Active",
|
||||
"i18n:govoplan-tenancy.add_tenant.b8e32af0": "Add tenant",
|
||||
"i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb": "Allow when system allows",
|
||||
"i18n:govoplan-tenancy.allowed.77c7b490": "Allowed",
|
||||
"i18n:govoplan-tenancy.api_keys.94fcf3c2": "API keys",
|
||||
"i18n:govoplan-tenancy.available.7c62a142": "Available",
|
||||
"i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
|
||||
"i18n:govoplan-tenancy.campaigns.01a23a28": "Campaigns",
|
||||
"i18n:govoplan-tenancy.campaigns.2282ffeb": "campaigns,",
|
||||
"i18n:govoplan-tenancy.cancel.77dfd213": "Cancel",
|
||||
"i18n:govoplan-tenancy.close.bbfa773e": "Close",
|
||||
"i18n:govoplan-tenancy.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-tenancy.create_tenant.4dbd55d9": "Create tenant",
|
||||
"i18n:govoplan-tenancy.created.accf40c8": "Created",
|
||||
"i18n:govoplan-tenancy.custom_groups.1f7b7c8f": "Custom groups",
|
||||
"i18n:govoplan-tenancy.custom_groups.453a605c": "custom groups",
|
||||
"i18n:govoplan-tenancy.custom_roles.d48dc976": "custom roles",
|
||||
"i18n:govoplan-tenancy.custom_roles.e78ef63d": "Custom roles",
|
||||
"i18n:govoplan-tenancy.custom_tenant_groups.570ee603": "Custom tenant groups",
|
||||
"i18n:govoplan-tenancy.custom_tenant_roles.a738c37c": "Custom tenant roles",
|
||||
"i18n:govoplan-tenancy.default_locale.b99d021f": "Default locale",
|
||||
"i18n:govoplan-tenancy.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-tenancy.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-tenancy.edit_tenant.e2ba43f9": "Edit tenant",
|
||||
"i18n:govoplan-tenancy.edit_value.fad75899": "Edit {value0}",
|
||||
"i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-tenancy.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-tenancy.files.6ce6c512": "Files",
|
||||
"i18n:govoplan-tenancy.general.9239ee2c": "General",
|
||||
"i18n:govoplan-tenancy.groups.07551586": "groups,",
|
||||
"i18n:govoplan-tenancy.groups.ae9629f4": "Groups",
|
||||
"i18n:govoplan-tenancy.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-tenancy.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
"i18n:govoplan-tenancy.initial_tenant_owner.682291a9": "Initial tenant owner",
|
||||
"i18n:govoplan-tenancy.inspect_value.9d5d1071": "Inspect {value0}",
|
||||
"i18n:govoplan-tenancy.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-tenancy.name.709a2322": "Name",
|
||||
"i18n:govoplan-tenancy.no_tenants_found.72d04cf4": "No tenants found.",
|
||||
"i18n:govoplan-tenancy.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-tenancy.reload.cce71553": "Reload",
|
||||
"i18n:govoplan-tenancy.save_general_settings.5c90f8c4": "Save general settings",
|
||||
"i18n:govoplan-tenancy.save_tenant.9eb2ac74": "Save tenant",
|
||||
"i18n:govoplan-tenancy.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-tenancy.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
|
||||
"i18n:govoplan-tenancy.slug.094da9b9": "Slug",
|
||||
"i18n:govoplan-tenancy.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-tenancy.suspend_tenant.151d283a": "Suspend tenant",
|
||||
"i18n:govoplan-tenancy.suspend_value.03a74b32": "Suspend {value0}",
|
||||
"i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||
"i18n:govoplan-tenancy.suspended.794696a7": "Suspended",
|
||||
"i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce": "System governance overrides",
|
||||
"i18n:govoplan-tenancy.tenancy": "Tenancy",
|
||||
"i18n:govoplan-tenancy.tenant.3ca93c78": "Tenant",
|
||||
"i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8": "Tenant API keys",
|
||||
"i18n:govoplan-tenancy.tenant_details.5976ba72": "Tenant details",
|
||||
"i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8": "Tenant general settings",
|
||||
"i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
|
||||
"i18n:govoplan-tenancy.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-tenancy.tenant_locale.8fc19914": "Tenant locale",
|
||||
"i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
|
||||
"i18n:govoplan-tenancy.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
|
||||
"i18n:govoplan-tenancy.tenants.1f7ae776": "Tenants",
|
||||
"i18n:govoplan-tenancy.the_selected_account.1211bfb9": "the selected account",
|
||||
"i18n:govoplan-tenancy.updated.f2f8570d": "Updated",
|
||||
"i18n:govoplan-tenancy.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-tenancy.users.57f2b181": "Users",
|
||||
"i18n:govoplan-tenancy.users.81651889": "users,",
|
||||
"i18n:govoplan-tenancy.value_suspended.31731a28": "{value0} suspended.",
|
||||
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-tenancy.actions.c3cd636a": "Aktionen",
|
||||
"i18n:govoplan-tenancy.active.a733b809": "Aktiv",
|
||||
"i18n:govoplan-tenancy.add_tenant.b8e32af0": "Mandant hinzufügen",
|
||||
"i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb": "Allow when system allows",
|
||||
"i18n:govoplan-tenancy.allowed.77c7b490": "Allowed",
|
||||
"i18n:govoplan-tenancy.api_keys.94fcf3c2": "API-Schlüssel",
|
||||
"i18n:govoplan-tenancy.available.7c62a142": "Verfuegbar",
|
||||
"i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
|
||||
"i18n:govoplan-tenancy.campaigns.01a23a28": "Kampagnen",
|
||||
"i18n:govoplan-tenancy.campaigns.2282ffeb": "campaigns,",
|
||||
"i18n:govoplan-tenancy.cancel.77dfd213": "Abbrechen",
|
||||
"i18n:govoplan-tenancy.close.bbfa773e": "Schließen",
|
||||
"i18n:govoplan-tenancy.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-tenancy.create_tenant.4dbd55d9": "Mandant erstellen",
|
||||
"i18n:govoplan-tenancy.created.accf40c8": "Erstellt",
|
||||
"i18n:govoplan-tenancy.custom_groups.1f7b7c8f": "Custom groups",
|
||||
"i18n:govoplan-tenancy.custom_groups.453a605c": "custom groups",
|
||||
"i18n:govoplan-tenancy.custom_roles.d48dc976": "custom roles",
|
||||
"i18n:govoplan-tenancy.custom_roles.e78ef63d": "Custom roles",
|
||||
"i18n:govoplan-tenancy.custom_tenant_groups.570ee603": "Custom tenant groups",
|
||||
"i18n:govoplan-tenancy.custom_tenant_roles.a738c37c": "Custom tenant roles",
|
||||
"i18n:govoplan-tenancy.default_locale.b99d021f": "Standardsprache",
|
||||
"i18n:govoplan-tenancy.denied.63b16bd4": "Denied",
|
||||
"i18n:govoplan-tenancy.description.55f8ebc8": "Beschreibung",
|
||||
"i18n:govoplan-tenancy.edit_tenant.e2ba43f9": "Mandant bearbeiten",
|
||||
"i18n:govoplan-tenancy.edit_value.fad75899": "Edit {value0}",
|
||||
"i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-tenancy.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-tenancy.files.6ce6c512": "Dateien",
|
||||
"i18n:govoplan-tenancy.general.9239ee2c": "Allgemein",
|
||||
"i18n:govoplan-tenancy.groups.07551586": "groups,",
|
||||
"i18n:govoplan-tenancy.groups.ae9629f4": "Gruppen",
|
||||
"i18n:govoplan-tenancy.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-tenancy.inherit_system_setting.7f125156": "Inherit system setting",
|
||||
"i18n:govoplan-tenancy.initial_tenant_owner.682291a9": "Initial tenant owner",
|
||||
"i18n:govoplan-tenancy.inspect_value.9d5d1071": "Inspect {value0}",
|
||||
"i18n:govoplan-tenancy.locale.8970f0e6": "Locale",
|
||||
"i18n:govoplan-tenancy.name.709a2322": "Name",
|
||||
"i18n:govoplan-tenancy.no_tenants_found.72d04cf4": "No tenants found.",
|
||||
"i18n:govoplan-tenancy.objects.72a83add": "Objects",
|
||||
"i18n:govoplan-tenancy.reload.cce71553": "Neu laden",
|
||||
"i18n:govoplan-tenancy.save_general_settings.5c90f8c4": "Save general settings",
|
||||
"i18n:govoplan-tenancy.save_tenant.9eb2ac74": "Mandant speichern",
|
||||
"i18n:govoplan-tenancy.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-tenancy.saving.ae7e8875": "Saving...",
|
||||
"i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
|
||||
"i18n:govoplan-tenancy.slug.094da9b9": "Slug",
|
||||
"i18n:govoplan-tenancy.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-tenancy.suspend_tenant.151d283a": "Suspend tenant",
|
||||
"i18n:govoplan-tenancy.suspend_value.03a74b32": "Suspend {value0}",
|
||||
"i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||
"i18n:govoplan-tenancy.suspended.794696a7": "Suspended",
|
||||
"i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce": "System governance overrides",
|
||||
"i18n:govoplan-tenancy.tenancy": "Mandantenfähigkeit",
|
||||
"i18n:govoplan-tenancy.tenant.3ca93c78": "Mandant",
|
||||
"i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8": "Mandanten-API-Schlüssel",
|
||||
"i18n:govoplan-tenancy.tenant_details.5976ba72": "Tenant details",
|
||||
"i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8": "Tenant general settings",
|
||||
"i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
|
||||
"i18n:govoplan-tenancy.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-tenancy.tenant_locale.8fc19914": "Tenant locale",
|
||||
"i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
|
||||
"i18n:govoplan-tenancy.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
|
||||
"i18n:govoplan-tenancy.tenants.1f7ae776": "Mandanten",
|
||||
"i18n:govoplan-tenancy.the_selected_account.1211bfb9": "the selected account",
|
||||
"i18n:govoplan-tenancy.updated.f2f8570d": "Aktualisiert",
|
||||
"i18n:govoplan-tenancy.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-tenancy.users.57f2b181": "Benutzer",
|
||||
"i18n:govoplan-tenancy.users.81651889": "users,",
|
||||
"i18n:govoplan-tenancy.value_suspended.31731a28": "{value0} suspended.",
|
||||
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/tenancy";
|
||||
export { default as TenantsPanel } from "./features/admin/TenantsPanel";
|
||||
export { default as TenantSettingsPanel } from "./features/admin/TenantSettingsPanel";
|
||||
export type {
|
||||
PlatformWebModule,
|
||||
PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const TenantsPanel = lazy(() => import("./features/admin/TenantsPanel"));
|
||||
const TenantSettingsPanel = lazy(
|
||||
() => import("./features/admin/TenantSettingsPanel")
|
||||
);
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "system-tenants",
|
||||
moduleId: "tenancy",
|
||||
kind: "management",
|
||||
surfaceId: "tenancy.admin.system-tenants",
|
||||
label: "i18n:govoplan-tenancy.tenants.1f7ae776",
|
||||
group: "SYSTEM",
|
||||
order: 10,
|
||||
anyOf: ["system:tenants:read"],
|
||||
render: ({ settings, auth, refreshAuth }) =>
|
||||
createElement(TenantsPanel, {
|
||||
settings,
|
||||
auth,
|
||||
canCreate: auth.scopes.includes("system:tenants:create"),
|
||||
canUpdate: auth.scopes.includes("system:tenants:update"),
|
||||
canSuspend: auth.scopes.includes("system:tenants:suspend"),
|
||||
onAuthRefresh: refreshAuth
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "tenant-settings",
|
||||
moduleId: "tenancy",
|
||||
kind: "settings",
|
||||
surfaceId: "tenancy.admin.tenant-settings",
|
||||
label: "i18n:govoplan-tenancy.general.9239ee2c",
|
||||
group: "TENANT",
|
||||
order: 90,
|
||||
anyOf: ["admin:settings:read"],
|
||||
render: ({ settings, auth, refreshAuth }) =>
|
||||
createElement(TenantSettingsPanel, {
|
||||
settings,
|
||||
canWrite: auth.scopes.includes("admin:settings:write"),
|
||||
onAuthRefresh: refreshAuth
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const tenancyModule: PlatformWebModule = {
|
||||
id: "tenancy",
|
||||
label: "i18n:govoplan-tenancy.tenancy",
|
||||
version: "1.0.0",
|
||||
optionalDependencies: ["access"],
|
||||
translations: {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
},
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "tenancy.admin.system-tenants",
|
||||
moduleId: "tenancy",
|
||||
kind: "section",
|
||||
label: "System tenants",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "tenancy.admin.tenant-settings",
|
||||
moduleId: "tenancy",
|
||||
kind: "section",
|
||||
label: "Tenant settings",
|
||||
order: 90
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default tenancyModule;
|
||||
Reference in New Issue
Block a user