Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43514be10 | ||
|
|
6a985d2a0e | ||
|
|
e8a22e54a5 | ||
|
|
e3c18f9aa8 | ||
|
|
d4bfc6e45a | ||
|
|
e76fe16870 | ||
|
|
2ab89eb809 | ||
|
|
118e96db43 | ||
|
|
b40615f8ac | ||
|
|
922b3f43b7 | ||
|
|
f823c30707 | ||
|
|
f065aa500a | ||
|
|
1dde038547 | ||
|
|
efbec82761 | ||
|
|
0ca9bd793c | ||
|
|
cd7cc674c7 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {}),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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 } : {})
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/tenancy";
|
||||
export { default as TenantsPanel } from "./features/admin/TenantsPanel";
|
||||
export { default as TenantSettingsPanel } from "./features/admin/TenantSettingsPanel";
|
||||
export type {
|
||||
PlatformWebModule,
|
||||
PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const TenantsPanel = lazy(() => import("./features/admin/TenantsPanel"));
|
||||
const TenantSettingsPanel = lazy(
|
||||
() => import("./features/admin/TenantSettingsPanel")
|
||||
);
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "system-tenants",
|
||||
moduleId: "tenancy",
|
||||
kind: "management",
|
||||
surfaceId: "tenancy.admin.system-tenants",
|
||||
label: "i18n:govoplan-tenancy.tenants.1f7ae776",
|
||||
group: "SYSTEM",
|
||||
order: 10,
|
||||
anyOf: ["system:tenants:read"],
|
||||
render: ({ settings, auth, refreshAuth }) =>
|
||||
createElement(TenantsPanel, {
|
||||
settings,
|
||||
auth,
|
||||
canCreate: auth.scopes.includes("system:tenants:create"),
|
||||
canUpdate: auth.scopes.includes("system:tenants:update"),
|
||||
canSuspend: auth.scopes.includes("system:tenants:suspend"),
|
||||
onAuthRefresh: refreshAuth
|
||||
})
|
||||
},
|
||||
{
|
||||
id: "tenant-settings",
|
||||
moduleId: "tenancy",
|
||||
kind: "settings",
|
||||
surfaceId: "tenancy.admin.tenant-settings",
|
||||
label: "i18n:govoplan-tenancy.general.9239ee2c",
|
||||
group: "TENANT",
|
||||
order: 90,
|
||||
anyOf: ["admin:settings:read"],
|
||||
render: ({ settings, auth, refreshAuth }) =>
|
||||
createElement(TenantSettingsPanel, {
|
||||
settings,
|
||||
canWrite: auth.scopes.includes("admin:settings:write"),
|
||||
onAuthRefresh: refreshAuth
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const tenancyModule: PlatformWebModule = {
|
||||
id: "tenancy",
|
||||
label: "i18n:govoplan-tenancy.tenancy",
|
||||
version: "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;
|
||||
Reference in New Issue
Block a user