Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4687e9e0d3 | ||
|
|
9bca590e53 | ||
|
|
64d6638c60 | ||
|
|
f43514be10 | ||
|
|
6a985d2a0e | ||
|
|
e8a22e54a5 | ||
|
|
e3c18f9aa8 | ||
|
|
d4bfc6e45a | ||
|
|
e76fe16870 | ||
|
|
2ab89eb809 | ||
|
|
118e96db43 | ||
|
|
b40615f8ac | ||
|
|
922b3f43b7 | ||
|
|
f823c30707 | ||
|
|
f065aa500a | ||
|
|
1dde038547 |
@@ -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.
|
||||||
@@ -5,11 +5,28 @@
|
|||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-tenancy` owns tenant lifecycle, tenant administration API route
|
`govoplan-tenancy` owns tenant lifecycle, tenant administration API route
|
||||||
contributions, and the `tenancy.tenantResolver` capability during the GovOPlaN
|
contributions, the `tenancy.tenantResolver` capability, and the tenant registry
|
||||||
module split.
|
and tenant settings WebUI panels during the GovOPlaN module split.
|
||||||
|
|
||||||
`govoplan-access` no longer hard-depends on this module. Access can run in the
|
`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
|
single-scope compatibility mode used by the core/access baseline; installing
|
||||||
tenancy adds explicit tenant management and resolver behavior. The shared scope
|
tenancy adds explicit tenant management and resolver behavior. The shared scope
|
||||||
storage table is core-owned as `core_scopes`; tenancy provides lifecycle and
|
storage table is core-owned as `core_scopes`; tenancy provides lifecycle and
|
||||||
administration behavior over those rows rather than owning the table.
|
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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/tenancy-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"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.18",
|
||||||
|
"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]
|
[project]
|
||||||
name = "govoplan-tenancy"
|
name = "govoplan-tenancy"
|
||||||
version = "0.1.8"
|
version = "0.1.18"
|
||||||
description = "GovOPlaN tenancy platform module."
|
description = "GovOPlaN tenancy platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import and_, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -15,9 +18,12 @@ from govoplan_core.core.access import (
|
|||||||
TenantContextSwitcher,
|
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.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.core.runtime import get_registry
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_core.i18n import (
|
from govoplan_core.i18n import (
|
||||||
|
REFERENCE_LANGUAGE_CODE,
|
||||||
i18n_settings,
|
i18n_settings,
|
||||||
normalize_enabled_language_codes,
|
normalize_enabled_language_codes,
|
||||||
system_enabled_language_codes,
|
system_enabled_language_codes,
|
||||||
@@ -30,6 +36,7 @@ from govoplan_core.tenancy.service import (
|
|||||||
assert_tenant_governance_override_allowed,
|
assert_tenant_governance_override_allowed,
|
||||||
effective_tenant_governance,
|
effective_tenant_governance,
|
||||||
tenant_counts,
|
tenant_counts,
|
||||||
|
tenant_counts_many,
|
||||||
)
|
)
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
from govoplan_tenancy.backend.lifecycle import (
|
from govoplan_tenancy.backend.lifecycle import (
|
||||||
@@ -70,6 +77,17 @@ TENANT_SETTINGS_RESOURCE = "tenant_settings_section"
|
|||||||
ADMIN_MODULE_ID = "admin"
|
ADMIN_MODULE_ID = "admin"
|
||||||
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
|
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
|
||||||
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "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:
|
def _tenant_access_provisioner() -> TenantAccessProvisioner:
|
||||||
@@ -97,7 +115,26 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
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)
|
governance = effective_tenant_governance(session, tenant)
|
||||||
return TenantAdminItem(
|
return TenantAdminItem(
|
||||||
id=tenant.id,
|
id=tenant.id,
|
||||||
@@ -115,12 +152,36 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
|
|||||||
"allow_api_keys": governance.allow_api_keys,
|
"allow_api_keys": governance.allow_api_keys,
|
||||||
},
|
},
|
||||||
is_active=tenant.is_active,
|
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,
|
created_at=tenant.created_at,
|
||||||
updated_at=tenant.updated_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:
|
def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrincipal, *, mode: str = "retire") -> TenantDeletionPlanResponse:
|
||||||
issues: list[TenantLifecycleIssue] = []
|
issues: list[TenantLifecycleIssue] = []
|
||||||
counts = tenant_counts(session, tenant.id)
|
counts = tenant_counts(session, tenant.id)
|
||||||
@@ -224,9 +285,11 @@ def _record_tenant_settings_section_changes(
|
|||||||
after: dict[str, Any],
|
after: dict[str, Any],
|
||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
changed = False
|
||||||
for section in TENANT_SETTINGS_SECTIONS:
|
for section in TENANT_SETTINGS_SECTIONS:
|
||||||
if before.get(section) == after.get(section):
|
if before.get(section) == after.get(section):
|
||||||
continue
|
continue
|
||||||
|
changed = True
|
||||||
record_change(
|
record_change(
|
||||||
session,
|
session,
|
||||||
module_id=TENANCY_MODULE_ID,
|
module_id=TENANCY_MODULE_ID,
|
||||||
@@ -239,6 +302,16 @@ def _record_tenant_settings_section_changes(
|
|||||||
actor_id=principal.user.id,
|
actor_id=principal.user.id,
|
||||||
payload={"section": section},
|
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:
|
def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None:
|
||||||
@@ -254,6 +327,16 @@ def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: s
|
|||||||
actor_id=principal.user.id,
|
actor_id=principal.user.id,
|
||||||
payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active},
|
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):
|
def _tenant_list_delta_query(session: Session, *, since_sequence: int):
|
||||||
@@ -291,6 +374,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)
|
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]):
|
def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]):
|
||||||
return [
|
return [
|
||||||
{"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE}
|
{"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE}
|
||||||
@@ -371,21 +498,54 @@ def switch_tenant_context(
|
|||||||
|
|
||||||
@router.get("/tenants", response_model=TenantListResponse)
|
@router.get("/tenants", response_model=TenantListResponse)
|
||||||
def list_tenants(
|
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),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
|
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
|
||||||
):
|
):
|
||||||
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
|
tenants, pagination = _tenant_page(
|
||||||
return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants])
|
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:
|
def _full_tenant_list_delta_response(
|
||||||
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
|
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(
|
return TenantListDeltaResponse(
|
||||||
tenants=[_tenant_item(session, tenant) for tenant in tenants],
|
tenants=_tenant_items(session, tenants),
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=_tenant_list_watermark(session),
|
watermark=(
|
||||||
has_more=False,
|
_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,
|
full=True,
|
||||||
|
**pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -397,22 +557,31 @@ def list_tenants_delta(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
|
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
|
||||||
):
|
):
|
||||||
del principal
|
del principal
|
||||||
if since is None:
|
full_cursor = _decode_tenant_full_cursor(since)
|
||||||
return _full_tenant_list_delta_response(session)
|
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)
|
entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit)
|
||||||
if entries is None:
|
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"]
|
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
|
||||||
tenants = []
|
tenants = []
|
||||||
if changed_ids:
|
if changed_ids:
|
||||||
tenants = session.query(Tenant).filter(Tenant.id.in_(changed_ids)).order_by(Tenant.name.asc()).all()
|
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}
|
visible_tenant_ids = {tenant.id for tenant in tenants}
|
||||||
return TenantListDeltaResponse(
|
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),
|
deleted=_tenant_list_deleted_entries(entries, visible_tenant_ids),
|
||||||
watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more),
|
watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more),
|
||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
full=False,
|
full=False,
|
||||||
|
total=len(tenants),
|
||||||
|
page=1,
|
||||||
|
page_size=limit,
|
||||||
|
pages=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,6 +661,57 @@ def create_tenant(
|
|||||||
return _tenant_item(session, tenant)
|
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 REFERENCE_LANGUAGE_CODE
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem)
|
||||||
def update_tenant(
|
def update_tenant(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -499,43 +719,13 @@ def update_tenant(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
):
|
):
|
||||||
non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
|
_require_tenant_update_permissions(principal, payload)
|
||||||
if payload.model_fields_set.intersection(non_status_fields):
|
tenant = _tenant_or_404(session, tenant_id)
|
||||||
_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")
|
|
||||||
was_active = tenant.is_active
|
was_active = tenant.is_active
|
||||||
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
|
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
|
||||||
if payload.name is not None:
|
_apply_tenant_content_updates(tenant, payload)
|
||||||
tenant.name = payload.name.strip()
|
_apply_tenant_governance_updates(session, tenant, payload)
|
||||||
if "description" in payload.model_fields_set:
|
_apply_tenant_status_update(tenant, payload, principal)
|
||||||
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
|
|
||||||
session.add(tenant)
|
session.add(tenant)
|
||||||
audit_event(
|
audit_event(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
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
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||||
|
|
||||||
|
|
||||||
class TenantAdminItem(BaseModel):
|
class TenantAdminItem(BaseModel):
|
||||||
@@ -13,7 +14,11 @@ class TenantAdminItem(BaseModel):
|
|||||||
slug: str = Field(min_length=1, max_length=100)
|
slug: str = Field(min_length=1, max_length=100)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -27,9 +32,13 @@ class TenantAdminItem(BaseModel):
|
|||||||
|
|
||||||
class TenantListResponse(BaseModel):
|
class TenantListResponse(BaseModel):
|
||||||
tenants: list[TenantAdminItem]
|
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)
|
tenants: list[TenantAdminItem] = Field(default_factory=list)
|
||||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
watermark: str | None = None
|
watermark: str | None = None
|
||||||
@@ -54,7 +63,7 @@ class TenantCreateRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
owner_account_id: str | None = None
|
owner_account_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -120,7 +129,11 @@ class TenantSettingsItem(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
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):
|
def _tenant_resolver(context: ModuleContext):
|
||||||
@@ -30,7 +32,7 @@ def _route_factory(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="tenancy",
|
id="tenancy",
|
||||||
name="Tenancy",
|
name="Tenancy",
|
||||||
version="0.1.8",
|
version="0.1.18",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -40,6 +42,105 @@ manifest = ModuleManifest(
|
|||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
|
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. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. 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. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. 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()
|
||||||
@@ -2,7 +2,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import UTC, datetime
|
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 (
|
||||||
|
TenantCreateRequest,
|
||||||
|
TenantSettingsItem,
|
||||||
|
TenantUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
|
||||||
from govoplan_tenancy.backend.lifecycle import (
|
from govoplan_tenancy.backend.lifecycle import (
|
||||||
TENANT_EVENT_CREATED,
|
TENANT_EVENT_CREATED,
|
||||||
TENANT_EVENT_DELETION_REQUESTED,
|
TENANT_EVENT_DELETION_REQUESTED,
|
||||||
@@ -15,7 +29,25 @@ from govoplan_tenancy.backend.lifecycle import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class TenantLifecycleContractTests(unittest.TestCase):
|
||||||
|
def test_new_tenant_contracts_use_german_reference_default(self) -> None:
|
||||||
|
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||||
|
|
||||||
|
self.assertEqual("de", tenant.default_locale)
|
||||||
|
self.assertEqual(
|
||||||
|
"de",
|
||||||
|
TenantSettingsItem(id="tenant-1", slug="example", name="Example").default_locale,
|
||||||
|
)
|
||||||
|
|
||||||
def test_lifecycle_event_names_are_stable(self) -> None:
|
def test_lifecycle_event_names_are_stable(self) -> None:
|
||||||
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
|
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
|
||||||
self.assertEqual("tenant.suspended", tenant_lifecycle_event_type("suspended"))
|
self.assertEqual("tenant.suspended", tenant_lifecycle_event_type("suspended"))
|
||||||
@@ -66,5 +98,81 @@ class TenantLifecycleContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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("de", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/tenancy-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"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.18",
|
||||||
|
"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: "de",
|
||||||
|
available_languages: [
|
||||||
|
{ code: "de", label: "German", native_label: "Deutsch" },
|
||||||
|
{ code: "en", label: "English", native_label: "English" }
|
||||||
|
],
|
||||||
|
system_enabled_language_codes: ["de", "en"],
|
||||||
|
enabled_language_codes: ["de", "en"],
|
||||||
|
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: "de",
|
||||||
|
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 || "de",
|
||||||
|
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