Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f401b134e3 | ||
|
|
3b45064069 | ||
|
|
c71ef013f1 | ||
|
|
28e6dac289 | ||
|
|
ed424c729c | ||
|
|
c22da17c64 | ||
|
|
550c14b92e | ||
|
|
935cee8ccd | ||
|
|
50c952565b | ||
|
|
89ba59fcea | ||
|
|
a1d7b649aa | ||
|
|
8a75c82a5d | ||
|
|
6d4ada55d7 | ||
|
|
01d6ca1605 | ||
|
|
eb2e497f9a | ||
|
|
176c1d826f | ||
|
|
bc589a3d4f | ||
|
|
9d522ef6e0 | ||
|
|
1e55a80d3c | ||
|
|
218f94fa23 | ||
|
|
2841cd67cc | ||
|
|
3842987a4e | ||
|
|
57e2a34c89 | ||
|
|
e93630ab87 | ||
|
|
06a0c0d26c | ||
|
|
e890a90d08 | ||
|
|
60e2676809 | ||
|
|
b71523e364 | ||
|
|
d428f3390a | ||
|
|
6dbccd5e08 | ||
|
|
fef4e10afd | ||
|
|
8ee12c9aa8 | ||
|
|
f245603077 | ||
|
|
f3e69b97ee | ||
|
|
36291a57c1 | ||
|
|
e8a3e1c18f | ||
|
|
158b59dfb1 | ||
|
|
0fcd4dc06f | ||
|
|
e8cb9d4fb2 | ||
|
|
fcf93f438d | ||
|
|
f0ff4ee51d | ||
|
|
c9e1fb287f | ||
|
|
11ecf362a3 | ||
|
|
24edb7eb8a | ||
|
|
8c82d5b4f8 | ||
|
|
f362f3806b | ||
|
|
30a0281c66 | ||
|
|
970625edff | ||
|
|
0f2a9beca7 |
@@ -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
|
||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
webui/node_modules/
|
||||||
|
webui/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
|
||||||
|
# GovOPlaN shared ignore rules from govoplan-core
|
||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
# TypeScript cache
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
*$py.class
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
develop-eggs/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
cover/
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
# mypy
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
# Ruff stuff:
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
# ---> VisualStudioCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
*.db
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GovOPlaN Admin Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns generic administration sections, governance templates, configuration packages, and operator-facing module lifecycle controls.
|
||||||
|
|
||||||
|
## 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 Admin 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
|
||||||
|
|
||||||
|
- Consume module metadata and extension points; do not import optional module internals.
|
||||||
|
- Keep package mutation in the trusted installer process rather than request handlers.
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
# GovOPlaN Admin
|
# GovOPlaN Admin
|
||||||
|
|
||||||
|
The Admin-owned workspace sections, lifecycle stages, consequence classes,
|
||||||
|
contextual-help contract, and verification evidence are recorded in
|
||||||
|
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-admin` owns generic system administration API and WebUI contributions
|
`govoplan-admin` owns generic system administration API and WebUI contributions
|
||||||
during the GovOPlaN module split.
|
during the GovOPlaN module split.
|
||||||
|
|
||||||
This repository owns the live `governance_templates` and
|
This repository owns the live `admin_governance_templates` and
|
||||||
`governance_template_assignments` tables while preserving their historical
|
`admin_governance_template_assignments` tables. Core migrations rename the
|
||||||
table names. It contributes the stable
|
historical unprefixed governance tables for existing development databases. It
|
||||||
|
contributes the stable
|
||||||
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
||||||
CRUD plus materialization into access-owned tenant groups and roles.
|
CRUD plus materialization into access-owned tenant groups and roles. The
|
||||||
|
`/synchronize` operation validates selected templates and tenants in bounded
|
||||||
|
bulk reads, delegates one versioned projection batch to Access, and returns an
|
||||||
|
auditable outcome for every assignment. Dry runs do not mutate Access data;
|
||||||
|
applied retries are idempotent and never silently skip blocked assignments.
|
||||||
|
|
||||||
## WebUI Package
|
## WebUI Package
|
||||||
|
|
||||||
@@ -23,3 +36,84 @@ section entries through core's `admin.sections` UI capability:
|
|||||||
|
|
||||||
The route shell in access collects those sections at runtime, applies their
|
The route shell in access collects those sections at runtime, applies their
|
||||||
scope requirements, and renders them without importing admin package internals.
|
scope requirements, and renders them without importing admin package internals.
|
||||||
|
|
||||||
|
## Module Lifecycle Administration
|
||||||
|
|
||||||
|
The admin module owns the operator surfaces for module lifecycle management:
|
||||||
|
|
||||||
|
- installed/enabled/desired module state
|
||||||
|
- runtime activation and deactivation of installed modules
|
||||||
|
- signed catalog install planning
|
||||||
|
- automatic discovery from the signed public stable directory when no
|
||||||
|
deployment catalog override is configured
|
||||||
|
- non-destructive uninstall planning, with explicit `destroy_data` retirement
|
||||||
|
options where a module provides a retirement provider
|
||||||
|
- installer preflight status, maintenance-mode blockers, migration/restart
|
||||||
|
checklist entries, and rendered operator commands
|
||||||
|
- installer daemon request queue, cancellation/retry, recent run summaries,
|
||||||
|
rollback status, run IDs, request IDs, and trace IDs
|
||||||
|
|
||||||
|
Package mutation is intentionally not executed inside the FastAPI request. The
|
||||||
|
admin UI records operator intent and queues or renders commands for the trusted
|
||||||
|
installer process described in
|
||||||
|
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
Catalog entries can be searched by module, package, repository, or tag and
|
||||||
|
filtered by availability, installed state, update state, and blockers. Each row
|
||||||
|
shows its signed source revision and immutable artifact digest together with
|
||||||
|
configuration requirements and release notes when the publisher supplies them.
|
||||||
|
Withdrawn entries and targets with missing dependencies, incompatible named
|
||||||
|
interfaces, or an unsupported current-version window cannot be added to a plan.
|
||||||
|
Selecting an eligible entry preserves its signed registry URLs and integrity
|
||||||
|
evidence in the reviewed plan. The installer, not the browser or API request,
|
||||||
|
downloads and verifies those artifacts. On a shared or Kubernetes deployment,
|
||||||
|
the same plan requires a new immutable image composition instead of changing
|
||||||
|
one running replica.
|
||||||
|
|
||||||
|
The WebUI presents the lifecycle as five derived stages: plan, preflight,
|
||||||
|
installer request, daemon execution, and run evidence. The projection resets
|
||||||
|
when the saved plan changes and associates evidence only with an installer
|
||||||
|
request created at or after the current plan revision. It therefore cannot make
|
||||||
|
an old successful run look like evidence for a new plan. The earliest queue
|
||||||
|
blocker is shown through Core's actionable blocker pattern with the required
|
||||||
|
action, responsible operator or administrator, and destination. Contextual help
|
||||||
|
uses the stable `admin.module-lifecycle-workflow` documentation topic.
|
||||||
|
|
||||||
|
## Tenant Module Entitlements
|
||||||
|
|
||||||
|
Deployment lifecycle and tenant availability are separate administration
|
||||||
|
workflows. **System > Tenant modules** lets a system administrator select a
|
||||||
|
tenant, mark installed modules unavailable, available, or forced, and set the
|
||||||
|
tenant's current selection. **Tenant > Modules** lets an account with the
|
||||||
|
`admin:module:write` permission change only the available selection. The
|
||||||
|
`module_admin` role template grants the narrow read/write pair for that task.
|
||||||
|
|
||||||
|
Core closes required dependencies, retains protected administration modules,
|
||||||
|
uses an optimistic entitlement revision, and records audit and governed
|
||||||
|
configuration evidence. A selected module that is not globally active remains
|
||||||
|
configured but unavailable at runtime. Module selection never grants module
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
User and group module visibility is configured through Views, where each WebUI
|
||||||
|
module is represented by its root module surface. This keeps tenant operational
|
||||||
|
state distinct from presentation preferences.
|
||||||
|
|
||||||
|
Licensing remains a generic catalog/preset contract. Official open-source
|
||||||
|
GovOPlaN directory entries carry no feature requirement; a license affects an
|
||||||
|
entry only when that catalog explicitly declares `license_features`.
|
||||||
|
|
||||||
|
## Package Surfaces
|
||||||
|
|
||||||
|
The admin UI intentionally exposes two different package concepts:
|
||||||
|
|
||||||
|
- **Configuration packages** are import/export bundles for module-owned
|
||||||
|
configuration data. They support dry-run diagnostics, approval, apply, and
|
||||||
|
export workflows without installing Python or WebUI packages.
|
||||||
|
- **Module package catalog** and **operator install plan** live under
|
||||||
|
**Modules**. They describe approved release artifacts, install/update/remove
|
||||||
|
plans, preflight blockers, maintenance-mode requirements, installer-daemon
|
||||||
|
requests, and rollback visibility.
|
||||||
|
|
||||||
|
These surfaces should stay separate in navigation and copy. If future UI work
|
||||||
|
combines them visually, it must still preserve the operator distinction between
|
||||||
|
configuration mutation and package installation.
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Admin interface pattern migration
|
||||||
|
|
||||||
|
This document records the interface-pattern coverage for the surfaces
|
||||||
|
contributed by `govoplan-admin`. The Access module hosts the administration
|
||||||
|
tree; Admin supplies only its declared sections through `admin.sections`.
|
||||||
|
|
||||||
|
## Surface inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Authority and state model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Administration overview | Navigation dashboard | Counts are server projections. Links exist only for sections already admitted by capabilities, permissions, and the active View. |
|
||||||
|
| System settings | Adaptive configuration | A local draft is compared with the saved server state. Reload preserves an unsaved draft, Save requires system write authority, and maintenance controls also require maintenance authority. |
|
||||||
|
| Configuration changes | Governed work queue and evidence list | Open approval requests and immutable applied history are separate server-authoritative grids. Approval requires an explicit confirmation. |
|
||||||
|
| Configuration packages | Guided preflight/apply/export workspace | JSON input is validated locally, dry-run evidence is shown separately, approval is optional where policy allows it, and application is explicitly confirmed. Reference selectors replace raw IDs unless historical manual mode is selected deliberately. |
|
||||||
|
| Role and group templates | Governed definition directory | Definitions, tenant availability, and role permissions are edited in one draft. Deletion is confirmed and remains blocked by materialized dependencies in the backend. |
|
||||||
|
| Module management | Guided lifecycle workflow and operations evidence | Desired runtime state, package plan, preflight, maintenance gate, daemon request, and durable run evidence are distinct stages. Disabled controls name the earliest blocker. |
|
||||||
|
|
||||||
|
## Consequence classes
|
||||||
|
|
||||||
|
- Overview navigation, reload, filtering, inspecting evidence, dry runs, and
|
||||||
|
exports are reversible.
|
||||||
|
- Settings, templates, desired module state, package plans, and approval
|
||||||
|
requests are governed mutations with permission and validation reasons.
|
||||||
|
- Approving a change, applying a configuration package, enabling maintenance,
|
||||||
|
clearing a saved module plan, cancelling an installer request, and deleting
|
||||||
|
a governance template require shared confirmation surfaces.
|
||||||
|
- Actual package mutation remains outside the API process and is performed by
|
||||||
|
the supervised installer. Run and rollback evidence remains durable.
|
||||||
|
|
||||||
|
## Shared controls and verification
|
||||||
|
|
||||||
|
Admin uses Core `AdminPageLayout`, `DataGrid`, `ReferenceSelect`, `StageRail`,
|
||||||
|
`Dialog`, `ConfirmDialog`, `TableActionGroup`, `ActionBlockerHint`,
|
||||||
|
`DocumentationHelpLink`, `ToggleSwitch`, and status/alert primitives. This
|
||||||
|
inherits Core focus restoration, keyboard order, responsive overflow,
|
||||||
|
disabled-action tooltips, and accessible dialog semantics.
|
||||||
|
|
||||||
|
The focused WebUI check rejects browser-native confirmation calls, private
|
||||||
|
sibling-module imports, untranslated Admin-owned structural headings, missing
|
||||||
|
contextual help, and missing disabled reasons on consequential controls. The
|
||||||
|
manifest regression test fixes the help-context and surface declaration.
|
||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/admin-webui",
|
"name": "@govoplan/admin-webui",
|
||||||
"version": "0.1.5",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.5",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+3
-4
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-admin"
|
name = "govoplan-admin"
|
||||||
version = "0.1.5"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN generic administration module."
|
description = "GovOPlaN generic administration 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.5",
|
"govoplan-core>=0.1.45",
|
||||||
"govoplan-access>=0.1.5",
|
"govoplan-access>=0.1.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -22,4 +22,3 @@ govoplan_admin = ["py.typed"]
|
|||||||
|
|
||||||
[project.entry-points."govoplan.modules"]
|
[project.entry-points."govoplan.modules"]
|
||||||
admin = "govoplan_admin.backend.manifest:get_manifest"
|
admin = "govoplan_admin.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
Metadata-Version: 2.4
|
|
||||||
Name: govoplan-admin
|
|
||||||
Version: 0.1.4
|
|
||||||
Summary: GovOPlaN generic administration module.
|
|
||||||
Author: GovOPlaN
|
|
||||||
Requires-Python: >=3.12
|
|
||||||
Description-Content-Type: text/markdown
|
|
||||||
Requires-Dist: govoplan-core>=0.1.4
|
|
||||||
Requires-Dist: govoplan-access>=0.1.4
|
|
||||||
|
|
||||||
# GovOPlaN Admin
|
|
||||||
|
|
||||||
`govoplan-admin` owns generic system administration API and WebUI contributions
|
|
||||||
during the GovOPlaN module split.
|
|
||||||
|
|
||||||
This repository owns the live `governance_templates` and
|
|
||||||
`governance_template_assignments` tables while preserving their historical
|
|
||||||
table names. It contributes the stable
|
|
||||||
`/api/v1/admin/system/governance-templates` routes and owns governance-template
|
|
||||||
CRUD plus materialization into access-owned tenant groups and roles.
|
|
||||||
|
|
||||||
## WebUI Package
|
|
||||||
|
|
||||||
The repository root and `webui/` directory both expose the package
|
|
||||||
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
|
|
||||||
itself; `govoplan-access` owns the route shell. Instead, admin contributes
|
|
||||||
section entries through core's `admin.sections` UI capability:
|
|
||||||
|
|
||||||
- overview
|
|
||||||
- system settings
|
|
||||||
- governance template tenant roles
|
|
||||||
- governance template groups
|
|
||||||
|
|
||||||
The route shell in access collects those sections at runtime, applies their
|
|
||||||
scope requirements, and renders them without importing admin package internals.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
README.md
|
|
||||||
pyproject.toml
|
|
||||||
src/govoplan_admin/__init__.py
|
|
||||||
src/govoplan_admin/py.typed
|
|
||||||
src/govoplan_admin.egg-info/PKG-INFO
|
|
||||||
src/govoplan_admin.egg-info/SOURCES.txt
|
|
||||||
src/govoplan_admin.egg-info/dependency_links.txt
|
|
||||||
src/govoplan_admin.egg-info/entry_points.txt
|
|
||||||
src/govoplan_admin.egg-info/requires.txt
|
|
||||||
src/govoplan_admin.egg-info/top_level.txt
|
|
||||||
src/govoplan_admin/backend/__init__.py
|
|
||||||
src/govoplan_admin/backend/governance.py
|
|
||||||
src/govoplan_admin/backend/manifest.py
|
|
||||||
src/govoplan_admin/backend/api/__init__.py
|
|
||||||
src/govoplan_admin/backend/api/v1/__init__.py
|
|
||||||
src/govoplan_admin/backend/api/v1/routes.py
|
|
||||||
src/govoplan_admin/backend/api/v1/schemas.py
|
|
||||||
src/govoplan_admin/backend/db/__init__.py
|
|
||||||
src/govoplan_admin/backend/db/models.py
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[govoplan.modules]
|
|
||||||
admin = govoplan_admin.backend.manifest:get_manifest
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
govoplan-core>=0.1.4
|
|
||||||
govoplan-access>=0.1.4
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
govoplan_admin
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,59 +3,11 @@ 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, NavigationPreferencesPayload
|
||||||
RETENTION_DAY_KEYS = (
|
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||||
"raw_campaign_json_retention_days",
|
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
|
||||||
"generated_eml_retention_days",
|
|
||||||
"stored_report_detail_retention_days",
|
|
||||||
"mock_mailbox_retention_days",
|
|
||||||
"audit_detail_retention_days",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
RETENTION_POLICY_FIELD_KEYS = (
|
|
||||||
"store_raw_campaign_json",
|
|
||||||
*RETENTION_DAY_KEYS,
|
|
||||||
"audit_detail_level",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
|
||||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
|
||||||
if value in (None, ""):
|
|
||||||
return default_allow_lower_level_limits() if fill_defaults else None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("allow_lower_level_limits must be an object")
|
|
||||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
|
||||||
for key, allowed in value.items():
|
|
||||||
clean_key = str(key)
|
|
||||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
|
||||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
|
||||||
normalized[clean_key] = bool(allowed)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
class PrivacyRetentionPolicyItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
store_raw_campaign_json: bool = True
|
|
||||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
|
||||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
|
||||||
|
|
||||||
@field_validator("allow_lower_level_limits", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
|
||||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceModeItem(BaseModel):
|
class MaintenanceModeItem(BaseModel):
|
||||||
@@ -65,6 +17,14 @@ class MaintenanceModeItem(BaseModel):
|
|||||||
message: str | None = Field(default=None, max_length=500)
|
message: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class LanguagePackageItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
code: str = Field(min_length=1, max_length=20)
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
native_label: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class AdminOverviewResponse(BaseModel):
|
class AdminOverviewResponse(BaseModel):
|
||||||
active_tenant_id: str
|
active_tenant_id: str
|
||||||
active_tenant_name: str
|
active_tenant_name: str
|
||||||
@@ -81,13 +41,29 @@ class AdminOverviewResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SystemSettingsItem(BaseModel):
|
class SystemSettingsItem(BaseModel):
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
allow_tenant_custom_groups: bool = True
|
allow_tenant_custom_groups: bool = True
|
||||||
allow_tenant_custom_roles: bool = True
|
allow_tenant_custom_roles: bool = True
|
||||||
allow_tenant_api_keys: bool = True
|
allow_tenant_api_keys: bool = True
|
||||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||||
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
|
||||||
|
available_languages: list[LanguagePackageItem] = Field(default_factory=list)
|
||||||
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
appearance_palette_locked: bool = False
|
||||||
|
appearance_custom_overrides_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsDeltaResponse(BaseModel):
|
||||||
|
item: SystemSettingsItem | None = None
|
||||||
|
sections: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
changed_sections: list[str] = Field(default_factory=list)
|
||||||
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
|
watermark: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsUpdateRequest(BaseModel):
|
class SystemSettingsUpdateRequest(BaseModel):
|
||||||
@@ -99,6 +75,13 @@ class SystemSettingsUpdateRequest(BaseModel):
|
|||||||
allow_tenant_api_keys: bool
|
allow_tenant_api_keys: bool
|
||||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||||
maintenance_mode: MaintenanceModeItem | None = None
|
maintenance_mode: MaintenanceModeItem | None = None
|
||||||
|
available_languages: list[LanguagePackageItem] | None = None
|
||||||
|
enabled_language_codes: list[str] | None = None
|
||||||
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
appearance_palette_locked: bool | None = None
|
||||||
|
appearance_custom_overrides_allowed: bool | None = None
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ModuleCatalogItem(BaseModel):
|
class ModuleCatalogItem(BaseModel):
|
||||||
@@ -143,17 +126,76 @@ class ModuleStateUpdateRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
enabled_modules: list[str] = Field(default_factory=list)
|
enabled_modules: list[str] = Field(default_factory=list)
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleEntitlementItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
runtime_active: bool
|
||||||
|
availability: Literal["unavailable", "available", "forced"]
|
||||||
|
selected: bool
|
||||||
|
effective: bool
|
||||||
|
forced: bool
|
||||||
|
derived_dependency: bool
|
||||||
|
tenant_can_toggle: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleEntitlementResponse(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
revision: int
|
||||||
|
configured: bool
|
||||||
|
available_modules: list[str] = Field(default_factory=list)
|
||||||
|
forced_modules: list[str] = Field(default_factory=list)
|
||||||
|
selected_modules: list[str] = Field(default_factory=list)
|
||||||
|
effective_modules: list[str] = Field(default_factory=list)
|
||||||
|
derived_dependencies: list[str] = Field(default_factory=list)
|
||||||
|
modules: list[TenantModuleEntitlementItem] = Field(default_factory=list)
|
||||||
|
diagnostics: list[dict[str, str]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleTargetItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleTargetListResponse(BaseModel):
|
||||||
|
tenants: list[TenantModuleTargetItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemTenantModulePolicyUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
available_modules: list[str] = Field(default_factory=list, max_length=1000)
|
||||||
|
forced_modules: list[str] = Field(default_factory=list, max_length=1000)
|
||||||
|
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
|
||||||
|
expected_revision: int | None = Field(default=None, ge=0)
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleSelectionUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled_modules: list[str] = Field(default_factory=list, max_length=1000)
|
||||||
|
expected_revision: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ModuleInstallPlanItem(BaseModel):
|
class ModuleInstallPlanItem(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
module_id: str = Field(min_length=1, max_length=120)
|
module_id: str = Field(min_length=1, max_length=120)
|
||||||
action: Literal["install", "uninstall"]
|
action: Literal["install", "update", "uninstall"]
|
||||||
|
source: Literal["manual", "catalog"] = "manual"
|
||||||
|
catalog: dict[str, Any] | None = None
|
||||||
python_package: str | None = Field(default=None, max_length=200)
|
python_package: str | None = Field(default=None, max_length=200)
|
||||||
python_ref: str | None = Field(default=None, max_length=1000)
|
python_ref: str | None = Field(default=None, max_length=1000)
|
||||||
webui_package: str | None = Field(default=None, max_length=200)
|
webui_package: str | None = Field(default=None, max_length=200)
|
||||||
webui_ref: str | None = Field(default=None, max_length=1000)
|
webui_ref: str | None = Field(default=None, max_length=1000)
|
||||||
|
data_safety_acknowledged: bool = False
|
||||||
destroy_data: bool = False
|
destroy_data: bool = False
|
||||||
status: Literal["planned", "applied", "blocked"] = "planned"
|
status: Literal["planned", "applied", "blocked"] = "planned"
|
||||||
notes: str | None = Field(default=None, max_length=1000)
|
notes: str | None = Field(default=None, max_length=1000)
|
||||||
@@ -173,6 +215,63 @@ class ModuleInstallChecklistItem(BaseModel):
|
|||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInstallTargetItem(BaseModel):
|
||||||
|
module_id: str
|
||||||
|
action: Literal["install", "update", "uninstall"]
|
||||||
|
source: Literal["manual", "catalog"]
|
||||||
|
current_version: str | None = None
|
||||||
|
target_version: str | None = None
|
||||||
|
python_package: str | None = None
|
||||||
|
python_ref: str | None = None
|
||||||
|
webui_package: str | None = None
|
||||||
|
webui_ref: str | None = None
|
||||||
|
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
|
||||||
|
migration_notes: str | None = None
|
||||||
|
current_version_min: str | None = None
|
||||||
|
current_version_max_exclusive: str | None = None
|
||||||
|
bridge_release: bool = False
|
||||||
|
bridge_notes: str | None = None
|
||||||
|
allow_downgrade: bool = False
|
||||||
|
allow_same_version: bool = False
|
||||||
|
recovery_tested: bool = False
|
||||||
|
recovery_notes: str | None = None
|
||||||
|
data_safety_acknowledged: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleMigrationPlanStep(BaseModel):
|
||||||
|
module_id: str
|
||||||
|
action: Literal["install", "update", "uninstall"]
|
||||||
|
phase: Literal["upgrade", "retirement"]
|
||||||
|
source: Literal["manifest", "catalog", "pending"]
|
||||||
|
has_migration_metadata: bool = False
|
||||||
|
metadata_pending: bool = False
|
||||||
|
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
|
||||||
|
current_version: str | None = None
|
||||||
|
target_version: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleMigrationTaskPlanItem(BaseModel):
|
||||||
|
module_id: str
|
||||||
|
task_id: str
|
||||||
|
phase: Literal["pre_migration_check", "pre_migration_prepare", "post_migration_backfill", "post_migration_verify"]
|
||||||
|
summary: str
|
||||||
|
task_version: str = "1"
|
||||||
|
safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
|
||||||
|
idempotent: bool = True
|
||||||
|
timeout_seconds: int | None = None
|
||||||
|
source: Literal["manifest", "catalog", "pending"] = "manifest"
|
||||||
|
has_executor: bool = False
|
||||||
|
metadata_pending: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleMigrationExecutionPlan(BaseModel):
|
||||||
|
enabled_modules: list[str] = Field(default_factory=list)
|
||||||
|
requires_database_migration: bool = False
|
||||||
|
steps: list[ModuleMigrationPlanStep] = Field(default_factory=list)
|
||||||
|
tasks: list[ModuleMigrationTaskPlanItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ModuleInstallPreflightResponse(BaseModel):
|
class ModuleInstallPreflightResponse(BaseModel):
|
||||||
allowed: bool
|
allowed: bool
|
||||||
maintenance_mode: bool
|
maintenance_mode: bool
|
||||||
@@ -182,6 +281,8 @@ class ModuleInstallPreflightResponse(BaseModel):
|
|||||||
rollback_commands: list[str] = Field(default_factory=list)
|
rollback_commands: list[str] = Field(default_factory=list)
|
||||||
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
|
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
|
||||||
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
|
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
|
||||||
|
target_plan: list[ModuleInstallTargetItem] = Field(default_factory=list)
|
||||||
|
migration_plan: ModuleMigrationExecutionPlan = Field(default_factory=ModuleMigrationExecutionPlan)
|
||||||
|
|
||||||
|
|
||||||
class ModuleInstallPlanResponse(BaseModel):
|
class ModuleInstallPlanResponse(BaseModel):
|
||||||
@@ -213,6 +314,9 @@ class ModuleInstallerRunSummary(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
started_at: str | None = None
|
started_at: str | None = None
|
||||||
finished_at: str | None = None
|
finished_at: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
requested_by: str | None = None
|
||||||
|
trace: dict[str, Any] | None = None
|
||||||
rollback_status: str | None = None
|
rollback_status: str | None = None
|
||||||
supervisor_status: str | None = None
|
supervisor_status: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
@@ -224,6 +328,9 @@ class ModuleInstallerRunSummary(BaseModel):
|
|||||||
class ModuleInstallerRunListResponse(BaseModel):
|
class ModuleInstallerRunListResponse(BaseModel):
|
||||||
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
|
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
|
||||||
lock: ModuleInstallerLockStatus
|
lock: ModuleInstallerLockStatus
|
||||||
|
cursor: str | None = None
|
||||||
|
next_cursor: str | None = None
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ModuleInstallerRequestOptions(BaseModel):
|
class ModuleInstallerRequestOptions(BaseModel):
|
||||||
@@ -256,6 +363,7 @@ class ModuleInstallerRequestItem(BaseModel):
|
|||||||
cancelled_at: str | None = None
|
cancelled_at: str | None = None
|
||||||
cancelled_by: str | None = None
|
cancelled_by: str | None = None
|
||||||
retry_of: str | None = None
|
retry_of: str | None = None
|
||||||
|
trace: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
record_path: str | None = None
|
record_path: str | None = None
|
||||||
options: dict[str, Any] = Field(default_factory=dict)
|
options: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -264,6 +372,9 @@ class ModuleInstallerRequestItem(BaseModel):
|
|||||||
class ModuleInstallerRequestListResponse(BaseModel):
|
class ModuleInstallerRequestListResponse(BaseModel):
|
||||||
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
|
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
|
||||||
daemon: ModuleInstallerDaemonStatus
|
daemon: ModuleInstallerDaemonStatus
|
||||||
|
cursor: str | None = None
|
||||||
|
next_cursor: str | None = None
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ModuleInstallerRequestCreateRequest(BaseModel):
|
class ModuleInstallerRequestCreateRequest(BaseModel):
|
||||||
@@ -272,16 +383,93 @@ class ModuleInstallerRequestCreateRequest(BaseModel):
|
|||||||
options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions)
|
options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInterfaceProviderItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleInterfaceRequirementItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
version_min: str | None = None
|
||||||
|
version_max_exclusive: str | None = None
|
||||||
|
optional: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackageCatalogSource(BaseModel):
|
||||||
|
repository: str
|
||||||
|
tag: str
|
||||||
|
commit: str
|
||||||
|
repository_url: str | None = None
|
||||||
|
revision_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackageArtifactIdentity(BaseModel):
|
||||||
|
ref: str | None = None
|
||||||
|
path: str | None = None
|
||||||
|
artifact_path: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
filename: str | None = None
|
||||||
|
sha256: str | None = None
|
||||||
|
size: int | None = None
|
||||||
|
integrity: str | None = None
|
||||||
|
sbom_url: str | None = None
|
||||||
|
provenance_url: str | None = None
|
||||||
|
registry_identity: str | None = None
|
||||||
|
git_ref: str | None = None
|
||||||
|
source_commit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModulePackagePermissionItem(BaseModel):
|
||||||
|
scope: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
level: Literal["system", "tenant"]
|
||||||
|
resource: str
|
||||||
|
action: str
|
||||||
|
deprecated: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ModulePackageCatalogItem(BaseModel):
|
class ModulePackageCatalogItem(BaseModel):
|
||||||
module_id: str
|
module_id: str
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
version: str | None = None
|
version: str | None = None
|
||||||
action: Literal["install", "uninstall"] = "install"
|
action: Literal["install", "update", "uninstall"] = "install"
|
||||||
|
installed: bool = False
|
||||||
|
installed_version: str | None = None
|
||||||
|
update_available: bool = False
|
||||||
|
availability: Literal["available", "withdrawn"] = "available"
|
||||||
|
availability_reason: str | None = None
|
||||||
|
configuration_requirements: list[str] = Field(default_factory=list)
|
||||||
|
permissions: list[ModulePackagePermissionItem] = Field(default_factory=list)
|
||||||
|
release_notes_url: str | None = None
|
||||||
|
source: ModulePackageCatalogSource | None = None
|
||||||
|
artifact_integrity: dict[str, ModulePackageArtifactIdentity] = Field(default_factory=dict)
|
||||||
|
compatible: bool = True
|
||||||
|
plan_allowed: bool = True
|
||||||
|
compatibility_reasons: list[str] = Field(default_factory=list)
|
||||||
|
catalog_state: Literal["available", "installed", "update_available", "blocked", "withdrawn"] = "available"
|
||||||
|
dependencies: list[str] = Field(default_factory=list)
|
||||||
|
optional_dependencies: list[str] = Field(default_factory=list)
|
||||||
|
migration_safety: Literal["automatic", "requires_review", "forward_only", "destructive"] = "automatic"
|
||||||
|
migration_notes: str | None = None
|
||||||
|
migration_after: list[str] = Field(default_factory=list)
|
||||||
|
migration_before: list[str] = Field(default_factory=list)
|
||||||
|
current_version_min: str | None = None
|
||||||
|
current_version_max_exclusive: str | None = None
|
||||||
|
bridge_release: bool = False
|
||||||
|
bridge_notes: str | None = None
|
||||||
|
allow_downgrade: bool = False
|
||||||
|
allow_same_version: bool = False
|
||||||
|
recovery_tested: bool = False
|
||||||
|
recovery_notes: str | None = None
|
||||||
python_package: str | None = None
|
python_package: str | None = None
|
||||||
python_ref: str | None = None
|
python_ref: str | None = None
|
||||||
webui_package: str | None = None
|
webui_package: str | None = None
|
||||||
webui_ref: str | None = None
|
webui_ref: str | None = None
|
||||||
|
provides_interfaces: list[ModuleInterfaceProviderItem] = Field(default_factory=list)
|
||||||
|
requires_interfaces: list[ModuleInterfaceRequirementItem] = Field(default_factory=list)
|
||||||
license_features: list[str] = Field(default_factory=list)
|
license_features: list[str] = Field(default_factory=list)
|
||||||
license_allowed: bool = True
|
license_allowed: bool = True
|
||||||
license_enforced: bool = False
|
license_enforced: bool = False
|
||||||
@@ -291,6 +479,27 @@ class ModulePackageCatalogItem(BaseModel):
|
|||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleLicenseDiagnostics(BaseModel):
|
||||||
|
configured: bool = False
|
||||||
|
valid: bool = True
|
||||||
|
path: str | None = None
|
||||||
|
license_id: str | None = None
|
||||||
|
subject: str | None = None
|
||||||
|
features: list[str] = Field(default_factory=list)
|
||||||
|
valid_from: str | None = None
|
||||||
|
valid_until: str | None = None
|
||||||
|
signed: bool = False
|
||||||
|
trusted: bool = False
|
||||||
|
key_id: str | None = None
|
||||||
|
enforced: bool = False
|
||||||
|
allowed: bool = True
|
||||||
|
required_features: list[str] = Field(default_factory=list)
|
||||||
|
missing_features: list[str] = Field(default_factory=list)
|
||||||
|
expires_in_days: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
guidance: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ModulePackageCatalogResponse(BaseModel):
|
class ModulePackageCatalogResponse(BaseModel):
|
||||||
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
|
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
|
||||||
configured: bool = False
|
configured: bool = False
|
||||||
@@ -298,13 +507,17 @@ class ModulePackageCatalogResponse(BaseModel):
|
|||||||
path: str | None = None
|
path: str | None = None
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
source_type: str | None = None
|
source_type: str | None = None
|
||||||
|
cache_used: bool = False
|
||||||
|
cache_path: str | None = None
|
||||||
channel: str | None = None
|
channel: str | None = None
|
||||||
sequence: int | None = None
|
sequence: int | None = None
|
||||||
generated_at: str | None = None
|
generated_at: str | None = None
|
||||||
|
not_before: str | None = None
|
||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
signed: bool = False
|
signed: bool = False
|
||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
key_id: str | None = None
|
key_id: str | None = None
|
||||||
|
license: ModuleLicenseDiagnostics = Field(default_factory=ModuleLicenseDiagnostics)
|
||||||
warnings: list[str] = Field(default_factory=list)
|
warnings: list[str] = Field(default_factory=list)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
@@ -313,6 +526,7 @@ class ModuleInstallPlanUpdateRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GovernanceAssignment(BaseModel):
|
class GovernanceAssignment(BaseModel):
|
||||||
@@ -336,6 +550,22 @@ class GovernanceTemplateItem(BaseModel):
|
|||||||
|
|
||||||
class GovernanceTemplateListResponse(BaseModel):
|
class GovernanceTemplateListResponse(BaseModel):
|
||||||
templates: list[GovernanceTemplateItem]
|
templates: list[GovernanceTemplateItem]
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 500
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceTemplateListDeltaResponse(BaseModel):
|
||||||
|
templates: list[GovernanceTemplateItem] = Field(default_factory=list)
|
||||||
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
|
watermark: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
full: bool = False
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 500
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
class GovernanceTemplateCreateRequest(BaseModel):
|
class GovernanceTemplateCreateRequest(BaseModel):
|
||||||
@@ -348,6 +578,7 @@ class GovernanceTemplateCreateRequest(BaseModel):
|
|||||||
permissions: list[str] = Field(default_factory=list)
|
permissions: list[str] = Field(default_factory=list)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GovernanceTemplateUpdateRequest(BaseModel):
|
class GovernanceTemplateUpdateRequest(BaseModel):
|
||||||
@@ -358,3 +589,32 @@ class GovernanceTemplateUpdateRequest(BaseModel):
|
|||||||
permissions: list[str] = Field(default_factory=list)
|
permissions: list[str] = Field(default_factory=list)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
assignments: list[GovernanceAssignment] = Field(default_factory=list)
|
||||||
|
change_request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceSynchronizationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
template_ids: list[str] = Field(min_length=1, max_length=100)
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceSynchronizationOutcome(BaseModel):
|
||||||
|
assignment_id: str
|
||||||
|
template_id: str
|
||||||
|
tenant_id: str
|
||||||
|
kind: Literal["group", "role"]
|
||||||
|
operation: Literal["upsert", "remove"]
|
||||||
|
status: Literal["created", "updated", "unchanged", "removed", "absent", "blocked", "failed"]
|
||||||
|
resource_id: str | None = None
|
||||||
|
blocker_codes: list[str] = Field(default_factory=list)
|
||||||
|
message: str | None = None
|
||||||
|
provenance: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceSynchronizationResponse(BaseModel):
|
||||||
|
version: Literal["1"] = "1"
|
||||||
|
operation_id: str
|
||||||
|
dry_run: bool
|
||||||
|
outcomes: list[GovernanceSynchronizationOutcome]
|
||||||
|
counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ def new_uuid() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class GovernanceTemplate(Base, TimestampMixin):
|
class GovernanceTemplate(Base, TimestampMixin):
|
||||||
__tablename__ = "governance_templates"
|
__tablename__ = "admin_governance_templates"
|
||||||
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
|
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
@@ -26,12 +26,12 @@ class GovernanceTemplate(Base, TimestampMixin):
|
|||||||
|
|
||||||
|
|
||||||
class GovernanceTemplateAssignment(Base, TimestampMixin):
|
class GovernanceTemplateAssignment(Base, TimestampMixin):
|
||||||
__tablename__ = "governance_template_assignments"
|
__tablename__ = "admin_governance_template_assignments"
|
||||||
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
|
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
template_id: Mapped[str] = mapped_column(ForeignKey("admin_governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
|
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_DSAR_CAPABILITY = dsar_capability_name("admin")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDsarProvider:
|
||||||
|
"""Declare the reviewed absence of subject data in Admin-owned tables."""
|
||||||
|
|
||||||
|
provider_id = "admin"
|
||||||
|
module_id = "admin"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
del session, tenant_id, subject
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, tenant_id, subject
|
||||||
|
if records:
|
||||||
|
raise ValueError("Admin DSAR cannot plan records it does not own.")
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del session, tenant_id, subject, request_id
|
||||||
|
if actions:
|
||||||
|
raise ValueError("Admin DSAR cannot execute actions it does not own.")
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ADMIN_DSAR_CAPABILITY", "AdminDsarProvider"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'admin.governance-and-module-lifecycle': {'limitations': ['Konfigurationspakete installieren '
|
||||||
|
'keine Module oder gewähren '
|
||||||
|
'Berechtigungen.',
|
||||||
|
'Generisches Rollback ist Snapshot '
|
||||||
|
'Recovery; Anbieter können eine engere '
|
||||||
|
'Vergütung separat aussetzen.',
|
||||||
|
'Geheimwerte werden niemals in '
|
||||||
|
'Exportprovenienz oder tragbare '
|
||||||
|
'Fragmente aufgenommen.'],
|
||||||
|
'operational_consequences': ['Das Ändern von '
|
||||||
|
'Paketeingaben ungültig '
|
||||||
|
'macht den vorherigen '
|
||||||
|
'Preflight und erfordert '
|
||||||
|
'eine weitere Überprüfung.',
|
||||||
|
'Eine teilweise Anwendung '
|
||||||
|
'stoppt vor späteren '
|
||||||
|
'Fragmenten und muss vor '
|
||||||
|
'dem erneuten Versuch '
|
||||||
|
'wiederhergestellt werden.',
|
||||||
|
'Bewahren Sie den '
|
||||||
|
'Pre-Apply-Datenbank-Snapshot '
|
||||||
|
'auf, bis die Überprüfung '
|
||||||
|
'nach der Anwendung '
|
||||||
|
'abgeschlossen ist.']}}
|
||||||
@@ -1,29 +1,38 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||||
AccessGovernanceMaterializer,
|
AccessGovernanceProjectionV1,
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceProjectionOutcome,
|
||||||
|
GovernanceProjectionResult,
|
||||||
GovernanceTemplateMaterialization,
|
GovernanceTemplateMaterialization,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_core.security.permissions import validate_tenant_permissions
|
from govoplan_core.security.permissions import validate_tenant_permissions
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
TEMPLATE_KINDS = {"group", "role"}
|
TEMPLATE_KINDS = {"group", "role"}
|
||||||
ASSIGNMENT_MODES = {"available", "required"}
|
ASSIGNMENT_MODES = {"available", "required"}
|
||||||
|
MAX_ASSIGNMENTS_PER_TEMPLATE = 500
|
||||||
|
MAX_TEMPLATES_PER_SYNCHRONIZATION = 100
|
||||||
|
|
||||||
|
|
||||||
def _governance_materializer() -> AccessGovernanceMaterializer:
|
def _governance_projection() -> AccessGovernanceProjectionV1:
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
|
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1):
|
||||||
raise AdminValidationError("Access governance materializer capability is not configured.")
|
raise AdminValidationError("Access governance projection v1 capability is not configured.")
|
||||||
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1)
|
||||||
if not isinstance(capability, AccessGovernanceMaterializer):
|
if not isinstance(capability, AccessGovernanceProjectionV1):
|
||||||
raise AdminValidationError("Access governance materializer capability is invalid.")
|
raise AdminValidationError("Access governance projection v1 capability is invalid.")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +55,67 @@ def _materialization(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command(
|
||||||
|
item: GovernanceTemplate,
|
||||||
|
assignment: GovernanceTemplateAssignment,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
source: str,
|
||||||
|
) -> GovernanceProjectionCommand:
|
||||||
|
return GovernanceProjectionCommand(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
operation=operation, # type: ignore[arg-type]
|
||||||
|
template=_materialization(
|
||||||
|
item,
|
||||||
|
tenant_id=assignment.tenant_id,
|
||||||
|
required=assignment.mode == "required",
|
||||||
|
),
|
||||||
|
provenance={
|
||||||
|
"source": source,
|
||||||
|
"template_id": item.id,
|
||||||
|
"assignment_mode": assignment.mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile(
|
||||||
|
session: Session,
|
||||||
|
commands: Iterable[GovernanceProjectionCommand],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> GovernanceProjectionResult:
|
||||||
|
bounded = tuple(commands)
|
||||||
|
if not bounded:
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=f"admin-governance:{uuid.uuid4()}",
|
||||||
|
outcomes=(),
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
batch = GovernanceProjectionBatch(
|
||||||
|
operation_id=f"admin-governance:{uuid.uuid4()}",
|
||||||
|
commands=bounded,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
result = _governance_projection().reconcile(session, batch)
|
||||||
|
expected = {item.assignment_id for item in bounded}
|
||||||
|
returned = {item.assignment_id for item in result.outcomes}
|
||||||
|
if expected != returned or len(result.outcomes) != len(bounded):
|
||||||
|
raise AdminValidationError(f"Access governance projection returned an incomplete {source} result.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_blocked(result: GovernanceProjectionResult) -> None:
|
||||||
|
if not result.blocked:
|
||||||
|
return
|
||||||
|
summaries = [
|
||||||
|
f"{item.tenant_id}: {item.message or ', '.join(item.blocker_codes) or item.status}"
|
||||||
|
for item in result.blocked[:10]
|
||||||
|
]
|
||||||
|
suffix = "" if len(result.blocked) <= 10 else f" (+{len(result.blocked) - 10} more)"
|
||||||
|
raise AdminConflictError("Governance synchronization blocked: " + "; ".join(summaries) + suffix)
|
||||||
|
|
||||||
|
|
||||||
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
def validate_template(kind: str, permissions: list[str]) -> list[str]:
|
||||||
if kind not in TEMPLATE_KINDS:
|
if kind not in TEMPLATE_KINDS:
|
||||||
raise AdminValidationError("Template kind must be group or role.")
|
raise AdminValidationError("Template kind must be group or role.")
|
||||||
@@ -107,7 +177,6 @@ def update_template(
|
|||||||
item.permissions = validate_template(item.kind, permissions)
|
item.permissions = validate_template(item.kind, permissions)
|
||||||
item.is_active = is_active
|
item.is_active = is_active
|
||||||
set_template_assignments(session, item, assignments)
|
set_template_assignments(session, item, assignments)
|
||||||
sync_template(session, item)
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -115,55 +184,167 @@ def set_template_assignments(
|
|||||||
session: Session,
|
session: Session,
|
||||||
item: GovernanceTemplate,
|
item: GovernanceTemplate,
|
||||||
assignments: list[dict[str, str]],
|
assignments: list[dict[str, str]],
|
||||||
) -> None:
|
) -> GovernanceProjectionResult:
|
||||||
|
if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE:
|
||||||
|
raise AdminValidationError(
|
||||||
|
f"A governance template supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments."
|
||||||
|
)
|
||||||
desired: dict[str, str] = {}
|
desired: dict[str, str] = {}
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
tenant_id = assignment.get("tenant_id", "")
|
tenant_id = assignment.get("tenant_id", "")
|
||||||
mode = assignment.get("mode", "available")
|
mode = assignment.get("mode", "available")
|
||||||
|
if not tenant_id:
|
||||||
|
raise AdminValidationError("Template assignments require a tenant id.")
|
||||||
|
if tenant_id in desired:
|
||||||
|
raise AdminValidationError(f"Duplicate tenant assignment: {tenant_id}")
|
||||||
if mode not in ASSIGNMENT_MODES:
|
if mode not in ASSIGNMENT_MODES:
|
||||||
raise AdminValidationError("Template assignment mode must be available or required.")
|
raise AdminValidationError("Template assignment mode must be available or required.")
|
||||||
tenant = session.get(Tenant, tenant_id)
|
|
||||||
if tenant is None:
|
|
||||||
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
|
|
||||||
desired[tenant_id] = mode
|
desired[tenant_id] = mode
|
||||||
|
|
||||||
|
known_tenants = {
|
||||||
|
tenant_id
|
||||||
|
for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(desired)).all()
|
||||||
|
} if desired else set()
|
||||||
|
missing_tenants = sorted(set(desired) - known_tenants)
|
||||||
|
if missing_tenants:
|
||||||
|
preview = ", ".join(missing_tenants[:10])
|
||||||
|
suffix = "" if len(missing_tenants) <= 10 else f" (+{len(missing_tenants) - 10} more)"
|
||||||
|
raise AdminValidationError(f"Unknown tenants: {preview}{suffix}")
|
||||||
|
|
||||||
existing = {
|
existing = {
|
||||||
row.tenant_id: row
|
row.tenant_id: row
|
||||||
for row in session.query(GovernanceTemplateAssignment)
|
for row in session.query(GovernanceTemplateAssignment)
|
||||||
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
for tenant_id, row in list(existing.items()):
|
removed = [row for tenant_id, row in existing.items() if tenant_id not in desired]
|
||||||
if tenant_id in desired:
|
retained: list[GovernanceTemplateAssignment] = []
|
||||||
row.mode = desired[tenant_id]
|
|
||||||
continue
|
|
||||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
|
|
||||||
session.delete(row)
|
|
||||||
|
|
||||||
for tenant_id, mode in desired.items():
|
for tenant_id, mode in desired.items():
|
||||||
if tenant_id not in existing:
|
row = existing.get(tenant_id)
|
||||||
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
|
if row is None:
|
||||||
|
row = GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode)
|
||||||
|
session.add(row)
|
||||||
|
else:
|
||||||
|
row.mode = mode
|
||||||
|
retained.append(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
sync_template(session, item)
|
|
||||||
|
|
||||||
|
commands = [
|
||||||
def sync_template(session: Session, item: GovernanceTemplate) -> None:
|
_command(item, row, operation="remove", source="admin.assignment-reconciliation")
|
||||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
for row in removed
|
||||||
GovernanceTemplateAssignment.template_id == item.id
|
]
|
||||||
).all()
|
commands.extend(
|
||||||
for assignment in assignments:
|
_command(item, row, operation="upsert", source="admin.assignment-reconciliation")
|
||||||
required = assignment.mode == "required"
|
for row in retained
|
||||||
_governance_materializer().sync_template(
|
|
||||||
session,
|
|
||||||
_materialization(item, tenant_id=assignment.tenant_id, required=required),
|
|
||||||
)
|
)
|
||||||
|
result = _reconcile(
|
||||||
|
session,
|
||||||
|
commands,
|
||||||
|
source="assignment reconciliation",
|
||||||
|
)
|
||||||
|
_raise_blocked(result)
|
||||||
|
for row in removed:
|
||||||
|
session.delete(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_template(session: Session, item: GovernanceTemplate) -> None:
|
def sync_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
|
||||||
|
return synchronize_templates(session, template_ids=(item.id,), dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize_templates(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
template_ids: Iterable[str],
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GovernanceProjectionResult:
|
||||||
|
requested = tuple(dict.fromkeys(template_ids))
|
||||||
|
if not requested or len(requested) > MAX_TEMPLATES_PER_SYNCHRONIZATION:
|
||||||
|
raise AdminValidationError(
|
||||||
|
f"Select between 1 and {MAX_TEMPLATES_PER_SYNCHRONIZATION} governance templates."
|
||||||
|
)
|
||||||
|
templates = session.query(GovernanceTemplate).filter(GovernanceTemplate.id.in_(requested)).all()
|
||||||
|
templates_by_id = {item.id: item for item in templates}
|
||||||
|
missing_templates = sorted(set(requested) - set(templates_by_id))
|
||||||
|
if missing_templates:
|
||||||
|
raise AdminValidationError("Unknown governance templates: " + ", ".join(missing_templates[:10]))
|
||||||
|
assignments = (
|
||||||
|
session.query(GovernanceTemplateAssignment)
|
||||||
|
.filter(GovernanceTemplateAssignment.template_id.in_(requested))
|
||||||
|
.order_by(
|
||||||
|
GovernanceTemplateAssignment.template_id.asc(),
|
||||||
|
GovernanceTemplateAssignment.tenant_id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(assignments) > MAX_ASSIGNMENTS_PER_TEMPLATE:
|
||||||
|
raise AdminValidationError(
|
||||||
|
f"A synchronization run supports at most {MAX_ASSIGNMENTS_PER_TEMPLATE} tenant assignments."
|
||||||
|
)
|
||||||
|
tenant_ids = {item.tenant_id for item in assignments}
|
||||||
|
known_tenants = {
|
||||||
|
tenant_id
|
||||||
|
for (tenant_id,) in session.query(Tenant.id).filter(Tenant.id.in_(tenant_ids)).all()
|
||||||
|
} if tenant_ids else set()
|
||||||
|
|
||||||
|
valid_commands = [
|
||||||
|
_command(
|
||||||
|
templates_by_id[assignment.template_id],
|
||||||
|
assignment,
|
||||||
|
operation="upsert",
|
||||||
|
source="admin.bulk-synchronization",
|
||||||
|
)
|
||||||
|
for assignment in assignments
|
||||||
|
if assignment.tenant_id in known_tenants
|
||||||
|
]
|
||||||
|
result = _reconcile(
|
||||||
|
session,
|
||||||
|
valid_commands,
|
||||||
|
source="bulk synchronization",
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
invalid_outcomes = [
|
||||||
|
GovernanceProjectionOutcome(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
template_id=assignment.template_id,
|
||||||
|
tenant_id=assignment.tenant_id,
|
||||||
|
kind=templates_by_id[assignment.template_id].kind, # type: ignore[arg-type]
|
||||||
|
operation="upsert",
|
||||||
|
status="blocked",
|
||||||
|
blocker_codes=("unknown_tenant",),
|
||||||
|
message="The assigned tenant no longer exists.",
|
||||||
|
provenance={
|
||||||
|
"source": "admin.bulk-synchronization",
|
||||||
|
"template_id": assignment.template_id,
|
||||||
|
"assignment_mode": assignment.mode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for assignment in assignments
|
||||||
|
if assignment.tenant_id not in known_tenants
|
||||||
|
]
|
||||||
|
outcome_by_assignment = {
|
||||||
|
item.assignment_id: item for item in (*result.outcomes, *invalid_outcomes)
|
||||||
|
}
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=result.operation_id,
|
||||||
|
outcomes=tuple(outcome_by_assignment[item.id] for item in assignments),
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
|
||||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||||
GovernanceTemplateAssignment.template_id == item.id
|
GovernanceTemplateAssignment.template_id == item.id
|
||||||
).all()
|
).all()
|
||||||
for assignment in assignments:
|
result = _reconcile(
|
||||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
|
session,
|
||||||
|
(
|
||||||
|
_command(item, assignment, operation="remove", source="admin.template-deletion")
|
||||||
|
for assignment in assignments
|
||||||
|
),
|
||||||
|
source="template deletion",
|
||||||
|
)
|
||||||
|
_raise_blocked(result)
|
||||||
session.delete(item)
|
session.delete(item)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,9 +1,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_admin.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
|
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_admin.backend.dsar_provider import (
|
||||||
|
ADMIN_DSAR_CAPABILITY,
|
||||||
|
AdminDsarProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _route_factory(context: ModuleContext):
|
def _route_factory(context: ModuleContext):
|
||||||
@@ -13,12 +37,502 @@ def _route_factory(context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> AdminDsarProvider:
|
||||||
|
return AdminDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_PERMISSIONS = (
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="admin:module:read",
|
||||||
|
module_id="admin",
|
||||||
|
resource="module",
|
||||||
|
action="read",
|
||||||
|
label="View tenant modules",
|
||||||
|
description="Inspect module availability, requirements, and effective state for the active tenant.",
|
||||||
|
category="Administration",
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="admin:module:write",
|
||||||
|
module_id="admin",
|
||||||
|
resource="module",
|
||||||
|
action="write",
|
||||||
|
label="Manage tenant modules",
|
||||||
|
description="Enable or disable modules for the active tenant within system policy.",
|
||||||
|
category="Administration",
|
||||||
|
level="tenant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_ROLE_TEMPLATES = (
|
||||||
|
RoleTemplate(
|
||||||
|
slug="module_admin",
|
||||||
|
name="Module administrator",
|
||||||
|
description="Manage the active tenant's module selection within system policy.",
|
||||||
|
permissions=("admin:module:read", "admin:module:write"),
|
||||||
|
level="tenant",
|
||||||
|
managed=True,
|
||||||
|
protected=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="admin",
|
id="admin",
|
||||||
name="Admin",
|
name="Admin",
|
||||||
version="0.1.5",
|
version="0.1.23",
|
||||||
dependencies=("access",),
|
permissions=ADMIN_PERMISSIONS,
|
||||||
|
role_templates=ADMIN_ROLE_TEMPLATES,
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=ADMIN_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
),
|
||||||
|
capability_factories={ADMIN_DSAR_CAPABILITY: _dsar_provider},
|
||||||
|
capability_documentation={
|
||||||
|
ADMIN_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Administration data-subject request coverage",
|
||||||
|
summary=(
|
||||||
|
"Records the reviewed absence of subject identifiers in Admin-owned "
|
||||||
|
"governance-template state."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.data-subject-request-coverage",
|
||||||
|
title="Administration data-subject request coverage",
|
||||||
|
summary=(
|
||||||
|
"Admin-owned governance templates contain no data-subject or operator "
|
||||||
|
"identity fields."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The Admin module stores reusable governance-template definitions and "
|
||||||
|
"tenant availability assignments. These tables contain permissions, "
|
||||||
|
"template labels, and tenant identifiers, but no account, membership, "
|
||||||
|
"identity, email, author, approver, or other data-subject reference. "
|
||||||
|
"Admin therefore contributes an explicit zero-result provider so the "
|
||||||
|
"privacy workspace can distinguish reviewed non-applicability from an "
|
||||||
|
"unexplained coverage gap. Module-lifecycle and configuration-change "
|
||||||
|
"actor evidence remains owned by Core, Audit, Access, or Ops and is "
|
||||||
|
"returned by those modules' providers."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("privacy_officer", "system_admin", "auditor"),
|
||||||
|
related_modules=("core", "access", "audit", "ops"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Abdeckung von Betroffenenanfragen in der Administration",
|
||||||
|
"summary": (
|
||||||
|
"Die von Admin verwalteten Governance-Vorlagen enthalten keine Felder mit Betroffenen- oder "
|
||||||
|
"Betriebsidentitäten."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Das Admin-Modul speichert wiederverwendbare Definitionen von Governance-Vorlagen und deren Verfügbarkeit für "
|
||||||
|
"Mandanten. Diese Tabellen enthalten Berechtigungen, Vorlagenbezeichnungen und Mandantenkennungen, aber keine "
|
||||||
|
"Konten, Mitgliedschaften, Identitäten, E-Mail-Adressen, Autorinnen, Freigebenden oder andere Betroffenenverweise. "
|
||||||
|
"Admin stellt deshalb einen ausdrücklichen Provider mit leerem Ergebnis bereit, damit der Datenschutz-Arbeitsbereich "
|
||||||
|
"eine geprüfte Nichtanwendbarkeit von einer ungeklärten Abdeckungslücke unterscheiden kann. Akteursnachweise zu "
|
||||||
|
"Modul-Lebenszyklus und Konfigurationsänderungen gehören weiterhin Core, Audit, Access oder Ops und werden von deren "
|
||||||
|
"Providern geliefert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"coverage_classification": "reviewed_no_subject_data",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.workspace",
|
||||||
|
title="Use the administration workspace",
|
||||||
|
summary="The administration workspace shows only the sections supplied by enabled modules and allowed by the current account's permissions.",
|
||||||
|
body="System and tenant administration share one workspace. Available sections can include settings, configuration changes and packages, governance templates, groups, and module lifecycle controls. A missing section normally means that its owning module is disabled or the current account lacks the required authority. System appearance settings select a validated palette default. Changing the separate palette lock additionally requires policy-write authority; a system lock suppresses tenant and personal palette choices, while an unlocked default remains inheritable and overridable. The separate advanced-override policy is disabled by default and also requires policy-write authority. Enabling it permits tenants to inherit or narrow access to Core's versioned, accessibility-validated personal accent, surface, and status editor; palette locks continue to win. Opening lists and creation or edit dialogs does not save changes. If an authorized section reports a load failure, reload after deployment and report the frontend diagnostic; do not broaden permissions or recreate records to work around a render failure.",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "system_admin", "operator"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Administrationsarbeitsbereich verwenden",
|
||||||
|
"summary": (
|
||||||
|
"Der Administrationsarbeitsbereich zeigt nur Bereiche, die aktivierte Module bereitstellen und für die das aktuelle "
|
||||||
|
"Konto berechtigt ist."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Das Öffnen von Listen sowie Anlage- oder Bearbeitungsdialogen speichert keine Änderungen. Meldet ein berechtigter Bereich einen Ladefehler, laden Sie nach der Bereitstellung neu und melden Sie die Oberflächendiagnose; erweitern Sie keine Berechtigungen und legen Sie keine Datensätze erneut an, um einen Darstellungsfehler zu umgehen. "
|
||||||
|
"System- und Mandantenadministration teilen sich einen Arbeitsbereich. Verfügbare Bereiche können Einstellungen, "
|
||||||
|
"Konfigurationsänderungen und -pakete, Governance-Vorlagen, Gruppen und die Steuerung des Modul-Lebenszyklus umfassen. "
|
||||||
|
"Fehlt ein Bereich, ist normalerweise sein zuständiges Modul deaktiviert oder das aktuelle Konto besitzt nicht die "
|
||||||
|
"erforderliche Berechtigung. Die Systemeinstellungen für das Erscheinungsbild wählen eine validierte Standardpalette. "
|
||||||
|
"Das Ändern der getrennten Palettensperre erfordert zusätzlich die Berechtigung zum Schreiben von Richtlinien; eine "
|
||||||
|
"Systemsperre unterdrückt Mandanten- und persönliche Paletten, ein ungesperrter Standard bleibt vererbbar und "
|
||||||
|
"überschreibbar. Die getrennte Richtlinie für erweiterte Anpassungen ist standardmäßig deaktiviert und verlangt ebenfalls "
|
||||||
|
"die Richtlinienschreibberechtigung. Wird sie aktiviert, können Mandanten den Zugriff auf Cores versionierten und auf "
|
||||||
|
"Barrierefreiheit geprüften Editor für persönliche Akzent-, Flächen- und Statusfarben erben oder einschränken; "
|
||||||
|
"Palettensperren behalten Vorrang."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"admin.workspace",
|
||||||
|
"admin.overview",
|
||||||
|
"admin.section-navigation",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.tenant-module-entitlements",
|
||||||
|
title="Govern modules per tenant",
|
||||||
|
summary="System administrators set each tenant's module ceiling and forced modules; tenant module administrators choose within that ceiling.",
|
||||||
|
body=(
|
||||||
|
"Deployment activation installs and loads module code for the whole instance. Tenant module governance is a separate entitlement layer: system administrators mark modules unavailable, available, or forced for a tenant and may also change that tenant's selection. A tenant module administrator can only enable or disable available modules; forced modules and required dependencies remain effective. Module entitlement never grants permissions, and malformed policy fails closed to protected administration modules. Disabling a module stops new API, capability, schedule, and worker admission for that tenant; accepted durable work remains queued and requires an operator decision rather than being executed or discarded. Enabling a capability module such as Encryption only makes its services available; data encryption remains an explicit owning-module policy or migration decision."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "module_admin", "tenant_admin"),
|
||||||
|
related_modules=("access", "policy", "views", "encryption"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Module je Mandant steuern",
|
||||||
|
"summary": (
|
||||||
|
"Systemadministrierende legen Modulobergrenzen und erzwungene Module je Mandant fest; "
|
||||||
|
"Mandanten-Moduladministrierende wählen innerhalb dieser Grenzen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die Aktivierung im Deployment installiert und lädt Modulcode für die gesamte Instanz. Die Modulsteuerung pro Mandant "
|
||||||
|
"ist eine getrennte Berechtigungsebene: Systemadministrierende markieren Module für einen Mandanten als nicht verfügbar, "
|
||||||
|
"verfügbar oder erzwungen und können auch dessen Auswahl ändern. Mandanten-Moduladministrierende dürfen nur verfügbare "
|
||||||
|
"Module aktivieren oder deaktivieren; erzwungene Module und erforderliche Abhängigkeiten bleiben wirksam. Eine "
|
||||||
|
"Modulberechtigung gewährt niemals Zugriffsrechte, und fehlerhafte Richtlinien schließen geschützte Administrationsmodule "
|
||||||
|
"sicher aus. Das Deaktivieren eines Moduls stoppt neue API-, Fähigkeits-, Zeitplan- und Worker-Aufnahmen für den Mandanten. "
|
||||||
|
"Bereits angenommene dauerhafte Arbeit bleibt in der Warteschlange und erfordert eine Betriebsentscheidung, statt "
|
||||||
|
"ausgeführt oder verworfen zu werden. Die Aktivierung eines Fähigkeitsmoduls wie Encryption stellt nur dessen Dienste "
|
||||||
|
"bereit; die Verschlüsselung von Daten bleibt eine ausdrückliche Richtlinien- oder Migrationsentscheidung des "
|
||||||
|
"zuständigen Moduls."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"admin.system-tenant-modules",
|
||||||
|
"admin.tenant-modules",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.governance-and-module-lifecycle",
|
||||||
|
title="Govern configuration and module lifecycle",
|
||||||
|
summary="Admin owns reusable governance templates, configuration packages, and the operator-facing module lifecycle queue.",
|
||||||
|
body=(
|
||||||
|
"Configuration packages import or export module-owned configuration; they do not install software. The operator selects a package and tenant, runs preflight, and supplies only the deployment values declared by that package through generated fields. Editing the package, tenant, or supplied data makes the previous preflight stale and disables Apply until a new blocker-free preflight succeeds. Provider applies may commit independently, so the result states whether nothing changed, a retained database snapshot is the rollback path, or a partial apply requires recovery; the screen never promises atomic cross-module undo. Export selects the providers named by the package fragments, redacts secret requirements, and records source version, module versions, exporter, scope, and timestamp as provenance. Module catalog actions create reviewed install, update, activation, deactivation, or retirement requests for the trusted installer process. Governance templates materialize approved role and group structures through the owning Access contracts. Template assignment validation bulk-loads the selected tenants before mutation, and synchronization delegates one bounded versioned batch to Access instead of issuing per-tenant calls. Operators may preview or apply synchronization for up to 100 selected templates and 500 assignments. Every assignment returns an explicit outcome and provenance; missing tenants, protected memberships, role mappings, and module vetoes remain visible as blockers rather than being skipped. Retries are idempotent and preview/application runs are audited."
|
||||||
|
),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "operator", "module_admin"),
|
||||||
|
related_modules=("access", "audit", "ops"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Konfiguration und Modul-Lebenszyklus steuern",
|
||||||
|
"summary": "Admin stellt wiederverwendbare Governance-Vorlagen, Konfigurationspakete und die Bedienoberfläche für den Modul-Lebenszyklus bereit.",
|
||||||
|
"body": (
|
||||||
|
"Konfigurationspakete importieren oder exportieren modul-eigene Konfiguration; sie installieren keine Software. "
|
||||||
|
"Die ausführende Person wählt Paket und Mandant, startet die Vorprüfung und erfasst ausschließlich die vom Paket deklarierten Einsatzwerte in erzeugten Feldern. "
|
||||||
|
"Eine Änderung an Paket, Mandant oder Eingabedaten macht die vorherige Vorprüfung ungültig und sperrt Anwenden, bis eine neue Vorprüfung ohne Blocker erfolgreich ist. "
|
||||||
|
"Provider können ihre Änderungen unabhängig festschreiben. Das Ergebnis weist deshalb ausdrücklich aus, ob nichts geändert wurde, ob der aufbewahrte Datenbank-Snapshot der Rücksetzweg ist oder ob eine Teilanwendung Wiederherstellung verlangt; eine atomare modulübergreifende Rücknahme wird nicht zugesagt. "
|
||||||
|
"Beim Export werden die in den Fragmenten genannten Provider ausgewählt, geheime Anforderungen geschwärzt und Quellversion, Modulversionen, exportierende Identität, Umfang und Zeitpunkt als Herkunftsnachweis festgehalten. "
|
||||||
|
"Aktionen im Modulkatalog erzeugen geprüfte Installations-, Aktualisierungs-, Aktivierungs-, Deaktivierungs- oder Stilllegungsaufträge für den vertrauenswürdigen Installer-Prozess. "
|
||||||
|
"Governance-Vorlagen materialisieren freigegebene Rollen- und Gruppenstrukturen über die zuständigen Access-Verträge. "
|
||||||
|
"Die Validierung lädt ausgewählte Mandanten gesammelt; die Synchronisierung übergibt einen begrenzten, versionierten Stapel an Access. Bis zu 100 Vorlagen und 500 Zuordnungen werden je Lauf verarbeitet. "
|
||||||
|
"Jede Zuordnung liefert Ergebnis und Herkunft; fehlende Mandanten, geschützte Mitgliedschaften, Rollenabbildungen und Modul-Vetos bleiben als Blocker sichtbar. Wiederholungen sind idempotent, Vorschau und Anwendung werden protokolliert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"admin.system-settings",
|
||||||
|
"admin.configuration-changes",
|
||||||
|
"admin.configuration-packages",
|
||||||
|
"admin.governance-templates",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Configuration packages do not install modules or grant permissions.",
|
||||||
|
"Generic rollback is snapshot recovery; providers may expose narrower compensation separately.",
|
||||||
|
"Secret values are never included in export provenance or portable fragments.",
|
||||||
|
],
|
||||||
|
"operational_consequences": [
|
||||||
|
"Changing package inputs invalidates the previous preflight and requires another review.",
|
||||||
|
"A partial apply stops before later fragments and must be recovered before retrying.",
|
||||||
|
"Retain the pre-apply database snapshot until post-apply verification is complete.",
|
||||||
|
],
|
||||||
|
"api_paths": [
|
||||||
|
"/api/v1/admin/configuration-packages/dry-run",
|
||||||
|
"/api/v1/admin/configuration-packages/apply",
|
||||||
|
"/api/v1/admin/configuration-packages/export",
|
||||||
|
"/api/v1/admin/system/governance-templates",
|
||||||
|
"/api/v1/admin/system/governance-templates/synchronize",
|
||||||
|
],
|
||||||
|
"synchronization_limits": {"templates": 100, "assignments": 500},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.language-reference-and-packages",
|
||||||
|
title="Manage reference language and language packages",
|
||||||
|
summary="German is the reference and new-installation default while configured tenant and user language choices remain effective.",
|
||||||
|
body=(
|
||||||
|
"German is the first-class product acceptance language and the default for a new system or tenant. English remains installed as the source-code fallback. The dedicated Language packages administration surface separates package installation, activation, deactivation, uninstall eligibility, and the default for newly created tenants from general system settings. Its compatibility matrix derives each package state from the installed module ID, exact module version, and available runtime translation catalog; a missing catalog is shown as incompatible rather than treated as translated. German, English, and the active default cannot be uninstalled. System administrators control available and enabled language packages and the system default; tenant and user choices can select only languages allowed above them. Existing explicit preferences are preserved when this baseline is introduced. Disabling a language must not discard translated content or silently rewrite a stored preference."
|
||||||
|
),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "tenant_admin"),
|
||||||
|
related_modules=("tenancy", "access", "docs"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Referenzsprache und Sprachpakete verwalten",
|
||||||
|
"summary": (
|
||||||
|
"Deutsch ist Referenzsprache und Standard für Neuinstallationen, während konfigurierte Sprachwahlen von Mandanten und "
|
||||||
|
"Benutzenden wirksam bleiben."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Deutsch ist die maßgebliche Sprache für die Produktabnahme und Standard für ein neues System oder einen neuen Mandanten. "
|
||||||
|
"Englisch bleibt als Rückfall für den Quellcode installiert. Der eigene Administrationsbereich für Sprachpakete trennt "
|
||||||
|
"Installation, Aktivierung, Deaktivierung, Deinstallierbarkeit und den Standard für neu angelegte Mandanten von den "
|
||||||
|
"allgemeinen Systemeinstellungen. Die Kompatibilitätsmatrix leitet den Zustand jedes Pakets aus installierter Modulkennung, "
|
||||||
|
"exakter Modulversion und vorhandenem Laufzeit-Übersetzungskatalog ab; ein fehlender Katalog wird als inkompatibel angezeigt "
|
||||||
|
"und nicht als übersetzt behandelt. Deutsch, Englisch und der aktive Standard können nicht deinstalliert werden. "
|
||||||
|
"Systemadministrierende steuern verfügbare und aktivierte Sprachpakete sowie den Systemstandard; Mandanten und Benutzende "
|
||||||
|
"können nur Sprachen wählen, die von den übergeordneten Ebenen erlaubt sind. Bereits ausdrücklich gespeicherte Präferenzen "
|
||||||
|
"bleiben bei Einführung dieses Standards erhalten. Das Deaktivieren einer Sprache darf weder übersetzte Inhalte verwerfen "
|
||||||
|
"noch eine gespeicherte Präferenz stillschweigend umschreiben."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"route": "/admin?section=system-language-packages",
|
||||||
|
"screen": "Language packages",
|
||||||
|
"help_contexts": [
|
||||||
|
"admin.system-language-packages",
|
||||||
|
"admin.system-language-packages.compatibility",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.system-navigation",
|
||||||
|
title="Set the system navigation baseline",
|
||||||
|
summary="System administrators define the instance-wide side-rail order, visibility baseline, and entries that lower scopes must keep visible.",
|
||||||
|
body=(
|
||||||
|
"The system navigation setting starts from module-declared defaults. Tenant settings take precedence over the system order and visibility, and personal settings take precedence over both. A system lock prevents tenant and user preferences from hiding an entry but does not prevent them from moving it. Removing the system preference restores module defaults. Navigation policy changes presentation only: module entitlement, View policy, and permissions still decide whether a surface is accessible."
|
||||||
|
" Use the shared layout editor to drag modules and separators, add or restore available modules, remove entries from the rail, and label groups. Keyboard handles support Space, arrows, Enter, and Escape; arrow buttons also move individual entries. Collapsed rails retain group dividers. Changes stay in the page draft until Save, and Use inherited layout restores live module defaults rather than copying them. Removing a navigation entry never uninstalls a module or deletes its records."
|
||||||
|
),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin",),
|
||||||
|
related_modules=("tenancy", "access", "views"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Systemweite Navigationsgrundlage festlegen",
|
||||||
|
"summary": (
|
||||||
|
"Systemadministrierende definieren die instanzweite Reihenfolge und Sichtbarkeitsgrundlage der Seitenleiste sowie "
|
||||||
|
"Einträge, die untergeordnete Ebenen sichtbar halten müssen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die systemweite Navigation beginnt mit den von den Modulen deklarierten Standardwerten. Mandanteneinstellungen haben "
|
||||||
|
"bei Reihenfolge und Sichtbarkeit Vorrang vor dem System, persönliche Einstellungen vor beiden. Eine Systemsperre "
|
||||||
|
"verhindert, dass Mandanten- oder Benutzereinstellungen einen Eintrag ausblenden, erlaubt aber weiterhin dessen "
|
||||||
|
"Verschiebung. Das Entfernen der Systempräferenz stellt die Modulstandardwerte wieder her. Navigationsrichtlinien ändern "
|
||||||
|
"nur die Darstellung; Modulberechtigung, View-Richtlinie und Zugriffsrechte bestimmen weiterhin, ob eine Oberfläche "
|
||||||
|
"erreichbar ist."
|
||||||
|
" Im gemeinsamen Anordnungseditor ziehen Sie Module und Trennlinien, ergänzen verfügbare Module, entfernen Einträge aus der Leiste und benennen Gruppen. Tastaturgriffe unterstützen Leertaste, Pfeile, Eingabe und Escape; Pfeilschaltflächen verschieben einzelne Einträge ebenfalls. Eingeklappte Leisten behalten Gruppentrenner. Änderungen bleiben bis zum Speichern im Seitenentwurf. Geerbte Anordnung verwenden stellt die aktuellen Modulstandardwerte wieder her, statt sie zu kopieren. Das Entfernen eines Navigationseintrags deinstalliert kein Modul und löscht keine Datensätze."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["admin.system-settings"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.module-lifecycle-workflow",
|
||||||
|
title="Plan and supervise module lifecycle changes",
|
||||||
|
summary="Move a reviewed module package plan through preflight, maintenance-gated queueing, daemon execution, and durable run evidence.",
|
||||||
|
body=(
|
||||||
|
"The Modules administration surface projects one operator workflow: save a package plan, resolve preflight findings, enter maintenance mode with the required authority, queue a supervised installer request, and inspect the matching run record. "
|
||||||
|
"When no deployment-specific catalog is configured, the package directory discovers the signed public GovOPlaN stable catalog. Operators can search and filter available, installed, update, blocked, and withdrawn entries; each row exposes the signed source revision, artifact digest, release notes, configuration requirements, and manifest-declared permission scopes supplied by the catalog. Permission disclosure supports review but never grants a scope. Missing dependencies, incompatible named interfaces, unsupported update windows, and withdrawn releases block plan creation. Selecting an eligible entry copies its exact signed registry identities into the plan; artifact download and digest verification happen only in the trusted installer. "
|
||||||
|
"The stage indicator is derived from the saved plan timestamp, the latest matching request, and its run; an older request is never presented as evidence for a newer plan. "
|
||||||
|
"Disabled queue actions name the earliest blocker, the person who can resolve it, and the plan surface where work continues. Package mutation remains outside the FastAPI process and recovery evidence remains durable in the installer ledger. Shared deployments require an immutable image rollout rather than node-local mutation. Tenant entitlement and user/View visibility remain policy settings, not package lifecycle operations."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "operator", "module_admin"),
|
||||||
|
related_modules=("ops", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Änderungen am Modul-Lebenszyklus planen und überwachen",
|
||||||
|
"summary": (
|
||||||
|
"Einen geprüften Modulpaketplan durch Vorprüfung, wartungsgeschützte Einreihung, Daemon-Ausführung und dauerhafte "
|
||||||
|
"Laufnachweise führen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der Administrationsbereich Module bildet einen Betriebsablauf ab: Paketplan speichern, Befunde der Vorprüfung beheben, "
|
||||||
|
"mit der erforderlichen Berechtigung in den Wartungsmodus wechseln, einen überwachten Installer-Auftrag einreihen und "
|
||||||
|
"den zugehörigen Laufdatensatz prüfen. Ist kein deploymentspezifischer Katalog konfiguriert, ermittelt das "
|
||||||
|
"Paketverzeichnis den signierten öffentlichen stabilen GovOPlaN-Katalog. Verfügbare, installierte, aktualisierbare, "
|
||||||
|
"blockierte und zurückgezogene Einträge lassen sich suchen und filtern. Jede Zeile zeigt signierte Quellrevision, "
|
||||||
|
"Artefakt-Digest, Versionshinweise, Konfigurationsanforderungen und die vom Manifest deklarierten Berechtigungen des "
|
||||||
|
"Katalogs. Diese Offenlegung unterstützt die Prüfung, gewährt aber keine Berechtigung. Fehlende Abhängigkeiten, "
|
||||||
|
"inkompatible benannte Schnittstellen, nicht unterstützte Aktualisierungsfenster und zurückgezogene Releases blockieren "
|
||||||
|
"die Planerstellung. Die Auswahl eines geeigneten Eintrags kopiert seine exakten signierten Registry-Kennungen in den Plan; "
|
||||||
|
"Download und Digest-Prüfung erfolgen ausschließlich im vertrauenswürdigen Installer. Die Stufenanzeige wird aus "
|
||||||
|
"Speicherzeitpunkt des Plans, neuestem passendem Auftrag und dessen Lauf abgeleitet; ein älterer Auftrag gilt nie als "
|
||||||
|
"Nachweis für einen neueren Plan. Deaktivierte Einreihungsaktionen nennen den frühesten Blocker, die zuständige Person und "
|
||||||
|
"die Planoberfläche für die Fortsetzung. Paketänderungen bleiben außerhalb des FastAPI-Prozesses, "
|
||||||
|
"Wiederherstellungsnachweise dauerhaft im Installer-Ledger. Gemeinsame Deployments benötigen ein unveränderliches "
|
||||||
|
"Image-Rollout statt lokaler Änderungen je Knoten. Mandantenberechtigung und Benutzer-/View-Sichtbarkeit bleiben "
|
||||||
|
"Richtlinieneinstellungen und sind keine Paket-Lebenszyklusaktionen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"context_ids": [
|
||||||
|
"admin.module-lifecycle",
|
||||||
|
"admin.module-lifecycle.queue-blocker",
|
||||||
|
],
|
||||||
|
"help_contexts": [
|
||||||
|
"admin.module-lifecycle",
|
||||||
|
"admin.module-lifecycle.queue-blocker",
|
||||||
|
"admin.module-lifecycle.plan",
|
||||||
|
"admin.module-lifecycle.catalog",
|
||||||
|
"admin.module-lifecycle.evidence",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="admin.data-subject-request-workspace",
|
||||||
|
title="Review data-subject requests",
|
||||||
|
summary="The privacy workspace makes provider coverage, exports, retained evidence, and selected erasure actions explicit.",
|
||||||
|
body=(
|
||||||
|
"The data-subject request section is visible only with the Access privacy read permission. Create and search operations require privacy management authority; exports and erasure execution have separate permissions. Subject selectors support account, membership, identity, email, and namespaced source-system references. The workspace never treats a module without a provider as searched. It lists provider coverage and failures next to collected records. An erasure plan separates executable provider actions from retained or manual-review items. Destructive execution uses the current resource revision, selected actions, and an exact confirmation phrase."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("privacy_officer", "tenant_owner", "operator"),
|
||||||
|
related_modules=("access", "audit", "policy"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("admin", "access"),
|
||||||
|
any_scopes=(
|
||||||
|
"access:privacy:read",
|
||||||
|
"access:privacy:manage",
|
||||||
|
"access:privacy:export",
|
||||||
|
"access:privacy:erase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen prüfen",
|
||||||
|
"summary": "Der Datenschutz-Arbeitsbereich weist Anbieterabdeckung, Exporte, aufbewahrte Nachweise und ausgewählte Löschaktionen explizit aus.",
|
||||||
|
"body": "Der Bereich ist nur mit dem Leserecht für Datenschutzanfragen sichtbar. Anlage und Suche, Export sowie Löschausführung besitzen getrennte Rechte. Betroffene Personen können über Konto, Mitgliedschaft, Identität, E-Mail-Adresse und namensraumgebundene Quellsystemreferenzen gesucht werden. Module ohne Anbieter werden nicht als durchsucht dargestellt. Ein Löschplan trennt ausführbare Aktionen von aufzubewahrenden oder manuell zu prüfenden Einträgen. Die Ausführung verlangt die aktuelle Revision, eine Aktionsauswahl und eine exakte Bestätigung.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"route": "/admin?section=tenant-data-subject-requests",
|
||||||
|
"api_path": "/api/v1/admin/privacy/data-subject-requests",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="admin",
|
||||||
|
package_name="@govoplan/admin-webui",
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.overview",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Administration overview",
|
||||||
|
order=0,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-settings",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="System settings",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-language-packages",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Language packages",
|
||||||
|
order=15,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-configuration-changes",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Configuration changes",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-configuration-packages",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Configuration packages",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-role-templates",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Role templates",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-groups",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Group templates",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-modules",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Modules",
|
||||||
|
order=85,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.system-tenant-modules",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Tenant modules",
|
||||||
|
order=86,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.tenant-data-subject-requests",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Data-subject requests",
|
||||||
|
order=55,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="admin.section.tenant-modules",
|
||||||
|
module_id="admin",
|
||||||
|
kind="section",
|
||||||
|
label="Modules",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
|
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
@@ -27,6 +541,32 @@ manifest = ModuleManifest(
|
|||||||
label="Admin",
|
label="Admin",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="runtime_meta",
|
||||||
|
kind="presentation",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="README.md",
|
||||||
|
test_ref="tests/test_catalog_plan.py",
|
||||||
|
known_limits=(
|
||||||
|
"Some module-specific administration surfaces still own their own navigation and release evidence.",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"administration workspace",
|
||||||
|
"configuration package workflow",
|
||||||
|
"module lifecycle request",
|
||||||
|
),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"module installation effect",
|
||||||
|
"access policy",
|
||||||
|
"module-owned settings",
|
||||||
|
),
|
||||||
|
operations_docs=("README.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""GovOPlaN Admin backend tests."""
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_admin.backend.api.v1.routes import (
|
||||||
|
_catalog_plan_item,
|
||||||
|
_module_package_catalog_item,
|
||||||
|
)
|
||||||
|
from govoplan_admin.backend.api.v1.schemas import SystemSettingsItem
|
||||||
|
from govoplan_core.core.modules import ModuleInterfaceProvider, ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogPlanItemTests(unittest.TestCase):
|
||||||
|
def test_system_settings_use_german_reference_default(self) -> None:
|
||||||
|
self.assertEqual("de", SystemSettingsItem().default_locale)
|
||||||
|
|
||||||
|
def test_builds_catalog_item_and_preserves_catalog_metadata(self) -> None:
|
||||||
|
validation: dict[str, object] = {
|
||||||
|
"valid": True,
|
||||||
|
"source": "https://catalog.example.test/modules.json",
|
||||||
|
"source_type": "remote",
|
||||||
|
"channel": "stable",
|
||||||
|
"signed": True,
|
||||||
|
"trusted": True,
|
||||||
|
"modules": [
|
||||||
|
"ignored",
|
||||||
|
{"module_id": "other", "action": "install"},
|
||||||
|
{
|
||||||
|
"module_id": "calendar",
|
||||||
|
"action": "install",
|
||||||
|
"python_package": "govoplan-calendar",
|
||||||
|
"python_ref": 42,
|
||||||
|
"webui_package": "@govoplan/calendar-webui",
|
||||||
|
"artifact_integrity": {
|
||||||
|
"python": {
|
||||||
|
"url": "https://packages.example.test/calendar.whl",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "Catalog note",
|
||||||
|
"license_features": ["calendar.sync", "", 7],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
decision = {
|
||||||
|
"allowed": True,
|
||||||
|
"reason": "Feature expires soon.",
|
||||||
|
"missing_features": ["calendar.future"],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value=decision,
|
||||||
|
) as license_decision:
|
||||||
|
item, returned_validation = _catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
{"calendar"},
|
||||||
|
validation=validation,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(returned_validation, validation)
|
||||||
|
self.assertEqual(item.module_id, "calendar")
|
||||||
|
self.assertEqual(item.action, "update")
|
||||||
|
self.assertEqual(item.source, "catalog")
|
||||||
|
self.assertEqual(item.python_package, "govoplan-calendar")
|
||||||
|
self.assertIsNone(item.python_ref)
|
||||||
|
self.assertEqual(item.webui_package, "@govoplan/calendar-webui")
|
||||||
|
self.assertIsNone(item.webui_ref)
|
||||||
|
self.assertEqual(item.artifact_integrity["python"]["sha256"], "a" * 64)
|
||||||
|
self.assertEqual(item.notes, "Catalog note\nLicense warning: Feature expires soon.")
|
||||||
|
self.assertEqual(item.catalog["source"], "https://catalog.example.test/modules.json")
|
||||||
|
self.assertEqual(item.catalog["channel"], "stable")
|
||||||
|
self.assertTrue(item.catalog["signed"])
|
||||||
|
license_decision.assert_called_once_with(["calendar.sync", "7"])
|
||||||
|
|
||||||
|
def test_rejects_catalog_action_when_license_is_missing(self) -> None:
|
||||||
|
validation: dict[str, object] = {
|
||||||
|
"valid": True,
|
||||||
|
"modules": [{"module_id": "calendar", "action": "install", "license_features": ["calendar.sync"]}],
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value={"allowed": False, "missing_features": ["calendar.sync", "calendar.write"]},
|
||||||
|
), self.assertRaises(HTTPException) as raised:
|
||||||
|
_catalog_plan_item("calendar", set(), validation=validation)
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(
|
||||||
|
raised.exception.detail,
|
||||||
|
"License does not allow install for calendar: calendar.sync, calendar.write.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_invalid_or_missing_catalog_entries(self) -> None:
|
||||||
|
with self.subTest("invalid catalog"), self.assertRaises(HTTPException) as invalid:
|
||||||
|
_catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
set(),
|
||||||
|
validation={"valid": False, "error": "Signature is invalid."},
|
||||||
|
)
|
||||||
|
self.assertEqual(invalid.exception.status_code, 422)
|
||||||
|
self.assertEqual(invalid.exception.detail, "Signature is invalid.")
|
||||||
|
|
||||||
|
with self.subTest("entry not found"), self.assertRaises(HTTPException) as missing:
|
||||||
|
_catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
set(),
|
||||||
|
validation={
|
||||||
|
"valid": True,
|
||||||
|
"modules": [
|
||||||
|
{"module_id": "calendar", "action": "remove"},
|
||||||
|
{"module_id": "other", "action": "install"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.exception.status_code, 404)
|
||||||
|
self.assertEqual(missing.exception.detail, "Catalog install/update entry not found: calendar")
|
||||||
|
|
||||||
|
with self.subTest("withdrawn entry"), self.assertRaises(HTTPException) as withdrawn:
|
||||||
|
_catalog_plan_item(
|
||||||
|
"calendar",
|
||||||
|
set(),
|
||||||
|
validation={
|
||||||
|
"valid": True,
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "calendar",
|
||||||
|
"action": "install",
|
||||||
|
"availability": "withdrawn",
|
||||||
|
"availability_reason": "Superseded after a security review.",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(withdrawn.exception.status_code, 409)
|
||||||
|
self.assertIn("security review", withdrawn.exception.detail)
|
||||||
|
|
||||||
|
def test_catalog_item_reports_dependency_interface_and_update_compatibility(self) -> None:
|
||||||
|
raw_item: dict[str, object] = {
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"action": "install",
|
||||||
|
"permissions": [{
|
||||||
|
"scope": "calendar:event:read",
|
||||||
|
"label": "Read events",
|
||||||
|
"description": "Read calendar events.",
|
||||||
|
"category": "Calendar",
|
||||||
|
"level": "tenant",
|
||||||
|
"resource": "event",
|
||||||
|
"action": "read",
|
||||||
|
"deprecated": False,
|
||||||
|
}],
|
||||||
|
"dependencies": ["access"],
|
||||||
|
"current_version_min": "0.1.5",
|
||||||
|
"current_version_max_exclusive": "0.2.0",
|
||||||
|
"requires_interfaces": [{
|
||||||
|
"name": "files.storage",
|
||||||
|
"version_min": "2.0.0",
|
||||||
|
"optional": False,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
available = {
|
||||||
|
"calendar": ModuleManifest(id="calendar", name="Calendar", version="0.1.4"),
|
||||||
|
"files": ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="0.1.0",
|
||||||
|
provides_interfaces=(ModuleInterfaceProvider(name="files.storage", version="1.0.0"),),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value={"allowed": True, "enforced": False, "missing_features": []},
|
||||||
|
):
|
||||||
|
item = _module_package_catalog_item(raw_item, available, [raw_item])
|
||||||
|
|
||||||
|
self.assertFalse(item.compatible)
|
||||||
|
self.assertFalse(item.plan_allowed)
|
||||||
|
self.assertEqual("blocked", item.catalog_state)
|
||||||
|
self.assertTrue(item.update_available)
|
||||||
|
self.assertEqual("calendar:event:read", item.permissions[0].scope)
|
||||||
|
self.assertEqual(3, len(item.compatibility_reasons))
|
||||||
|
self.assertTrue(any("update window" in reason for reason in item.compatibility_reasons))
|
||||||
|
self.assertTrue(any("Required module access" in reason for reason in item.compatibility_reasons))
|
||||||
|
self.assertTrue(any("Required interface files.storage" in reason for reason in item.compatibility_reasons))
|
||||||
|
|
||||||
|
def test_catalog_item_accepts_dependencies_and_interfaces_from_same_catalog(self) -> None:
|
||||||
|
raw_item: dict[str, object] = {
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"action": "install",
|
||||||
|
"dependencies": ["access"],
|
||||||
|
"requires_interfaces": [{
|
||||||
|
"name": "access.directory",
|
||||||
|
"version_min": "1.0.0",
|
||||||
|
"optional": False,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
provider: dict[str, object] = {
|
||||||
|
"module_id": "access",
|
||||||
|
"availability": "available",
|
||||||
|
"provides_interfaces": [{"name": "access.directory", "version": "1.1.0"}],
|
||||||
|
}
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value={"allowed": True, "enforced": False, "missing_features": []},
|
||||||
|
):
|
||||||
|
item = _module_package_catalog_item(raw_item, {}, [raw_item, provider])
|
||||||
|
|
||||||
|
self.assertTrue(item.compatible)
|
||||||
|
self.assertTrue(item.plan_allowed)
|
||||||
|
self.assertEqual("available", item.catalog_state)
|
||||||
|
self.assertEqual([], item.compatibility_reasons)
|
||||||
|
|
||||||
|
def test_catalog_item_distinguishes_current_release_and_downgrade(self) -> None:
|
||||||
|
available = {
|
||||||
|
"calendar": ModuleManifest(id="calendar", name="Calendar", version="0.2.0"),
|
||||||
|
}
|
||||||
|
license_decision = {"allowed": True, "enforced": False, "missing_features": []}
|
||||||
|
with patch(
|
||||||
|
"govoplan_admin.backend.api.v1.routes.module_license_decision",
|
||||||
|
return_value=license_decision,
|
||||||
|
):
|
||||||
|
current = _module_package_catalog_item({
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"action": "install",
|
||||||
|
}, available)
|
||||||
|
refresh = _module_package_catalog_item({
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"action": "install",
|
||||||
|
"allow_same_version": True,
|
||||||
|
}, available)
|
||||||
|
downgrade = _module_package_catalog_item({
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.1.9",
|
||||||
|
"action": "install",
|
||||||
|
}, available)
|
||||||
|
allowed_downgrade = _module_package_catalog_item({
|
||||||
|
"module_id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.1.9",
|
||||||
|
"action": "install",
|
||||||
|
"allow_downgrade": True,
|
||||||
|
}, available)
|
||||||
|
|
||||||
|
self.assertTrue(current.compatible)
|
||||||
|
self.assertFalse(current.plan_allowed)
|
||||||
|
self.assertFalse(current.update_available)
|
||||||
|
self.assertEqual("installed", current.catalog_state)
|
||||||
|
self.assertTrue(refresh.plan_allowed)
|
||||||
|
self.assertFalse(downgrade.compatible)
|
||||||
|
self.assertFalse(downgrade.plan_allowed)
|
||||||
|
self.assertTrue(any("does not allow a downgrade" in reason for reason in downgrade.compatibility_reasons))
|
||||||
|
self.assertTrue(allowed_downgrade.compatible)
|
||||||
|
self.assertTrue(allowed_downgrade.plan_allowed)
|
||||||
|
self.assertFalse(allowed_downgrade.update_available)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_admin.backend.dsar_provider import (
|
||||||
|
ADMIN_DSAR_CAPABILITY,
|
||||||
|
AdminDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_admin.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
create_data_subject_request,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: AdminDsarProvider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (ADMIN_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
if name != ADMIN_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type("State", (), {"effective_modules": ("admin",)})()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
if name != ADMIN_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "admin"})(),)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.provider = AdminDsarProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_provider_explicitly_reports_no_subject_records(self) -> None:
|
||||||
|
self.assertIsInstance(self.provider, DsarProvider)
|
||||||
|
subject = DsarSubjectRef(account_id="account-1")
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=subject
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=subject,
|
||||||
|
records=(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_and_workflow_record_reviewed_coverage(self) -> None:
|
||||||
|
self.assertIn(ADMIN_DSAR_CAPABILITY, manifest.capability_factories)
|
||||||
|
self.assertIn(
|
||||||
|
"admin.data-subject-request-coverage",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
|
row = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-ADMIN-1",
|
||||||
|
request_kind="access",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
purpose="Administration coverage review",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="operator-1",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=row,
|
||||||
|
expected_revision=row.resource_revision,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", row.status)
|
||||||
|
self.assertEqual(0, row.search_result["record_count"])
|
||||||
|
self.assertEqual(["admin"], row.coverage["covered_modules"])
|
||||||
|
self.assertEqual([], row.coverage["modules_without_provider"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||||
|
from govoplan_admin.backend.governance import set_template_assignments, synchronize_templates
|
||||||
|
from govoplan_core.core.access import GovernanceProjectionOutcome, GovernanceProjectionResult
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class _Projection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.batches = []
|
||||||
|
|
||||||
|
def reconcile(self, session: object, batch):
|
||||||
|
del session
|
||||||
|
self.batches.append(batch)
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=batch.operation_id,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
outcomes=tuple(
|
||||||
|
GovernanceProjectionOutcome(
|
||||||
|
assignment_id=command.assignment_id,
|
||||||
|
template_id=command.template.template_id,
|
||||||
|
tenant_id=command.template.tenant_id,
|
||||||
|
kind=command.template.kind,
|
||||||
|
operation=command.operation,
|
||||||
|
status=("removed" if command.operation == "remove" else "created"),
|
||||||
|
resource_id=f"resource-{command.assignment_id}",
|
||||||
|
provenance=dict(command.provenance),
|
||||||
|
)
|
||||||
|
for command in batch.commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, projection: _Projection) -> None:
|
||||||
|
self.projection = projection
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == "access.governanceProjection.v1"
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.projection
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceBulkSyncTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.projection = _Projection()
|
||||||
|
self.registry_patch = patch(
|
||||||
|
"govoplan_admin.backend.governance.get_registry",
|
||||||
|
return_value=_Registry(self.projection),
|
||||||
|
)
|
||||||
|
self.registry_patch.start()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.registry_patch.stop()
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _template(self) -> GovernanceTemplate:
|
||||||
|
item = GovernanceTemplate(
|
||||||
|
id="template-1",
|
||||||
|
kind="role",
|
||||||
|
slug="reviewer",
|
||||||
|
name="Reviewer",
|
||||||
|
permissions=["access:role:read"],
|
||||||
|
)
|
||||||
|
self.session.add(item)
|
||||||
|
self.session.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def test_assignment_validation_and_projection_use_bounded_reads(self) -> None:
|
||||||
|
item = self._template()
|
||||||
|
tenants = [
|
||||||
|
Tenant(id=f"tenant-{index}", slug=f"tenant-{index}", name=f"Tenant {index}")
|
||||||
|
for index in range(200)
|
||||||
|
]
|
||||||
|
self.session.add_all(tenants)
|
||||||
|
self.session.commit()
|
||||||
|
self.session.refresh(item)
|
||||||
|
select_count = 0
|
||||||
|
|
||||||
|
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||||
|
nonlocal select_count
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
select_count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||||
|
try:
|
||||||
|
result = set_template_assignments(
|
||||||
|
self.session,
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
{"tenant_id": f"tenant-{index}", "mode": "required" if index % 2 else "available"}
|
||||||
|
for index in range(len(tenants))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||||
|
|
||||||
|
self.assertEqual(2, select_count)
|
||||||
|
self.assertEqual(200, len(result.outcomes))
|
||||||
|
self.assertEqual(1, len(self.projection.batches))
|
||||||
|
self.assertEqual(200, len(self.projection.batches[0].commands))
|
||||||
|
self.assertEqual(200, self.session.query(GovernanceTemplateAssignment).count())
|
||||||
|
|
||||||
|
def test_bulk_synchronization_reports_stale_tenant_without_skipping_valid_assignment(self) -> None:
|
||||||
|
item = self._template()
|
||||||
|
self.session.add(Tenant(id="tenant-valid", slug="valid", name="Valid"))
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
GovernanceTemplateAssignment(
|
||||||
|
id="assignment-valid",
|
||||||
|
template_id=item.id,
|
||||||
|
tenant_id="tenant-valid",
|
||||||
|
mode="required",
|
||||||
|
),
|
||||||
|
GovernanceTemplateAssignment(
|
||||||
|
id="assignment-stale",
|
||||||
|
template_id=item.id,
|
||||||
|
tenant_id="tenant-missing",
|
||||||
|
mode="available",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
result = synchronize_templates(
|
||||||
|
self.session,
|
||||||
|
template_ids=(item.id,),
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["blocked", "created"],
|
||||||
|
sorted(item.status for item in result.outcomes),
|
||||||
|
)
|
||||||
|
stale = next(item for item in result.outcomes if item.assignment_id == "assignment-stale")
|
||||||
|
self.assertEqual(("unknown_tenant",), stale.blocker_codes)
|
||||||
|
self.assertEqual(
|
||||||
|
["assignment-valid"],
|
||||||
|
[command.assignment_id for command in self.projection.batches[-1].commands],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_admin.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(german),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
expected = {
|
||||||
|
"admin.workspace": {"admin.workspace", "admin.overview"},
|
||||||
|
"admin.governance-and-module-lifecycle": {
|
||||||
|
"admin.system-settings",
|
||||||
|
"admin.configuration-changes",
|
||||||
|
"admin.configuration-packages",
|
||||||
|
"admin.governance-templates",
|
||||||
|
},
|
||||||
|
"admin.language-reference-and-packages": {
|
||||||
|
"admin.system-language-packages",
|
||||||
|
"admin.system-language-packages.compatibility",
|
||||||
|
},
|
||||||
|
"admin.module-lifecycle-workflow": {
|
||||||
|
"admin.module-lifecycle",
|
||||||
|
"admin.module-lifecycle.queue-blocker",
|
||||||
|
"admin.module-lifecycle.plan",
|
||||||
|
"admin.module-lifecycle.evidence",
|
||||||
|
},
|
||||||
|
"admin.tenant-module-entitlements": {
|
||||||
|
"admin.system-tenant-modules",
|
||||||
|
"admin.tenant-modules",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for topic_id, contexts in expected.items():
|
||||||
|
self.assertIn(topic_id, topics)
|
||||||
|
metadata = topics[topic_id].metadata or {}
|
||||||
|
self.assertTrue(
|
||||||
|
contexts.issubset(set(metadata.get("help_contexts", ()))),
|
||||||
|
topic_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_contributed_surfaces_remain_declared(self) -> None:
|
||||||
|
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||||
|
self.assertEqual(
|
||||||
|
surface_ids,
|
||||||
|
{
|
||||||
|
"admin.section.overview",
|
||||||
|
"admin.section.system-settings",
|
||||||
|
"admin.section.system-language-packages",
|
||||||
|
"admin.section.system-configuration-changes",
|
||||||
|
"admin.section.system-configuration-packages",
|
||||||
|
"admin.section.system-role-templates",
|
||||||
|
"admin.section.system-groups",
|
||||||
|
"admin.section.system-modules",
|
||||||
|
"admin.section.system-tenant-modules",
|
||||||
|
"admin.section.tenant-data-subject-requests",
|
||||||
|
"admin.section.tenant-modules",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_configuration_package_help_explains_safety_boundaries(self) -> None:
|
||||||
|
topic = next(
|
||||||
|
item
|
||||||
|
for item in manifest.documentation
|
||||||
|
if item.id == "admin.governance-and-module-lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("previous preflight stale", topic.body)
|
||||||
|
self.assertIn("partial apply requires recovery", topic.body)
|
||||||
|
self.assertIn("provenance", topic.body)
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertIn("vorherige Vorprüfung ungültig", german.get("body", ""))
|
||||||
|
self.assertIn("Teilanwendung Wiederherstellung", german.get("body", ""))
|
||||||
|
|
||||||
|
metadata = topic.metadata or {}
|
||||||
|
self.assertIn(
|
||||||
|
"Generic rollback is snapshot recovery; providers may expose narrower compensation separately.",
|
||||||
|
metadata.get("limitations", ()),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"Changing package inputs invalidates the previous preflight and requires another review.",
|
||||||
|
metadata.get("operational_consequences", ()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_module_administrator_has_only_tenant_module_permissions(self) -> None:
|
||||||
|
permissions = {item.scope for item in manifest.permissions}
|
||||||
|
template = next(
|
||||||
|
item for item in manifest.role_templates if item.slug == "module_admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({"admin:module:read", "admin:module:write"}, permissions)
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(sorted(permissions)), tuple(sorted(template.permissions))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_lifecycle_has_a_dedicated_admin_section() -> None:
|
||||||
|
module_source = (ROOT / "webui/src/module.ts").read_text(encoding="utf-8")
|
||||||
|
panel_source = (
|
||||||
|
ROOT / "webui/src/features/admin/LanguagePackagesPanel.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
general_source = (
|
||||||
|
ROOT / "webui/src/features/admin/SystemSettingsPanel.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'id: "system-language-packages"' in module_source
|
||||||
|
assert 'surfaceId: "admin.section.system-language-packages"' in module_source
|
||||||
|
assert "data-language-package-lifecycle" in panel_source
|
||||||
|
assert "module.version" in panel_source
|
||||||
|
assert "module.translations?.[language.code]" in panel_source
|
||||||
|
assert "uninstallPackage" in panel_source
|
||||||
|
assert "setPackageActive" in panel_source
|
||||||
|
assert "default_locale" in panel_source
|
||||||
|
assert "language_packages" not in general_source
|
||||||
|
assert "available_languages:" not in general_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_lifecycle_names_every_required_state() -> None:
|
||||||
|
source = (
|
||||||
|
ROOT / "webui/src/features/admin/LanguagePackagesPanel.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
translations = (
|
||||||
|
ROOT / "webui/src/i18n/generatedTranslations.ts"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
for marker in (
|
||||||
|
"installed.7bb4405c",
|
||||||
|
"available.7c8f1005",
|
||||||
|
"active.a733b809",
|
||||||
|
"inactive.09af574c",
|
||||||
|
"incompatible.lp001",
|
||||||
|
"uninstallable.lp001",
|
||||||
|
):
|
||||||
|
assert marker in source
|
||||||
|
for locale_marker in ('"en": {', '"de": {'):
|
||||||
|
assert locale_marker in translations
|
||||||
|
assert translations.count("language_package_administration.lp001") == 2
|
||||||
+10
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/admin-webui",
|
"name": "@govoplan/admin-webui",
|
||||||
"version": "0.1.5",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -12,12 +12,16 @@
|
|||||||
"import": "./src/index.ts"
|
"import": "./src/index.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:installer-workflow": "node --experimental-strip-types --test tests/module-installer-workflow.test.ts",
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.5",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, "..");
|
||||||
|
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||||
|
|
||||||
|
const overview = read("src/features/admin/AdminOverviewPanel.tsx");
|
||||||
|
const settings = read("src/features/admin/SystemSettingsPanel.tsx");
|
||||||
|
const changes = read("src/features/admin/ConfigurationChangesPanel.tsx");
|
||||||
|
const packages = read("src/features/admin/ConfigurationPackagesPanel.tsx");
|
||||||
|
const templates = read("src/features/admin/GovernanceTemplatesPanel.tsx");
|
||||||
|
const modules = read("src/features/admin/ModuleManagementPanel.tsx");
|
||||||
|
const moduleSource = read("src/module.ts");
|
||||||
|
const allSource = [overview, settings, changes, packages, templates, modules].join("\n");
|
||||||
|
|
||||||
|
for (const source of [overview, settings, changes, packages, templates, modules]) {
|
||||||
|
assert.match(source, /AdminPageLayout/);
|
||||||
|
assert.match(source, /DocumentationHelpLink/);
|
||||||
|
assert.match(source, /disabledReason|help=/);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const source of [changes, packages, templates, modules]) {
|
||||||
|
assert.match(source, /ConfirmDialog/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(changes, /DataGrid/);
|
||||||
|
assert.match(packages, /ReferenceSelect/);
|
||||||
|
assert.match(packages, /manualReferences/);
|
||||||
|
assert.match(packages, /reviewedFingerprint === currentFingerprint/);
|
||||||
|
assert.match(packages, /RequiredDataInputs/);
|
||||||
|
assert.match(packages, /type=\{item\.secret \? "password"/);
|
||||||
|
assert.match(packages, /ConfigurationRollbackState/);
|
||||||
|
assert.match(packages, /result\.provenance/);
|
||||||
|
assert.match(templates, /TableActionGroup/);
|
||||||
|
assert.match(modules, /StageRail/);
|
||||||
|
assert.match(modules, /ActionBlockerHint/);
|
||||||
|
assert.match(moduleSource, /version: "0\.1\.8"/);
|
||||||
|
|
||||||
|
assert.doesNotMatch(overview, /title="(?:ADMINISTRATION|GLOBAL|TENANT|GROUP|USER)"/);
|
||||||
|
assert.doesNotMatch(allSource, /window\.(alert|confirm|prompt)\s*\(/);
|
||||||
|
assert.doesNotMatch(allSource, /@govoplan\/(?!core-webui)[^"']+-webui\//);
|
||||||
|
|
||||||
|
console.log("Admin interface pattern-language checks passed.");
|
||||||
+705
-99
@@ -1,80 +1,28 @@
|
|||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings, DeltaDeletedItem, NavigationPreferences, PrivacyRetentionPolicy, UserUiPalette } from "@govoplan/core-webui";
|
||||||
import { apiFetch } from "@govoplan/core-webui";
|
import { apiDownload, apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||||
|
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||||
export type PermissionItem = {
|
export type {
|
||||||
scope: string;
|
AdminOverview,
|
||||||
label: string;
|
PrivacyRetentionLimitPermissionPatch,
|
||||||
description: string;
|
PrivacyRetentionLimitPermissions,
|
||||||
category: string;
|
PrivacyRetentionPolicy,
|
||||||
level: "tenant" | "system";
|
PrivacyRetentionPolicyFieldKey,
|
||||||
system_template_id?: string | null;
|
PrivacyRetentionPolicyPatch,
|
||||||
system_required?: boolean;
|
PermissionItem,
|
||||||
};
|
TenantAdminItem
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
export type AdminOverview = {
|
|
||||||
active_tenant_id: string;
|
|
||||||
active_tenant_name: string;
|
|
||||||
tenant_count?: number | null;
|
|
||||||
system_account_count?: number | null;
|
|
||||||
system_group_template_count?: number | null;
|
|
||||||
system_role_template_count?: number | null;
|
|
||||||
user_count: number;
|
|
||||||
active_user_count: number;
|
|
||||||
group_count: number;
|
|
||||||
role_count: number;
|
|
||||||
active_api_key_count: number;
|
|
||||||
capabilities: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TenantAdminItem = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
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;
|
|
||||||
effective_governance: Record<string, boolean>;
|
|
||||||
is_active: boolean;
|
|
||||||
counts: Record<string, number>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyFieldKey =
|
|
||||||
| "store_raw_campaign_json"
|
|
||||||
| "raw_campaign_json_retention_days"
|
|
||||||
| "generated_eml_retention_days"
|
|
||||||
| "stored_report_detail_retention_days"
|
|
||||||
| "mock_mailbox_retention_days"
|
|
||||||
| "audit_detail_retention_days"
|
|
||||||
| "audit_detail_level";
|
|
||||||
|
|
||||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
|
||||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicy = {
|
|
||||||
store_raw_campaign_json: boolean;
|
|
||||||
raw_campaign_json_retention_days?: number | null;
|
|
||||||
generated_eml_retention_days?: number | null;
|
|
||||||
stored_report_detail_retention_days?: number | null;
|
|
||||||
mock_mailbox_retention_days?: number | null;
|
|
||||||
audit_detail_retention_days?: number | null;
|
|
||||||
audit_detail_level: "full" | "redacted" | "minimal";
|
|
||||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
|
||||||
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MaintenanceMode = {
|
export type MaintenanceMode = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LanguagePackage = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
native_label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SystemSettingsItem = {
|
export type SystemSettingsItem = {
|
||||||
default_locale: string;
|
default_locale: string;
|
||||||
allow_tenant_custom_groups: boolean;
|
allow_tenant_custom_groups: boolean;
|
||||||
@@ -82,9 +30,26 @@ export type SystemSettingsItem = {
|
|||||||
allow_tenant_api_keys: boolean;
|
allow_tenant_api_keys: boolean;
|
||||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||||
maintenance_mode: MaintenanceMode;
|
maintenance_mode: MaintenanceMode;
|
||||||
|
available_languages: LanguagePackage[];
|
||||||
|
enabled_language_codes: string[];
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
|
navigation: NavigationPreferences | null;
|
||||||
|
appearance_palette: UserUiPalette;
|
||||||
|
appearance_palette_locked: boolean;
|
||||||
|
appearance_custom_overrides_allowed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemSettingsDeltaSections = Partial<{
|
||||||
|
defaults: Pick<SystemSettingsItem, "default_locale">;
|
||||||
|
tenant_capabilities: Pick<SystemSettingsItem, "allow_tenant_custom_groups" | "allow_tenant_custom_roles" | "allow_tenant_api_keys">;
|
||||||
|
languages: Pick<SystemSettingsItem, "available_languages" | "enabled_language_codes">;
|
||||||
|
privacy_retention_policy: SystemSettingsItem["privacy_retention_policy"];
|
||||||
|
maintenance_mode: SystemSettingsItem["maintenance_mode"];
|
||||||
|
settings: SystemSettingsItem["settings"];
|
||||||
|
navigation: SystemSettingsItem["navigation"];
|
||||||
|
appearance: Pick<SystemSettingsItem, "appearance_palette" | "appearance_palette_locked" | "appearance_custom_overrides_allowed">;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type SystemSettingsUpdatePayload = {
|
export type SystemSettingsUpdatePayload = {
|
||||||
default_locale: string;
|
default_locale: string;
|
||||||
allow_tenant_custom_groups: boolean;
|
allow_tenant_custom_groups: boolean;
|
||||||
@@ -92,8 +57,127 @@ export type SystemSettingsUpdatePayload = {
|
|||||||
allow_tenant_api_keys: boolean;
|
allow_tenant_api_keys: boolean;
|
||||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||||
maintenance_mode?: MaintenanceMode | null;
|
maintenance_mode?: MaintenanceMode | null;
|
||||||
|
available_languages?: LanguagePackage[] | null;
|
||||||
|
enabled_language_codes?: string[] | null;
|
||||||
|
navigation?: NavigationPreferences | null;
|
||||||
|
appearance_palette?: UserUiPalette;
|
||||||
|
appearance_palette_locked?: boolean;
|
||||||
|
appearance_custom_overrides_allowed?: boolean;
|
||||||
|
change_request_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConfigurationChangeRequest = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
target?: Record<string, unknown>;
|
||||||
|
dry_run: boolean;
|
||||||
|
requested_by: string;
|
||||||
|
requested_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
status: string;
|
||||||
|
approvals: Array<Record<string, unknown>>;
|
||||||
|
plan: Record<string, unknown>;
|
||||||
|
value_preview?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationChangeRecord = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
key: string;
|
||||||
|
target?: Record<string, unknown>;
|
||||||
|
actor_user_id: string;
|
||||||
|
approval_request_id?: string | null;
|
||||||
|
approval_user_ids: string[];
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
rollback_value?: unknown;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageDiagnostic = {
|
||||||
|
severity: "blocker" | "warning" | "info";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
module_id?: string | null;
|
||||||
|
object_ref?: string | null;
|
||||||
|
resolution?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageRequiredData = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
data_type: string;
|
||||||
|
required: boolean;
|
||||||
|
secret: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackagePlanItem = {
|
||||||
|
action: "create" | "update" | "bind" | "skip" | "blocked" | "noop";
|
||||||
|
module_id: string;
|
||||||
|
fragment_type: string;
|
||||||
|
fragment_id?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageFragment = {
|
||||||
|
module_id: string;
|
||||||
|
fragment_type: string;
|
||||||
|
fragment_id?: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageRollback = {
|
||||||
|
status: "blocked_before_apply" | "not_required" | "database_restore_required" | "partial_apply_requires_recovery";
|
||||||
|
summary: string;
|
||||||
|
recovery_action?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageExportProvenance = {
|
||||||
|
exported_at: string;
|
||||||
|
source_core_version: string;
|
||||||
|
module_versions: Record<string, string>;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
exporter_id?: string | null;
|
||||||
|
selection: {
|
||||||
|
scopes: string[];
|
||||||
|
module_ids: string[];
|
||||||
|
object_refs: string[];
|
||||||
|
};
|
||||||
|
redacted_secret_keys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfigurationPackageRunPayload = {
|
||||||
|
package: Record<string, unknown>;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
supplied_data?: Record<string, unknown>;
|
||||||
|
change_request_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeltaResponseFields = {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||||
|
return apiQuery(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemSettingsDeltaResponse = {
|
||||||
|
item?: SystemSettingsItem | null;
|
||||||
|
sections: SystemSettingsDeltaSections;
|
||||||
|
changed_sections: string[];
|
||||||
|
} & DeltaResponseFields;
|
||||||
|
|
||||||
|
export type ConfigurationChangesDeltaResponse = {
|
||||||
|
requests: ConfigurationChangeRequest[];
|
||||||
|
history: ConfigurationChangeRecord[];
|
||||||
|
} & DeltaResponseFields;
|
||||||
|
|
||||||
export type ModuleCatalogItem = {
|
export type ModuleCatalogItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -132,13 +216,52 @@ export type ModuleCatalogResponse = {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantModuleAvailability = "unavailable" | "available" | "forced";
|
||||||
|
|
||||||
|
export type TenantModuleEntitlementItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dependencies: string[];
|
||||||
|
runtime_active: boolean;
|
||||||
|
availability: TenantModuleAvailability;
|
||||||
|
selected: boolean;
|
||||||
|
effective: boolean;
|
||||||
|
forced: boolean;
|
||||||
|
derived_dependency: boolean;
|
||||||
|
tenant_can_toggle: boolean;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantModuleEntitlementResponse = {
|
||||||
|
tenant_id: string;
|
||||||
|
revision: number;
|
||||||
|
configured: boolean;
|
||||||
|
available_modules: string[];
|
||||||
|
forced_modules: string[];
|
||||||
|
selected_modules: string[];
|
||||||
|
effective_modules: string[];
|
||||||
|
derived_dependencies: string[];
|
||||||
|
modules: TenantModuleEntitlementItem[];
|
||||||
|
diagnostics: Array<{ code: string; message: string; severity: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantModuleTarget = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModuleInstallPlanItem = {
|
export type ModuleInstallPlanItem = {
|
||||||
module_id: string;
|
module_id: string;
|
||||||
action: "install" | "uninstall";
|
action: "install" | "update" | "uninstall";
|
||||||
|
source: "manual" | "catalog";
|
||||||
|
catalog?: Record<string, unknown> | null;
|
||||||
python_package?: string | null;
|
python_package?: string | null;
|
||||||
python_ref?: string | null;
|
python_ref?: string | null;
|
||||||
webui_package?: string | null;
|
webui_package?: string | null;
|
||||||
webui_ref?: string | null;
|
webui_ref?: string | null;
|
||||||
|
data_safety_acknowledged: boolean;
|
||||||
destroy_data: boolean;
|
destroy_data: boolean;
|
||||||
status: "planned" | "applied" | "blocked";
|
status: "planned" | "applied" | "blocked";
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -158,6 +281,63 @@ export type ModuleInstallChecklistItem = {
|
|||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallTargetItem = {
|
||||||
|
module_id: string;
|
||||||
|
action: "install" | "update" | "uninstall";
|
||||||
|
source: "manual" | "catalog";
|
||||||
|
current_version?: string | null;
|
||||||
|
target_version?: string | null;
|
||||||
|
python_package?: string | null;
|
||||||
|
python_ref?: string | null;
|
||||||
|
webui_package?: string | null;
|
||||||
|
webui_ref?: string | null;
|
||||||
|
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
|
||||||
|
migration_notes?: string | null;
|
||||||
|
current_version_min?: string | null;
|
||||||
|
current_version_max_exclusive?: string | null;
|
||||||
|
bridge_release: boolean;
|
||||||
|
bridge_notes?: string | null;
|
||||||
|
allow_downgrade: boolean;
|
||||||
|
allow_same_version: boolean;
|
||||||
|
recovery_tested: boolean;
|
||||||
|
recovery_notes?: string | null;
|
||||||
|
data_safety_acknowledged: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleMigrationPlanStep = {
|
||||||
|
module_id: string;
|
||||||
|
action: "install" | "update" | "uninstall";
|
||||||
|
phase: "upgrade" | "retirement";
|
||||||
|
source: "manifest" | "catalog" | "pending";
|
||||||
|
has_migration_metadata: boolean;
|
||||||
|
metadata_pending: boolean;
|
||||||
|
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
|
||||||
|
current_version?: string | null;
|
||||||
|
target_version?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleMigrationTaskPlanItem = {
|
||||||
|
module_id: string;
|
||||||
|
task_id: string;
|
||||||
|
phase: "pre_migration_check" | "pre_migration_prepare" | "post_migration_backfill" | "post_migration_verify";
|
||||||
|
summary: string;
|
||||||
|
task_version: string;
|
||||||
|
safety: "automatic" | "requires_review" | "forward_only" | "destructive";
|
||||||
|
idempotent: boolean;
|
||||||
|
timeout_seconds?: number | null;
|
||||||
|
source: "manifest" | "catalog" | "pending";
|
||||||
|
has_executor: boolean;
|
||||||
|
metadata_pending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleMigrationExecutionPlan = {
|
||||||
|
enabled_modules: string[];
|
||||||
|
requires_database_migration: boolean;
|
||||||
|
steps: ModuleMigrationPlanStep[];
|
||||||
|
tasks: ModuleMigrationTaskPlanItem[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ModuleInstallPreflight = {
|
export type ModuleInstallPreflight = {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
maintenance_mode: boolean;
|
maintenance_mode: boolean;
|
||||||
@@ -167,6 +347,8 @@ export type ModuleInstallPreflight = {
|
|||||||
rollback_commands: string[];
|
rollback_commands: string[];
|
||||||
issues: ModuleInstallPreflightIssue[];
|
issues: ModuleInstallPreflightIssue[];
|
||||||
checklist: ModuleInstallChecklistItem[];
|
checklist: ModuleInstallChecklistItem[];
|
||||||
|
target_plan: ModuleInstallTargetItem[];
|
||||||
|
migration_plan: ModuleMigrationExecutionPlan;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModuleInstallPlanResponse = {
|
export type ModuleInstallPlanResponse = {
|
||||||
@@ -196,6 +378,9 @@ export type ModuleInstallerRunSummary = {
|
|||||||
status: string;
|
status: string;
|
||||||
started_at?: string | null;
|
started_at?: string | null;
|
||||||
finished_at?: string | null;
|
finished_at?: string | null;
|
||||||
|
request_id?: string | null;
|
||||||
|
requested_by?: string | null;
|
||||||
|
trace?: Record<string, unknown> | null;
|
||||||
rollback_status?: string | null;
|
rollback_status?: string | null;
|
||||||
supervisor_status?: string | null;
|
supervisor_status?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
@@ -207,6 +392,9 @@ export type ModuleInstallerRunSummary = {
|
|||||||
export type ModuleInstallerRunListResponse = {
|
export type ModuleInstallerRunListResponse = {
|
||||||
runs: ModuleInstallerRunSummary[];
|
runs: ModuleInstallerRunSummary[];
|
||||||
lock: ModuleInstallerLockStatus;
|
lock: ModuleInstallerLockStatus;
|
||||||
|
cursor?: string | null;
|
||||||
|
next_cursor?: string | null;
|
||||||
|
full?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModuleInstallerRequestOptions = {
|
export type ModuleInstallerRequestOptions = {
|
||||||
@@ -235,6 +423,7 @@ export type ModuleInstallerRequestItem = {
|
|||||||
cancelled_at?: string | null;
|
cancelled_at?: string | null;
|
||||||
cancelled_by?: string | null;
|
cancelled_by?: string | null;
|
||||||
retry_of?: string | null;
|
retry_of?: string | null;
|
||||||
|
trace?: Record<string, unknown> | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
record_path?: string | null;
|
record_path?: string | null;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
@@ -243,6 +432,56 @@ export type ModuleInstallerRequestItem = {
|
|||||||
export type ModuleInstallerRequestListResponse = {
|
export type ModuleInstallerRequestListResponse = {
|
||||||
requests: ModuleInstallerRequestItem[];
|
requests: ModuleInstallerRequestItem[];
|
||||||
daemon: ModuleInstallerDaemonStatus;
|
daemon: ModuleInstallerDaemonStatus;
|
||||||
|
cursor?: string | null;
|
||||||
|
next_cursor?: string | null;
|
||||||
|
full?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInterfaceProviderItem = {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInterfaceRequirementItem = {
|
||||||
|
name: string;
|
||||||
|
version_min?: string | null;
|
||||||
|
version_max_exclusive?: string | null;
|
||||||
|
optional: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModulePackageCatalogSource = {
|
||||||
|
repository: string;
|
||||||
|
tag: string;
|
||||||
|
commit: string;
|
||||||
|
repository_url?: string | null;
|
||||||
|
revision_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModulePackageArtifactIdentity = {
|
||||||
|
ref?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
artifact_path?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
filename?: string | null;
|
||||||
|
sha256?: string | null;
|
||||||
|
size?: number | null;
|
||||||
|
integrity?: string | null;
|
||||||
|
sbom_url?: string | null;
|
||||||
|
provenance_url?: string | null;
|
||||||
|
registry_identity?: string | null;
|
||||||
|
git_ref?: string | null;
|
||||||
|
source_commit?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModulePackagePermissionItem = {
|
||||||
|
scope: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
level: "system" | "tenant";
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
deprecated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ModulePackageCatalogItem = {
|
export type ModulePackageCatalogItem = {
|
||||||
@@ -250,11 +489,41 @@ export type ModulePackageCatalogItem = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
version?: string | null;
|
version?: string | null;
|
||||||
action: "install" | "uninstall";
|
action: "install" | "update" | "uninstall";
|
||||||
|
installed: boolean;
|
||||||
|
installed_version?: string | null;
|
||||||
|
update_available: boolean;
|
||||||
|
availability: "available" | "withdrawn";
|
||||||
|
availability_reason?: string | null;
|
||||||
|
configuration_requirements: string[];
|
||||||
|
permissions: ModulePackagePermissionItem[];
|
||||||
|
release_notes_url?: string | null;
|
||||||
|
source?: ModulePackageCatalogSource | null;
|
||||||
|
artifact_integrity: Record<string, ModulePackageArtifactIdentity>;
|
||||||
|
compatible: boolean;
|
||||||
|
plan_allowed: boolean;
|
||||||
|
compatibility_reasons: string[];
|
||||||
|
catalog_state: "available" | "installed" | "update_available" | "blocked" | "withdrawn";
|
||||||
|
dependencies: string[];
|
||||||
|
optional_dependencies: string[];
|
||||||
|
migration_safety: "automatic" | "requires_review" | "forward_only" | "destructive";
|
||||||
|
migration_notes?: string | null;
|
||||||
|
migration_after: string[];
|
||||||
|
migration_before: string[];
|
||||||
|
current_version_min?: string | null;
|
||||||
|
current_version_max_exclusive?: string | null;
|
||||||
|
bridge_release: boolean;
|
||||||
|
bridge_notes?: string | null;
|
||||||
|
allow_downgrade: boolean;
|
||||||
|
allow_same_version: boolean;
|
||||||
|
recovery_tested: boolean;
|
||||||
|
recovery_notes?: string | null;
|
||||||
python_package?: string | null;
|
python_package?: string | null;
|
||||||
python_ref?: string | null;
|
python_ref?: string | null;
|
||||||
webui_package?: string | null;
|
webui_package?: string | null;
|
||||||
webui_ref?: string | null;
|
webui_ref?: string | null;
|
||||||
|
provides_interfaces: ModuleInterfaceProviderItem[];
|
||||||
|
requires_interfaces: ModuleInterfaceRequirementItem[];
|
||||||
license_features: string[];
|
license_features: string[];
|
||||||
license_allowed: boolean;
|
license_allowed: boolean;
|
||||||
license_enforced: boolean;
|
license_enforced: boolean;
|
||||||
@@ -264,6 +533,27 @@ export type ModulePackageCatalogItem = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ModuleLicenseDiagnostics = {
|
||||||
|
configured: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
path?: string | null;
|
||||||
|
license_id?: string | null;
|
||||||
|
subject?: string | null;
|
||||||
|
features: string[];
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
signed: boolean;
|
||||||
|
trusted: boolean;
|
||||||
|
key_id?: string | null;
|
||||||
|
enforced: boolean;
|
||||||
|
allowed: boolean;
|
||||||
|
required_features: string[];
|
||||||
|
missing_features: string[];
|
||||||
|
expires_in_days?: number | null;
|
||||||
|
reason?: string | null;
|
||||||
|
guidance: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ModulePackageCatalogResponse = {
|
export type ModulePackageCatalogResponse = {
|
||||||
modules: ModulePackageCatalogItem[];
|
modules: ModulePackageCatalogItem[];
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -271,13 +561,17 @@ export type ModulePackageCatalogResponse = {
|
|||||||
path?: string | null;
|
path?: string | null;
|
||||||
source?: string | null;
|
source?: string | null;
|
||||||
source_type?: string | null;
|
source_type?: string | null;
|
||||||
|
cache_used: boolean;
|
||||||
|
cache_path?: string | null;
|
||||||
channel?: string | null;
|
channel?: string | null;
|
||||||
sequence?: number | null;
|
sequence?: number | null;
|
||||||
generated_at?: string | null;
|
generated_at?: string | null;
|
||||||
|
not_before?: string | null;
|
||||||
expires_at?: string | null;
|
expires_at?: string | null;
|
||||||
signed: boolean;
|
signed: boolean;
|
||||||
trusted: boolean;
|
trusted: boolean;
|
||||||
key_id?: string | null;
|
key_id?: string | null;
|
||||||
|
license: ModuleLicenseDiagnostics;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
};
|
};
|
||||||
@@ -301,24 +595,132 @@ export type GovernanceTemplateItem = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
export type GovernanceSynchronizationOutcome = {
|
||||||
return apiFetch(settings, "/api/v1/admin/overview");
|
assignment_id: string;
|
||||||
}
|
template_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
kind: "group" | "role";
|
||||||
|
operation: "upsert" | "remove";
|
||||||
|
status: "created" | "updated" | "unchanged" | "removed" | "absent" | "blocked" | "failed";
|
||||||
|
resource_id?: string | null;
|
||||||
|
blocker_codes: string[];
|
||||||
|
message?: string | null;
|
||||||
|
provenance: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
export type GovernanceSynchronizationResponse = {
|
||||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
version: "1";
|
||||||
return response.permissions;
|
operation_id: string;
|
||||||
}
|
dry_run: boolean;
|
||||||
|
outcomes: GovernanceSynchronizationOutcome[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
export type DataSubjectSelector = {
|
||||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
account_id?: string | null;
|
||||||
return response.tenants;
|
identity_id?: string | null;
|
||||||
}
|
membership_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
external_references?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSubjectRequestSummary = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
reference: string;
|
||||||
|
request_kind: "access" | "erasure" | "access_and_erasure";
|
||||||
|
status: string;
|
||||||
|
subject: DataSubjectSelector;
|
||||||
|
purpose: string;
|
||||||
|
legal_basis?: string | null;
|
||||||
|
due_at?: string | null;
|
||||||
|
requested_by_account_id: string;
|
||||||
|
record_count: number;
|
||||||
|
executable_action_count: number;
|
||||||
|
coverage: {
|
||||||
|
covered_modules?: string[];
|
||||||
|
modules_without_provider?: string[];
|
||||||
|
provider_discovery_failures?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
evidence_sha256?: string | null;
|
||||||
|
resource_revision: number;
|
||||||
|
etag: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSubjectRecord = {
|
||||||
|
provider_id: string;
|
||||||
|
module_id: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
observed_at?: string | null;
|
||||||
|
immutable_evidence: boolean;
|
||||||
|
retention_reason?: string | null;
|
||||||
|
source_path?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSubjectErasureAction = {
|
||||||
|
action_id: string;
|
||||||
|
provider_id: string;
|
||||||
|
module_id: string;
|
||||||
|
kind: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
title: string;
|
||||||
|
rationale: string;
|
||||||
|
executable: boolean;
|
||||||
|
irreversible: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSubjectRequestDetail = {
|
||||||
|
request: DataSubjectRequestSummary;
|
||||||
|
search: {
|
||||||
|
records?: DataSubjectRecord[];
|
||||||
|
provider_runs?: Array<Record<string, unknown>>;
|
||||||
|
searched_at?: string;
|
||||||
|
record_count?: number;
|
||||||
|
};
|
||||||
|
erasure_plan: {
|
||||||
|
actions?: DataSubjectErasureAction[];
|
||||||
|
provider_runs?: Array<Record<string, unknown>>;
|
||||||
|
planned_at?: string;
|
||||||
|
executable_count?: number;
|
||||||
|
retained_count?: number;
|
||||||
|
};
|
||||||
|
execution: {
|
||||||
|
results?: Array<{ action_id: string; status: string; summary: string; evidence?: Record<string, unknown> }>;
|
||||||
|
executed_at?: string;
|
||||||
|
successful_count?: number;
|
||||||
|
failed_count?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSubjectRequestCreatePayload = {
|
||||||
|
reference: string;
|
||||||
|
request_kind: DataSubjectRequestSummary["request_kind"];
|
||||||
|
subject: DataSubjectSelector;
|
||||||
|
purpose: string;
|
||||||
|
legal_basis?: string | null;
|
||||||
|
due_at?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchSystemSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<SystemSettingsDeltaResponse> {
|
||||||
|
const suffix = deltaSuffix(options);
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/settings/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
@@ -327,10 +729,50 @@ export function fetchModuleCatalog(settings: ApiSettings): Promise<ModuleCatalog
|
|||||||
return apiFetch(settings, "/api/v1/admin/system/modules");
|
return apiFetch(settings, "/api/v1/admin/system/modules");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateModuleState(settings: ApiSettings, enabledModules: string[]): Promise<ModuleCatalogResponse> {
|
export function updateModuleState(settings: ApiSettings, enabledModules: string[], changeRequestId?: string | null): Promise<ModuleCatalogResponse> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/modules", {
|
return apiFetch(settings, "/api/v1/admin/system/modules", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ enabled_modules: enabledModules })
|
body: JSON.stringify({ enabled_modules: enabledModules, change_request_id: changeRequestId ?? null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSystemTenantModules(settings: ApiSettings, tenantId: string): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantModuleTargets(settings: ApiSettings): Promise<TenantModuleTarget[]> {
|
||||||
|
const response = await apiFetch<{ tenants: TenantModuleTarget[] }>(settings, "/api/v1/admin/system/tenant-module-targets");
|
||||||
|
return response.tenants;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemTenantModules(
|
||||||
|
settings: ApiSettings,
|
||||||
|
tenantId: string,
|
||||||
|
payload: {
|
||||||
|
available_modules: string[];
|
||||||
|
forced_modules: string[];
|
||||||
|
enabled_modules: string[];
|
||||||
|
expected_revision: number;
|
||||||
|
change_request_id?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/system/tenants/${encodeURIComponent(tenantId)}/modules`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTenantModules(settings: ApiSettings): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantModules(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: { enabled_modules: string[]; expected_revision: number }
|
||||||
|
): Promise<TenantModuleEntitlementResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/modules", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,12 +780,12 @@ export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
|
|||||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchModuleInstallerRuns(settings: ApiSettings): Promise<ModuleInstallerRunListResponse> {
|
export function fetchModuleInstallerRuns(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRunListResponse> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-runs");
|
return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-runs", options));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchModuleInstallerRequests(settings: ApiSettings): Promise<ModuleInstallerRequestListResponse> {
|
export function fetchModuleInstallerRequests(settings: ApiSettings, options: { page_size?: number; cursor?: string | null } = {}): Promise<ModuleInstallerRequestListResponse> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-requests");
|
return apiFetch(settings, apiPath("/api/v1/admin/system/modules/install-requests", options));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> {
|
export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> {
|
||||||
@@ -373,10 +815,10 @@ export function planModuleUninstall(settings: ApiSettings, moduleId: string): Pr
|
|||||||
return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" });
|
return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise<ModuleInstallPlanResponse> {
|
export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[], changeRequestId?: string | null): Promise<ModuleInstallPlanResponse> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", {
|
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ items })
|
body: JSON.stringify({ items, change_request_id: changeRequestId ?? null })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,19 +827,183 @@ export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleIns
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
const pageSize = 500;
|
||||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
const templates: GovernanceTemplateItem[] = [];
|
||||||
return response.templates;
|
for (let page = 1; ; page += 1) {
|
||||||
|
const response = await apiFetch<{
|
||||||
|
templates: GovernanceTemplateItem[];
|
||||||
|
pages?: number;
|
||||||
|
}>(
|
||||||
|
settings,
|
||||||
|
apiPath("/api/v1/admin/system/governance-templates", {
|
||||||
|
kind,
|
||||||
|
page,
|
||||||
|
page_size: pageSize
|
||||||
|
})
|
||||||
|
);
|
||||||
|
templates.push(...response.templates);
|
||||||
|
if (page >= (response.pages ?? 1)) {
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||||
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
|
||||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
|
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
|
||||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
|
return apiFetch(settings, apiPath(`/api/v1/admin/system/governance-templates/${templateId}`, { change_request_id: changeRequestId }), { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synchronizeGovernanceTemplates(
|
||||||
|
settings: ApiSettings,
|
||||||
|
templateIds: string[],
|
||||||
|
dryRun = false
|
||||||
|
): Promise<GovernanceSynchronizationResponse> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/governance-templates/synchronize", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ template_ids: templateIds, dry_run: dryRun })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataSubjectRequests(settings: ApiSettings): Promise<DataSubjectRequestSummary[]> {
|
||||||
|
const response = await apiFetch<{ items: DataSubjectRequestSummary[] }>(settings, "/api/v1/admin/privacy/data-subject-requests");
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDataSubjectRequest(settings: ApiSettings, requestId: string): Promise<DataSubjectRequestDetail> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(requestId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataSubjectRequest(settings: ApiSettings, payload: DataSubjectRequestCreatePayload): Promise<DataSubjectRequestDetail> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/privacy/data-subject-requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<DataSubjectRequestDetail> {
|
||||||
|
return mutateDataSubjectRequest(settings, request, "search", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planDataSubjectErasure(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<DataSubjectRequestDetail> {
|
||||||
|
return mutateDataSubjectRequest(settings, request, "erasure-plan", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeDataSubjectErasure(
|
||||||
|
settings: ApiSettings,
|
||||||
|
request: DataSubjectRequestSummary,
|
||||||
|
actionIds: string[],
|
||||||
|
confirmation: string
|
||||||
|
): Promise<DataSubjectRequestDetail> {
|
||||||
|
return mutateDataSubjectRequest(settings, request, "execute", {
|
||||||
|
action_ids: actionIds,
|
||||||
|
confirmation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportDataSubjectRequest(settings: ApiSettings, request: DataSubjectRequestSummary): Promise<void> {
|
||||||
|
const safeReference = request.reference.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
return apiDownload(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/export`,
|
||||||
|
`dsar-${safeReference || request.id}.json`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutateDataSubjectRequest(
|
||||||
|
settings: ApiSettings,
|
||||||
|
request: DataSubjectRequestSummary,
|
||||||
|
operation: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<DataSubjectRequestDetail> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/privacy/data-subject-requests/${encodeURIComponent(request.id)}/${operation}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "If-Match": request.etag },
|
||||||
|
body: JSON.stringify({ base_revision: request.resource_revision, ...payload })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/configuration-changes");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchConfigurationChangesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<ConfigurationChangesDeltaResponse> {
|
||||||
|
const suffix = deltaSuffix(options);
|
||||||
|
return apiFetch(settings, `/api/v1/admin/configuration-changes/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfigurationChangeRequest(settings: ApiSettings, payload: {
|
||||||
|
key: string;
|
||||||
|
value?: unknown;
|
||||||
|
dry_run?: boolean;
|
||||||
|
target?: Record<string, unknown>;
|
||||||
|
reason?: string | null;
|
||||||
|
}): Promise<ConfigurationChangeRequest> {
|
||||||
|
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
return response.request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise<ConfigurationChangeRequest> {
|
||||||
|
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason: reason ?? null })
|
||||||
|
});
|
||||||
|
return response.request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record<string, unknown> }> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
|
||||||
|
diagnostics: ConfigurationPackageDiagnostic[];
|
||||||
|
required_data: ConfigurationPackageRequiredData[];
|
||||||
|
plan: ConfigurationPackagePlanItem[];
|
||||||
|
}> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
|
||||||
|
diagnostics: ConfigurationPackageDiagnostic[];
|
||||||
|
created_refs: Record<string, string>;
|
||||||
|
updated_refs: Record<string, string>;
|
||||||
|
rollback?: ConfigurationPackageRollback | null;
|
||||||
|
}> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportConfigurationPackage(settings: ApiSettings, payload: {
|
||||||
|
tenant_id?: string | null;
|
||||||
|
scopes?: string[];
|
||||||
|
module_ids?: string[];
|
||||||
|
object_refs?: string[];
|
||||||
|
}): Promise<{
|
||||||
|
fragments: ConfigurationPackageFragment[];
|
||||||
|
data_requirements: ConfigurationPackageRequiredData[];
|
||||||
|
diagnostics: ConfigurationPackageDiagnostic[];
|
||||||
|
provenance?: ConfigurationPackageExportProvenance | null;
|
||||||
|
}> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/configuration-packages/export", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card, MetricCard } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
import { AdminPageLayout, DocumentationHelpLink, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
|
import { ADMIN_INTERFACE_I18N, ADMIN_WORKSPACE_DOCUMENTATION } from "./interfacePatterns";
|
||||||
|
|
||||||
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet<string> }) {
|
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: {settings: ApiSettings;onSelect: (section: string) => void;availableSections: ReadonlySet<string>;}) {
|
||||||
const [overview, setOverview] = useState<AdminOverview | null>(null);
|
const [overview, setOverview] = useState<AdminOverview | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -14,8 +16,8 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {setOverview(await fetchAdminOverview(settings));}
|
try {setOverview(await fetchAdminOverview(settings));}
|
||||||
catch (err) { setError(adminErrorMessage(err)); }
|
catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
finally { setLoading(false); }
|
{setLoading(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl]);
|
useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
@@ -24,70 +26,104 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
|||||||
overview.tenant_count,
|
overview.tenant_count,
|
||||||
overview.system_account_count,
|
overview.system_account_count,
|
||||||
overview.system_group_template_count,
|
overview.system_group_template_count,
|
||||||
overview.system_role_template_count
|
overview.system_role_template_count].
|
||||||
].some((value) => value !== null && value !== undefined));
|
some((value) => value !== null && value !== undefined));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout title="Administration" description="System-wide governance and tenant-local access management, separated by scope and enforced by the backend." loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>Reload</Button>}>
|
<AdminPageLayout title="i18n:govoplan-admin.administration.b8be3d12" description="i18n:govoplan-admin.system_wide_governance_and_tenant_local_access_m.cda72499" loading={loading} error={error} actions={<><DocumentationHelpLink reference={ADMIN_WORKSPACE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : undefined}>i18n:govoplan-admin.reload.cce71553</Button></>}>
|
||||||
{overview && <>
|
{overview && <>
|
||||||
{hasSystemMetrics && <>
|
{hasSystemMetrics && <>
|
||||||
<div className="admin-overview-section-label">System</div>
|
<div className="admin-overview-section-label">i18n:govoplan-admin.system.bc0792d8</div>
|
||||||
<div className="metric-grid">
|
<MetricGrid>
|
||||||
<Metric title="Tenants" value={overview.tenant_count ?? "—"} text="Registered tenant spaces." />
|
<MetricCard label="i18n:govoplan-admin.tenants.1f7ae776" value={overview.tenant_count ?? "—"} detail="i18n:govoplan-admin.registered_tenant_spaces.61e17d70" />
|
||||||
<Metric title="Users" value={overview.system_account_count ?? "—"} text="Global login accounts across all tenants." />
|
<MetricCard label="i18n:govoplan-admin.users.57f2b181" value={overview.system_account_count ?? "—"} detail="i18n:govoplan-admin.global_login_accounts_across_all_tenants.2aab129d" />
|
||||||
<Metric title="Central groups" value={overview.system_group_template_count ?? "—"} text="System-governed group definitions." />
|
<MetricCard label="i18n:govoplan-admin.central_groups.5c9b5b66" value={overview.system_group_template_count ?? "—"} detail="i18n:govoplan-admin.system_governed_group_definitions.de8e5dc9" />
|
||||||
<Metric title="Tenant roles" value={overview.system_role_template_count ?? "—"} text="Centrally governed tenant roles." />
|
<MetricCard label="i18n:govoplan-admin.tenant_roles.51aca82d" value={overview.system_role_template_count ?? "—"} detail="i18n:govoplan-admin.centrally_governed_tenant_roles.797c689b" />
|
||||||
</div>
|
</MetricGrid>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
{hasSystemArea(availableSections) && <Card title="System administration">
|
{hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
|
||||||
<div className="admin-overview-grid">
|
<div className="admin-overview-grid">
|
||||||
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
|
{availableSections.has("system-modules") && <AreaLink title="i18n:govoplan-admin.modules.04e9462c" text="i18n:govoplan-admin.installed_modules_runtime_state_and_startup_stat.38dd7028" onClick={() => onSelect("system-modules")} />}
|
||||||
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
|
{availableSections.has("system-tenant-modules") && <AreaLink title="Tenant modules" text="Set per-tenant availability, forced modules, and current selection." onClick={() => onSelect("system-tenant-modules")} />}
|
||||||
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
|
{availableSections.has("system-configuration-packages") && <AreaLink title="i18n:govoplan-admin.configuration_packages.eb2f05f1" text="i18n:govoplan-admin.preflight_approve_apply_and_export_configuration.276bd0f8" onClick={() => onSelect("system-configuration-packages")} />}
|
||||||
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
|
{availableSections.has("system-settings") && <AreaLink title="i18n:govoplan-admin.maintenance.94de303b" text="i18n:govoplan-admin.instance_defaults_and_tenant_governance_capabili.99d6b2fa" onClick={() => onSelect("system-settings")} />}
|
||||||
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
|
{availableSections.has("system-language-packages") && <AreaLink title="i18n:govoplan-admin.language_packages" text="i18n:govoplan-admin.language_package_administration_overview.lp001" onClick={() => onSelect("system-language-packages")} />}
|
||||||
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
|
{availableSections.has("system-configuration-changes") && <AreaLink title="i18n:govoplan-admin.changes.8aa57de6" text="i18n:govoplan-admin.configuration_requests_approvals_and_version_his.19f37335" onClick={() => onSelect("system-configuration-changes")} />}
|
||||||
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
|
{availableSections.has("system-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.system_level_administrative_history.49f76723" onClick={() => onSelect("system-audit")} />}
|
||||||
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
|
|
||||||
{availableSections.has("system-modules") && <AreaLink title="Modules" text="Installed modules, runtime state and startup state." onClick={() => onSelect("system-modules")} />}
|
|
||||||
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
<div className="admin-overview-section-label">Active tenant: {overview.active_tenant_name}</div>
|
{hasAnySection(availableSections, globalSectionIds) && <Card title={ADMIN_INTERFACE_I18N.globalHeading}>
|
||||||
<div className="metric-grid">
|
|
||||||
<Metric title="Tenant users" value={`${overview.active_user_count}/${overview.user_count}`} text="Active and total memberships." />
|
|
||||||
<Metric title="Groups" value={overview.group_count} text="Tenant-local and centrally managed groups." />
|
|
||||||
<Metric title="Roles" value={overview.role_count} text="Tenant-local and centrally managed roles." />
|
|
||||||
<Metric title="API keys" value={overview.active_api_key_count} text="Active tenant automation credentials." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title="Tenant administration">
|
|
||||||
<div className="admin-overview-grid">
|
<div className="admin-overview-grid">
|
||||||
{availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
|
{availableSections.has("system-tenants") && <AreaLink title="i18n:govoplan-admin.tenants.1f7ae776" text="i18n:govoplan-admin.create_suspend_and_govern_tenant_spaces.77992a39" onClick={() => onSelect("system-tenants")} />}
|
||||||
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
|
{availableSections.has("system-roles") && <AreaLink title="i18n:govoplan-admin.system_roles.a9461aa6" text="i18n:govoplan-admin.instance_wide_roles_assigned_directly_to_global_.91050488" onClick={() => onSelect("system-roles")} />}
|
||||||
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
|
{availableSections.has("system-role-templates") && <AreaLink title="i18n:govoplan-admin.tenant_roles.51aca82d" text="i18n:govoplan-admin.centrally_governed_tenant_roles_and_their_availa.d879d5d1" onClick={() => onSelect("system-role-templates")} />}
|
||||||
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
|
{availableSections.has("system-groups") && <AreaLink title="i18n:govoplan-admin.central_groups.5c9b5b66" text="i18n:govoplan-admin.group_definitions_made_available_or_required_in_.55402e16" onClick={() => onSelect("system-groups")} />}
|
||||||
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
|
{availableSections.has("system-users") && <AreaLink title="i18n:govoplan-admin.users.57f2b181" text="i18n:govoplan-admin.global_accounts_tenant_memberships_and_system_ro.939ed01f" onClick={() => onSelect("system-users")} />}
|
||||||
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
|
{availableSections.has("system-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("system-file-connectors")} />}
|
||||||
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
|
{availableSections.has("system-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("system-mail-servers")} />}
|
||||||
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
|
{availableSections.has("system-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.instance_privacy_retention_policy_and_lower_leve.b8d14069" helpRiskReviewed="standard" onClick={() => onSelect("system-retention")} />}
|
||||||
|
{availableSections.has("system-view-policy") && <AreaLink title="View policy" text="Limit View actions, definitions, and surfaces across the instance." onClick={() => onSelect("system-view-policy")} />}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>}
|
||||||
|
|
||||||
|
<div className="admin-overview-section-label">i18n:govoplan-admin.active_tenant.dfadf0aa {overview.active_tenant_name}</div>
|
||||||
|
<MetricGrid>
|
||||||
|
<MetricCard label="i18n:govoplan-admin.tenant_users.cb800b38" value={`${overview.active_user_count}/${overview.user_count}`} detail="i18n:govoplan-admin.active_and_total_memberships.c0c20f10" />
|
||||||
|
<MetricCard label="i18n:govoplan-admin.groups.ae9629f4" value={overview.group_count} detail="i18n:govoplan-admin.tenant_local_and_centrally_managed_groups.4a6296b5" />
|
||||||
|
<MetricCard label="i18n:govoplan-admin.roles.47dcc27d" value={overview.role_count} detail="i18n:govoplan-admin.tenant_local_and_centrally_managed_roles.4995a7b2" />
|
||||||
|
<MetricCard label="i18n:govoplan-admin.api_keys.94fcf3c2" value={overview.active_api_key_count} detail="i18n:govoplan-admin.active_tenant_automation_credentials.240c659e" />
|
||||||
|
</MetricGrid>
|
||||||
|
|
||||||
|
{hasAnySection(availableSections, tenantSectionIds) && <Card title={ADMIN_INTERFACE_I18N.tenantHeading}>
|
||||||
|
<div className="admin-overview-grid">
|
||||||
|
{availableSections.has("tenant-roles") && <AreaLink title="i18n:govoplan-admin.roles.47dcc27d" text="i18n:govoplan-admin.tenant_permission_bundles_and_system_managed_rol.bb563bea" onClick={() => onSelect("tenant-roles")} />}
|
||||||
|
{availableSections.has("tenant-groups") && <AreaLink title="i18n:govoplan-admin.groups.ae9629f4" text="i18n:govoplan-admin.tenant_memberships_and_inherited_roles.16eda9f6" onClick={() => onSelect("tenant-groups")} />}
|
||||||
|
{availableSections.has("tenant-users") && <AreaLink title="i18n:govoplan-admin.users.57f2b181" text="i18n:govoplan-admin.membership_status_groups_and_direct_roles_in_the.ab6c10b6" onClick={() => onSelect("tenant-users")} />}
|
||||||
|
{availableSections.has("tenant-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.reusable_file_server_connections_credentials_and.8e5c7d43" onClick={() => onSelect("tenant-file-connectors")} />}
|
||||||
|
{availableSections.has("tenant-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.reusable_encrypted_smtp_imap_profiles_and_mail_p.31f150d1" onClick={() => onSelect("tenant-mail-servers")} />}
|
||||||
|
{availableSections.has("tenant-api-keys") && <AreaLink title="i18n:govoplan-admin.api_keys.94fcf3c2" text="i18n:govoplan-admin.scoped_automation_credentials_capped_by_owner_pe.b3e20e54" onClick={() => onSelect("tenant-api-keys")} />}
|
||||||
|
{availableSections.has("tenant-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.tenant_level_privacy_retention_limits_inherited_.48f4989d" helpRiskReviewed="standard" onClick={() => onSelect("tenant-retention")} />}
|
||||||
|
{availableSections.has("tenant-modules") && <AreaLink title="Modules" text="Choose modules made available to this tenant by system policy." onClick={() => onSelect("tenant-modules")} />}
|
||||||
|
{availableSections.has("tenant-view-policy") && <AreaLink title="View policy" text="Narrow inherited View actions and available surfaces for this tenant." onClick={() => onSelect("tenant-view-policy")} />}
|
||||||
|
{availableSections.has("tenant-settings") && <AreaLink title="i18n:govoplan-admin.general.9239ee2c" text="i18n:govoplan-admin.tenant_locale_and_tenant_specific_settings.ac49c83b" onClick={() => onSelect("tenant-settings")} />}
|
||||||
|
{availableSections.has("tenant-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.tenant_level_administrative_history_only.55495c3c" onClick={() => onSelect("tenant-audit")} />}
|
||||||
|
</div>
|
||||||
|
</Card>}
|
||||||
|
|
||||||
|
{hasAnySection(availableSections, groupSectionIds) && <Card title={ADMIN_INTERFACE_I18N.groupHeading}>
|
||||||
|
<div className="admin-overview-grid">
|
||||||
|
{availableSections.has("tenant-group-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.group_file_connector_policy_limits" onClick={() => onSelect("tenant-group-file-connectors")} />}
|
||||||
|
{availableSections.has("tenant-group-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.group_mail_server_policy_limits" onClick={() => onSelect("tenant-group-mail-servers")} />}
|
||||||
|
{availableSections.has("tenant-group-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.group_retention_policy_limits" helpRiskReviewed="standard" onClick={() => onSelect("tenant-group-retention")} />}
|
||||||
|
{availableSections.has("group-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected group." onClick={() => onSelect("group-view-policy")} />}
|
||||||
|
</div>
|
||||||
|
</Card>}
|
||||||
|
|
||||||
|
{hasAnySection(availableSections, userSectionIds) && <Card title={ADMIN_INTERFACE_I18N.userHeading}>
|
||||||
|
<div className="admin-overview-grid">
|
||||||
|
{availableSections.has("tenant-user-file-connectors") && <AreaLink title="i18n:govoplan-admin.file_connections.1e362326" text="i18n:govoplan-admin.user_file_connector_policy_limits" onClick={() => onSelect("tenant-user-file-connectors")} />}
|
||||||
|
{availableSections.has("tenant-user-mail-servers") && <AreaLink title="i18n:govoplan-admin.mail_servers.d627326a" text="i18n:govoplan-admin.user_mail_server_policy_limits" onClick={() => onSelect("tenant-user-mail-servers")} />}
|
||||||
|
{availableSections.has("tenant-user-retention") && <AreaLink title="i18n:govoplan-admin.retention.c7199d9e" text="i18n:govoplan-admin.user_retention_policy_limits" helpRiskReviewed="standard" onClick={() => onSelect("tenant-user-retention")} />}
|
||||||
|
{availableSections.has("user-view-policy") && <AreaLink title="View policy" text="Set View limits for a selected user." onClick={() => onSelect("user-view-policy")} />}
|
||||||
|
</div>
|
||||||
|
</Card>}
|
||||||
</>}
|
</>}
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasSystemArea(sections: ReadonlySet<string>): boolean {
|
const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-language-packages", "system-configuration-changes", "system-audit"];
|
||||||
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section));
|
const globalSectionIds = ["system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-file-connectors", "system-mail-servers", "system-retention", "system-view-policy"];
|
||||||
|
const tenantSectionIds = ["tenant-roles", "tenant-groups", "tenant-users", "tenant-file-connectors", "tenant-mail-servers", "tenant-api-keys", "tenant-retention", "tenant-view-policy", "tenant-modules", "tenant-settings", "tenant-audit"];
|
||||||
|
const groupSectionIds = ["tenant-group-file-connectors", "tenant-group-mail-servers", "tenant-group-retention", "group-view-policy"];
|
||||||
|
const userSectionIds = ["tenant-user-file-connectors", "tenant-user-mail-servers", "tenant-user-retention", "user-view-policy"];
|
||||||
|
|
||||||
|
function hasAnySection(sections: ReadonlySet<string>, candidates: readonly string[]): boolean {
|
||||||
|
return candidates.some((section) => sections.has(section));
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
|
function AreaLink({ title, text, helpRiskReviewed, onClick }: {title: string;text: string;helpRiskReviewed?: "standard";onClick: () => void;}) {
|
||||||
return <Card title={title}><strong className="module-big-number">{value}</strong><p className="muted">{text}</p></Card>;
|
return <button className="admin-overview-link" data-help-risk-reviewed={helpRiskReviewed} onClick={onClick}><strong>{title}</strong><span>{text}</span></button>;
|
||||||
}
|
|
||||||
|
|
||||||
function AreaLink({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
|
|
||||||
return <button className="admin-overview-link" onClick={onClick}><strong>{title}</strong><span>{text}</span></button>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Check, RefreshCw } from "lucide-react";
|
||||||
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
|
import { AdminPageLayout, Button, Card, ConfirmDialog, DataGrid, DocumentationHelpLink, StatusBadge, TableActionGroup, adminErrorMessage, formatDateTime, i18nMessage, mergeDeltaRows, useDeltaWatermarks, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
approveConfigurationChangeRequest,
|
||||||
|
fetchConfigurationChangesDelta,
|
||||||
|
type ConfigurationChangeRecord,
|
||||||
|
type ConfigurationChangeRequest } from
|
||||||
|
"../../api/admin";
|
||||||
|
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
const DELTA_KEY = "admin:configuration-changes";
|
||||||
|
|
||||||
|
export default function ConfigurationChangesPanel({ settings, canApprove }: {settings: ApiSettings;canApprove: boolean;}) {
|
||||||
|
const [requests, setRequests] = useState<ConfigurationChangeRequest[]>([]);
|
||||||
|
const [history, setHistory] = useState<ConfigurationChangeRecord[]>([]);
|
||||||
|
const requestsRef = useRef<ConfigurationChangeRequest[]>([]);
|
||||||
|
const historyRef = useRef<ConfigurationChangeRecord[]>([]);
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState("");
|
||||||
|
const [approving, setApproving] = useState<ConfigurationChangeRequest | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
let nextWatermark = getDeltaWatermark(DELTA_KEY);
|
||||||
|
let nextRequests = requestsRef.current;
|
||||||
|
let nextHistory = historyRef.current;
|
||||||
|
let hasMore = false;
|
||||||
|
do {
|
||||||
|
const payload = await fetchConfigurationChangesDelta(settings, { since: nextWatermark });
|
||||||
|
nextRequests = payload.full ? payload.requests : mergeDeltaRows(nextRequests, payload.requests, payload.deleted, (request) => request.id, { sort: sortConfigurationRequests });
|
||||||
|
nextHistory = payload.full ? payload.history : mergeDeltaRows(nextHistory, payload.history, payload.deleted, (record) => record.id, { sort: sortConfigurationHistory });
|
||||||
|
nextWatermark = payload.watermark ?? null;
|
||||||
|
hasMore = payload.has_more;
|
||||||
|
} while (hasMore);
|
||||||
|
requestsRef.current = nextRequests;
|
||||||
|
historyRef.current = nextHistory;
|
||||||
|
setRequests(nextRequests);
|
||||||
|
setHistory(nextHistory);
|
||||||
|
setDeltaWatermark(DELTA_KEY, nextWatermark);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestsRef.current = [];
|
||||||
|
historyRef.current = [];
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
async function approve(request: ConfigurationChangeRequest) {
|
||||||
|
setBusyId(request.id);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
await approveConfigurationChangeRequest(settings, request.id);
|
||||||
|
setMessage(i18nMessage("i18n:govoplan-admin.approved_value.52da808b", { value0: request.key }));
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
setApproving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = useMemo(() => requests.filter((item) => item.status !== "applied" && item.status !== "rejected"), [requests]);
|
||||||
|
const requestColumns: DataGridColumn<ConfigurationChangeRequest>[] = [
|
||||||
|
{
|
||||||
|
id: "setting",
|
||||||
|
header: "i18n:govoplan-admin.setting.fb449f71",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (request) => `${request.label || request.key} ${request.key}`,
|
||||||
|
render: (request) => <div><strong>{request.label || request.key}</strong><span className="muted block">{request.key}</span></div>
|
||||||
|
},
|
||||||
|
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (request) => request.status, render: (request) => <StatusBadge status={statusTone(request.status)} label={request.status} /> },
|
||||||
|
{ id: "requested", header: "i18n:govoplan-admin.requested.c26bf60f", width: 180, sortable: true, value: (request) => request.requested_at, render: (request) => formatDateTime(request.requested_at) },
|
||||||
|
{ id: "approvals", header: "i18n:govoplan-admin.approvals.deb9d03c", width: 110, sortable: true, value: (request) => request.approvals.length },
|
||||||
|
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (request) => targetLabel(request.target), render: (request) => targetLabel(request.target) },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-admin.action.97c89a4d",
|
||||||
|
width: 72,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (request) => <TableActionGroup actions={[{
|
||||||
|
id: "approve",
|
||||||
|
label: "i18n:govoplan-admin.approve.7b2c7f14",
|
||||||
|
icon: <Check size={16} aria-hidden="true" />,
|
||||||
|
applicable: request.status === "pending_approval",
|
||||||
|
disabled: !canApprove || Boolean(busyId),
|
||||||
|
disabledReason: request.status !== "pending_approval"
|
||||||
|
? "i18n:govoplan-admin.request_is_not_pending_approval.6bf3c031"
|
||||||
|
: !canApprove
|
||||||
|
? ADMIN_INTERFACE_I18N.governanceWriteRequired
|
||||||
|
: busyId
|
||||||
|
? ADMIN_INTERFACE_I18N.busy
|
||||||
|
: undefined,
|
||||||
|
onClick: () => setApproving(request)
|
||||||
|
}]} />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const historyColumns: DataGridColumn<ConfigurationChangeRecord>[] = [
|
||||||
|
{ id: "version", header: "i18n:govoplan-admin.version.2da600bf", width: 100, sortable: true, value: (record) => record.version, render: (record) => `#${record.version}` },
|
||||||
|
{ id: "setting", header: "i18n:govoplan-admin.setting.fb449f71", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (record) => `${record.key} ${record.id}`, render: (record) => <div><strong>{record.key}</strong><span className="muted block">{record.id}</span></div> },
|
||||||
|
{ id: "status", header: "i18n:govoplan-admin.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (record) => record.status, render: (record) => <StatusBadge status={statusTone(record.status)} label={record.status} /> },
|
||||||
|
{ id: "applied", header: "i18n:govoplan-admin.applied.a3e4a569", width: 180, sortable: true, value: (record) => record.created_at, render: (record) => formatDateTime(record.created_at) },
|
||||||
|
{ id: "approvers", header: "i18n:govoplan-admin.approvers.0e2de1fb", width: 120, sortable: true, value: (record) => record.approval_user_ids.length, render: (record) => record.approval_user_ids.length || "-" },
|
||||||
|
{ id: "target", header: "i18n:govoplan-admin.target.61ad50a9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, filterable: true, value: (record) => targetLabel(record.target), render: (record) => targetLabel(record.target) }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title="i18n:govoplan-admin.configuration_changes.82933bbb"
|
||||||
|
description="i18n:govoplan-admin.safety_controlled_configuration_requests_approva.e8259509"
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={message}
|
||||||
|
actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || Boolean(busyId)} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busyId ? ADMIN_INTERFACE_I18N.busy : undefined}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button></>}>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.requests.f7194e6a">
|
||||||
|
<DataGrid
|
||||||
|
id="admin-configuration-change-requests"
|
||||||
|
rows={pending}
|
||||||
|
columns={requestColumns}
|
||||||
|
getRowKey={(request) => request.id}
|
||||||
|
emptyText="i18n:govoplan-admin.no_open_requests.580c95f9"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.history.90ccd649">
|
||||||
|
<DataGrid
|
||||||
|
id="admin-configuration-change-history"
|
||||||
|
rows={history}
|
||||||
|
columns={historyColumns}
|
||||||
|
getRowKey={(record) => record.id}
|
||||||
|
emptyText="i18n:govoplan-admin.no_applied_configuration_changes.6a3ae4a7"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</AdminPageLayout>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(approving)}
|
||||||
|
title={ADMIN_INTERFACE_I18N.approveTitle}
|
||||||
|
message={ADMIN_INTERFACE_I18N.approveMessage}
|
||||||
|
confirmLabel="i18n:govoplan-admin.approve.7b2c7f14"
|
||||||
|
busy={Boolean(busyId)}
|
||||||
|
onCancel={() => setApproving(null)}
|
||||||
|
onConfirm={() => approving && void approve(approving)}
|
||||||
|
/>
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: string): string {
|
||||||
|
if (status === "approved" || status === "applied") return "success";
|
||||||
|
if (status === "pending_approval") return "warning";
|
||||||
|
if (status === "rejected") return "error";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetLabel(target?: Record<string, unknown>): string {
|
||||||
|
if (!target || !Object.keys(target).length) return "-";
|
||||||
|
return Object.entries(target).map(([key, value]) => `${key}: ${String(value)}`).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConfigurationRequests(left: ConfigurationChangeRequest, right: ConfigurationChangeRequest): number {
|
||||||
|
return new Date(right.updated_at || right.requested_at).getTime() - new Date(left.updated_at || left.requested_at).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortConfigurationHistory(left: ConfigurationChangeRecord, right: ConfigurationChangeRecord): number {
|
||||||
|
return right.version - left.version;
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Check, Download, Play, RefreshCw } from "lucide-react";
|
||||||
|
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
|
import { AdminPageLayout, Button, Card, ConfirmDialog, DataGrid, DocumentationHelpLink, FormField, ReferenceSelect, StatusBadge, ToggleSwitch, adminErrorMessage, i18nMessage, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
applyConfigurationPackage,
|
||||||
|
createConfigurationChangeRequest,
|
||||||
|
dryRunConfigurationPackage,
|
||||||
|
exportConfigurationPackage,
|
||||||
|
fetchConfigurationPackageCatalogValidation,
|
||||||
|
type ConfigurationChangeRequest,
|
||||||
|
type ConfigurationPackageDiagnostic,
|
||||||
|
type ConfigurationPackagePlanItem,
|
||||||
|
type ConfigurationPackageRollback,
|
||||||
|
type ConfigurationPackageRequiredData } from
|
||||||
|
"../../api/admin";
|
||||||
|
import {
|
||||||
|
createChangeRequestReferenceProvider,
|
||||||
|
createTenantReferenceProvider
|
||||||
|
} from "./configurationReferenceProviders";
|
||||||
|
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N } from "./interfacePatterns";
|
||||||
|
|
||||||
|
const SAMPLE_ACCESS_PACKAGE = {
|
||||||
|
package_id: "govoplan.access.minimal-office",
|
||||||
|
name: "i18n:govoplan-admin.minimal_office_access.af48f49a",
|
||||||
|
version: "0.1.0",
|
||||||
|
required_modules: [{ module_id: "access" }],
|
||||||
|
required_capabilities: ["configuration.provider", "access.configuration"],
|
||||||
|
fragments: [
|
||||||
|
{
|
||||||
|
module_id: "access",
|
||||||
|
fragment_type: "roles",
|
||||||
|
payload: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
slug: "case-clerk",
|
||||||
|
name: "i18n:govoplan-admin.case_clerk.b78a314a",
|
||||||
|
description: "i18n:govoplan-admin.handles_incoming_administrative_work.dc80c349",
|
||||||
|
permissions: ["admin:users:read"]
|
||||||
|
}]
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module_id: "access",
|
||||||
|
fragment_type: "groups",
|
||||||
|
payload: { items: [{ slug: "front-office", name: "i18n:govoplan-admin.front_office.d9dcfee1" }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module_id: "access",
|
||||||
|
fragment_type: "group_role_assignments",
|
||||||
|
payload: { items: [{ group: "front-office", role: "case-clerk" }] }
|
||||||
|
}]
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
type DryRunResult = {
|
||||||
|
diagnostics: ConfigurationPackageDiagnostic[];
|
||||||
|
required_data: ConfigurationPackageRequiredData[];
|
||||||
|
plan: ConfigurationPackagePlanItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApplyResult = {
|
||||||
|
diagnostics: ConfigurationPackageDiagnostic[];
|
||||||
|
created_refs: Record<string, string>;
|
||||||
|
updated_refs: Record<string, string>;
|
||||||
|
rollback?: ConfigurationPackageRollback | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfigurationPackagesPanel({ settings, auth, canWrite }: {settings: ApiSettings;auth: AuthInfo;canWrite: boolean;}) {
|
||||||
|
const [catalogValidation, setCatalogValidation] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [packageText, setPackageText] = useState(() => JSON.stringify(SAMPLE_ACCESS_PACKAGE, null, 2));
|
||||||
|
const activeTenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||||
|
const [tenantId, setTenantId] = useState(activeTenantId);
|
||||||
|
const [suppliedDataText, setSuppliedDataText] = useState("{}");
|
||||||
|
const [changeRequestId, setChangeRequestId] = useState("");
|
||||||
|
const [manualReferences, setManualReferences] = useState(false);
|
||||||
|
const [dryRun, setDryRun] = useState<DryRunResult | null>(null);
|
||||||
|
const [reviewedFingerprint, setReviewedFingerprint] = useState("");
|
||||||
|
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
||||||
|
const [exportText, setExportText] = useState("");
|
||||||
|
const [lastRequest, setLastRequest] = useState<ConfigurationChangeRequest | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [confirmApply, setConfirmApply] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
const parsedPackage = useMemo(() => parseObject(packageText), [packageText]);
|
||||||
|
const parsedSuppliedData = useMemo(() => parseObject(suppliedDataText), [suppliedDataText]);
|
||||||
|
const canRun = Boolean(parsedPackage.value && parsedSuppliedData.value);
|
||||||
|
const currentFingerprint = useMemo(
|
||||||
|
() => packageRunFingerprint(parsedPackage.value, parsedSuppliedData.value, tenantId),
|
||||||
|
[parsedPackage.value, parsedSuppliedData.value, tenantId]
|
||||||
|
);
|
||||||
|
const dryRunReady = Boolean(
|
||||||
|
dryRun &&
|
||||||
|
reviewedFingerprint === currentFingerprint &&
|
||||||
|
!dryRun.diagnostics.some((item) => item.severity === "blocker")
|
||||||
|
);
|
||||||
|
const applyDisabledReason = !dryRun
|
||||||
|
? "i18n:govoplan-admin.run_preflight_before_apply.c214a009"
|
||||||
|
: reviewedFingerprint !== currentFingerprint
|
||||||
|
? "i18n:govoplan-admin.preflight_is_stale.c214a010"
|
||||||
|
: dryRun.diagnostics.some((item) => item.severity === "blocker")
|
||||||
|
? "i18n:govoplan-admin.preflight_finished_with_blockers.7e2ca12b"
|
||||||
|
: undefined;
|
||||||
|
const tenantProvider = useMemo(
|
||||||
|
() => createTenantReferenceProvider(settings),
|
||||||
|
[settings.accessToken, settings.apiBaseUrl, settings.apiKey]
|
||||||
|
);
|
||||||
|
const changeRequestProvider = useMemo(
|
||||||
|
() => createChangeRequestReferenceProvider(settings, {
|
||||||
|
purpose: "configuration_packages.apply",
|
||||||
|
tenantId
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
settings.accessToken,
|
||||||
|
settings.apiBaseUrl,
|
||||||
|
settings.apiKey,
|
||||||
|
tenantId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadCatalog() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchConfigurationPackageCatalogValidation(settings);
|
||||||
|
setCatalogValidation(response.validation);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {void loadCatalog();}, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
setTenantId((current) => current || activeTenantId);
|
||||||
|
}, [activeTenantId]);
|
||||||
|
|
||||||
|
async function runDryRun() {
|
||||||
|
const manifest = requireParsedPackage();
|
||||||
|
const suppliedData = requireParsedSuppliedData();
|
||||||
|
if (!manifest || !suppliedData) return;
|
||||||
|
setBusy("dry-run");
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
setApplyResult(null);
|
||||||
|
try {
|
||||||
|
const result = await dryRunConfigurationPackage(settings, runPayload(manifest, suppliedData));
|
||||||
|
setDryRun(result);
|
||||||
|
setReviewedFingerprint(packageRunFingerprint(manifest, suppliedData, tenantId));
|
||||||
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.preflight_finished_with_blockers.7e2ca12b" : "i18n:govoplan-admin.preflight_passed.c0c99055");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestApproval() {
|
||||||
|
const manifest = requireParsedPackage();
|
||||||
|
if (!manifest) return;
|
||||||
|
setBusy("approval");
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const request = await createConfigurationChangeRequest(settings, {
|
||||||
|
key: "configuration_packages.apply",
|
||||||
|
value: manifest,
|
||||||
|
dry_run: true,
|
||||||
|
target: tenantId.trim() ? { tenant_id: tenantId.trim() } : {},
|
||||||
|
reason: "i18n:govoplan-admin.configuration_package_apply.c270e5f3"
|
||||||
|
});
|
||||||
|
setLastRequest(request);
|
||||||
|
setChangeRequestId(request.id);
|
||||||
|
setMessage(i18nMessage("i18n:govoplan-admin.change_request_created_value.4af6d3d2", { value0: request.id }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPackage() {
|
||||||
|
const manifest = requireParsedPackage();
|
||||||
|
const suppliedData = requireParsedSuppliedData();
|
||||||
|
if (!manifest || !suppliedData) return;
|
||||||
|
setBusy("apply");
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const result = await applyConfigurationPackage(settings, runPayload(manifest, suppliedData, changeRequestId.trim() || null));
|
||||||
|
setApplyResult(result);
|
||||||
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.apply_finished_with_blockers.78487a12" : "i18n:govoplan-admin.package_applied.63782ce7");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPackage() {
|
||||||
|
const manifest = requireParsedPackage();
|
||||||
|
if (!manifest) return;
|
||||||
|
const moduleIds = packageProviderModuleIds(manifest);
|
||||||
|
setBusy("export");
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const result = await exportConfigurationPackage(settings, {
|
||||||
|
tenant_id: tenantId.trim() || null,
|
||||||
|
module_ids: moduleIds,
|
||||||
|
scopes: tenantId.trim() ? ["tenant"] : ["system"]
|
||||||
|
});
|
||||||
|
const exported = {
|
||||||
|
package_id: `${String(manifest.package_id || "govoplan.configuration")}.export`,
|
||||||
|
name: String(manifest.name || "i18n:govoplan-admin.export.f3e4fadb"),
|
||||||
|
version: "0.1.0",
|
||||||
|
required_modules: moduleIds.map((module_id) => ({ module_id })),
|
||||||
|
required_capabilities: ["configuration.provider", ...moduleIds.map((module_id) => `${module_id}.configuration`)],
|
||||||
|
fragments: result.fragments,
|
||||||
|
data_requirements: result.data_requirements,
|
||||||
|
provenance: result.provenance
|
||||||
|
};
|
||||||
|
setExportText(JSON.stringify(exported, null, 2));
|
||||||
|
setMessage(result.diagnostics.some((item) => item.severity === "blocker") ? "i18n:govoplan-admin.export_finished_with_blockers.e2f70611" : "i18n:govoplan-admin.export.f3e4fadb");
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSuppliedDataValue(item: ConfigurationPackageRequiredData, rawValue: string | boolean) {
|
||||||
|
const current = parsedSuppliedData.value ?? {};
|
||||||
|
const next = { ...current };
|
||||||
|
if (rawValue === "") {
|
||||||
|
delete next[item.key];
|
||||||
|
} else if (item.data_type === "boolean") {
|
||||||
|
next[item.key] = Boolean(rawValue);
|
||||||
|
} else if (item.data_type === "integer") {
|
||||||
|
next[item.key] = Number.parseInt(String(rawValue), 10);
|
||||||
|
} else if (item.data_type === "number") {
|
||||||
|
next[item.key] = Number.parseFloat(String(rawValue));
|
||||||
|
} else {
|
||||||
|
next[item.key] = rawValue;
|
||||||
|
}
|
||||||
|
setSuppliedDataText(JSON.stringify(next, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPayload(manifest: Record<string, unknown>, suppliedData: Record<string, unknown>, requestId?: string | null) {
|
||||||
|
return {
|
||||||
|
package: manifest,
|
||||||
|
tenant_id: tenantId.trim() || null,
|
||||||
|
supplied_data: suppliedData,
|
||||||
|
change_request_id: requestId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireParsedPackage(): Record<string, unknown> | null {
|
||||||
|
if (!parsedPackage.value) {
|
||||||
|
setError(parsedPackage.error || "i18n:govoplan-admin.package_json_must_be_an_object.db825bb3");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsedPackage.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireParsedSuppliedData(): Record<string, unknown> | null {
|
||||||
|
if (!parsedSuppliedData.value) {
|
||||||
|
setError(parsedSuppliedData.error || "i18n:govoplan-admin.supplied_data_json_must_be_an_object.b265eb92");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsedSuppliedData.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title="i18n:govoplan-admin.configuration_packages.eb2f05f1"
|
||||||
|
description="i18n:govoplan-admin.import_preflight_approve_apply_and_export_module.29aec929"
|
||||||
|
loading={loading}
|
||||||
|
error={error || parsedPackage.error || parsedSuppliedData.error || ""}
|
||||||
|
success={message}
|
||||||
|
actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void loadCatalog()} disabled={loading || Boolean(busy)} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}><RefreshCw size={16} /> i18n:govoplan-admin.reload.cce71553</Button></>}>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.catalog.4a88d27b">
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<StatusBadge status={catalogValidation?.valid ? "success" : catalogValidation?.configured ? "warning" : "inactive"} label={catalogValidation?.valid ? "i18n:govoplan-admin.valid.a4aefa35" : catalogValidation?.configured ? "i18n:govoplan-admin.needs_attention.a126722e" : "i18n:govoplan-admin.not_configured.811931bb"} />
|
||||||
|
{catalogValidation?.signed !== undefined && <StatusBadge status={catalogValidation.signed ? "success" : "inactive"} label={catalogValidation.signed ? "i18n:govoplan-admin.signed.6e3665d8" : "i18n:govoplan-admin.unsigned.e91344ea"} />}
|
||||||
|
{catalogValidation?.trusted !== undefined && <StatusBadge status={catalogValidation.trusted ? "success" : "warning"} label={catalogValidation.trusted ? "i18n:govoplan-admin.trusted.99f7ed54" : "i18n:govoplan-admin.untrusted.cdc7838a"} />}
|
||||||
|
</div>
|
||||||
|
<pre className="code-panel module-install-plan-commands">{JSON.stringify(catalogValidation ?? {}, null, 2)}</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.package.7431e3df">
|
||||||
|
<div className="module-installer-request-grid">
|
||||||
|
<div className="wide">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={manualReferences}
|
||||||
|
onChange={setManualReferences}
|
||||||
|
label={ADMIN_INTERFACE_I18N.enterReferencesManually}
|
||||||
|
help={ADMIN_INTERFACE_I18N.manualReferenceHelp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="wide">
|
||||||
|
<FormField label="i18n:govoplan-admin.tenant_id.59eba244" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
|
||||||
|
{manualReferences ? (
|
||||||
|
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} placeholder="active tenant" />
|
||||||
|
) : (
|
||||||
|
<ReferenceSelect
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(value) => {
|
||||||
|
setTenantId(value);
|
||||||
|
setChangeRequestId("");
|
||||||
|
}}
|
||||||
|
provider={tenantProvider}
|
||||||
|
aria-label={ADMIN_INTERFACE_I18N.tenantPickerLabel}
|
||||||
|
placeholder={ADMIN_INTERFACE_I18N.selectTenant}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="wide">
|
||||||
|
<FormField label="i18n:govoplan-admin.change_request_id.96ee3239" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
|
||||||
|
{manualReferences ? (
|
||||||
|
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." />
|
||||||
|
) : (
|
||||||
|
<ReferenceSelect
|
||||||
|
value={changeRequestId}
|
||||||
|
onChange={(value) => setChangeRequestId(value)}
|
||||||
|
provider={changeRequestProvider}
|
||||||
|
aria-label={ADMIN_INTERFACE_I18N.requestPickerLabel}
|
||||||
|
placeholder={ADMIN_INTERFACE_I18N.selectEligibleRequest}
|
||||||
|
emptyText={ADMIN_INTERFACE_I18N.noEligibleRequests}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<label className="wide"><span>i18n:govoplan-admin.package_json.a2b10f38</span><textarea rows={18} value={packageText} onChange={(event) => setPackageText(event.target.value)} /></label>
|
||||||
|
<label className="wide"><span>i18n:govoplan-admin.supplied_data_json.6932bfb7</span><textarea rows={6} value={suppliedDataText} onChange={(event) => setSuppliedDataText(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={() => void runDryRun()} disabled={!canRun || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canRun ? ADMIN_INTERFACE_I18N.validJsonRequired : undefined}><Play size={16} /> i18n:govoplan-admin.dry_run.3d14659c</Button>
|
||||||
|
<Button onClick={() => void requestApproval()} disabled={!canWrite || !parsedPackage.value || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !parsedPackage.value ? ADMIN_INTERFACE_I18N.packageRequired : undefined}><Check size={16} /> i18n:govoplan-admin.request_approval.6245aea1</Button>
|
||||||
|
<Button variant="primary" onClick={() => setConfirmApply(true)} disabled={!canWrite || !canRun || !dryRunReady || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canRun ? ADMIN_INTERFACE_I18N.validJsonRequired : applyDisabledReason}><Check size={16} /> i18n:govoplan-admin.apply.cfea419c</Button>
|
||||||
|
<Button onClick={() => void exportPackage()} disabled={!parsedPackage.value || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !parsedPackage.value ? ADMIN_INTERFACE_I18N.packageRequired : undefined}><Download size={16} /> i18n:govoplan-admin.export.f3e4fadb</Button>
|
||||||
|
</div>
|
||||||
|
{lastRequest && <p className="muted small-note">i18n:govoplan-admin.last_request.4508ef35 {lastRequest.id} ({lastRequest.status})</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{dryRun && <Card title="i18n:govoplan-admin.preflight.8016a487">
|
||||||
|
<PackageDiagnostics diagnostics={dryRun.diagnostics} />
|
||||||
|
<RequiredDataInputs
|
||||||
|
items={dryRun.required_data}
|
||||||
|
values={parsedSuppliedData.value ?? {}}
|
||||||
|
onChange={setSuppliedDataValue}
|
||||||
|
/>
|
||||||
|
<RequiredData items={dryRun.required_data} />
|
||||||
|
<PackagePlan items={dryRun.plan} />
|
||||||
|
</Card>}
|
||||||
|
|
||||||
|
{applyResult && <Card title="i18n:govoplan-admin.apply_result.0fde1c3c">
|
||||||
|
<PackageDiagnostics diagnostics={applyResult.diagnostics} />
|
||||||
|
<ReferenceMap title="i18n:govoplan-admin.created.accf40c8" refs={applyResult.created_refs} />
|
||||||
|
<ReferenceMap title="i18n:govoplan-admin.updated.f2f8570d" refs={applyResult.updated_refs} />
|
||||||
|
{applyResult.rollback && <ConfigurationRollbackState rollback={applyResult.rollback} />}
|
||||||
|
</Card>}
|
||||||
|
|
||||||
|
{exportText && <Card title="i18n:govoplan-admin.export.f3e4fadb">
|
||||||
|
<textarea className="code-panel module-install-plan-commands" rows={16} value={exportText} onChange={(event) => setExportText(event.target.value)} />
|
||||||
|
</Card>}
|
||||||
|
</AdminPageLayout>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmApply}
|
||||||
|
title={ADMIN_INTERFACE_I18N.applyTitle}
|
||||||
|
message={ADMIN_INTERFACE_I18N.applyMessage}
|
||||||
|
confirmLabel={ADMIN_INTERFACE_I18N.applyConfirm}
|
||||||
|
busy={busy === "apply"}
|
||||||
|
onCancel={() => setConfirmApply(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmApply(false);
|
||||||
|
void applyPackage();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(text: string): {value: Record<string, unknown> | null;error: string;} {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(text) as unknown;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return { value: null, error: "i18n:govoplan-admin.json_must_be_an_object.848569c9" };
|
||||||
|
return { value: value as Record<string, unknown>, error: "" };
|
||||||
|
} catch (err) {
|
||||||
|
return { value: null, error: err instanceof Error ? err.message : "i18n:govoplan-admin.invalid_json.01ccb74f" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageRunFingerprint(manifest: Record<string, unknown> | null, suppliedData: Record<string, unknown> | null, tenantId: string): string {
|
||||||
|
if (!manifest || !suppliedData) return "";
|
||||||
|
return JSON.stringify({ manifest, suppliedData, tenantId: tenantId.trim() || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageProviderModuleIds(manifest: Record<string, unknown>): string[] {
|
||||||
|
const fragments = Array.isArray(manifest.fragments) ? manifest.fragments : [];
|
||||||
|
const moduleIds = fragments.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||||
|
const moduleId = (item as Record<string, unknown>).module_id;
|
||||||
|
return typeof moduleId === "string" && moduleId.trim() ? [moduleId.trim()] : [];
|
||||||
|
});
|
||||||
|
return Array.from(new Set(moduleIds.length > 0 ? moduleIds : ["access"])).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequiredDataInputs({ items, values, onChange }: {
|
||||||
|
items: ConfigurationPackageRequiredData[];
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
onChange: (item: ConfigurationPackageRequiredData, value: string | boolean) => void;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="module-installer-request-grid">
|
||||||
|
{items.map((item) => {
|
||||||
|
const value = values[item.key];
|
||||||
|
return (
|
||||||
|
<FormField key={item.key} label={item.label} documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
|
||||||
|
{item.data_type === "boolean" ? (
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(checked) => onChange(item, checked)}
|
||||||
|
label={item.label}
|
||||||
|
help={item.description || undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type={item.secret ? "password" : item.data_type === "integer" || item.data_type === "number" ? "number" : "text"}
|
||||||
|
value={value === undefined || value === null ? "" : String(value)}
|
||||||
|
required={item.required}
|
||||||
|
autoComplete={item.secret ? "off" : undefined}
|
||||||
|
onChange={(event) => onChange(item, event.target.value)}
|
||||||
|
/>
|
||||||
|
{item.description ? <span className="muted small-note">{item.description}</span> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigurationRollbackState({ rollback }: { rollback: ConfigurationPackageRollback }) {
|
||||||
|
const status = rollback.status === "not_required" ? "success" : rollback.status === "blocked_before_apply" ? "inactive" : "warning";
|
||||||
|
const copy = {
|
||||||
|
blocked_before_apply: {
|
||||||
|
label: "i18n:govoplan-admin.package_no_changes_applied.c214a001",
|
||||||
|
summary: "i18n:govoplan-admin.package_blocked_before_apply.c214a002"
|
||||||
|
},
|
||||||
|
not_required: {
|
||||||
|
label: "i18n:govoplan-admin.package_no_rollback_needed.c214a003",
|
||||||
|
summary: "i18n:govoplan-admin.package_all_fragments_noop.c214a004"
|
||||||
|
},
|
||||||
|
database_restore_required: {
|
||||||
|
label: "i18n:govoplan-admin.package_keep_database_snapshot.c214a005",
|
||||||
|
summary: "i18n:govoplan-admin.package_snapshot_is_rollback.c214a006"
|
||||||
|
},
|
||||||
|
partial_apply_requires_recovery: {
|
||||||
|
label: "i18n:govoplan-admin.package_partial_apply.c214a007",
|
||||||
|
summary: "i18n:govoplan-admin.package_recover_before_retry.c214a008"
|
||||||
|
}
|
||||||
|
}[rollback.status];
|
||||||
|
return (
|
||||||
|
<div className="stack compact-stack">
|
||||||
|
<StatusBadge status={status} label={copy.label} />
|
||||||
|
<p>{copy.summary}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageDiagnostics({ diagnostics }: {diagnostics: ConfigurationPackageDiagnostic[];}) {
|
||||||
|
if (diagnostics.length === 0) return <p className="muted">i18n:govoplan-admin.no_diagnostics.2b6e2630</p>;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackageDiagnostic>[] = [
|
||||||
|
{ id: "severity", header: "i18n:govoplan-admin.severity.de314fa0", width: 130, sortable: true, filterable: true, value: (item) => item.severity, render: (item) => <StatusBadge status={diagnosticTone(item.severity)} label={item.severity} /> },
|
||||||
|
{ id: "code", header: "i18n:govoplan-admin.code.adac6937", width: 190, sortable: true, filterable: true, value: (item) => item.code, render: (item) => <code>{item.code}</code> },
|
||||||
|
{ id: "owner", header: "i18n:govoplan-admin.owner.89ff3122", width: 150, sortable: true, filterable: true, value: (item) => item.module_id || "-", render: (item) => item.module_id || "-" },
|
||||||
|
{ id: "object", header: "i18n:govoplan-admin.object.2883f191", width: 180, sortable: true, filterable: true, value: (item) => item.object_ref || "-", render: (item) => item.object_ref || "-" },
|
||||||
|
{ id: "message", header: "i18n:govoplan-admin.message.68f4145f", width: "minmax(260px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (item) => `${item.message} ${item.resolution || ""}`, render: (item) => <div>{item.message}{item.resolution ? <span className="muted block">{item.resolution}</span> : null}</div> }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
id="admin-configuration-package-diagnostics"
|
||||||
|
rows={diagnostics}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(item, index) => `${item.code}-${index}`}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequiredData({ items }: {items: ConfigurationPackageRequiredData[];}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackageRequiredData>[] = [
|
||||||
|
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(200px, 1fr)", minWidth: 170, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
|
||||||
|
{ id: "label", header: "i18n:govoplan-admin.label.74341e3c", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (item) => item.label },
|
||||||
|
{ id: "type", header: "i18n:govoplan-admin.type.3deb7456", width: 140, sortable: true, filterable: true, value: (item) => item.data_type },
|
||||||
|
{ id: "required", header: "i18n:govoplan-admin.required.eed6bfb4", width: 110, sortable: true, value: (item) => item.required, render: (item) => item.required ? "yes" : "no" },
|
||||||
|
{ id: "secret", header: "i18n:govoplan-admin.secret.f4e7a874", width: 100, sortable: true, value: (item) => item.secret, render: (item) => item.secret ? "yes" : "no" }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>i18n:govoplan-admin.required_data.1b1c1b34</h3>
|
||||||
|
<DataGrid id="admin-configuration-package-required-data" rows={items} columns={columns} getRowKey={(item) => item.key} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackagePlan({ items }: {items: ConfigurationPackagePlanItem[];}) {
|
||||||
|
if (items.length === 0) return <p className="muted">i18n:govoplan-admin.no_plan_items.7108c582</p>;
|
||||||
|
const columns: DataGridColumn<ConfigurationPackagePlanItem>[] = [
|
||||||
|
{ id: "action", header: "i18n:govoplan-admin.action.97c89a4d", width: 130, sortable: true, filterable: true, value: (item) => item.action, render: (item) => <StatusBadge status={planTone(item.action)} label={item.action} /> },
|
||||||
|
{ id: "module", header: "i18n:govoplan-admin.module.b8ff0289", width: 170, sortable: true, filterable: true, value: (item) => item.module_id },
|
||||||
|
{ id: "fragment", header: "i18n:govoplan-admin.fragment.3f19d616", width: 170, sortable: true, filterable: true, value: (item) => item.fragment_type },
|
||||||
|
{ id: "id", header: "i18n:govoplan-admin.id.474ae526", width: 180, sortable: true, filterable: true, value: (item) => item.fragment_id || "-", render: (item) => item.fragment_id || "-" },
|
||||||
|
{ id: "summary", header: "i18n:govoplan-admin.summary.12b71c3e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (item) => item.summary || "-", render: (item) => item.summary || "-" }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>i18n:govoplan-admin.plan.ae2f98a0</h3>
|
||||||
|
<DataGrid id="admin-configuration-package-plan" rows={items} columns={columns} getRowKey={(item, index) => `${item.module_id}-${item.fragment_type}-${item.fragment_id ?? index}`} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferenceMap({ title, refs }: {title: string;refs: Record<string, string>;}) {
|
||||||
|
const entries = Object.entries(refs).map(([key, value]) => ({ key, value }));
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
const columns: DataGridColumn<{key: string;value: string;}>[] = [
|
||||||
|
{ id: "key", header: "i18n:govoplan-admin.key.c67dd20e", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.key, render: (item) => <code>{item.key}</code> },
|
||||||
|
{ id: "value", header: "Value", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (item) => item.value }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<DataGrid id={`admin-configuration-package-refs-${title}`} rows={entries} columns={columns} getRowKey={(item) => item.key} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticTone(severity: ConfigurationPackageDiagnostic["severity"]): string {
|
||||||
|
if (severity === "blocker") return "error";
|
||||||
|
if (severity === "warning") return "warning";
|
||||||
|
return "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTone(action: ConfigurationPackagePlanItem["action"]): string {
|
||||||
|
if (action === "blocked") return "error";
|
||||||
|
if (action === "skip" || action === "noop") return "inactive";
|
||||||
|
if (action === "update" || action === "bind") return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Download, Eye, Play, Plus, RefreshCw, Search, ShieldCheck } from "lucide-react";
|
||||||
|
import type { ApiSettings, DataGridColumn } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
AdminIconButton,
|
||||||
|
AdminPageLayout,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
MetricCard,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
adminErrorMessage,
|
||||||
|
formatDateTime
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createDataSubjectRequest,
|
||||||
|
executeDataSubjectErasure,
|
||||||
|
exportDataSubjectRequest,
|
||||||
|
fetchDataSubjectRequest,
|
||||||
|
fetchDataSubjectRequests,
|
||||||
|
planDataSubjectErasure,
|
||||||
|
searchDataSubjectRequest,
|
||||||
|
type DataSubjectErasureAction,
|
||||||
|
type DataSubjectRecord,
|
||||||
|
type DataSubjectRequestCreatePayload,
|
||||||
|
type DataSubjectRequestDetail,
|
||||||
|
type DataSubjectRequestSummary
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
title: "i18n:govoplan-admin.data_subject_requests.ds001",
|
||||||
|
description: "i18n:govoplan-admin.data_subject_requests.ds002",
|
||||||
|
create: "i18n:govoplan-admin.data_subject_requests.ds003",
|
||||||
|
reload: "i18n:govoplan-admin.data_subject_requests.ds004",
|
||||||
|
reference: "i18n:govoplan-admin.data_subject_requests.ds005",
|
||||||
|
kindLabel: "i18n:govoplan-admin.data_subject_requests.ds006",
|
||||||
|
statusLabel: "i18n:govoplan-admin.data_subject_requests.ds007",
|
||||||
|
subject: "i18n:govoplan-admin.data_subject_requests.ds008",
|
||||||
|
due: "i18n:govoplan-admin.data_subject_requests.ds009",
|
||||||
|
actions: "i18n:govoplan-admin.data_subject_requests.ds010",
|
||||||
|
inspect: "i18n:govoplan-admin.data_subject_requests.ds011",
|
||||||
|
noRequests: "i18n:govoplan-admin.data_subject_requests.ds012",
|
||||||
|
records: "i18n:govoplan-admin.data_subject_requests.ds013",
|
||||||
|
executable: "i18n:govoplan-admin.data_subject_requests.ds014",
|
||||||
|
uncovered: "i18n:govoplan-admin.data_subject_requests.ds015",
|
||||||
|
runSearch: "i18n:govoplan-admin.data_subject_requests.ds016",
|
||||||
|
plan: "i18n:govoplan-admin.data_subject_requests.ds017",
|
||||||
|
export: "i18n:govoplan-admin.data_subject_requests.ds018",
|
||||||
|
execute: "i18n:govoplan-admin.data_subject_requests.ds019",
|
||||||
|
collectedRecords: "i18n:govoplan-admin.data_subject_requests.ds020",
|
||||||
|
erasurePlan: "i18n:govoplan-admin.data_subject_requests.ds021",
|
||||||
|
coverage: "i18n:govoplan-admin.data_subject_requests.ds022",
|
||||||
|
noSelection: "i18n:govoplan-admin.data_subject_requests.ds023",
|
||||||
|
provider: "i18n:govoplan-admin.data_subject_requests.ds024",
|
||||||
|
category: "i18n:govoplan-admin.data_subject_requests.ds025",
|
||||||
|
evidence: "i18n:govoplan-admin.data_subject_requests.ds026",
|
||||||
|
decision: "i18n:govoplan-admin.data_subject_requests.ds027",
|
||||||
|
rationale: "i18n:govoplan-admin.data_subject_requests.ds028",
|
||||||
|
select: "i18n:govoplan-admin.data_subject_requests.ds029",
|
||||||
|
createTitle: "i18n:govoplan-admin.data_subject_requests.ds030",
|
||||||
|
purpose: "i18n:govoplan-admin.data_subject_requests.ds031",
|
||||||
|
legalBasis: "i18n:govoplan-admin.data_subject_requests.ds032",
|
||||||
|
email: "i18n:govoplan-admin.data_subject_requests.ds033",
|
||||||
|
accountIdLabel: "i18n:govoplan-admin.data_subject_requests.ds034",
|
||||||
|
membershipIdLabel: "i18n:govoplan-admin.data_subject_requests.ds035",
|
||||||
|
identityIdLabel: "i18n:govoplan-admin.data_subject_requests.ds036",
|
||||||
|
notes: "i18n:govoplan-admin.data_subject_requests.ds037",
|
||||||
|
save: "i18n:govoplan-admin.data_subject_requests.ds038",
|
||||||
|
cancel: "i18n:govoplan-admin.data_subject_requests.ds039",
|
||||||
|
executeTitle: "i18n:govoplan-admin.data_subject_requests.ds040",
|
||||||
|
executeWarning: "i18n:govoplan-admin.data_subject_requests.ds041",
|
||||||
|
confirmation: "i18n:govoplan-admin.data_subject_requests.ds042",
|
||||||
|
confirmationHelp: "i18n:govoplan-admin.data_subject_requests.ds043",
|
||||||
|
missingSelector: "i18n:govoplan-admin.data_subject_requests.ds044",
|
||||||
|
providerGap: "i18n:govoplan-admin.data_subject_requests.ds045",
|
||||||
|
created: "i18n:govoplan-admin.data_subject_requests.ds046",
|
||||||
|
searchComplete: "i18n:govoplan-admin.data_subject_requests.ds047",
|
||||||
|
planComplete: "i18n:govoplan-admin.data_subject_requests.ds048",
|
||||||
|
executionComplete: "i18n:govoplan-admin.data_subject_requests.ds049",
|
||||||
|
exportComplete: "i18n:govoplan-admin.data_subject_requests.ds050",
|
||||||
|
accessRequest: "i18n:govoplan-admin.data_subject_requests.ds051",
|
||||||
|
erasureRequest: "i18n:govoplan-admin.data_subject_requests.ds052",
|
||||||
|
combinedRequest: "i18n:govoplan-admin.data_subject_requests.ds053",
|
||||||
|
mutable: "i18n:govoplan-admin.data_subject_requests.ds054",
|
||||||
|
retainedEvidence: "i18n:govoplan-admin.data_subject_requests.ds055",
|
||||||
|
irreversible: "i18n:govoplan-admin.data_subject_requests.ds056",
|
||||||
|
executableState: "i18n:govoplan-admin.data_subject_requests.ds057",
|
||||||
|
retainedReview: "i18n:govoplan-admin.data_subject_requests.ds058",
|
||||||
|
externalReferences: "i18n:govoplan-admin.data_subject_requests.ds059",
|
||||||
|
externalReferencesHelp: "i18n:govoplan-admin.data_subject_requests.ds060",
|
||||||
|
invalidExternalReferences: "i18n:govoplan-admin.data_subject_requests.ds061"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
reference: string;
|
||||||
|
requestKind: DataSubjectRequestSummary["request_kind"];
|
||||||
|
email: string;
|
||||||
|
accountId: string;
|
||||||
|
membershipId: string;
|
||||||
|
identityId: string;
|
||||||
|
externalReferences: string;
|
||||||
|
purpose: string;
|
||||||
|
legalBasis: string;
|
||||||
|
dueDate: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyDraft: Draft = {
|
||||||
|
reference: "",
|
||||||
|
requestKind: "access_and_erasure",
|
||||||
|
email: "",
|
||||||
|
accountId: "",
|
||||||
|
membershipId: "",
|
||||||
|
identityId: "",
|
||||||
|
externalReferences: "",
|
||||||
|
purpose: "",
|
||||||
|
legalBasis: "",
|
||||||
|
dueDate: "",
|
||||||
|
notes: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DataSubjectRequestsPanel({
|
||||||
|
settings,
|
||||||
|
canManage,
|
||||||
|
canExport,
|
||||||
|
canErase
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
canManage: boolean;
|
||||||
|
canExport: boolean;
|
||||||
|
canErase: boolean;
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<DataSubjectRequestSummary[]>([]);
|
||||||
|
const [selected, setSelected] = useState<DataSubjectRequestDetail | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||||
|
const [selectedActions, setSelectedActions] = useState<string[]>([]);
|
||||||
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
async function load(preferredId?: string) {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const next = await fetchDataSubjectRequests(settings);
|
||||||
|
setItems(next);
|
||||||
|
const requestId = preferredId ?? selected?.request.id;
|
||||||
|
if (requestId && next.some((item) => item.id === requestId)) {
|
||||||
|
setSelected(await fetchDataSubjectRequest(settings, requestId));
|
||||||
|
} else if (selected && !next.some((item) => item.id === selected.request.id)) {
|
||||||
|
setSelected(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||||
|
|
||||||
|
async function inspect(item: DataSubjectRequestSummary) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const detail = await fetchDataSubjectRequest(settings, item.id);
|
||||||
|
setSelected(detail);
|
||||||
|
setSelectedActions(defaultActionSelection(detail));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (![draft.email, draft.accountId, draft.membershipId, draft.identityId, draft.externalReferences].some((value) => value.trim())) {
|
||||||
|
setError(I18N.missingSelector);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validExternalReferenceLines(draft.externalReferences)) {
|
||||||
|
setError(I18N.invalidExternalReferences);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const detail = await createDataSubjectRequest(settings, createPayload(draft));
|
||||||
|
setCreating(false);
|
||||||
|
setDraft(emptyDraft);
|
||||||
|
setSelected(detail);
|
||||||
|
setSuccess(I18N.created);
|
||||||
|
await load(detail.request.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(
|
||||||
|
operation: (request: DataSubjectRequestSummary) => Promise<DataSubjectRequestDetail>,
|
||||||
|
message: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!selected) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const detail = await operation(selected.request);
|
||||||
|
setSelected(detail);
|
||||||
|
setSelectedActions(defaultActionSelection(detail));
|
||||||
|
setSuccess(message);
|
||||||
|
await load(detail.request.id);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (!selected) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await exportDataSubjectRequest(settings, selected.request);
|
||||||
|
setSuccess(I18N.exportComplete);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
if (!selected) return;
|
||||||
|
const succeeded = await mutate(
|
||||||
|
(request) => executeDataSubjectErasure(settings, request, selectedActions, confirmation),
|
||||||
|
I18N.executionComplete
|
||||||
|
);
|
||||||
|
if (succeeded) {
|
||||||
|
setExecuting(false);
|
||||||
|
setConfirmation("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestColumns = useMemo<DataGridColumn<DataSubjectRequestSummary>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: I18N.reference,
|
||||||
|
width: "minmax(180px, 1fr)",
|
||||||
|
minWidth: 160,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => `${row.reference} ${subjectLabel(row)}`,
|
||||||
|
render: (row) => <div><strong>{row.reference}</strong><span className="muted block">{subjectLabel(row)}</span></div>
|
||||||
|
},
|
||||||
|
{ id: "kind", header: I18N.kindLabel, width: 170, sortable: true, filterable: true, value: (row) => row.request_kind },
|
||||||
|
{ id: "status", header: I18N.statusLabel, width: 150, sortable: true, filterable: true, value: (row) => row.status, render: (row) => <StatusBadge status={statusTone(row.status)} label={row.status} /> },
|
||||||
|
{ id: "due", header: I18N.due, width: 170, sortable: true, value: (row) => row.due_at ?? "", render: (row) => row.due_at ? formatDateTime(row.due_at) : "-" },
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: I18N.actions,
|
||||||
|
width: 72,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => <TableActionGroup actions={[{
|
||||||
|
id: "inspect",
|
||||||
|
label: I18N.inspect,
|
||||||
|
icon: <Eye size={16} />,
|
||||||
|
disabled: busy,
|
||||||
|
onClick: () => void inspect(row)
|
||||||
|
}]} />
|
||||||
|
}
|
||||||
|
], [busy]);
|
||||||
|
|
||||||
|
const recordColumns = useMemo<DataGridColumn<DataSubjectRecord>[]>(() => [
|
||||||
|
{ id: "title", header: I18N.records, width: "minmax(220px, 1.3fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.resource_type}`, render: (row) => <div><strong>{row.title}</strong><span className="muted block">{row.resource_type}</span></div> },
|
||||||
|
{ id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id },
|
||||||
|
{ id: "category", header: I18N.category, width: 170, sortable: true, filterable: true, value: (row) => row.category },
|
||||||
|
{ id: "evidence", header: I18N.evidence, width: 190, filterable: true, value: (row) => row.immutable_evidence ? row.retention_reason || "retained" : "mutable", render: (row) => row.immutable_evidence ? <StatusBadge status="warning" label={row.retention_reason || I18N.retainedEvidence} /> : <StatusBadge status="active" label={I18N.mutable} /> }
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const actionColumns = useMemo<DataGridColumn<DataSubjectErasureAction>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: I18N.select,
|
||||||
|
width: 72,
|
||||||
|
render: (row) => <input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedActions.includes(row.action_id)}
|
||||||
|
disabled={!row.executable || busy}
|
||||||
|
aria-label={`${I18N.select}: ${row.title}`}
|
||||||
|
onChange={(event) => setSelectedActions((current) => event.target.checked ? [...current, row.action_id] : current.filter((id) => id !== row.action_id))}
|
||||||
|
/>
|
||||||
|
},
|
||||||
|
{ id: "action", header: I18N.decision, width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (row) => `${row.title} ${row.kind}`, render: (row) => <div><strong>{row.title}</strong><span className="muted block">{row.kind}</span></div> },
|
||||||
|
{ id: "provider", header: I18N.provider, width: 140, sortable: true, filterable: true, value: (row) => row.module_id },
|
||||||
|
{ id: "rationale", header: I18N.rationale, width: "minmax(260px, 1.4fr)", minWidth: 220, resizable: true, value: (row) => row.rationale },
|
||||||
|
{ id: "state", header: I18N.statusLabel, width: 150, value: (row) => row.executable ? "executable" : row.kind, render: (row) => <StatusBadge status={row.executable ? (row.irreversible ? "warning" : "active") : "inactive"} label={row.executable ? (row.irreversible ? I18N.irreversible : I18N.executableState) : I18N.retainedReview} /> }
|
||||||
|
], [busy, selectedActions]);
|
||||||
|
|
||||||
|
const coverageGaps = selected?.request.coverage.modules_without_provider ?? [];
|
||||||
|
const actions = selected?.erasure_plan.actions ?? [];
|
||||||
|
const records = selected?.search.records ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title={I18N.title}
|
||||||
|
description={I18N.description}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<>
|
||||||
|
<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw size={16} /> {I18N.reload}</Button>
|
||||||
|
<AdminIconButton label={I18N.create} icon={<Plus />} variant="primary" onClick={() => setCreating(true)} disabled={!canManage || busy} />
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid
|
||||||
|
id="admin-data-subject-requests"
|
||||||
|
rows={items}
|
||||||
|
columns={requestColumns}
|
||||||
|
initialFit="container"
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
emptyText={I18N.noRequests}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected ? <>
|
||||||
|
<MetricGrid>
|
||||||
|
<MetricCard label={I18N.records} value={selected.request.record_count} tone="info" />
|
||||||
|
<MetricCard label={I18N.executable} value={selected.request.executable_action_count} tone={selected.request.executable_action_count ? "warning" : "neutral"} />
|
||||||
|
<MetricCard label={I18N.uncovered} value={coverageGaps.length} tone={coverageGaps.length ? "warning" : "good"} />
|
||||||
|
</MetricGrid>
|
||||||
|
|
||||||
|
<Card title={selected.request.reference}>
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionItem term={<>{I18N.subject}</>}>{subjectLabel(selected.request)}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>{I18N.kindLabel}</>}>{selected.request.request_kind}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>{I18N.statusLabel}</>}><StatusBadge status={statusTone(selected.request.status)} label={selected.request.status} /></DescriptionItem>
|
||||||
|
<DescriptionItem term={<>{I18N.due}</>}>{selected.request.due_at ? formatDateTime(selected.request.due_at) : "-"}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>{I18N.purpose}</>}>{selected.request.purpose}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>{I18N.legalBasis}</>}>{selected.request.legal_basis || "-"}</DescriptionItem>
|
||||||
|
</DescriptionList>
|
||||||
|
<div className="button-row">
|
||||||
|
<Button onClick={() => void mutate((request) => searchDataSubjectRequest(settings, request), I18N.searchComplete)} disabled={!canManage || busy}><Search size={16} /> {I18N.runSearch}</Button>
|
||||||
|
{selected.request.request_kind !== "access" && <Button onClick={() => void mutate((request) => planDataSubjectErasure(settings, request), I18N.planComplete)} disabled={!canManage || busy || !selected.search.searched_at}><ShieldCheck size={16} /> {I18N.plan}</Button>}
|
||||||
|
<Button onClick={() => void download()} disabled={!canExport || busy}><Download size={16} /> {I18N.export}</Button>
|
||||||
|
{actions.some((action) => action.executable) && <Button variant="danger" onClick={() => setExecuting(true)} disabled={!canErase || busy || !selectedActions.length}><Play size={16} /> {I18N.execute}</Button>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{coverageGaps.length > 0 && <DismissibleAlert tone="warning"><strong>{I18N.providerGap}</strong><br />{I18N.coverage}: {coverageGaps.join(", ")}</DismissibleAlert>}
|
||||||
|
|
||||||
|
<Card title={I18N.collectedRecords}>
|
||||||
|
<DataGrid id={`admin-dsar-records-${selected.request.id}`} rows={records} columns={recordColumns} initialFit="container" getRowKey={(row) => `${row.provider_id}:${row.resource_type}:${row.resource_id}`} emptyText={I18N.noSelection} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{actions.length > 0 && <Card title={I18N.erasurePlan}>
|
||||||
|
<DataGrid id={`admin-dsar-actions-${selected.request.id}`} rows={actions} columns={actionColumns} initialFit="container" getRowKey={(row) => row.action_id} emptyText={I18N.noSelection} />
|
||||||
|
</Card>}
|
||||||
|
</> : <DismissibleAlert tone="info">{I18N.noSelection}</DismissibleAlert>}
|
||||||
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide"
|
||||||
|
open={creating}
|
||||||
|
title={I18N.createTitle}
|
||||||
|
className=""
|
||||||
|
portal
|
||||||
|
closeDisabled={busy}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
footer={<><Button onClick={() => setCreating(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="primary" onClick={() => void create()} disabled={busy || !draft.reference.trim() || !draft.purpose.trim()}>{I18N.save}</Button></>}
|
||||||
|
helpContextId="admin.privacy.data-subject-requests"
|
||||||
|
>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
|
<FormField label={I18N.reference}><input value={draft.reference} onChange={(event) => setDraft({ ...draft, reference: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.kindLabel}><select value={draft.requestKind} onChange={(event) => setDraft({ ...draft, requestKind: event.target.value as Draft["requestKind"] })}><option value="access">{I18N.accessRequest}</option><option value="erasure">{I18N.erasureRequest}</option><option value="access_and_erasure">{I18N.combinedRequest}</option></select></FormField>
|
||||||
|
<FormField label={I18N.email}><input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.accountIdLabel}><input value={draft.accountId} onChange={(event) => setDraft({ ...draft, accountId: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.membershipIdLabel}><input value={draft.membershipId} onChange={(event) => setDraft({ ...draft, membershipId: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.identityIdLabel}><input value={draft.identityId} onChange={(event) => setDraft({ ...draft, identityId: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.externalReferences} help={I18N.externalReferencesHelp}><textarea rows={3} value={draft.externalReferences} onChange={(event) => setDraft({ ...draft, externalReferences: event.target.value })} placeholder="source.namespace=reference" /></FormField>
|
||||||
|
<FormField label={I18N.due}><input type="date" value={draft.dueDate} onChange={(event) => setDraft({ ...draft, dueDate: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.legalBasis}><input value={draft.legalBasis} onChange={(event) => setDraft({ ...draft, legalBasis: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.purpose}><textarea rows={3} value={draft.purpose} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} /></FormField>
|
||||||
|
<FormField label={I18N.notes}><textarea rows={3} value={draft.notes} onChange={(event) => setDraft({ ...draft, notes: event.target.value })} /></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="large"
|
||||||
|
open={executing && Boolean(selected)}
|
||||||
|
title={I18N.executeTitle}
|
||||||
|
className=""
|
||||||
|
portal
|
||||||
|
role="alertdialog"
|
||||||
|
closeDisabled={busy}
|
||||||
|
onClose={() => setExecuting(false)}
|
||||||
|
footer={<><Button onClick={() => setExecuting(false)} disabled={busy}>{I18N.cancel}</Button><Button variant="danger" onClick={() => void execute()} disabled={busy || !selected || confirmation !== `ERASE ${selected.request.id}`}>{I18N.execute}</Button></>}
|
||||||
|
>
|
||||||
|
<DismissibleAlert tone="warning">{I18N.executeWarning}</DismissibleAlert>
|
||||||
|
<p>{selectedActions.length} / {actions.filter((action) => action.executable).length}</p>
|
||||||
|
<FormField label={I18N.confirmation} help={selected ? `${I18N.confirmationHelp}: ERASE ${selected.request.id}` : I18N.confirmationHelp}>
|
||||||
|
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" />
|
||||||
|
</FormField>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPayload(draft: Draft): DataSubjectRequestCreatePayload {
|
||||||
|
return {
|
||||||
|
reference: draft.reference.trim(),
|
||||||
|
request_kind: draft.requestKind,
|
||||||
|
subject: {
|
||||||
|
email: optional(draft.email),
|
||||||
|
account_id: optional(draft.accountId),
|
||||||
|
membership_id: optional(draft.membershipId),
|
||||||
|
identity_id: optional(draft.identityId),
|
||||||
|
external_references: externalReferenceLines(draft.externalReferences)
|
||||||
|
},
|
||||||
|
purpose: draft.purpose.trim(),
|
||||||
|
legal_basis: optional(draft.legalBasis),
|
||||||
|
due_at: draft.dueDate ? new Date(`${draft.dueDate}T23:59:59`).toISOString() : null,
|
||||||
|
notes: optional(draft.notes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultActionSelection(detail: DataSubjectRequestDetail): string[] {
|
||||||
|
return (detail.erasure_plan.actions ?? []).filter((action) => action.executable).map((action) => action.action_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjectLabel(request: DataSubjectRequestSummary): string {
|
||||||
|
const subject = request.subject;
|
||||||
|
return subject.email || subject.membership_id || subject.identity_id || subject.account_id || Object.values(subject.external_references ?? {})[0] || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(value: string): string {
|
||||||
|
if (value === "completed") return "success";
|
||||||
|
if (value.includes("partial")) return "warning";
|
||||||
|
if (value === "draft") return "inactive";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional(value: string): string | null {
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validExternalReferenceLines(value: string): boolean {
|
||||||
|
return value.split(/\r?\n/).every((line) => {
|
||||||
|
if (!line.trim()) return true;
|
||||||
|
const separator = line.indexOf("=");
|
||||||
|
return separator > 0 && Boolean(line.slice(separator + 1).trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalReferenceLines(value: string): Record<string, string> {
|
||||||
|
return Object.fromEntries(value.split(/\r?\n/).flatMap((line) => {
|
||||||
|
const separator = line.indexOf("=");
|
||||||
|
if (separator <= 0) return [];
|
||||||
|
const key = line.slice(0, separator).trim();
|
||||||
|
const reference = line.slice(separator + 1).trim();
|
||||||
|
return key && reference ? [[key, reference]] : [];
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Search, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { ApiSettings } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
@@ -13,13 +14,15 @@ import {
|
|||||||
fetchGovernanceTemplates,
|
fetchGovernanceTemplates,
|
||||||
fetchPermissionCatalog,
|
fetchPermissionCatalog,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
|
synchronizeGovernanceTemplates,
|
||||||
updateGovernanceTemplate,
|
updateGovernanceTemplate,
|
||||||
type GovernanceAssignment,
|
type GovernanceAssignment,
|
||||||
type GovernanceTemplateItem,
|
type GovernanceTemplateItem,
|
||||||
type PermissionItem,
|
type PermissionItem,
|
||||||
type TenantAdminItem
|
type TenantAdminItem } from
|
||||||
} from "../../api/admin";
|
"../../api/admin";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N, mutationDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -35,12 +38,12 @@ export default function GovernanceTemplatesPanel({
|
|||||||
kind,
|
kind,
|
||||||
canWrite,
|
canWrite,
|
||||||
onAuthRefresh
|
onAuthRefresh
|
||||||
}: {
|
|
||||||
settings: ApiSettings;
|
|
||||||
kind: "group" | "role";
|
|
||||||
canWrite: boolean;
|
|
||||||
onAuthRefresh: () => Promise<void>;
|
|
||||||
}) {
|
}: {settings: ApiSettings;kind: "group" | "role";canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
|
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
|
||||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||||
@@ -48,10 +51,19 @@ export default function GovernanceTemplatesPanel({
|
|||||||
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
|
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
|
||||||
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
|
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
|
||||||
const [draft, setDraft] = useState(emptyDraft);
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||||
|
const permissionsByScope = useMemo(() => new Map(permissions.map((permission) => [permission.scope, permission])), [permissions]);
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: closeEditor
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -60,34 +72,43 @@ export default function GovernanceTemplatesPanel({
|
|||||||
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
|
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
|
||||||
fetchGovernanceTemplates(settings, kind),
|
fetchGovernanceTemplates(settings, kind),
|
||||||
fetchTenants(settings),
|
fetchTenants(settings),
|
||||||
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
|
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])]
|
||||||
]);
|
);
|
||||||
setItems(nextItems);
|
setItems(nextItems);
|
||||||
setTenants(nextTenants);
|
setTenants(nextTenants);
|
||||||
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
|
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
|
||||||
} catch (err) { setError(adminErrorMessage(err)); }
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
finally { setLoading(false); }
|
{setLoading(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl, kind]);
|
useEffect(() => {void load();}, [settings.accessToken, settings.apiBaseUrl, kind]);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
setEditing("new");
|
setEditing("new");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(item: GovernanceTemplateItem) {
|
function openEdit(item: GovernanceTemplateItem) {
|
||||||
setDraft({
|
const nextDraft = {
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
isActive: item.is_active,
|
isActive: item.is_active,
|
||||||
permissions: item.permissions,
|
permissions: item.permissions,
|
||||||
assignments: item.assignments
|
assignments: item.assignments
|
||||||
});
|
};
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setSavedDraftKey(draftKey(nextDraft));
|
||||||
setEditing(item);
|
setEditing(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
setEditing(null);
|
||||||
|
setDraft(emptyDraft);
|
||||||
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
|
}
|
||||||
|
|
||||||
function assignmentMode(tenantId: string): "none" | "available" | "required" {
|
function assignmentMode(tenantId: string): "none" | "available" | "required" {
|
||||||
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
|
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
|
||||||
}
|
}
|
||||||
@@ -95,13 +116,13 @@ export default function GovernanceTemplatesPanel({
|
|||||||
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
|
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
assignments: mode === "none"
|
assignments: mode === "none" ?
|
||||||
? current.assignments.filter((item) => item.tenant_id !== tenantId)
|
current.assignments.filter((item) => item.tenant_id !== tenantId) :
|
||||||
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
|
[...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save(): Promise<boolean> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -115,7 +136,7 @@ export default function GovernanceTemplatesPanel({
|
|||||||
is_active: draft.isActive,
|
is_active: draft.isActive,
|
||||||
assignments: draft.assignments
|
assignments: draft.assignments
|
||||||
});
|
});
|
||||||
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
|
setSuccess(i18nMessage("i18n:govoplan-admin.value_template_created.d68a5166", { value0: kind === "group" ? "i18n:govoplan-admin.group.171a0606" : "i18n:govoplan-admin.role.c3f104d1" }));
|
||||||
} else if (editing) {
|
} else if (editing) {
|
||||||
await updateGovernanceTemplate(settings, editing.id, {
|
await updateGovernanceTemplate(settings, editing.id, {
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
@@ -124,13 +145,14 @@ export default function GovernanceTemplatesPanel({
|
|||||||
is_active: draft.isActive,
|
is_active: draft.isActive,
|
||||||
assignments: draft.assignments
|
assignments: draft.assignments
|
||||||
});
|
});
|
||||||
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
|
setSuccess(i18nMessage("i18n:govoplan-admin.value_updated_and_synchronized_to_assigned_tenan.d136eef2", { value0: draft.name }));
|
||||||
}
|
}
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
await onAuthRefresh();
|
await onAuthRefresh();
|
||||||
} catch (err) { setError(adminErrorMessage(err)); }
|
return true;
|
||||||
finally { setBusy(false); }
|
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||||
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
@@ -139,47 +161,65 @@ export default function GovernanceTemplatesPanel({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await deleteGovernanceTemplate(settings, deleting.id);
|
await deleteGovernanceTemplate(settings, deleting.id);
|
||||||
setSuccess(`${deleting.name} deleted.`);
|
setSuccess(i18nMessage("i18n:govoplan-admin.value_deleted.3c4bf574", { value0: deleting.name }));
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
await load();
|
await load();
|
||||||
await onAuthRefresh();
|
await onAuthRefresh();
|
||||||
} catch (err) { setError(adminErrorMessage(err)); }
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
finally { setBusy(false); }
|
{setBusy(false);}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function synchronize(item: GovernanceTemplateItem) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await synchronizeGovernanceTemplates(settings, [item.id]);
|
||||||
|
const blocked = result.outcomes.filter((outcome) => outcome.status === "blocked" || outcome.status === "failed");
|
||||||
|
if (blocked.length) {
|
||||||
|
setError(blocked.map((outcome) => outcome.message || outcome.blocker_codes.join(", ")).join("; "));
|
||||||
|
} else {
|
||||||
|
setSuccess(i18nMessage("i18n:govoplan-admin.value_updated_and_synchronized_to_assigned_tenan.d136eef2", { value0: item.name }));
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
await onAuthRefresh();
|
||||||
|
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
|
||||||
{
|
{
|
||||||
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
|
id: "template", header: kind === "group" ? "i18n:govoplan-admin.group_template.973e0fa6" : "i18n:govoplan-admin.tenant_role.6b53115d", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
|
||||||
value: (row) => `${row.name} ${row.slug}`,
|
value: (row) => `${row.name} ${row.slug}`,
|
||||||
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
|
id: "tenants", header: "i18n:govoplan-admin.tenant_availability.067a4f31", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
|
||||||
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
|
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
|
||||||
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
|
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
|
||||||
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
|
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
|
||||||
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
|
return i18nMessage("i18n:govoplan-admin.value_value.c189e8bc", { value0: tenant?.name || assignment.tenant_id, value1: assignment.mode });
|
||||||
}).join(", ") : "—"
|
}).join(", ") : "—"
|
||||||
},
|
},
|
||||||
...(kind === "role" ? [{
|
...(kind === "role" ? [{
|
||||||
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
|
id: "permissions", header: "i18n:govoplan-admin.permissions.d06d5557", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
|
||||||
value: (row: GovernanceTemplateItem) => row.effective_permission_count
|
value: (row: GovernanceTemplateItem) => row.effective_permission_count
|
||||||
}] : []),
|
}] : []),
|
||||||
{ id: "status", header: "Status", 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: "status", header: "i18n:govoplan-admin.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: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
|
id: "actions", header: "i18n:govoplan-admin.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right",
|
||||||
render: (row) => <div className="admin-icon-actions">
|
render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-admin.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
{ id: "synchronize", label: "i18n:govoplan-admin.sync.905f6309", icon: <RefreshCw />, disabled: !canWrite || busy, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined, onClick: () => void synchronize(row) },
|
||||||
</div>
|
{ id: "delete", label: i18nMessage("i18n:govoplan-admin.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined, onClick: () => setDeleting(row) }
|
||||||
}
|
]} />
|
||||||
], [canWrite, kind, tenants]);
|
}],
|
||||||
|
[busy, canWrite, kind, tenants]);
|
||||||
|
|
||||||
const title = kind === "group" ? "Central groups" : "Tenant roles";
|
const title = kind === "group" ? "i18n:govoplan-admin.central_groups.5c9b5b66" : "i18n:govoplan-admin.tenant_roles.51aca82d";
|
||||||
const description = kind === "group"
|
const description = kind === "group" ?
|
||||||
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
|
"i18n:govoplan-admin.centrally_defined_group_identities_that_are_prov.7761fce9" :
|
||||||
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
|
"i18n:govoplan-admin.centrally_governed_tenant_roles_that_are_provisi.8739fca3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -189,41 +229,73 @@ export default function GovernanceTemplatesPanel({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button><AdminIconButton label={kind === "group" ? "i18n:govoplan-admin.add_group_template.b74d8f0f" : "i18n:govoplan-admin.add_tenant_role.fcd904ea"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ADMIN_INTERFACE_I18N.governanceWriteRequired : undefined} /></>}>
|
||||||
>
|
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
|
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={i18nMessage("i18n:govoplan-admin.no_central_value_templates_found.081149fa", { value0: kind })} />
|
||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
|
title={editing === "new" ? kind === "group" ? "i18n:govoplan-admin.create_group_template.72407248" : "i18n:govoplan-admin.create_tenant_role.f58db104" : kind === "group" ? "i18n:govoplan-admin.edit_group_template.9bc72d21" : "i18n:govoplan-admin.edit_tenant_role.c15a260d"}
|
||||||
onClose={() => !busy && setEditing(null)}
|
onClose={() => !busy && setEditing(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : kind === "group" ? "i18n:govoplan-admin.save_template.0885fab2" : "i18n:govoplan-admin.save_tenant_role.8fc0d37d"}</Button></>}>
|
||||||
>
|
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-admin.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-admin.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
<FormField label="i18n:govoplan-admin.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-admin.active.a733b809</option><option value="inactive">i18n:govoplan-admin.inactive.09af574c</option></select></FormField>
|
||||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-admin.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
|
{kind === "role" && <div className="form-field"><span className="form-label">i18n:govoplan-admin.tenant_permissions.246294bc</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<span className="form-label">Tenant availability</span>
|
<span className="form-label">i18n:govoplan-admin.tenant_availability.067a4f31</span>
|
||||||
<div className="admin-selection-list admin-governance-mode">
|
<div className="admin-selection-list admin-governance-mode">
|
||||||
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
|
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">i18n:govoplan-admin.not_available.d1a17af1</option><option value="available">i18n:govoplan-admin.available.7c62a142</option><option value="required">i18n:govoplan-admin.required.eed6bfb4</option></select></div>)}
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
|
<p className="muted small-note">i18n:govoplan-admin.required_means_the_definition_must_remain_presen.8462063c</p>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-admin.template_details.d5d75e4d"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-admin.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-admin.kind.e00ac23f</>}>{viewing.kind}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-admin.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-admin.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-admin.tenants.1f7ae776</>}>{viewing.assignments.length || "i18n:govoplan-admin.none.6eef6648"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-admin.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-admin.permissions.d06d5557</>}>{viewing.permissions.length ? <PermissionDetails scopes={viewing.permissions} permissionsByScope={permissionsByScope} /> : "—"}</DescriptionItem></DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "i18n:govoplan-admin.delete_group_template.8745d842" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} message={i18nMessage("i18n:govoplan-admin.delete_value_removal_is_blocked_while_a_material.d4eba73d", { value0: deleting?.name })} confirmLabel={kind === "group" ? "i18n:govoplan-admin.delete_template.399bf72a" : "i18n:govoplan-admin.delete_tenant_role.4efa0813"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
</>
|
</>);
|
||||||
);
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(draft: typeof emptyDraft): string {
|
||||||
|
return JSON.stringify(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionDetails({ scopes, permissionsByScope }: {scopes: string[];permissionsByScope: ReadonlyMap<string, PermissionItem>;}) {
|
||||||
|
const groups = groupPermissionScopes(scopes, permissionsByScope);
|
||||||
|
return (
|
||||||
|
<div className="admin-permission-details">
|
||||||
|
{groups.map((group) => <section key={group.module}>
|
||||||
|
<strong>{group.module}</strong>
|
||||||
|
<ul>
|
||||||
|
{group.permissions.map((permission) => <li key={permission.scope}>
|
||||||
|
<span>{permission.label}</span>
|
||||||
|
<code>{permission.scope}</code>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>)}
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPermissionScopes(scopes: string[], permissionsByScope: ReadonlyMap<string, PermissionItem>) {
|
||||||
|
const groups = new Map<string, {scope: string;label: string}[]>();
|
||||||
|
for (const scope of [...scopes].sort()) {
|
||||||
|
const moduleId = scope.split(":", 1)[0] || "other";
|
||||||
|
const permission = permissionsByScope.get(scope);
|
||||||
|
const group = groups.get(moduleId) ?? [];
|
||||||
|
group.push({ scope, label: permission?.label || scope });
|
||||||
|
groups.set(moduleId, group);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([module, permissions]) => ({ module, permissions }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ApiSettings, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
AdminPageLayout,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
FormGrid,
|
||||||
|
StatusBadge,
|
||||||
|
adminErrorMessage,
|
||||||
|
i18nMessage,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
usePlatformModules,
|
||||||
|
useUnsavedDraftGuard
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchSystemSettingsDelta,
|
||||||
|
updateSystemSettings,
|
||||||
|
type LanguagePackage,
|
||||||
|
type SystemSettingsItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
import {
|
||||||
|
ADMIN_GOVERNANCE_DOCUMENTATION,
|
||||||
|
ADMIN_INTERFACE_I18N,
|
||||||
|
mutationDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
import {
|
||||||
|
SYSTEM_SETTINGS_FALLBACK,
|
||||||
|
applySystemSettingsSections,
|
||||||
|
languageOptionLabel,
|
||||||
|
normalizeLanguageCode
|
||||||
|
} from "./systemSettingsModel";
|
||||||
|
|
||||||
|
const DELTA_KEY = "admin:language-packages";
|
||||||
|
const BUILT_IN_LANGUAGE_CODES = new Set(["de", "en"]);
|
||||||
|
|
||||||
|
type LanguageLifecycle = {
|
||||||
|
code: string;
|
||||||
|
package: LanguagePackage | null;
|
||||||
|
catalogCount: number;
|
||||||
|
installed: boolean;
|
||||||
|
active: boolean;
|
||||||
|
uninstallable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LanguagePackagesPanel({
|
||||||
|
settings,
|
||||||
|
canWrite
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
const [draft, setDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||||
|
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||||
|
const [packageDraft, setPackageDraft] = useState({ code: "", label: "", nativeLabel: "" });
|
||||||
|
const [selectedCode, setSelectedCode] = useState("de");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const dirty = languageDraftKey(draft) !== languageDraftKey(savedDraft);
|
||||||
|
const lifecycle = useMemo(
|
||||||
|
() => languageLifecycle(draft, modules),
|
||||||
|
[draft, modules]
|
||||||
|
);
|
||||||
|
const selectedLifecycle = lifecycle.find((item) => item.code === selectedCode) ?? lifecycle[0];
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => {
|
||||||
|
setDraft(savedDraft);
|
||||||
|
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const wasDirty = languageDraftKey(draft) !== languageDraftKey(savedDraft);
|
||||||
|
const response = await fetchSystemSettingsDelta(settings, {
|
||||||
|
since: getDeltaWatermark(DELTA_KEY)
|
||||||
|
});
|
||||||
|
setDeltaWatermark(DELTA_KEY, response.watermark);
|
||||||
|
if (response.full && response.item) {
|
||||||
|
setSavedDraft(response.item);
|
||||||
|
if (!wasDirty) setDraft(response.item);
|
||||||
|
} else if (response.changed_sections.length) {
|
||||||
|
setSavedDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||||
|
if (!wasDirty) {
|
||||||
|
setDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lifecycle.length && !lifecycle.some((item) => item.code === selectedCode)) {
|
||||||
|
setSelectedCode(lifecycle[0].code);
|
||||||
|
}
|
||||||
|
}, [lifecycle, selectedCode]);
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const saved = await updateSystemSettings(settings, {
|
||||||
|
default_locale: draft.default_locale,
|
||||||
|
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
||||||
|
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||||
|
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
||||||
|
available_languages: draft.available_languages,
|
||||||
|
enabled_language_codes: draft.enabled_language_codes
|
||||||
|
});
|
||||||
|
setDraft(saved);
|
||||||
|
setSavedDraft(saved);
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
setSuccess("i18n:govoplan-admin.language_package_settings_saved.lp001");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPackage(code: string, label?: string, nativeLabel?: string) {
|
||||||
|
const normalized = normalizeLanguageCode(code);
|
||||||
|
if (!normalized || draft.available_languages.some((item) => item.code === normalized)) return;
|
||||||
|
const resolvedLabel = label?.trim() || normalized.toUpperCase();
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
available_languages: [
|
||||||
|
...draft.available_languages,
|
||||||
|
{
|
||||||
|
code: normalized,
|
||||||
|
label: resolvedLabel,
|
||||||
|
native_label: nativeLabel?.trim() || resolvedLabel
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
setSelectedCode(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDraftPackage() {
|
||||||
|
const code = normalizeLanguageCode(packageDraft.code);
|
||||||
|
if (!code || !packageDraft.label.trim()) return;
|
||||||
|
installPackage(code, packageDraft.label, packageDraft.nativeLabel);
|
||||||
|
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPackageActive(code: string, active: boolean) {
|
||||||
|
const enabled = new Set(draft.enabled_language_codes);
|
||||||
|
if (active) enabled.add(code);
|
||||||
|
else enabled.delete(code);
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
enabled_language_codes: draft.available_languages
|
||||||
|
.map((item) => item.code)
|
||||||
|
.filter((item) => enabled.has(item))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallPackage(code: string) {
|
||||||
|
if (BUILT_IN_LANGUAGE_CODES.has(code) || code === draft.default_locale) return;
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
available_languages: draft.available_languages.filter((item) => item.code !== code),
|
||||||
|
enabled_language_codes: draft.enabled_language_codes.filter((item) => item !== code)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledDefaultOptions = draft.available_languages.filter((item) =>
|
||||||
|
draft.enabled_language_codes.includes(item.code)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="i18n:govoplan-admin.language_package_administration.lp001"
|
||||||
|
description="i18n:govoplan-admin.language_package_administration_description.lp001"
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<>
|
||||||
|
<DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} />
|
||||||
|
<Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button>
|
||||||
|
<Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, changed: dirty })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="admin-settings-form" data-language-package-lifecycle>
|
||||||
|
<Card title="i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd">
|
||||||
|
<FormField label="i18n:govoplan-admin.default_locale.b99d021f" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
|
||||||
|
<select
|
||||||
|
value={draft.default_locale}
|
||||||
|
onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}
|
||||||
|
disabled={!canWrite || busy || enabledDefaultOptions.length === 0}
|
||||||
|
>
|
||||||
|
{enabledDefaultOptions.map((item) => <option key={item.code} value={item.code}>{languageOptionLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<p className="muted small-note">i18n:govoplan-admin.default_language_inheritance_help.lp001</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.language_packages">
|
||||||
|
<div className="module-management-list">
|
||||||
|
{lifecycle.map((item) => (
|
||||||
|
<div className="module-management-row" key={item.code}>
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{item.package ? languageOptionLabel(item.package) : item.code.toUpperCase()}</strong>
|
||||||
|
<code>{item.code}</code>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-meta">
|
||||||
|
<StatusBadge status={item.installed ? "success" : "info"} label={item.installed ? "i18n:govoplan-admin.installed.7bb4405c" : "i18n:govoplan-admin.available.7c8f1005"} />
|
||||||
|
{item.installed && <StatusBadge status={item.active ? "active" : "inactive"} label={item.active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"} />}
|
||||||
|
<StatusBadge status={item.catalogCount ? "success" : "warning"} label={item.catalogCount ? "i18n:govoplan-admin.compatible.lp001" : "i18n:govoplan-admin.incompatible.lp001"} />
|
||||||
|
{item.uninstallable && <StatusBadge status="warning" label="i18n:govoplan-admin.uninstallable.lp001" />}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>{i18nMessage("i18n:govoplan-admin.module_version_catalogs.lp001", { value0: item.catalogCount })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-toggle">
|
||||||
|
{!item.installed && <Button onClick={() => installPackage(item.code)} disabledReason={mutationDisabledReason({ busy, permitted: canWrite })}>i18n:govoplan-admin.install_language_package</Button>}
|
||||||
|
{item.installed && <Button onClick={() => setPackageActive(item.code, !item.active)} disabledReason={item.active && item.code === draft.default_locale ? "i18n:govoplan-admin.default_language_must_remain_active.lp001" : mutationDisabledReason({ busy, permitted: canWrite })}>{item.active ? "i18n:govoplan-admin.deactivate.lp001" : "i18n:govoplan-admin.activate.lp001"}</Button>}
|
||||||
|
{item.uninstallable && <Button variant="danger" onClick={() => uninstallPackage(item.code)} disabledReason={mutationDisabledReason({ busy, permitted: canWrite })}>i18n:govoplan-admin.uninstall.a735da1d</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormGrid columns={3} collapseAt="standard" className="">
|
||||||
|
<FormField label="i18n:govoplan-admin.language_code">
|
||||||
|
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-admin.language_name">
|
||||||
|
<input value={packageDraft.label} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, label: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-admin.native_language_name">
|
||||||
|
<input value={packageDraft.nativeLabel} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.native_language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, nativeLabel: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={installDraftPackage} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(normalizeLanguageCode(packageDraft.code) && packageDraft.label.trim()) })}>i18n:govoplan-admin.install_language_package</Button>
|
||||||
|
</div>
|
||||||
|
<p className="muted small-note">i18n:govoplan-admin.language_packages_help</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-admin.module_version_compatibility.lp001">
|
||||||
|
{selectedLifecycle && <FormField label="i18n:govoplan-admin.language_packages">
|
||||||
|
<select value={selectedLifecycle.code} onChange={(event) => setSelectedCode(event.target.value)}>
|
||||||
|
{lifecycle.map((item) => <option key={item.code} value={item.code}>{item.code.toUpperCase()}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>}
|
||||||
|
{selectedLifecycle && modules.length > 0 ? (
|
||||||
|
<div className="module-management-list">
|
||||||
|
{modules.map((module) => <ModuleLanguageState key={module.id} module={module} language={selectedLifecycle} />)}
|
||||||
|
</div>
|
||||||
|
) : <p className="muted">i18n:govoplan-admin.no_module_language_catalogs.lp001</p>}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleLanguageState({ module, language }: { module: PlatformWebModule; language: LanguageLifecycle }) {
|
||||||
|
const compatible = Boolean(module.translations?.[language.code]);
|
||||||
|
const state = language.installed && compatible
|
||||||
|
? "i18n:govoplan-admin.installed.7bb4405c"
|
||||||
|
: compatible
|
||||||
|
? "i18n:govoplan-admin.available.7c8f1005"
|
||||||
|
: "i18n:govoplan-admin.incompatible.lp001";
|
||||||
|
return (
|
||||||
|
<div className="module-management-row">
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{module.label}</strong>
|
||||||
|
<code>{module.id}</code>
|
||||||
|
<span>v{module.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-meta">
|
||||||
|
<StatusBadge status={compatible ? (language.installed ? "success" : "info") : "warning"} label={state} />
|
||||||
|
{language.installed && compatible && <StatusBadge status={language.active ? "active" : "inactive"} label={language.active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageLifecycle(item: SystemSettingsItem, modules: PlatformWebModule[]): LanguageLifecycle[] {
|
||||||
|
const installed = new Map(item.available_languages.map((language) => [language.code, language]));
|
||||||
|
const codes = new Set(installed.keys());
|
||||||
|
modules.forEach((module) => Object.keys(module.translations ?? {}).forEach((code) => codes.add(code)));
|
||||||
|
return [...codes].sort().map((code) => {
|
||||||
|
const languagePackage = installed.get(code) ?? null;
|
||||||
|
const catalogCount = modules.filter((module) => Boolean(module.translations?.[code])).length;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
package: languagePackage,
|
||||||
|
catalogCount,
|
||||||
|
installed: Boolean(languagePackage),
|
||||||
|
active: item.enabled_language_codes.includes(code),
|
||||||
|
uninstallable: Boolean(
|
||||||
|
languagePackage && !BUILT_IN_LANGUAGE_CODES.has(code) && code !== item.default_locale
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageDraftKey(item: SystemSettingsItem): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
default_locale: item.default_locale,
|
||||||
|
available_languages: item.available_languages,
|
||||||
|
enabled_language_codes: item.enabled_language_codes
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,117 +4,168 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
|
import { AppearancePalettePreview, AppearancePaletteSelect } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
import { fetchSystemSettingsDelta, updateSystemSettings, type SystemSettingsItem } from "../../api/admin";
|
||||||
|
import { AdminPageLayout, DocumentationHelpLink, NavigationPreferenceEditor, adminErrorMessage, configurableNavigationItemsForModules, dispatchPlatformModulesChanged, useDeltaWatermarks, usePlatformModules, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
import { ADMIN_GOVERNANCE_DOCUMENTATION, ADMIN_INTERFACE_I18N, mutationDisabledReason } from "./interfacePatterns";
|
||||||
|
import { SYSTEM_SETTINGS_FALLBACK, applySystemSettingsSections } from "./systemSettingsModel";
|
||||||
|
|
||||||
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
const DELTA_KEY = "admin:system-settings";
|
||||||
store_raw_campaign_json: true,
|
|
||||||
raw_campaign_json_retention_days: true,
|
|
||||||
generated_eml_retention_days: true,
|
|
||||||
stored_report_detail_retention_days: true,
|
|
||||||
mock_mailbox_retention_days: true,
|
|
||||||
audit_detail_retention_days: true,
|
|
||||||
audit_detail_level: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
|
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance, canWritePolicy }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;canWritePolicy: boolean;}) {
|
||||||
store_raw_campaign_json: true,
|
const modules = usePlatformModules();
|
||||||
raw_campaign_json_retention_days: null,
|
const navigationItems = configurableNavigationItemsForModules(modules);
|
||||||
generated_eml_retention_days: null,
|
const [draft, setDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||||
stored_report_detail_retention_days: null,
|
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||||
mock_mailbox_retention_days: null,
|
|
||||||
audit_detail_retention_days: null,
|
|
||||||
audit_detail_level: "full",
|
|
||||||
allow_lower_level_limits: defaultAllowLowerLevelLimits
|
|
||||||
};
|
|
||||||
|
|
||||||
const fallback: SystemSettingsItem = {
|
|
||||||
default_locale: "en",
|
|
||||||
allow_tenant_custom_groups: true,
|
|
||||||
allow_tenant_custom_roles: true,
|
|
||||||
allow_tenant_api_keys: true,
|
|
||||||
privacy_retention_policy: defaultPrivacyPolicy,
|
|
||||||
maintenance_mode: { enabled: false, message: "" },
|
|
||||||
settings: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
|
|
||||||
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const dirty = systemSettingsDraftKey(draft) !== systemSettingsDraftKey(savedDraft);
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => setDraft(savedDraft)
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const loaded = await fetchSystemSettings(settings);
|
const wasDirty = systemSettingsDraftKey(draft) !== systemSettingsDraftKey(savedDraft);
|
||||||
setDraft(loaded);
|
const response = await fetchSystemSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
|
||||||
|
setDeltaWatermark(DELTA_KEY, response.watermark);
|
||||||
|
if (response.full && response.item) {
|
||||||
|
setSavedDraft(response.item);
|
||||||
|
if (!wasDirty) setDraft(response.item);
|
||||||
|
} else if (response.changed_sections.length) {
|
||||||
|
setSavedDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||||
|
if (!wasDirty) setDraft((current) => applySystemSettingsSections(current, response.sections));
|
||||||
}
|
}
|
||||||
catch (err) { setError(adminErrorMessage(err)); }
|
}
|
||||||
finally { setLoading(false); }
|
catch (err) {setError(adminErrorMessage(err));} finally
|
||||||
|
{setLoading(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
useEffect(() => {
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||||
|
|
||||||
async function save() {
|
async function save(): Promise<boolean> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
setDraft(await updateSystemSettings(settings, {
|
const saved = await updateSystemSettings(settings, {
|
||||||
default_locale: draft.default_locale,
|
default_locale: draft.default_locale,
|
||||||
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
|
||||||
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||||
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
||||||
maintenance_mode: draft.maintenance_mode
|
maintenance_mode: draft.maintenance_mode,
|
||||||
}));
|
navigation: draft.navigation,
|
||||||
setSuccess("System settings saved.");
|
appearance_palette: draft.appearance_palette,
|
||||||
} catch (err) { setError(adminErrorMessage(err)); }
|
appearance_palette_locked: draft.appearance_palette_locked,
|
||||||
finally { setBusy(false); }
|
appearance_custom_overrides_allowed: draft.appearance_custom_overrides_allowed
|
||||||
|
});
|
||||||
|
setDraft(saved);
|
||||||
|
setSavedDraft(saved);
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
dispatchPlatformModulesChanged();
|
||||||
|
setSuccess("i18n:govoplan-admin.system_settings_saved.dac4f17b");
|
||||||
|
return true;
|
||||||
|
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||||
|
{setBusy(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout
|
<AdminPageLayout
|
||||||
title="System general settings"
|
title="i18n:govoplan-admin.system_general_settings.7cd662ed"
|
||||||
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
|
description="i18n:govoplan-admin.instance_wide_defaults_and_tenant_governance_cap.096a4f97"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
|
actions={<><DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, changed: dirty })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button></>}>
|
||||||
>
|
|
||||||
<div className="admin-settings-form">
|
<div className="admin-settings-form">
|
||||||
<Card title="Defaults for newly created tenants">
|
<Card title="i18n:govoplan-admin.tenant_administration_capabilities.5d265972">
|
||||||
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
|
|
||||||
</Card>
|
|
||||||
<Card title="Tenant administration capabilities">
|
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
|
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_defined_groups_by_default.32099d5a" />
|
||||||
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
|
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_defined_roles_by_default.b1f79ee9" />
|
||||||
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
|
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} disabled={!canWrite || busy} help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined} label="i18n:govoplan-admin.allow_tenant_api_keys_by_default.58a6cf17" />
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
|
<p className="muted small-note">i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Maintenance mode">
|
<Card title="System navigation order">
|
||||||
|
<NavigationPreferenceEditor
|
||||||
|
items={navigationItems}
|
||||||
|
productAreas={modules.flatMap((module) => module.productAreas ?? [])}
|
||||||
|
value={draft.navigation}
|
||||||
|
onChange={(navigation) => setDraft({ ...draft, navigation })}
|
||||||
|
scope="system"
|
||||||
|
disabled={!canWrite || busy}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="i18n:govoplan-admin.appearance_defaults">
|
||||||
|
<FormField label="i18n:govoplan-admin.system_palette_default" help="i18n:govoplan-admin.system_palette_default_help">
|
||||||
|
<AppearancePaletteSelect
|
||||||
|
value={draft.appearance_palette}
|
||||||
|
onChange={(appearance_palette) => setDraft({ ...draft, appearance_palette: appearance_palette ?? "default" })}
|
||||||
|
disabled={!canWrite || busy || (draft.appearance_palette_locked && !canWritePolicy)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={draft.appearance_palette_locked}
|
||||||
|
onChange={(appearance_palette_locked) => setDraft({ ...draft, appearance_palette_locked })}
|
||||||
|
disabled={!canWrite || !canWritePolicy || busy}
|
||||||
|
help={!canWritePolicy ? "i18n:govoplan-admin.appearance_lock_policy_permission" : undefined}
|
||||||
|
label="i18n:govoplan-admin.lock_system_palette"
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={draft.appearance_custom_overrides_allowed}
|
||||||
|
onChange={(appearance_custom_overrides_allowed) => setDraft({ ...draft, appearance_custom_overrides_allowed })}
|
||||||
|
disabled={!canWrite || !canWritePolicy || busy}
|
||||||
|
help={!canWritePolicy ? "i18n:govoplan-admin.appearance_policy_permission" : "i18n:govoplan-admin.custom_overrides_policy_help"}
|
||||||
|
label="i18n:govoplan-admin.allow_custom_appearance_overrides"
|
||||||
|
/>
|
||||||
|
<AppearancePalettePreview palette={draft.appearance_palette} />
|
||||||
|
<p className="muted small-note">i18n:govoplan-admin.system_palette_precedence_help</p>
|
||||||
|
</Card>
|
||||||
|
<Card title="i18n:govoplan-admin.maintenance_mode.98cca5c6">
|
||||||
<div className="settings-list">
|
<div className="settings-list">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={draft.maintenance_mode.enabled}
|
checked={draft.maintenance_mode.enabled}
|
||||||
disabled={!canWrite || !canAccessMaintenance}
|
disabled={!canWrite || !canAccessMaintenance || busy}
|
||||||
|
help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canAccessMaintenance ? ADMIN_INTERFACE_I18N.maintenanceAuthorityRequired : busy ? ADMIN_INTERFACE_I18N.busy : undefined}
|
||||||
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
|
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
|
||||||
label="Restrict authenticated API access to maintenance operators"
|
label="i18n:govoplan-admin.restrict_authenticated_api_access_to_maintenance.2bc47195" />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Maintenance message">
|
<FormField label="i18n:govoplan-admin.maintenance_message.ca62571f" help={!canWrite ? ADMIN_INTERFACE_I18N.writeRequired : undefined} documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={draft.maintenance_mode.message ?? ""}
|
value={draft.maintenance_mode.message ?? ""}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite || busy}
|
||||||
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })}
|
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })} />
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<p className="muted small-note">Changing the maintenance-mode flag requires system:maintenance:access. Login remains available so an operator can sign in during maintenance.</p>
|
<p className="muted small-note">i18n:govoplan-admin.changing_the_maintenance_mode_flag_requires_syst.4492050f</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>);
|
||||||
);
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemSettingsDraftKey(item: SystemSettingsItem): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
allow_tenant_custom_groups: item.allow_tenant_custom_groups,
|
||||||
|
allow_tenant_custom_roles: item.allow_tenant_custom_roles,
|
||||||
|
allow_tenant_api_keys: item.allow_tenant_api_keys,
|
||||||
|
maintenance_mode: item.maintenance_mode,
|
||||||
|
navigation: item.navigation,
|
||||||
|
appearance_palette: item.appearance_palette,
|
||||||
|
appearance_palette_locked: item.appearance_palette_locked,
|
||||||
|
appearance_custom_overrides_allowed: item.appearance_custom_overrides_allowed
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminPageLayout,
|
||||||
|
adminErrorMessage,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
dispatchPlatformModulesChanged,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
MetricCard,
|
||||||
|
SearchableSelect,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings,
|
||||||
|
type SearchableSelectOption
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { RefreshCw, Save, Undo2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
fetchSystemTenantModules,
|
||||||
|
fetchTenantModuleTargets,
|
||||||
|
fetchTenantModules,
|
||||||
|
updateSystemTenantModules,
|
||||||
|
updateTenantModules,
|
||||||
|
type TenantModuleAvailability,
|
||||||
|
type TenantModuleEntitlementResponse,
|
||||||
|
type TenantModuleTarget
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scope: "system" | "tenant";
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
available: Set<string>;
|
||||||
|
forced: Set<string>;
|
||||||
|
enabled: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENTATION = {
|
||||||
|
contextId: "admin.tenant-modules",
|
||||||
|
documentationType: "admin" as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TenantModuleManagementPanel({ settings, scope, canWrite }: Props) {
|
||||||
|
const [targets, setTargets] = useState<TenantModuleTarget[]>([]);
|
||||||
|
const [targetId, setTargetId] = useState("");
|
||||||
|
const [state, setState] = useState<TenantModuleEntitlementResponse | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const targetOptions = useMemo<SearchableSelectOption[]>(() => targets.map((target) => ({
|
||||||
|
value: target.id,
|
||||||
|
label: target.name,
|
||||||
|
description: `${target.slug}${target.is_active ? "" : " - inactive"}`,
|
||||||
|
searchText: `${target.name} ${target.slug}`
|
||||||
|
})), [targets]);
|
||||||
|
|
||||||
|
const dirty = Boolean(state && draft && (
|
||||||
|
!sameSet(draft.available, new Set(state.available_modules))
|
||||||
|
|| !sameSet(draft.forced, new Set(state.forced_modules))
|
||||||
|
|| !sameSet(draft.enabled, new Set(state.selected_modules))
|
||||||
|
));
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: discard
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initialize();
|
||||||
|
}, [scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
if (scope === "system") {
|
||||||
|
const loadedTargets = await fetchTenantModuleTargets(settings);
|
||||||
|
setTargets(loadedTargets);
|
||||||
|
const nextTarget = loadedTargets.some((item) => item.id === targetId)
|
||||||
|
? targetId
|
||||||
|
: loadedTargets[0]?.id ?? "";
|
||||||
|
setTargetId(nextTarget);
|
||||||
|
if (nextTarget) {
|
||||||
|
await load(nextTarget, false);
|
||||||
|
} else {
|
||||||
|
setState(null);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTargets([]);
|
||||||
|
setTargetId("");
|
||||||
|
await load(undefined, false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
setState(null);
|
||||||
|
setDraft(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(nextTargetId = targetId, manageLoading = true) {
|
||||||
|
if (scope === "system" && !nextTargetId) return;
|
||||||
|
if (manageLoading) setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const loaded = scope === "system"
|
||||||
|
? await fetchSystemTenantModules(settings, nextTargetId)
|
||||||
|
: await fetchTenantModules(settings);
|
||||||
|
setState(loaded);
|
||||||
|
setDraft(draftFromState(loaded));
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
if (manageLoading) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTarget(nextTargetId: string) {
|
||||||
|
if (!nextTargetId || nextTargetId === targetId) return;
|
||||||
|
setTargetId(nextTargetId);
|
||||||
|
await load(nextTargetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
if (state) setDraft(draftFromState(state));
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
if (!state || !draft || !dirty) return true;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const loaded = scope === "system"
|
||||||
|
? await updateSystemTenantModules(settings, targetId, {
|
||||||
|
available_modules: sorted(draft.available),
|
||||||
|
forced_modules: sorted(draft.forced),
|
||||||
|
enabled_modules: sorted(draft.enabled),
|
||||||
|
expected_revision: state.revision
|
||||||
|
})
|
||||||
|
: await updateTenantModules(settings, {
|
||||||
|
enabled_modules: sorted(draft.enabled),
|
||||||
|
expected_revision: state.revision
|
||||||
|
});
|
||||||
|
setState(loaded);
|
||||||
|
setDraft(draftFromState(loaded));
|
||||||
|
setSuccess("Tenant module selection saved.");
|
||||||
|
dispatchPlatformModulesChanged();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAvailability(moduleId: string, availability: TenantModuleAvailability) {
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const next = cloneDraft(current);
|
||||||
|
if (availability === "unavailable") {
|
||||||
|
next.available.delete(moduleId);
|
||||||
|
next.forced.delete(moduleId);
|
||||||
|
next.enabled.delete(moduleId);
|
||||||
|
} else {
|
||||||
|
next.available.add(moduleId);
|
||||||
|
if (availability === "forced") next.forced.add(moduleId);
|
||||||
|
else next.forced.delete(moduleId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(moduleId: string, enabled: boolean) {
|
||||||
|
setDraft((current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
const next = cloneDraft(current);
|
||||||
|
if (enabled) next.enabled.add(moduleId);
|
||||||
|
else next.enabled.delete(moduleId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = scope === "system"
|
||||||
|
? {
|
||||||
|
title: "Tenant modules",
|
||||||
|
description: "Set a tenant's module ceiling, forced modules, and current selection."
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Modules",
|
||||||
|
description: "Enable or disable modules made available to this tenant by system policy."
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title={labels.title}
|
||||||
|
description={labels.description}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
title="Reload saved module policy"
|
||||||
|
aria-label="Reload saved module policy"
|
||||||
|
onClick={() => void load()}
|
||||||
|
disabled={loading || busy || (scope === "system" && !targetId)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={discard} disabled={!dirty || busy}>
|
||||||
|
<Undo2 size={16} /> Discard
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>
|
||||||
|
<Save size={16} /> {busy ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<DocumentationHelpLink reference={DOCUMENTATION} label="Open module governance documentation" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{scope === "system" && (
|
||||||
|
<FormField label="Tenant" documentation={DOCUMENTATION}>
|
||||||
|
<SearchableSelect
|
||||||
|
value={targetId}
|
||||||
|
options={targetOptions}
|
||||||
|
onChange={(value) => void selectTarget(value)}
|
||||||
|
placeholder="Select tenant"
|
||||||
|
searchPlaceholder="Search tenants..."
|
||||||
|
disabled={loading || busy || targetOptions.length === 0}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state && draft && (
|
||||||
|
<>
|
||||||
|
<MetricGrid columns={5} minimum="compact">
|
||||||
|
<MetricCard label="Available" value={draft.available.size} tone="info" />
|
||||||
|
<MetricCard label="Forced" value={draft.forced.size} tone="warning" />
|
||||||
|
<MetricCard label="Selected" value={draft.enabled.size} />
|
||||||
|
<MetricCard label="Effective now" value={state.effective_modules.length} tone="good" />
|
||||||
|
</MetricGrid>
|
||||||
|
|
||||||
|
{state.diagnostics.map((diagnostic) => (
|
||||||
|
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
|
||||||
|
{diagnostic.message}
|
||||||
|
</DismissibleAlert>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card title={scope === "system" ? "Module policy and tenant selection" : "Tenant module selection"}>
|
||||||
|
<div className="module-management-list">
|
||||||
|
{state.modules.map((module) => {
|
||||||
|
const availability = draftAvailability(draft, module.id);
|
||||||
|
const effectiveSelection = draft.enabled.has(module.id) || availability === "forced" || module.derived_dependency;
|
||||||
|
const selectionLocked = availability !== "available" || module.derived_dependency;
|
||||||
|
return (
|
||||||
|
<div className={`module-management-row${dirtyModule(state, draft, module.id) ? " pending" : ""}`} key={module.id}>
|
||||||
|
<div className="module-management-main">
|
||||||
|
<div className="module-management-title">
|
||||||
|
<strong>{module.name}</strong>
|
||||||
|
<code>{module.id}</code>
|
||||||
|
<StatusBadge
|
||||||
|
status={module.runtime_active ? "success" : "neutral"}
|
||||||
|
label={module.runtime_active ? "Runtime active" : "Runtime inactive"}
|
||||||
|
/>
|
||||||
|
{module.effective && <StatusBadge status="info" label="Effective" />}
|
||||||
|
</div>
|
||||||
|
<div className="module-management-details">
|
||||||
|
<span>{module.dependencies.length ? `Requires ${module.dependencies.join(", ")}` : "No module dependencies"}</span>
|
||||||
|
{module.reason && <span>{module.reason}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="module-management-toggle">
|
||||||
|
{scope === "system" && (
|
||||||
|
<label>
|
||||||
|
<span>System policy</span>
|
||||||
|
<select
|
||||||
|
value={availability}
|
||||||
|
onChange={(event) => setAvailability(module.id, event.target.value as TenantModuleAvailability)}
|
||||||
|
disabled={!canWrite || busy || (module.id === "access" || module.id === "admin")}
|
||||||
|
>
|
||||||
|
<option value="unavailable">Unavailable</option>
|
||||||
|
<option value="available">Available</option>
|
||||||
|
<option value="forced">Forced</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Enabled for tenant"
|
||||||
|
checked={effectiveSelection}
|
||||||
|
onChange={(checked) => setEnabled(module.id, checked)}
|
||||||
|
disabled={!canWrite || busy || selectionLocked}
|
||||||
|
help={module.reason || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !state && (
|
||||||
|
<DismissibleAlert tone="info" dismissible={false}>
|
||||||
|
{scope === "system" ? "No tenant is available for module policy." : "Module policy is unavailable."}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromState(state: TenantModuleEntitlementResponse): Draft {
|
||||||
|
return {
|
||||||
|
available: new Set(state.available_modules),
|
||||||
|
forced: new Set(state.forced_modules),
|
||||||
|
enabled: new Set(state.selected_modules)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDraft(draft: Draft): Draft {
|
||||||
|
return {
|
||||||
|
available: new Set(draft.available),
|
||||||
|
forced: new Set(draft.forced),
|
||||||
|
enabled: new Set(draft.enabled)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftAvailability(draft: Draft, moduleId: string): TenantModuleAvailability {
|
||||||
|
if (draft.forced.has(moduleId)) return "forced";
|
||||||
|
if (draft.available.has(moduleId)) return "available";
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyModule(state: TenantModuleEntitlementResponse, draft: Draft, moduleId: string): boolean {
|
||||||
|
return state.available_modules.includes(moduleId) !== draft.available.has(moduleId)
|
||||||
|
|| state.forced_modules.includes(moduleId) !== draft.forced.has(moduleId)
|
||||||
|
|| state.selected_modules.includes(moduleId) !== draft.enabled.has(moduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSet(left: Set<string>, right: Set<string>): boolean {
|
||||||
|
return left.size === right.size && [...left].every((item) => right.has(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sorted(values: Set<string>): string[] {
|
||||||
|
return [...values].sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
filterSearchableSelectOptions,
|
||||||
|
unavailableReferenceOption,
|
||||||
|
type ApiSettings,
|
||||||
|
type ConfigurationReferenceSelectorsUiCapability,
|
||||||
|
type ReferenceOption,
|
||||||
|
type ReferenceOptionProvider
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchConfigurationChanges,
|
||||||
|
fetchTenants,
|
||||||
|
type ConfigurationChangeRequest
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
export const configurationReferenceSelectors: ConfigurationReferenceSelectorsUiCapability = {
|
||||||
|
tenantProvider: createTenantReferenceProvider,
|
||||||
|
changeRequestProvider: createChangeRequestReferenceProvider
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTenantReferenceProvider(
|
||||||
|
settings: ApiSettings
|
||||||
|
): ReferenceOptionProvider {
|
||||||
|
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||||
|
const tenants = await fetchTenants(settings);
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
return tenants.map((tenant) => ({
|
||||||
|
value: tenant.id,
|
||||||
|
label: tenant.name || tenant.slug || tenant.id,
|
||||||
|
description: [
|
||||||
|
tenant.slug,
|
||||||
|
tenant.is_active ? null : "Inactive",
|
||||||
|
tenant.id
|
||||||
|
].filter(Boolean).join(" · "),
|
||||||
|
kind: "tenant",
|
||||||
|
availability: tenant.is_active ? "available" : "inactive",
|
||||||
|
disabled: !tenant.is_active,
|
||||||
|
sourceModule: "tenancy",
|
||||||
|
provenance: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
active: tenant.is_active
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async search(query, context) {
|
||||||
|
const options = await catalogue(context.signal);
|
||||||
|
return retainSelected(
|
||||||
|
filterSearchableSelectOptions(options, query, context.limit),
|
||||||
|
options,
|
||||||
|
context.selectedValues
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async resolve(values, context) {
|
||||||
|
const options = await catalogue(context.signal);
|
||||||
|
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||||
|
return values.map(
|
||||||
|
(value) =>
|
||||||
|
byValue.get(value)
|
||||||
|
?? unavailableReferenceOption(value, "Unavailable tenant")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChangeRequestReferenceProvider(
|
||||||
|
settings: ApiSettings,
|
||||||
|
{
|
||||||
|
purpose,
|
||||||
|
tenantId
|
||||||
|
}: {
|
||||||
|
purpose: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
}
|
||||||
|
): ReferenceOptionProvider {
|
||||||
|
async function catalogue(signal: AbortSignal): Promise<{
|
||||||
|
eligible: ReferenceOption[];
|
||||||
|
all: ReferenceOption[];
|
||||||
|
}> {
|
||||||
|
const response = await fetchConfigurationChanges(settings);
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
const matching = response.requests.filter(
|
||||||
|
(request) =>
|
||||||
|
request.key === purpose
|
||||||
|
&& requestTargetsTenant(request, tenantId)
|
||||||
|
);
|
||||||
|
const all = matching.map(changeRequestOption);
|
||||||
|
return {
|
||||||
|
eligible: all.filter((option) => !option.disabled),
|
||||||
|
all
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async search(query, context) {
|
||||||
|
const options = await catalogue(context.signal);
|
||||||
|
return retainSelected(
|
||||||
|
filterSearchableSelectOptions(
|
||||||
|
options.eligible,
|
||||||
|
query,
|
||||||
|
context.limit
|
||||||
|
),
|
||||||
|
options.all,
|
||||||
|
context.selectedValues
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async resolve(values, context) {
|
||||||
|
const options = await catalogue(context.signal);
|
||||||
|
const byValue = new Map(
|
||||||
|
options.all.map((option) => [option.value, option])
|
||||||
|
);
|
||||||
|
return values.map(
|
||||||
|
(value) =>
|
||||||
|
byValue.get(value)
|
||||||
|
?? unavailableReferenceOption(
|
||||||
|
value,
|
||||||
|
"Unavailable configuration request"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeRequestOption(
|
||||||
|
request: ConfigurationChangeRequest
|
||||||
|
): ReferenceOption {
|
||||||
|
const closed = request.status === "applied" || request.status === "rejected";
|
||||||
|
const eligible = request.status === "approved" && request.dry_run;
|
||||||
|
return {
|
||||||
|
value: request.id,
|
||||||
|
label: request.label || request.key,
|
||||||
|
description: [
|
||||||
|
request.status.split("_").join(" "),
|
||||||
|
request.dry_run ? null : "dry run not recorded",
|
||||||
|
formatTimestamp(request.requested_at),
|
||||||
|
`requested by ${request.requested_by}`,
|
||||||
|
request.id
|
||||||
|
].filter(Boolean).join(" · "),
|
||||||
|
searchText: `${request.id} ${request.requested_by} ${request.status}`,
|
||||||
|
kind: "configuration_change_request",
|
||||||
|
availability: closed
|
||||||
|
? "unavailable"
|
||||||
|
: eligible
|
||||||
|
? "available"
|
||||||
|
: "inactive",
|
||||||
|
disabled: !eligible,
|
||||||
|
sourceModule: "admin",
|
||||||
|
provenance: {
|
||||||
|
purpose: request.key,
|
||||||
|
target: request.target ?? {},
|
||||||
|
requestedBy: request.requested_by,
|
||||||
|
requestedAt: request.requested_at,
|
||||||
|
status: request.status,
|
||||||
|
dryRunRecorded: request.dry_run
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTargetsTenant(
|
||||||
|
request: ConfigurationChangeRequest,
|
||||||
|
tenantId?: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!tenantId) return true;
|
||||||
|
const targetTenant = request.target?.tenant_id;
|
||||||
|
return !targetTenant || String(targetTenant) === tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainSelected(
|
||||||
|
matches: readonly ReferenceOption[],
|
||||||
|
catalogue: readonly ReferenceOption[],
|
||||||
|
selectedValues: readonly string[]
|
||||||
|
): ReferenceOption[] {
|
||||||
|
const result = [...matches];
|
||||||
|
const returned = new Set(result.map((option) => option.value));
|
||||||
|
const byValue = new Map(catalogue.map((option) => [option.value, option]));
|
||||||
|
for (const value of selectedValues) {
|
||||||
|
if (returned.has(value)) continue;
|
||||||
|
result.push(
|
||||||
|
byValue.get(value)
|
||||||
|
?? unavailableReferenceOption(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(): DOMException {
|
||||||
|
return new DOMException("The operation was aborted.", "AbortError");
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const ADMIN_WORKSPACE_DOCUMENTATION = {
|
||||||
|
topicId: "admin.workspace",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ADMIN_GOVERNANCE_DOCUMENTATION = {
|
||||||
|
topicId: "admin.governance-and-module-lifecycle",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const MODULE_LIFECYCLE_DOCUMENTATION = {
|
||||||
|
topicId: "admin.module-lifecycle-workflow",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ADMIN_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-admin.administration_data_is_loading.6bf3c001",
|
||||||
|
busy: "i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002",
|
||||||
|
writeRequired: "i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003",
|
||||||
|
governanceWriteRequired: "i18n:govoplan-admin.system_governance_write_permission_is_required.6bf3c004",
|
||||||
|
completeRequiredFields: "i18n:govoplan-admin.complete_the_required_fields_before_saving.6bf3c005",
|
||||||
|
noPendingChanges: "i18n:govoplan-admin.make_a_change_before_saving.6bf3c006",
|
||||||
|
validJsonRequired: "i18n:govoplan-admin.provide_valid_package_and_supplied_data_json.6bf3c007",
|
||||||
|
packageRequired: "i18n:govoplan-admin.provide_valid_package_json_before_requesting_approval.6bf3c008",
|
||||||
|
planRequired: "i18n:govoplan-admin.add_at_least_one_valid_plan_item.6bf3c009",
|
||||||
|
savePlanFirst: "i18n:govoplan-admin.save_the_changed_plan_before_queueing_it.6bf3c010",
|
||||||
|
maintenanceAuthorityRequired: "i18n:govoplan-admin.system_maintenance_authority_is_required.6bf3c011",
|
||||||
|
maintenanceRequired: "i18n:govoplan-admin.enable_maintenance_mode_before_this_action.6bf3c012",
|
||||||
|
protectedDefinition: "i18n:govoplan-admin.this_protected_module_cannot_be_changed.6bf3c013",
|
||||||
|
deactivateBeforeUninstall: "i18n:govoplan-admin.deactivate_the_module_before_planning_uninstall.6bf3c014",
|
||||||
|
catalogBlocked: "i18n:govoplan-admin.resolve_the_catalog_license_or_action_blocker_first.6bf3c015",
|
||||||
|
approveTitle: "i18n:govoplan-admin.approve_configuration_change.6bf3c016",
|
||||||
|
approveMessage: "i18n:govoplan-admin.approving_records_your_authority_and_may_unlock_application.6bf3c017",
|
||||||
|
applyTitle: "i18n:govoplan-admin.apply_configuration_package.6bf3c018",
|
||||||
|
applyMessage: "i18n:govoplan-admin.apply_the_current_package_to_the_selected_scope.6bf3c019",
|
||||||
|
applyConfirm: "i18n:govoplan-admin.apply_package.6bf3c020",
|
||||||
|
enterReferencesManually: "i18n:govoplan-admin.enter_reference_ids_manually.6bf3c021",
|
||||||
|
manualReferenceHelp: "i18n:govoplan-admin.use_manual_ids_only_for_intentional_historical_references.6bf3c022",
|
||||||
|
tenantPickerLabel: "i18n:govoplan-admin.configuration_package_tenant.6bf3c023",
|
||||||
|
selectTenant: "i18n:govoplan-admin.select_a_tenant.6bf3c024",
|
||||||
|
requestPickerLabel: "i18n:govoplan-admin.configuration_package_change_request.6bf3c025",
|
||||||
|
selectEligibleRequest: "i18n:govoplan-admin.select_an_eligible_request_optional.6bf3c026",
|
||||||
|
noEligibleRequests: "i18n:govoplan-admin.no_eligible_configuration_requests.6bf3c027",
|
||||||
|
administrationHeading: "i18n:govoplan-admin.administration.b8be3d12",
|
||||||
|
globalHeading: "i18n:govoplan-admin.global.6bf3c028",
|
||||||
|
tenantHeading: "i18n:govoplan-admin.tenant.6bf3c029",
|
||||||
|
groupHeading: "i18n:govoplan-admin.group.171a0606",
|
||||||
|
userHeading: "i18n:govoplan-admin.user.6bf3c030",
|
||||||
|
clearPlanTitle: "i18n:govoplan-admin.clear_saved_module_plan.6bf3c032",
|
||||||
|
clearPlanMessage: "i18n:govoplan-admin.clear_all_saved_module_install_update_and_uninstall_items.6bf3c033",
|
||||||
|
enableMaintenanceTitle: "i18n:govoplan-admin.enable_maintenance_mode.6bf3c034",
|
||||||
|
enableMaintenanceMessage: "i18n:govoplan-admin.restrict_normal_authenticated_access_while_module_work_runs.6bf3c035",
|
||||||
|
cancelRequestTitle: "i18n:govoplan-admin.cancel_installer_request.6bf3c036",
|
||||||
|
cancelRequestMessage: "i18n:govoplan-admin.cancel_the_queued_request_before_the_daemon_accepts_it.6bf3c037",
|
||||||
|
uninstallOnly: "i18n:govoplan-admin.this_option_only_applies_to_uninstall_plans.6bf3c038",
|
||||||
|
installUpdateOnly: "i18n:govoplan-admin.this_option_only_applies_to_install_and_update_plans.6bf3c039"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function mutationDisabledReason({
|
||||||
|
busy,
|
||||||
|
permitted,
|
||||||
|
complete = true,
|
||||||
|
changed = true
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
permitted: boolean;
|
||||||
|
complete?: boolean;
|
||||||
|
changed?: boolean;
|
||||||
|
}): string | undefined {
|
||||||
|
if (busy) return ADMIN_INTERFACE_I18N.busy;
|
||||||
|
if (!permitted) return ADMIN_INTERFACE_I18N.writeRequired;
|
||||||
|
if (!complete) return ADMIN_INTERFACE_I18N.completeRequiredFields;
|
||||||
|
if (!changed) return ADMIN_INTERFACE_I18N.noPendingChanges;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
export type ModuleInstallerWorkflowStageId =
|
||||||
|
| "plan"
|
||||||
|
| "preflight"
|
||||||
|
| "queue"
|
||||||
|
| "execute"
|
||||||
|
| "evidence";
|
||||||
|
|
||||||
|
export type ModuleInstallerWorkflowStageState =
|
||||||
|
| "complete"
|
||||||
|
| "current"
|
||||||
|
| "locked"
|
||||||
|
| "blocked"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export type ModuleInstallerWorkflowStage = {
|
||||||
|
id: ModuleInstallerWorkflowStageId;
|
||||||
|
state: ModuleInstallerWorkflowStageState;
|
||||||
|
current: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModuleInstallerQueueBlock =
|
||||||
|
| "write_access"
|
||||||
|
| "empty_plan"
|
||||||
|
| "invalid_plan"
|
||||||
|
| "unsaved_plan"
|
||||||
|
| "preflight"
|
||||||
|
| "maintenance_mode"
|
||||||
|
| "maintenance_access";
|
||||||
|
|
||||||
|
export type ModuleInstallerWorkflowInput = {
|
||||||
|
planItemCount: number;
|
||||||
|
planDirty: boolean;
|
||||||
|
planValid: boolean;
|
||||||
|
preflightAllowed: boolean | null;
|
||||||
|
maintenanceEnabled: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
canAccessMaintenance: boolean;
|
||||||
|
requestStatus?: string | null;
|
||||||
|
runStatus?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set([
|
||||||
|
"queued",
|
||||||
|
"pending",
|
||||||
|
"claimed",
|
||||||
|
"starting",
|
||||||
|
"running",
|
||||||
|
"cancelling",
|
||||||
|
"rolling_back"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FAILED_STATUSES = new Set([
|
||||||
|
"blocked",
|
||||||
|
"cancelled",
|
||||||
|
"failed",
|
||||||
|
"rollback_failed"
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function moduleInstallerQueueBlock(
|
||||||
|
input: ModuleInstallerWorkflowInput
|
||||||
|
): ModuleInstallerQueueBlock | null {
|
||||||
|
if (!input.canWrite) return "write_access";
|
||||||
|
if (input.planItemCount === 0) return "empty_plan";
|
||||||
|
if (!input.planValid) return "invalid_plan";
|
||||||
|
if (input.planDirty) return "unsaved_plan";
|
||||||
|
if (input.preflightAllowed !== true) return "preflight";
|
||||||
|
if (!input.canAccessMaintenance) return "maintenance_access";
|
||||||
|
if (!input.maintenanceEnabled) return "maintenance_mode";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleInstallerWorkflowStages(
|
||||||
|
input: ModuleInstallerWorkflowInput
|
||||||
|
): ModuleInstallerWorkflowStage[] {
|
||||||
|
const queueBlock = moduleInstallerQueueBlock(input);
|
||||||
|
const planReady = input.planItemCount > 0 && input.planValid && !input.planDirty;
|
||||||
|
const preflightReady = planReady && input.preflightAllowed === true;
|
||||||
|
const requestStatus = normalizeStatus(input.requestStatus);
|
||||||
|
const runStatus = normalizeStatus(input.runStatus);
|
||||||
|
const hasRequest = Boolean(requestStatus);
|
||||||
|
const effectiveStatus = runStatus || requestStatus;
|
||||||
|
const active = ACTIVE_STATUSES.has(effectiveStatus);
|
||||||
|
const terminalStatus = hasRequest && !active ? effectiveStatus : "";
|
||||||
|
const terminal = Boolean(terminalStatus);
|
||||||
|
const failed = FAILED_STATUSES.has(terminalStatus);
|
||||||
|
|
||||||
|
if (!planReady) {
|
||||||
|
return stagesAt("plan", input.planValid || input.planItemCount === 0 ? "current" : "blocked");
|
||||||
|
}
|
||||||
|
if (!preflightReady) {
|
||||||
|
return stagesAt("preflight", input.preflightAllowed === false ? "blocked" : "current", ["plan"]);
|
||||||
|
}
|
||||||
|
if (!hasRequest) {
|
||||||
|
return stagesAt("queue", queueBlock ? "blocked" : "current", ["plan", "preflight"]);
|
||||||
|
}
|
||||||
|
if (!terminal) {
|
||||||
|
return stagesAt("execute", "current", ["plan", "preflight", "queue"]);
|
||||||
|
}
|
||||||
|
return stagesAt(
|
||||||
|
"evidence",
|
||||||
|
failed ? "failed" : "current",
|
||||||
|
["plan", "preflight", "queue", "execute"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installerRequestMatchesPlan(
|
||||||
|
planUpdatedAt?: string | null,
|
||||||
|
requestCreatedAt?: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!planUpdatedAt) return true;
|
||||||
|
if (!requestCreatedAt) return false;
|
||||||
|
const planTime = Date.parse(planUpdatedAt);
|
||||||
|
const requestTime = Date.parse(requestCreatedAt);
|
||||||
|
return Number.isFinite(planTime)
|
||||||
|
&& Number.isFinite(requestTime)
|
||||||
|
&& requestTime >= planTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stagesAt(
|
||||||
|
currentId: ModuleInstallerWorkflowStageId,
|
||||||
|
currentState: Extract<ModuleInstallerWorkflowStageState, "current" | "blocked" | "failed">,
|
||||||
|
completed: ModuleInstallerWorkflowStageId[] = []
|
||||||
|
): ModuleInstallerWorkflowStage[] {
|
||||||
|
const ids: ModuleInstallerWorkflowStageId[] = [
|
||||||
|
"plan",
|
||||||
|
"preflight",
|
||||||
|
"queue",
|
||||||
|
"execute",
|
||||||
|
"evidence"
|
||||||
|
];
|
||||||
|
const currentIndex = ids.indexOf(currentId);
|
||||||
|
const completedIds = new Set(completed);
|
||||||
|
return ids.map((id, index) => {
|
||||||
|
const current = id === currentId;
|
||||||
|
const state: ModuleInstallerWorkflowStageState = current
|
||||||
|
? currentState
|
||||||
|
: completedIds.has(id)
|
||||||
|
? "complete"
|
||||||
|
: "locked";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
state,
|
||||||
|
current,
|
||||||
|
locked: !current && index > currentIndex
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value?: string | null): string {
|
||||||
|
return value?.trim().toLowerCase().replaceAll("-", "_") ?? "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type {
|
||||||
|
PrivacyRetentionLimitPermissions,
|
||||||
|
PrivacyRetentionPolicy,
|
||||||
|
SystemSettingsDeltaSections,
|
||||||
|
SystemSettingsItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
|
||||||
|
const defaultAllowLowerLevelLimits: PrivacyRetentionLimitPermissions = {
|
||||||
|
store_raw_campaign_json: true,
|
||||||
|
raw_campaign_json_retention_days: true,
|
||||||
|
generated_eml_retention_days: true,
|
||||||
|
stored_report_detail_retention_days: true,
|
||||||
|
mock_mailbox_retention_days: true,
|
||||||
|
audit_detail_retention_days: true,
|
||||||
|
audit_detail_level: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPrivacyPolicy: PrivacyRetentionPolicy = {
|
||||||
|
store_raw_campaign_json: true,
|
||||||
|
raw_campaign_json_retention_days: null,
|
||||||
|
generated_eml_retention_days: null,
|
||||||
|
stored_report_detail_retention_days: null,
|
||||||
|
mock_mailbox_retention_days: null,
|
||||||
|
audit_detail_retention_days: null,
|
||||||
|
audit_detail_level: "full",
|
||||||
|
allow_lower_level_limits: defaultAllowLowerLevelLimits
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SYSTEM_SETTINGS_FALLBACK: SystemSettingsItem = {
|
||||||
|
default_locale: "de",
|
||||||
|
allow_tenant_custom_groups: true,
|
||||||
|
allow_tenant_custom_roles: true,
|
||||||
|
allow_tenant_api_keys: true,
|
||||||
|
privacy_retention_policy: defaultPrivacyPolicy,
|
||||||
|
maintenance_mode: { enabled: false, message: "" },
|
||||||
|
available_languages: [
|
||||||
|
{ code: "de", label: "German", native_label: "Deutsch" },
|
||||||
|
{ code: "en", label: "English", native_label: "English" }
|
||||||
|
],
|
||||||
|
enabled_language_codes: ["de", "en"],
|
||||||
|
settings: {},
|
||||||
|
navigation: null,
|
||||||
|
appearance_palette: "default",
|
||||||
|
appearance_palette_locked: false,
|
||||||
|
appearance_custom_overrides_allowed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applySystemSettingsSections(
|
||||||
|
item: SystemSettingsItem,
|
||||||
|
sections: SystemSettingsDeltaSections
|
||||||
|
): SystemSettingsItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(sections.defaults ?? {}),
|
||||||
|
...(sections.tenant_capabilities ?? {}),
|
||||||
|
...(sections.languages ?? {}),
|
||||||
|
...(sections.privacy_retention_policy
|
||||||
|
? { privacy_retention_policy: sections.privacy_retention_policy }
|
||||||
|
: {}),
|
||||||
|
...(sections.maintenance_mode ? { maintenance_mode: sections.maintenance_mode } : {}),
|
||||||
|
...(sections.navigation !== undefined ? { navigation: sections.navigation } : {}),
|
||||||
|
...(sections.appearance ?? {}),
|
||||||
|
...(sections.settings ? { settings: sections.settings } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLanguageCode(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-")
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function languageOptionLabel(language: {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
native_label?: string | null;
|
||||||
|
}): string {
|
||||||
|
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,14 @@ export { default } from "./module";
|
|||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/admin";
|
export * from "./api/admin";
|
||||||
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
|
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
|
||||||
|
export { default as ConfigurationPackagesPanel } from "./features/admin/ConfigurationPackagesPanel";
|
||||||
|
export {
|
||||||
|
configurationReferenceSelectors,
|
||||||
|
createChangeRequestReferenceProvider,
|
||||||
|
createTenantReferenceProvider
|
||||||
|
} from "./features/admin/configurationReferenceProviders";
|
||||||
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
|
||||||
|
export { default as TenantModuleManagementPanel } from "./features/admin/TenantModuleManagementPanel";
|
||||||
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
|
||||||
|
export { default as LanguagePackagesPanel } from "./features/admin/LanguagePackagesPanel";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
|||||||
+143
-11
@@ -1,17 +1,32 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
|
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import { configurationReferenceSelectors } from "./features/admin/configurationReferenceProviders";
|
||||||
|
|
||||||
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
|
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
|
||||||
|
const ConfigurationChangesPanel = lazy(() => import("./features/admin/ConfigurationChangesPanel"));
|
||||||
|
const ConfigurationPackagesPanel = lazy(() => import("./features/admin/ConfigurationPackagesPanel"));
|
||||||
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
|
||||||
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
||||||
|
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
|
||||||
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
|
||||||
|
const LanguagePackagesPanel = lazy(() => import("./features/admin/LanguagePackagesPanel"));
|
||||||
|
const DataSubjectRequestsPanel = lazy(() => import("./features/admin/DataSubjectRequestsPanel"));
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
|
||||||
const adminSections: AdminSectionsUiCapability = {
|
const adminSections: AdminSectionsUiCapability = {
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: "overview",
|
||||||
label: "Overview",
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.overview",
|
||||||
|
label: "i18n:govoplan-admin.overview.0efc2e6b",
|
||||||
group: "ROOT",
|
group: "ROOT",
|
||||||
order: 0,
|
order: 0,
|
||||||
anyOf: adminReadScopes,
|
anyOf: adminReadScopes,
|
||||||
@@ -23,19 +38,69 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system-settings",
|
id: "system-settings",
|
||||||
label: "General",
|
moduleId: "admin",
|
||||||
|
kind: "settings",
|
||||||
|
surfaceId: "admin.section.system-settings",
|
||||||
|
label: "i18n:govoplan-admin.general.9239ee2c",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 10,
|
order: 10,
|
||||||
allOf: ["system:settings:read"],
|
allOf: ["system:settings:read"],
|
||||||
render: ({ settings, auth }) => createElement(SystemSettingsPanel, {
|
render: ({ settings, auth }) => createElement(SystemSettingsPanel, {
|
||||||
settings,
|
settings,
|
||||||
canWrite: hasScope(auth, "system:settings:write"),
|
canWrite: hasScope(auth, "system:settings:write"),
|
||||||
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
canAccessMaintenance: hasScope(auth, "system:maintenance:access"),
|
||||||
|
canWritePolicy: hasScope(auth, "admin:policies:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-language-packages",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "settings",
|
||||||
|
surfaceId: "admin.section.system-language-packages",
|
||||||
|
label: "i18n:govoplan-admin.language_packages",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 15,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(LanguagePackagesPanel, {
|
||||||
|
settings,
|
||||||
|
canWrite: hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-configuration-changes",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-configuration-changes",
|
||||||
|
label: "i18n:govoplan-admin.changes.8aa57de6",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 20,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(ConfigurationChangesPanel, {
|
||||||
|
settings,
|
||||||
|
canApprove: hasScope(auth, "system:settings:write") || hasScope(auth, "system:governance:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-configuration-packages",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-configuration-packages",
|
||||||
|
label: "i18n:govoplan-admin.configuration_packages.eb2f05f1",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 30,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(ConfigurationPackagesPanel, {
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
canWrite: hasScope(auth, "system:settings:write")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system-role-templates",
|
id: "system-role-templates",
|
||||||
label: "Tenant roles",
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-role-templates",
|
||||||
|
label: "i18n:govoplan-admin.tenant_roles.51aca82d",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 40,
|
order: 40,
|
||||||
allOf: ["system:governance:read"],
|
allOf: ["system:governance:read"],
|
||||||
@@ -48,7 +113,10 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system-modules",
|
id: "system-modules",
|
||||||
label: "Modules",
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-modules",
|
||||||
|
label: "i18n:govoplan-admin.modules.04e9462c",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 85,
|
order: 85,
|
||||||
allOf: ["system:settings:read"],
|
allOf: ["system:settings:read"],
|
||||||
@@ -58,9 +126,58 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "system-tenant-modules",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-tenant-modules",
|
||||||
|
label: "Tenant modules",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 86,
|
||||||
|
allOf: ["system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||||
|
settings,
|
||||||
|
scope: "system",
|
||||||
|
canWrite: hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-data-subject-requests",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.tenant-data-subject-requests",
|
||||||
|
label: "i18n:govoplan-admin.data_subject_requests.ds001",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 55,
|
||||||
|
allOf: ["access:privacy:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(DataSubjectRequestsPanel, {
|
||||||
|
settings,
|
||||||
|
canManage: hasScope(auth, "access:privacy:manage"),
|
||||||
|
canExport: hasScope(auth, "access:privacy:export"),
|
||||||
|
canErase: hasScope(auth, "access:privacy:erase")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-modules",
|
||||||
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.tenant-modules",
|
||||||
|
label: "Modules",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 60,
|
||||||
|
anyOf: ["admin:module:read", "admin:module:write", "system:settings:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(TenantModuleManagementPanel, {
|
||||||
|
settings,
|
||||||
|
scope: "tenant",
|
||||||
|
canWrite: hasScope(auth, "admin:module:write") || hasScope(auth, "system:settings:write")
|
||||||
|
})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "system-groups",
|
id: "system-groups",
|
||||||
label: "Groups",
|
moduleId: "admin",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "admin.section.system-groups",
|
||||||
|
label: "i18n:govoplan-admin.groups.ae9629f4",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 50,
|
order: 50,
|
||||||
allOf: ["system:governance:read"],
|
allOf: ["system:governance:read"],
|
||||||
@@ -70,17 +187,32 @@ const adminSections: AdminSectionsUiCapability = {
|
|||||||
canWrite: hasScope(auth, "system:governance:write"),
|
canWrite: hasScope(auth, "system:governance:write"),
|
||||||
onAuthRefresh: refreshAuth
|
onAuthRefresh: refreshAuth
|
||||||
})
|
})
|
||||||
}
|
}]
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminModule: PlatformWebModule = {
|
export const adminModule: PlatformWebModule = {
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "Admin",
|
label: "i18n:govoplan-admin.admin.4e7afebc",
|
||||||
version: "1.0.0",
|
version: "0.1.8",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "admin.section.overview", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.overview.0efc2e6b", order: 0 },
|
||||||
|
{ id: "admin.section.system-settings", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.system_general_settings.7cd662ed", order: 10 },
|
||||||
|
{ id: "admin.section.system-language-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.language_packages", order: 15 },
|
||||||
|
{ id: "admin.section.system-configuration-changes", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_changes.82933bbb", order: 20 },
|
||||||
|
{ id: "admin.section.system-configuration-packages", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.configuration_packages.eb2f05f1", order: 30 },
|
||||||
|
{ id: "admin.section.system-role-templates", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.tenant_roles.51aca82d", order: 40 },
|
||||||
|
{ id: "admin.section.system-groups", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.central_groups.5c9b5b66", order: 50 },
|
||||||
|
{ id: "admin.section.system-modules", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.modules.04e9462c", order: 85 },
|
||||||
|
{ id: "admin.section.system-tenant-modules", moduleId: "admin", kind: "section", label: "Tenant modules", order: 86 },
|
||||||
|
{ id: "admin.section.tenant-data-subject-requests", moduleId: "admin", kind: "section", label: "i18n:govoplan-admin.data_subject_requests.ds001", order: 55 },
|
||||||
|
{ id: "admin.section.tenant-modules", moduleId: "admin", kind: "section", label: "Modules", order: 60 }
|
||||||
|
],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"admin.sections": adminSections
|
"admin.sections": adminSections,
|
||||||
|
"admin.configurationReferences": configurationReferenceSelectors
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
installerRequestMatchesPlan,
|
||||||
|
moduleInstallerQueueBlock,
|
||||||
|
moduleInstallerWorkflowStages,
|
||||||
|
type ModuleInstallerWorkflowInput
|
||||||
|
} from "../src/features/admin/moduleInstallerWorkflow.ts";
|
||||||
|
|
||||||
|
const ready: ModuleInstallerWorkflowInput = {
|
||||||
|
planItemCount: 1,
|
||||||
|
planDirty: false,
|
||||||
|
planValid: true,
|
||||||
|
preflightAllowed: true,
|
||||||
|
maintenanceEnabled: true,
|
||||||
|
canWrite: true,
|
||||||
|
canAccessMaintenance: true
|
||||||
|
};
|
||||||
|
|
||||||
|
test("keeps the operator on the earliest incomplete installer stage", () => {
|
||||||
|
assert.equal(moduleInstallerWorkflowStages({ ...ready, planItemCount: 0 })[0].current, true);
|
||||||
|
assert.equal(moduleInstallerWorkflowStages({ ...ready, planDirty: true })[0].current, true);
|
||||||
|
assert.equal(moduleInstallerWorkflowStages({ ...ready, preflightAllowed: false })[1].state, "blocked");
|
||||||
|
assert.equal(moduleInstallerWorkflowStages({ ...ready, maintenanceEnabled: false })[2].state, "blocked");
|
||||||
|
assert.equal(moduleInstallerWorkflowStages(ready)[2].state, "current");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moves queued work through execution to durable evidence", () => {
|
||||||
|
const queued = moduleInstallerWorkflowStages({ ...ready, requestStatus: "queued" });
|
||||||
|
assert.equal(queued[2].state, "complete");
|
||||||
|
assert.equal(queued[3].state, "current");
|
||||||
|
|
||||||
|
const completed = moduleInstallerWorkflowStages({
|
||||||
|
...ready,
|
||||||
|
requestStatus: "completed",
|
||||||
|
runStatus: "completed"
|
||||||
|
});
|
||||||
|
assert.deepEqual(completed.map((stage) => stage.state), [
|
||||||
|
"complete",
|
||||||
|
"complete",
|
||||||
|
"complete",
|
||||||
|
"complete",
|
||||||
|
"current"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const failed = moduleInstallerWorkflowStages({
|
||||||
|
...ready,
|
||||||
|
requestStatus: "failed",
|
||||||
|
runStatus: "rollback_failed"
|
||||||
|
});
|
||||||
|
assert.equal(failed[4].state, "failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports one actionable queue blocker in deterministic order", () => {
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, canWrite: false }), "write_access");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, planItemCount: 0 }), "empty_plan");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, planValid: false }), "invalid_plan");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, planDirty: true }), "unsaved_plan");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, preflightAllowed: false }), "preflight");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false }), "maintenance_mode");
|
||||||
|
assert.equal(moduleInstallerQueueBlock({ ...ready, canAccessMaintenance: false }), "maintenance_access");
|
||||||
|
assert.equal(
|
||||||
|
moduleInstallerQueueBlock({ ...ready, maintenanceEnabled: false, canAccessMaintenance: false }),
|
||||||
|
"maintenance_access"
|
||||||
|
);
|
||||||
|
assert.equal(moduleInstallerQueueBlock(ready), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not present an older installer request as evidence for a newer plan", () => {
|
||||||
|
assert.equal(installerRequestMatchesPlan(null, "2026-08-03T10:00:00Z"), true);
|
||||||
|
assert.equal(
|
||||||
|
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T10:00:01Z"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
installerRequestMatchesPlan("2026-08-03T10:00:00Z", "2026-08-03T09:59:59Z"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(installerRequestMatchesPlan("invalid", "2026-08-03T10:00:00Z"), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user