16 Commits
Author SHA1 Message Date
zemion f43514be10 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:13 +02:00
zemion 6a985d2a0e Make package publication retries hash-safe 2026-08-04 14:32:21 +02:00
zemion e8a22e54a5 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion e3c18f9aa8 Preserve tenant module entitlement state 2026-08-04 08:21:50 +02:00
zemion d4bfc6e45a Add protected package release workflow 2026-08-04 04:14:08 +02:00
zemion e76fe16870 Migrate Tenancy interface patterns 2026-08-03 11:42:34 +02:00
zemion 2ab89eb809 docs: declare institutional architecture boundary 2026-08-01 17:48:40 +02:00
zemion 118e96db43 feat: own tenant administration WebUI 2026-07-31 04:21:34 +02:00
zemion b40615f8ac perf(tenancy): batch list-page summaries 2026-07-30 01:15:28 +02:00
zemion 922b3f43b7 fix: invalidate auth context after tenant changes 2026-07-29 19:24:15 +02:00
zemion f823c30707 perf: batch tenant summary response assembly 2026-07-29 14:16:29 +02:00
zemion f065aa500a Clean Tenancy API typing and validation 2026-07-21 03:16:24 +02:00
zemion 1dde038547 intermittent commit 2026-07-14 13:22:12 +02:00
zemion efbec82761 Release v0.1.8 2026-07-11 16:49:04 +02:00
zemion 0ca9bd793c Release v0.1.7 2026-07-11 02:34:58 +02:00
zemion cd7cc674c7 Define tenant lifecycle event contract 2026-07-11 00:46:09 +02:00
24 changed files with 2380 additions and 59 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Tenancy Codex Guide
## Scope
This repository owns tenant lifecycle, tenant administration, tenant context resolution, and tenant settings over Core's shared scope storage.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Tenancy internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Core owns shared scope storage; Access owns authentication and permission evaluation.
- Preserve tenant isolation, ownership, and lifecycle recovery guarantees.
+23 -2
View File
@@ -1,11 +1,32 @@
# GovOPlaN Tenancy
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- 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.
The tenant registry and active-tenant settings follow the shared interface
pattern contract documented in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
Manifest-provided documentation topics back contextual help for tenant fields,
governance limits, permission blockers, and lifecycle consequences.
Core's `module_entitlements` tenant-setting key is reserved. Generic tenant
updates preserve it even when replacing the remaining settings document;
system and tenant module administrators change it through the Admin module's
dedicated, revision-checked module policy APIs.
+43
View File
@@ -0,0 +1,43 @@
# Tenancy Interface Pattern Migration
This document records the bounded migration of Tenancy-owned WebUI surfaces to
the GovOPlaN interface pattern language. Core owns the shared components and the
Admin host; Tenancy owns the behavior and documentation described here.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `tenancy.admin.system-tenants` | Administration directory and list-detail | Create, configure, suspend | Shared admin layout, DataGrid, stable row actions, adaptive create/edit dialog, lifecycle confirmation, contextual help |
| Tenant details dialog | Read-only evidence/detail | None | Stable labels, effective governance provenance, retained object counts |
| `tenancy.admin.tenant-settings` | Effective configuration | Configure | Shared admin layout, language selection, dirty-state guard, permission blocker, contextual help |
## Consequence And Availability Rules
- Creating a tenant establishes a new data and administration boundary and
provisions a protected initial owner.
- Tenant slugs are immutable after creation.
- System policy caps tenant governance overrides. A tenant can narrow an
allowance but cannot loosen a system denial.
- Suspension retains tenant-owned records and audit evidence. The active
tenant cannot be suspended until the operator changes context.
- Unavailable actions remain visible when they belong to the surface and state
the missing permission, inapplicable state, responsible actor, and
destination where applicable.
- Dirty dialogs and settings use the shared unsaved-change guard. Consequential
suspension continues to use the shared destructive confirmation dialog.
## State And Accessibility Evidence
The panels use Core loading, error, success, empty, disabled-action, blocker,
dialog, and status components. Row actions reserve a stable three-action area,
retain translated accessible labels, and do not disappear for row-specific
permission or lifecycle states. Dialog order follows identity, ownership,
locale/status, description, and governed capabilities. Shared dialogs own focus
containment and restoration, and the existing Admin shell provides responsive
composition.
Stable help references are contributed through the module manifest for the
tenant registry, tenant settings, lifecycle actions, and individual fields.
The WebUI structural test and backend documentation-contract test prevent those
references and state explanations from silently regressing.
+33
View File
@@ -27,3 +27,36 @@ their owning modules before physical deletion.
Tenant lifecycle planning uses registered tenant summary providers and delete
veto providers. Modules that own tenant-scoped data must contribute summaries
so destructive deletion cannot silently miss their rows.
Delete veto providers are registered through module manifests and receive
`(session, tenant_id, resource_id)`. Providers should return a structured
`DeleteVetoIssue`, a list of issues, or `None`; legacy providers that raise an
exception are treated as blocking module vetoes. Tenancy exposes those issues in
the deletion plan with module attribution and resource details, so operators can
see which module blocks or qualifies the lifecycle action.
## Lifecycle Events
`govoplan-tenancy.backend.lifecycle` is the module-local contract for tenant
lifecycle event names and payload shape. Modules that need to react to tenant
lifecycle changes should depend on the event type strings or the emitted audit
events, not on tenancy API route internals.
The stable lifecycle event names are:
- `tenant.created`: tenant registry entry was created and owner membership
provisioning was requested.
- `tenant.suspended`: tenant was marked inactive through the admin lifecycle
route.
- `tenant.resumed`: tenant was reactivated through the admin lifecycle route.
- `tenant.deletion_requested`: retirement or destructive erasure was requested
after lifecycle planning passed.
- `tenant.erasure_completed`: destructive tenant deletion completed.
Legacy audit actions such as `tenant.updated`, `tenant.retired`, and
`tenant.destroyed` can still be emitted for compatibility. New module behavior
should key off the explicit lifecycle events above.
Lifecycle event details use concrete tenant identifiers plus optional actor,
reason, count, and mode information. Destructive erasure is only emitted after
the tenant row is successfully scheduled for deletion in the same transaction.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@govoplan/tenancy-webui",
"version": "0.1.15",
"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.15",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-tenancy"
version = "0.1.6"
version = "0.1.15"
description = "GovOPlaN tenancy platform module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.6",
"govoplan-core>=0.1.15",
]
[tool.setuptools.packages.find]
+342 -51
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
@@ -15,6 +18,8 @@ from govoplan_core.core.access import (
TenantContextSwitcher,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
@@ -30,8 +35,16 @@ from govoplan_core.tenancy.service import (
assert_tenant_governance_override_allowed,
effective_tenant_governance,
tenant_counts,
tenant_counts_many,
)
from govoplan_tenancy.backend.db.models import Tenant
from govoplan_tenancy.backend.lifecycle import (
TENANT_EVENT_CREATED,
TENANT_EVENT_DELETION_REQUESTED,
TENANT_EVENT_ERASURE_COMPLETED,
tenant_lifecycle_event,
tenant_lifecycle_event_type,
)
from .schemas import (
TenantAdminItem,
@@ -63,6 +76,17 @@ TENANT_SETTINGS_RESOURCE = "tenant_settings_section"
ADMIN_MODULE_ID = "admin"
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings")
TENANT_NON_STATUS_UPDATE_FIELDS = {
"name",
"description",
"default_locale",
"settings",
"allow_custom_groups",
"allow_custom_roles",
"allow_api_keys",
}
TENANT_GOVERNANCE_OVERRIDE_FIELDS = ("allow_custom_groups", "allow_custom_roles", "allow_api_keys")
TENANT_FULL_CURSOR_PREFIX = "full:tenants:"
def _tenant_access_provisioner() -> TenantAccessProvisioner:
@@ -90,7 +114,26 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
def _require_tenant_update_permissions(principal: ApiPrincipal, payload: TenantUpdateRequest) -> None:
if payload.model_fields_set.intersection(TENANT_NON_STATUS_UPDATE_FIELDS):
_require_permission(principal, "system:tenants:update")
if payload.is_active is not None:
_require_permission(principal, "system:tenants:suspend")
def _tenant_or_404(session: Session, tenant_id: str) -> Tenant:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
def _tenant_item(
session: Session,
tenant: Tenant,
*,
counts: Mapping[str, int] | None = None,
) -> TenantAdminItem:
governance = effective_tenant_governance(session, tenant)
return TenantAdminItem(
id=tenant.id,
@@ -108,12 +151,36 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
"allow_api_keys": governance.allow_api_keys,
},
is_active=tenant.is_active,
counts=tenant_counts(session, tenant.id),
counts=(
dict(counts)
if counts is not None
else tenant_counts(
session,
tenant.id,
module_ids=("campaigns", "files"),
)
),
created_at=tenant.created_at,
updated_at=tenant.updated_at,
)
def _tenant_items(session: Session, tenants: Sequence[Tenant]) -> list[TenantAdminItem]:
counts_by_tenant = tenant_counts_many(
session,
[tenant.id for tenant in tenants],
module_ids=("campaigns", "files"),
)
return [
_tenant_item(
session,
tenant,
counts=counts_by_tenant.get(tenant.id, {}),
)
for tenant in tenants
]
def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrincipal, *, mode: str = "retire") -> TenantDeletionPlanResponse:
issues: list[TenantLifecycleIssue] = []
counts = tenant_counts(session, tenant.id)
@@ -149,7 +216,16 @@ def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrinci
))
registry = get_registry()
if registry is not None and hasattr(registry, "delete_veto_providers"):
if registry is not None and hasattr(registry, "collect_delete_veto_issues"):
for issue in registry.collect_delete_veto_issues("tenant", session, tenant.id, tenant.id):
issues.append(TenantLifecycleIssue(
severity=issue.severity,
code=issue.code,
message=issue.message,
module_id=issue.module_id,
details=dict(issue.details),
))
elif registry is not None and hasattr(registry, "delete_veto_providers"):
for provider in registry.delete_veto_providers("tenant"):
try:
provider(session, tenant.id, tenant.id)
@@ -208,9 +284,11 @@ def _record_tenant_settings_section_changes(
after: dict[str, Any],
principal: ApiPrincipal,
) -> None:
changed = False
for section in TENANT_SETTINGS_SECTIONS:
if before.get(section) == after.get(section):
continue
changed = True
record_change(
session,
module_id=TENANCY_MODULE_ID,
@@ -223,6 +301,16 @@ def _record_tenant_settings_section_changes(
actor_id=principal.user.id,
payload={"section": section},
)
if changed:
invalidate_auth_principals(
session,
tenant_id=tenant_id,
source_module="tenancy",
resource_type="tenant_settings",
resource_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
)
def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None:
@@ -238,6 +326,16 @@ def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: s
actor_id=principal.user.id,
payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active},
)
invalidate_auth_principals(
session,
tenant_id=tenant.id,
source_module="tenancy",
resource_type="tenant",
resource_id=tenant.id,
actor_type="user",
actor_id=principal.user.id,
reason=operation,
)
def _tenant_list_delta_query(session: Session, *, since_sequence: int):
@@ -275,6 +373,50 @@ def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session)
def _tenant_page(query, *, page: int, page_size: int):
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, {
"total": total,
"page": page,
"page_size": page_size,
"pages": pages,
}
def _tenant_full_cursor(
*,
page: int,
snapshot_sequence: int,
) -> str:
return f"{TENANT_FULL_CURSOR_PREFIX}{int(page)}:{int(snapshot_sequence)}"
def _decode_tenant_full_cursor(value: str | None) -> tuple[int, int] | None:
if not value or not value.startswith(TENANT_FULL_CURSOR_PREFIX):
return None
parts = value[len(TENANT_FULL_CURSOR_PREFIX):].split(":", 1)
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
)
try:
page, snapshot_sequence = (int(item) for item in parts)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
)
return page, snapshot_sequence
def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]):
return [
{"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE}
@@ -355,21 +497,54 @@ def switch_tenant_context(
@router.get("/tenants", response_model=TenantListResponse)
def list_tenants(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
):
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants])
tenants, pagination = _tenant_page(
session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()),
page=page,
page_size=page_size,
)
return TenantListResponse(
tenants=_tenant_items(session, tenants),
**pagination,
)
def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse:
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
def _full_tenant_list_delta_response(
session: Session,
*,
cursor: tuple[int, int] | None = None,
limit: int = 100,
) -> TenantListDeltaResponse:
page = cursor[0] if cursor is not None else 1
snapshot_sequence = (
cursor[1]
if cursor is not None
else decode_sequence_watermark(_tenant_list_watermark(session))
)
tenants, pagination = _tenant_page(
session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()),
page=page,
page_size=limit,
)
has_more = page < pagination["pages"]
return TenantListDeltaResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
tenants=_tenant_items(session, tenants),
deleted=[],
watermark=_tenant_list_watermark(session),
has_more=False,
watermark=(
_tenant_full_cursor(
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
),
has_more=has_more,
full=True,
**pagination,
)
@@ -381,22 +556,31 @@ def list_tenants_delta(
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
):
del principal
if since is None:
return _full_tenant_list_delta_response(session)
full_cursor = _decode_tenant_full_cursor(since)
if since is None or full_cursor is not None:
return _full_tenant_list_delta_response(
session,
cursor=full_cursor,
limit=limit,
)
entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit)
if entries is None:
return _full_tenant_list_delta_response(session)
return _full_tenant_list_delta_response(session, limit=limit)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
tenants = []
if changed_ids:
tenants = session.query(Tenant).filter(Tenant.id.in_(changed_ids)).order_by(Tenant.name.asc()).all()
visible_tenant_ids = {tenant.id for tenant in tenants}
return TenantListDeltaResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
tenants=_tenant_items(session, tenants),
deleted=_tenant_list_deleted_entries(entries, visible_tenant_ids),
watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
total=len(tenants),
page=1,
page_size=limit,
pages=1,
)
@@ -457,17 +641,76 @@ def create_tenant(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.created",
action=TENANT_EVENT_CREATED,
object_type="tenant",
object_id=tenant.id,
scope="system",
details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account_id, "owner_account_id": owner_account_id},
details=tenant_lifecycle_event(
"created",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
requested_by_account_id=owner_account_id,
details={"creator_account_id": principal.account_id, "owner_account_id": owner_account_id},
).audit_details(),
)
_record_tenant_list_change(session, tenant=tenant, operation="created", principal=principal)
session.commit()
return _tenant_item(session, tenant)
def _apply_tenant_content_updates(tenant: Tenant, payload: TenantUpdateRequest) -> None:
if payload.name is not None:
tenant.name = payload.name.strip()
if "description" in payload.model_fields_set:
tenant.description = _normalized_optional_text(payload.description)
if payload.default_locale is not None:
tenant.default_locale = _normalized_tenant_locale(payload.default_locale)
if payload.settings is not None:
current_settings = dict(tenant.settings or {})
next_settings = dict(payload.settings)
next_settings.pop(MODULE_ENTITLEMENTS_KEY, None)
if MODULE_ENTITLEMENTS_KEY in current_settings:
next_settings[MODULE_ENTITLEMENTS_KEY] = current_settings[
MODULE_ENTITLEMENTS_KEY
]
tenant.settings = next_settings
def _normalized_optional_text(value: str | None) -> str | None:
if value is None:
return None
clean = value.strip()
return clean or None
def _normalized_tenant_locale(value: str) -> str:
return value.strip() or "en"
def _apply_tenant_governance_updates(session: Session, tenant: Tenant, payload: TenantUpdateRequest) -> None:
try:
for field in TENANT_GOVERNANCE_OVERRIDE_FIELDS:
if field in payload.model_fields_set:
value = getattr(payload, field)
assert_tenant_governance_override_allowed(session, field=field, value=value)
setattr(tenant, field, value)
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
def _apply_tenant_status_update(tenant: Tenant, payload: TenantUpdateRequest, principal: ApiPrincipal) -> None:
if payload.is_active is None:
return
if not payload.is_active and tenant.id == principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to another tenant before suspending the active tenant.",
)
tenant.is_active = payload.is_active
@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem)
def update_tenant(
tenant_id: str,
@@ -475,42 +718,13 @@ def update_tenant(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
if payload.model_fields_set.intersection(non_status_fields):
_require_permission(principal, "system:tenants:update")
if payload.is_active is not None:
_require_permission(principal, "system:tenants:suspend")
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
_require_tenant_update_permissions(principal, payload)
tenant = _tenant_or_404(session, tenant_id)
was_active = tenant.is_active
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
if payload.name is not None:
tenant.name = payload.name.strip()
if "description" in payload.model_fields_set:
tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None
if payload.default_locale is not None:
tenant.default_locale = payload.default_locale.strip() or "en"
if payload.settings is not None:
tenant.settings = payload.settings
try:
if "allow_custom_groups" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups)
tenant.allow_custom_groups = payload.allow_custom_groups
if "allow_custom_roles" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles)
tenant.allow_custom_roles = payload.allow_custom_roles
if "allow_api_keys" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys)
tenant.allow_api_keys = payload.allow_api_keys
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
if payload.is_active is not None:
if not payload.is_active and tenant.id == principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to another tenant before suspending the active tenant.",
)
tenant.is_active = payload.is_active
_apply_tenant_content_updates(tenant, payload)
_apply_tenant_governance_updates(session, tenant, payload)
_apply_tenant_status_update(tenant, payload, principal)
session.add(tenant)
audit_event(
session,
@@ -522,6 +736,25 @@ def update_tenant(
object_id=tenant.id,
details=payload.model_dump(exclude_unset=True),
)
if payload.is_active is not None and payload.is_active != was_active:
lifecycle_phase = "resumed" if payload.is_active else "suspended"
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=tenant_lifecycle_event_type(lifecycle_phase),
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
lifecycle_phase,
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
details={"previous_is_active": was_active, "is_active": tenant.is_active},
).audit_details(),
)
after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
_record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal)
_record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal)
@@ -562,6 +795,26 @@ def retire_tenant(
if request.mode == "destroy":
deleted_item = _tenant_item(session, tenant)
deletion_event = tenant_lifecycle_event(
"deletion_requested",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
reason=request.reason,
counts=plan.counts,
details={"mode": request.mode},
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_DELETION_REQUESTED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=deletion_event.audit_details(),
)
audit_event(
session,
tenant_id=tenant.id,
@@ -572,6 +825,25 @@ def retire_tenant(
object_id=tenant.id,
details={"reason": request.reason, "counts": plan.counts},
)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_ERASURE_COMPLETED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"erasure_completed",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
reason=request.reason,
counts=plan.counts,
details={"mode": request.mode},
).audit_details(),
)
_record_tenant_list_change(session, tenant=tenant, operation="deleted", principal=principal)
session.delete(tenant)
session.commit()
@@ -590,6 +862,25 @@ def retire_tenant(
tenant.settings = settings_payload
tenant.is_active = False
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action=TENANT_EVENT_DELETION_REQUESTED,
scope="system",
object_type="tenant",
object_id=tenant.id,
details=tenant_lifecycle_event(
"deletion_requested",
tenant_id=tenant.id,
tenant_slug=tenant.slug,
tenant_name=tenant.name,
actor_account_id=principal.account_id,
reason=request.reason,
counts=plan.counts,
details={"mode": request.mode},
).audit_details(),
)
audit_event(
session,
tenant_id=tenant.id,
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem
@@ -27,9 +27,13 @@ class TenantAdminItem(BaseModel):
class TenantListResponse(BaseModel):
tenants: list[TenantAdminItem]
total: int = 0
page: int = 1
page_size: int = 100
pages: int = 1
class TenantListDeltaResponse(BaseModel):
class TenantListDeltaResponse(TenantListResponse):
tenants: list[TenantAdminItem] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
@@ -79,6 +83,7 @@ class TenantLifecycleIssue(BaseModel):
code: str
message: str
module_id: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class TenantDeletionPlanResponse(BaseModel):
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
TenantLifecyclePhase = Literal[
"created",
"suspended",
"resumed",
"deletion_requested",
"erasure_completed",
]
TENANT_EVENT_CREATED = "tenant.created"
TENANT_EVENT_SUSPENDED = "tenant.suspended"
TENANT_EVENT_RESUMED = "tenant.resumed"
TENANT_EVENT_DELETION_REQUESTED = "tenant.deletion_requested"
TENANT_EVENT_ERASURE_COMPLETED = "tenant.erasure_completed"
TENANT_LIFECYCLE_EVENTS: tuple[str, ...] = (
TENANT_EVENT_CREATED,
TENANT_EVENT_SUSPENDED,
TENANT_EVENT_RESUMED,
TENANT_EVENT_DELETION_REQUESTED,
TENANT_EVENT_ERASURE_COMPLETED,
)
_EVENT_BY_PHASE: Mapping[TenantLifecyclePhase, str] = {
"created": TENANT_EVENT_CREATED,
"suspended": TENANT_EVENT_SUSPENDED,
"resumed": TENANT_EVENT_RESUMED,
"deletion_requested": TENANT_EVENT_DELETION_REQUESTED,
"erasure_completed": TENANT_EVENT_ERASURE_COMPLETED,
}
@dataclass(frozen=True, slots=True)
class TenantLifecycleEvent:
event_type: str
tenant_id: str
tenant_slug: str | None = None
tenant_name: str | None = None
actor_account_id: str | None = None
requested_by_account_id: str | None = None
reason: str | None = None
counts: Mapping[str, int] = field(default_factory=dict)
occurred_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
def audit_details(self) -> dict[str, object]:
payload: dict[str, object] = dict(self.details)
if self.tenant_slug is not None:
payload["slug"] = self.tenant_slug
if self.tenant_name is not None:
payload["name"] = self.tenant_name
if self.actor_account_id is not None:
payload["actor_account_id"] = self.actor_account_id
if self.requested_by_account_id is not None:
payload["requested_by_account_id"] = self.requested_by_account_id
if self.reason is not None:
payload["reason"] = self.reason
if self.counts:
payload["counts"] = dict(self.counts)
if self.occurred_at is not None:
payload["occurred_at"] = self.occurred_at.isoformat()
return payload
def tenant_lifecycle_event_type(phase: TenantLifecyclePhase) -> str:
return _EVENT_BY_PHASE[phase]
def tenant_lifecycle_event(
phase: TenantLifecyclePhase,
*,
tenant_id: str,
tenant_slug: str | None = None,
tenant_name: str | None = None,
actor_account_id: str | None = None,
requested_by_account_id: str | None = None,
reason: str | None = None,
counts: Mapping[str, int] | None = None,
occurred_at: datetime | None = None,
details: Mapping[str, object] | None = None,
) -> TenantLifecycleEvent:
return TenantLifecycleEvent(
event_type=tenant_lifecycle_event_type(phase),
tenant_id=tenant_id,
tenant_slug=tenant_slug,
tenant_name=tenant_name,
actor_account_id=actor_account_id,
requested_by_account_id=requested_by_account_id,
reason=reason,
counts=dict(counts or {}),
occurred_at=occurred_at,
details=dict(details or {}),
)
+103 -2
View File
@@ -6,7 +6,9 @@ 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 DocumentationLink, DocumentationTopic, FrontendModule, ModuleContext, ModuleManifest
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
def _tenant_resolver(context: ModuleContext):
@@ -30,7 +32,7 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest(
id="tenancy",
name="Tenancy",
version="0.1.6",
version="0.1.15",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -40,6 +42,105 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
},
documentation=(
DocumentationTopic(
id="tenancy.current-context",
title="Work in the correct tenant context",
summary="The active tenant determines which tenant-scoped data, roles, settings, and module configuration are visible for a request.",
body="Accounts with access to more than one tenant can switch context through the platform tenant selector. Switching changes the active scope; it does not copy data or grant new authority. Always verify the selected tenant before creating or changing tenant-owned records.",
documentation_types=("user",),
audience=("user", "tenant_admin"),
related_modules=("access",),
metadata={
"kind": "reference",
"help_contexts": ["tenancy.current-context", "tenancy.selector"],
},
),
DocumentationTopic(
id="tenancy.lifecycle-and-settings",
title="Administer tenant lifecycle and settings",
summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.",
body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.",
documentation_types=("admin",),
audience=("system_admin", "tenant_admin", "operator"),
related_modules=("access", "admin", "audit"),
links=(
DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"),
DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"),
DocumentationLink(label="Tenant settings API", href="/api/v1/admin/tenant/settings", kind="api"),
),
metadata={
"kind": "reference",
"help_contexts": [
"tenancy.admin.system-tenants",
"tenancy.admin.tenant-settings",
"tenancy.admin.lifecycle",
"tenancy.admin.blocked",
],
},
),
DocumentationTopic(
id="tenancy.reference.admin-fields",
title="Tenant administration fields and consequences",
summary="Tenant identity, ownership, locale, governance overrides, and lifecycle state have different mutation and recovery consequences.",
body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. Locale and enabled languages are bounded by system language packages. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it.",
documentation_types=("admin",),
audience=("system_admin", "tenant_admin", "operator"),
related_modules=("access", "admin", "audit"),
links=(
DocumentationLink(label="Tenant administration", href="/admin", kind="runtime"),
DocumentationLink(label="Tenant registry API", href="/api/v1/admin/tenants", kind="api"),
),
metadata={
"kind": "reference",
"help_contexts": [
"tenancy.field.slug",
"tenancy.field.initial-owner",
"tenancy.field.locale",
"tenancy.field.languages",
"tenancy.field.governance",
"tenancy.action.suspend",
],
"consequence_classes": {
"create": "Creates a new tenant boundary and provisions its protected initial owner.",
"update": "Changes tenant-local identity, locale, or governance configuration.",
"suspend": "Blocks normal tenant use while retaining data and audit evidence.",
},
},
),
),
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,
),
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
kind="foundation",
maturity="vertical_slice",
documentation_ref="docs/TENANCY_MODULE_BOUNDARY.md",
test_ref="tests/test_tenant_lifecycle.py",
known_limits=("Cross-region tenant relocation and complete major-version recovery evidence are not implemented.",),
owned_concepts=("tenant lifecycle", "tenant context", "tenant settings"),
non_owned_concepts=("account authorization", "organization hierarchy", "module-owned tenant data"),
recovery_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
security_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
),
)
@@ -0,0 +1,34 @@
from __future__ import annotations
import unittest
from govoplan_tenancy.backend.manifest import manifest
class TenancyInterfaceDocumentationContractTests(unittest.TestCase):
def test_tenancy_admin_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
surfaces = {surface.id for surface in frontend.view_surfaces} # type: ignore[union-attr]
self.assertEqual(
{
"tenancy.admin.system-tenants",
"tenancy.admin.tenant-settings",
},
surfaces,
)
def test_tenancy_topics_publish_stable_help_contexts(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertIn("tenancy.current-context", topics)
self.assertIn("tenancy.lifecycle-and-settings", topics)
self.assertIn("tenancy.reference.admin-fields", topics)
lifecycle_contexts = set(topics["tenancy.lifecycle-and-settings"].metadata["help_contexts"])
self.assertIn("tenancy.admin.system-tenants", lifecycle_contexts)
self.assertIn("tenancy.admin.tenant-settings", lifecycle_contexts)
self.assertIn("tenancy.action.suspend", topics["tenancy.reference.admin-fields"].metadata["help_contexts"])
if __name__ == "__main__":
unittest.main()
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from fastapi import HTTPException
from govoplan_tenancy.backend.api.v1.routes import (
_apply_tenant_content_updates,
_apply_tenant_status_update,
_require_tenant_update_permissions,
)
from govoplan_tenancy.backend.api.v1.schemas import TenantUpdateRequest
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
from govoplan_tenancy.backend.lifecycle import (
TENANT_EVENT_CREATED,
TENANT_EVENT_DELETION_REQUESTED,
TENANT_EVENT_ERASURE_COMPLETED,
TENANT_EVENT_RESUMED,
TENANT_EVENT_SUSPENDED,
TENANT_LIFECYCLE_EVENTS,
tenant_lifecycle_event,
tenant_lifecycle_event_type,
)
class FakePrincipal:
def __init__(self, scopes: set[str], *, tenant_id: str = "tenant-1") -> None:
self.scopes = frozenset(scopes)
self.tenant_id = tenant_id
def has(self, required_scope: str) -> bool:
return required_scope in self.scopes
class TenantLifecycleContractTests(unittest.TestCase):
def test_lifecycle_event_names_are_stable(self) -> None:
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
self.assertEqual("tenant.suspended", tenant_lifecycle_event_type("suspended"))
self.assertEqual("tenant.resumed", tenant_lifecycle_event_type("resumed"))
self.assertEqual("tenant.deletion_requested", tenant_lifecycle_event_type("deletion_requested"))
self.assertEqual("tenant.erasure_completed", tenant_lifecycle_event_type("erasure_completed"))
self.assertEqual(
(
TENANT_EVENT_CREATED,
TENANT_EVENT_SUSPENDED,
TENANT_EVENT_RESUMED,
TENANT_EVENT_DELETION_REQUESTED,
TENANT_EVENT_ERASURE_COMPLETED,
),
TENANT_LIFECYCLE_EVENTS,
)
def test_lifecycle_event_builds_audit_details_without_losing_extra_fields(self) -> None:
occurred_at = datetime(2026, 7, 11, 12, 30, tzinfo=UTC)
event = tenant_lifecycle_event(
"deletion_requested",
tenant_id="tenant-1",
tenant_slug="city",
tenant_name="City Office",
actor_account_id="account-1",
requested_by_account_id="account-2",
reason="end of contract",
counts={"files": 2},
occurred_at=occurred_at,
details={"mode": "retire"},
)
self.assertEqual(TENANT_EVENT_DELETION_REQUESTED, event.event_type)
self.assertEqual("tenant-1", event.tenant_id)
self.assertEqual(
{
"mode": "retire",
"slug": "city",
"name": "City Office",
"actor_account_id": "account-1",
"requested_by_account_id": "account-2",
"reason": "end of contract",
"counts": {"files": 2},
"occurred_at": "2026-07-11T12:30:00+00:00",
},
event.audit_details(),
)
class TenantUpdateHelperTests(unittest.TestCase):
def test_tenant_update_permissions_separate_content_and_status_changes(self) -> None:
_require_tenant_update_permissions(
FakePrincipal({"system:tenants:suspend"}), # type: ignore[arg-type]
TenantUpdateRequest(is_active=False),
)
_require_tenant_update_permissions(
FakePrincipal({"system:tenants:update"}), # type: ignore[arg-type]
TenantUpdateRequest(name="Updated"),
)
with self.assertRaises(HTTPException) as missing_update:
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(name="Updated")) # type: ignore[arg-type]
self.assertEqual(403, missing_update.exception.status_code)
self.assertEqual("Missing scope: system:tenants:update", missing_update.exception.detail)
with self.assertRaises(HTTPException) as missing_suspend:
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(is_active=False)) # type: ignore[arg-type]
self.assertEqual(403, missing_suspend.exception.status_code)
self.assertEqual("Missing scope: system:tenants:suspend", missing_suspend.exception.detail)
def test_tenant_content_updates_normalize_blank_fields_and_defaults(self) -> None:
tenant = SimpleNamespace(name="Old", description="Old description", default_locale="de", settings={})
payload = TenantUpdateRequest(
name=" New tenant ",
description=" ",
default_locale=" ",
settings={"theme": "contrast"},
)
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
self.assertEqual("New tenant", tenant.name)
self.assertIsNone(tenant.description)
self.assertEqual("en", tenant.default_locale)
self.assertEqual({"theme": "contrast"}, tenant.settings)
def test_tenant_content_update_preserves_reserved_module_entitlements(self) -> None:
entitlement = {"schema_version": 1, "revision": 4}
tenant = SimpleNamespace(
name="Old",
description=None,
default_locale="en",
settings={MODULE_ENTITLEMENTS_KEY: entitlement, "theme": "old"},
)
payload = TenantUpdateRequest(
settings={
"theme": "contrast",
MODULE_ENTITLEMENTS_KEY: {"revision": 999},
}
)
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
self.assertEqual("contrast", tenant.settings["theme"])
self.assertEqual(entitlement, tenant.settings[MODULE_ENTITLEMENTS_KEY])
def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None:
tenant = SimpleNamespace(id="tenant-1", is_active=True)
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
with self.assertRaises(HTTPException) as captured:
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
self.assertEqual(409, captured.exception.status_code)
self.assertEqual("Switch to another tenant before suspending the active tenant.", captured.exception.detail)
def test_tenant_status_update_allows_other_tenant_suspension(self) -> None:
tenant = SimpleNamespace(id="tenant-2", is_active=True)
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
self.assertFalse(tenant.is_active)
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@govoplan/tenancy-webui",
"version": "0.1.15",
"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",
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"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,30 @@
import { readFileSync } from "node:fs";
function source(path) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const tenants = source("../src/features/admin/TenantsPanel.tsx");
const settings = source("../src/features/admin/TenantSettingsPanel.tsx");
const patterns = source("../src/features/admin/interfacePatterns.ts");
const moduleSource = source("../src/module.ts");
const translations = source("../src/i18n/generatedTranslations.ts");
assert(tenants.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Both Tenancy admin surfaces expose contextual documentation");
assert(tenants.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only Tenancy states identify the actor, action, and destination");
assert(tenants.includes("disabledReason") && settings.includes("disabledReason"), "Disabled Tenancy actions explain their state");
assert(tenants.includes("requestDiscard(closeEditor)"), "The tenant editor uses the shared unsaved-change guard when closing");
assert(settings.includes("requestDiscard(() => void load())"), "Tenant settings protect dirty state when reloading");
assert(tenants.includes("ConfirmDialog") && tenants.includes("confirmSuspend"), "Tenant suspension remains explicitly confirmed");
assert(tenants.includes("minimumSlots={3}"), "Tenant row actions reserve stable keyboard and visual positions");
assert(!tenants.includes("applicable:"), "Row-specific unavailable actions stay visible with an explanation");
assert(patterns.includes('topicId: "tenancy.lifecycle-and-settings"') && patterns.includes('topicId: "tenancy.reference.admin-fields"'), "Tenancy uses stable manifest-backed help references");
assert(moduleSource.includes('version: "0.1.8"'), "The WebUI contribution reports the module release version");
assert(moduleSource.includes('label: "i18n:govoplan-tenancy.tenants.1f7ae776"') && moduleSource.includes('label: "i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"'), "View-surface labels are localized");
assert(translations.includes('"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011"'), "Availability explanations are present in the translation catalog");
console.log("Tenancy surfaces satisfy the recorded interface pattern-language contract.");
@@ -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"
);
+162
View File
@@ -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,204 @@
import { useEffect, useState } from "react";
import {
ActionBlockerHint,
AdminPageLayout,
AdminSelectionList,
Button,
Card,
DocumentationHelpLink,
FormField,
adminErrorMessage,
useDeltaWatermarks,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/tenancy";
import {
TENANCY_ADMIN_DOCUMENTATION,
TENANCY_FIELD_DOCUMENTATION,
TENANCY_INTERFACE_I18N,
tenantMutationDisabledReason
} from "./interfacePatterns";
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 { requestDiscard } = useUnsavedChanges();
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);
const saveDisabledReason = tenantMutationDisabledReason({
busy,
permitted: canWrite,
complete: Boolean(draft.default_locale.trim() && draft.enabled_language_codes.length),
changed: dirty,
permissionReason: TENANCY_INTERFACE_I18N.settingsWriteRequired
});
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={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => requestDiscard(() => void load())} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.ae7e8875" : "i18n:govoplan-tenancy.save_general_settings.5c90f8c4"}</Button></>}>
{!canWrite && <ActionBlockerHint
reason={{
summary: TENANCY_INTERFACE_I18N.readOnlySummary,
requiredAction: TENANCY_INTERFACE_I18N.settingsPermissionGuidance,
actor: TENANCY_INTERFACE_I18N.administratorActor,
target: TENANCY_INTERFACE_I18N.tenantSettingsTarget
}}
labels={{
requiredAction: TENANCY_INTERFACE_I18N.requiredActionLabel,
actor: TENANCY_INTERFACE_I18N.actorLabel,
target: TENANCY_INTERFACE_I18N.targetLabel
}}
documentation={TENANCY_ADMIN_DOCUMENTATION}
/>}
<div className="admin-settings-form">
<Card title="i18n:govoplan-tenancy.locale.8970f0e6">
<FormField label="i18n:govoplan-tenancy.tenant_locale.8fc19914" help={!canWrite ? TENANCY_INTERFACE_I18N.settingsWriteRequired : "i18n:govoplan-tenancy.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b"} documentation={TENANCY_FIELD_DOCUMENTATION}>
<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"><span>i18n:govoplan-tenancy.tenant_languages_help</span>{" "}<span>{TENANCY_INTERFACE_I18N.defaultLanguageRequired}</span></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 } : {})
};
}
+349
View File
@@ -0,0 +1,349 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
import type { ApiSettings, AuthInfo, TenantAdminItem } from "@govoplan/core-webui";
import {
ActionBlockerHint,
AdminIconButton,
AdminPageLayout,
Button,
ConfirmDialog,
DataGrid,
Dialog,
DocumentationHelpLink,
FormField,
StatusBadge,
TableActionGroup,
adminErrorMessage,
formatAdminDateTime as formatDateTime,
i18nMessage,
useDeltaWatermarks,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type DataGridColumn
} from "@govoplan/core-webui";
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantOwnerCandidate } from "../../api/tenancy";
import { loadDeltaRows } from "./utils/deltaRows";
import {
TENANCY_ADMIN_DOCUMENTATION,
TENANCY_FIELD_DOCUMENTATION,
TENANCY_INTERFACE_I18N,
tenantMutationDisabledReason
} from "./interfacePatterns";
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 { translateText } = usePlatformLanguage();
const { requestDiscard } = useUnsavedChanges();
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));
}
function requestCloseEditor() {
if (busy) return;
requestDiscard(closeEditor);
}
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 || translateText("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 ? "" : translateText("i18n:govoplan-tenancy.custom_groups.453a605c"),
systemAllowsCustomRoles ? "" : translateText("i18n:govoplan-tenancy.custom_roles.d48dc976"),
systemAllowsApiKeys ? "" : translateText("i18n:govoplan-tenancy.api_keys.94fcf3c2")
].filter(Boolean).join(", ");
const saveDisabledReason = tenantMutationDisabledReason({
busy,
permitted: editing === "new" ? canCreate : canUpdate,
complete: Boolean(draft.name.trim() && draft.slug.trim() && (editing !== "new" || draft.ownerAccountId)),
changed: editing === "new" || dirty,
permissionReason: editing === "new" ? TENANCY_INTERFACE_I18N.createRequired : TENANCY_INTERFACE_I18N.updateRequired
});
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 || busy, disabledReason: busy ? TENANCY_INTERFACE_I18N.busy : !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined, onClick: () => openEdit(row) },
{ id: "suspend", label: i18nMessage("i18n:govoplan-tenancy.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canSuspend || row.id === activeTenantId || !row.is_active || busy, disabledReason: busy ? TENANCY_INTERFACE_I18N.busy : !canSuspend ? TENANCY_INTERFACE_I18N.suspendRequired : row.id === activeTenantId ? TENANCY_INTERFACE_I18N.activeTenant : !row.is_active ? TENANCY_INTERFACE_I18N.alreadySuspended : undefined, onClick: () => setConfirmSuspend(row) }
]} minimumSlots={3} /> }],
[activeTenantId, busy, 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={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-tenancy.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} /></>}>
{!canCreate && !canUpdate && !canSuspend && <ActionBlockerHint
reason={{
summary: TENANCY_INTERFACE_I18N.readOnlySummary,
requiredAction: TENANCY_INTERFACE_I18N.registryPermissionGuidance,
actor: TENANCY_INTERFACE_I18N.administratorActor,
target: TENANCY_INTERFACE_I18N.tenantRegistryTarget
}}
labels={{
requiredAction: TENANCY_INTERFACE_I18N.requiredActionLabel,
actor: TENANCY_INTERFACE_I18N.actorLabel,
target: TENANCY_INTERFACE_I18N.targetLabel
}}
documentation={TENANCY_ADMIN_DOCUMENTATION}
/>}
<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={requestCloseEditor} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={requestCloseEditor} disabled={busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{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" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-tenancy.slug.094da9b9" help={editing !== "new" ? "i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025" : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><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" documentation={TENANCY_FIELD_DOCUMENTATION}><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" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><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" help={!canSuspend ? TENANCY_INTERFACE_I18N.suspendRequired : undefined} documentation={TENANCY_ADMIN_DOCUMENTATION}><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" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><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} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-tenancy.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-tenancy.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} 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"><span>i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a</span>{" "}{systemDeniedGovernance}{" "}<span>i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244</span></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"} ({overrideLabel(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"} ({overrideLabel(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"} ({overrideLabel(fromOverride(viewing.allow_api_keys))})</dd></div>
<div><dt>i18n:govoplan-tenancy.objects.72a83add</dt><dd>{viewing.counts.users ?? 0}{" "}<span>i18n:govoplan-tenancy.users.81651889</span>{" "}{viewing.counts.groups ?? 0}{" "}<span>i18n:govoplan-tenancy.groups.07551586</span>{" "}{viewing.counts.campaigns ?? 0}{" "}<span>i18n:govoplan-tenancy.campaigns.2282ffeb</span>{" "}{viewing.counts.files ?? 0}{" "}<span>i18n:govoplan-tenancy.files_lowercase.7c9a1026</span></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, disabledReason, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;disabledReason?: string;allowDisabled?: boolean;}) {
return <FormField label={label} help={disabledReason ?? (allowDisabled ? TENANCY_INTERFACE_I18N.governanceSystemLimit : undefined)} documentation={TENANCY_FIELD_DOCUMENTATION}><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 overrideLabel(value: OverrideValue): string {
if (value === "allow") return TENANCY_INTERFACE_I18N.allowLabel;
if (value === "deny") return TENANCY_INTERFACE_I18N.denyLabel;
return TENANCY_INTERFACE_I18N.inheritLabel;
}
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,58 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const TENANCY_ADMIN_DOCUMENTATION = {
topicId: "tenancy.lifecycle-and-settings",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const TENANCY_FIELD_DOCUMENTATION = {
topicId: "tenancy.reference.admin-fields",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const TENANCY_INTERFACE_I18N = {
loading: "i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001",
busy: "i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002",
createRequired: "i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003",
updateRequired: "i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004",
suspendRequired: "i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005",
settingsWriteRequired: "i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006",
completeRequiredFields: "i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007",
noPendingChanges: "i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008",
activeTenant: "i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009",
alreadySuspended: "i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010",
readOnlySummary: "i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011",
requiredActionLabel: "i18n:govoplan-tenancy.required_action.7c9a1012",
actorLabel: "i18n:govoplan-tenancy.responsible_actor.7c9a1013",
targetLabel: "i18n:govoplan-tenancy.destination.7c9a1014",
administratorActor: "i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015",
tenantRegistryTarget: "i18n:govoplan-tenancy.administration_tenants.7c9a1016",
tenantSettingsTarget: "i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017",
registryPermissionGuidance: "i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018",
settingsPermissionGuidance: "i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019",
defaultLanguageRequired: "i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020",
governanceSystemLimit: "i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021",
inheritLabel: "i18n:govoplan-tenancy.inherit.7c9a1022",
allowLabel: "i18n:govoplan-tenancy.allow.7c9a1023",
denyLabel: "i18n:govoplan-tenancy.deny.7c9a1024"
} as const;
export function tenantMutationDisabledReason({
busy,
permitted,
complete = true,
changed = true,
permissionReason
}: {
busy: boolean;
permitted: boolean;
complete?: boolean;
changed?: boolean;
permissionReason: string;
}): string | undefined {
if (busy) return TENANCY_INTERFACE_I18N.busy;
if (!permitted) return permissionReason;
if (!complete) return TENANCY_INTERFACE_I18N.completeRequiredFields;
if (!changed) return TENANCY_INTERFACE_I18N.noPendingChanges;
return undefined;
}
@@ -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;
}
+202
View File
@@ -0,0 +1,202 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Tenant information is loading.",
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "A tenant change is in progress.",
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Tenant creation permission is required.",
"i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004": "Tenant update permission is required.",
"i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005": "Tenant suspension permission is required.",
"i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006": "Tenant settings write permission is required.",
"i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007": "Complete all required tenant fields before saving.",
"i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008": "Make a change before saving.",
"i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009": "Switch to another tenant before suspending the active tenant.",
"i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010": "This tenant is already suspended.",
"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011": "Tenant administration is read-only.",
"i18n:govoplan-tenancy.required_action.7c9a1012": "Required action",
"i18n:govoplan-tenancy.responsible_actor.7c9a1013": "Responsible actor",
"i18n:govoplan-tenancy.destination.7c9a1014": "Destination",
"i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015": "A system or tenant owner with the required permission",
"i18n:govoplan-tenancy.administration_tenants.7c9a1016": "Administration > Tenants",
"i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017": "Administration > Tenant settings",
"i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018": "Request a tenant-management permission or contact a system owner.",
"i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019": "Request tenant-settings write permission or contact a tenant owner.",
"i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020": "The default language must remain enabled.",
"i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021": "System policy prevents this tenant from enabling the capability.",
"i18n:govoplan-tenancy.inherit.7c9a1022": "inherit",
"i18n:govoplan-tenancy.allow.7c9a1023": "allow",
"i18n:govoplan-tenancy.deny.7c9a1024": "deny",
"i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025": "The tenant slug is immutable after creation.",
"i18n:govoplan-tenancy.files_lowercase.7c9a1026": "files",
"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": "Erlauben, wenn systemweit zulässig",
"i18n:govoplan-tenancy.allowed.77c7b490": "Erlaubt",
"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": "da die aktuelle Systemeinstellung dies verweigert.",
"i18n:govoplan-tenancy.campaigns.01a23a28": "Campaigns",
"i18n:govoplan-tenancy.campaigns.2282ffeb": "Kampagnen,",
"i18n:govoplan-tenancy.cancel.77dfd213": "Cancel",
"i18n:govoplan-tenancy.close.bbfa773e": "Close",
"i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Mandantenbereiche erstellen und verwalten. Eine Sperrung bewahrt Kampagnen, Dateien und Nachweise; der Mandant der aktuellen Sitzung kann nicht gesperrt werden.",
"i18n:govoplan-tenancy.create_tenant.4dbd55d9": "Create tenant",
"i18n:govoplan-tenancy.created.accf40c8": "Created",
"i18n:govoplan-tenancy.custom_groups.1f7b7c8f": "Eigene Gruppen",
"i18n:govoplan-tenancy.custom_groups.453a605c": "eigene Gruppen",
"i18n:govoplan-tenancy.custom_roles.d48dc976": "eigene Rollen",
"i18n:govoplan-tenancy.custom_roles.e78ef63d": "Eigene Rollen",
"i18n:govoplan-tenancy.custom_tenant_groups.570ee603": "Eigene Mandantengruppen",
"i18n:govoplan-tenancy.custom_tenant_roles.a738c37c": "Eigene Mandantenrollen",
"i18n:govoplan-tenancy.default_locale.b99d021f": "Default locale",
"i18n:govoplan-tenancy.denied.63b16bd4": "Verweigert",
"i18n:govoplan-tenancy.description.55f8ebc8": "Description",
"i18n:govoplan-tenancy.edit_tenant.e2ba43f9": "Edit tenant",
"i18n:govoplan-tenancy.edit_value.fad75899": "{value0} bearbeiten",
"i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a": "Eine ausdrückliche Erlaubnis ist nicht verfügbar für",
"i18n:govoplan-tenancy.explicitly_deny.17ad945a": "Ausdrücklich verweigern",
"i18n:govoplan-tenancy.files.6ce6c512": "Files",
"i18n:govoplan-tenancy.general.9239ee2c": "General",
"i18n:govoplan-tenancy.groups.07551586": "Gruppen,",
"i18n:govoplan-tenancy.groups.ae9629f4": "Groups",
"i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868": "Vererben folgt der aktuellen Systemeinstellung. Eine ausdrückliche Verweigerung schränkt ein; eine ausdrückliche Erlaubnis gilt nur, solange sie systemweit zulässig ist.",
"i18n:govoplan-tenancy.inherit_system_setting.7f125156": "Systemeinstellung vererben",
"i18n:govoplan-tenancy.initial_tenant_owner.682291a9": "Anfängliche Mandanteneigentümerschaft",
"i18n:govoplan-tenancy.inspect_value.9d5d1071": "{value0} anzeigen",
"i18n:govoplan-tenancy.locale.8970f0e6": "Locale",
"i18n:govoplan-tenancy.name.709a2322": "Name",
"i18n:govoplan-tenancy.no_tenants_found.72d04cf4": "Keine Mandanten gefunden.",
"i18n:govoplan-tenancy.objects.72a83add": "Objekte",
"i18n:govoplan-tenancy.reload.cce71553": "Reload",
"i18n:govoplan-tenancy.save_general_settings.5c90f8c4": "Allgemeine Einstellungen speichern",
"i18n:govoplan-tenancy.save_tenant.9eb2ac74": "Save tenant",
"i18n:govoplan-tenancy.saving.56a2285c": "Speichern…",
"i18n:govoplan-tenancy.saving.ae7e8875": "Speichern...",
"i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86": "Einstellungen für den aktiven Mandantenkontext.",
"i18n:govoplan-tenancy.slug.094da9b9": "Slug",
"i18n:govoplan-tenancy.status.bae7d5be": "Status",
"i18n:govoplan-tenancy.suspend_tenant.151d283a": "Mandant sperren",
"i18n:govoplan-tenancy.suspend_value.03a74b32": "{value0} sperren",
"i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78": "{value0} sperren? Bestehende Daten bleiben erhalten, aber Mitglieder können den Mandanten nicht mehr verwenden.",
"i18n:govoplan-tenancy.suspended.794696a7": "Gesperrt",
"i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce": "Systemvorgaben",
"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": "Mandantendetails",
"i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8": "Allgemeine Mandanteneinstellungen",
"i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681": "Allgemeine Mandanteneinstellungen gespeichert.",
"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": "Mandantensprache",
"i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb": "Mandant {value0} mit {value1} als Eigentümerschaft erstellt.",
"i18n:govoplan-tenancy.tenant_value_updated.25b2c855": "Mandant {value0} aktualisiert.",
"i18n:govoplan-tenancy.tenants.1f7ae776": "Tenants",
"i18n:govoplan-tenancy.the_selected_account.1211bfb9": "das ausgewählte Konto",
"i18n:govoplan-tenancy.updated.f2f8570d": "Updated",
"i18n:govoplan-tenancy.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Wird als Standardsprache dieses Mandanten für mandantenbezogene Ansichten und Formatierungen verwendet.",
"i18n:govoplan-tenancy.users.57f2b181": "Users",
"i18n:govoplan-tenancy.users.81651889": "Benutzer,",
"i18n:govoplan-tenancy.value_suspended.31731a28": "{value0} gesperrt.",
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
},
"de": {
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Mandanteninformationen werden geladen.",
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "Eine Mandantenänderung wird gerade ausgeführt.",
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Die Berechtigung zum Erstellen von Mandanten ist erforderlich.",
"i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004": "Die Berechtigung zum Bearbeiten von Mandanten ist erforderlich.",
"i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005": "Die Berechtigung zum Sperren von Mandanten ist erforderlich.",
"i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006": "Die Schreibberechtigung für Mandanteneinstellungen ist erforderlich.",
"i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007": "Füllen Sie vor dem Speichern alle erforderlichen Mandantenfelder aus.",
"i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008": "Nehmen Sie vor dem Speichern eine Änderung vor.",
"i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009": "Wechseln Sie zu einem anderen Mandanten, bevor Sie den aktiven Mandanten sperren.",
"i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010": "Dieser Mandant ist bereits gesperrt.",
"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011": "Die Mandantenverwaltung ist schreibgeschützt.",
"i18n:govoplan-tenancy.required_action.7c9a1012": "Erforderliche Aktion",
"i18n:govoplan-tenancy.responsible_actor.7c9a1013": "Zuständige Stelle",
"i18n:govoplan-tenancy.destination.7c9a1014": "Ziel",
"i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015": "Eine System- oder Mandanteneigentümerschaft mit der erforderlichen Berechtigung",
"i18n:govoplan-tenancy.administration_tenants.7c9a1016": "Administration > Mandanten",
"i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017": "Administration > Mandanteneinstellungen",
"i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018": "Fordern Sie eine Mandantenverwaltungsberechtigung an oder wenden Sie sich an eine Systemeigentümerschaft.",
"i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019": "Fordern Sie die Schreibberechtigung für Mandanteneinstellungen an oder wenden Sie sich an eine Mandanteneigentümerschaft.",
"i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020": "Die Standardsprache muss aktiviert bleiben.",
"i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021": "Die Systemrichtlinie verhindert, dass dieser Mandant die Funktion aktiviert.",
"i18n:govoplan-tenancy.inherit.7c9a1022": "vererbt",
"i18n:govoplan-tenancy.allow.7c9a1023": "erlauben",
"i18n:govoplan-tenancy.deny.7c9a1024": "verweigern",
"i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025": "Der Mandanten-Slug kann nach der Erstellung nicht geändert werden.",
"i18n:govoplan-tenancy.files_lowercase.7c9a1026": "Dateien",
"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})"
}
};
+9
View File
@@ -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";
+83
View File
@@ -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: "0.1.8",
optionalDependencies: ["access"],
translations: {
en: generatedTranslations.en,
de: generatedTranslations.de
},
viewSurfaces: [
{
id: "tenancy.admin.system-tenants",
moduleId: "tenancy",
kind: "section",
label: "i18n:govoplan-tenancy.tenants.1f7ae776",
order: 10
},
{
id: "tenancy.admin.tenant-settings",
moduleId: "tenancy",
kind: "section",
label: "i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8",
order: 90
}
],
uiCapabilities: {
"admin.sections": adminSections
}
};
export default tenancyModule;