Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71ef013f1 | ||
|
|
28e6dac289 | ||
|
|
ed424c729c | ||
|
|
c22da17c64 | ||
|
|
550c14b92e | ||
|
|
935cee8ccd | ||
|
|
50c952565b | ||
|
|
89ba59fcea | ||
|
|
a1d7b649aa | ||
|
|
8a75c82a5d | ||
|
|
6d4ada55d7 | ||
|
|
01d6ca1605 | ||
|
|
eb2e497f9a | ||
|
|
176c1d826f | ||
|
|
bc589a3d4f | ||
|
|
9d522ef6e0 | ||
|
|
1e55a80d3c | ||
|
|
218f94fa23 |
@@ -16,7 +16,11 @@ This repository owns the live `admin_governance_templates` and
|
||||
historical unprefixed governance tables for existing development databases. 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.
|
||||
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
|
||||
|
||||
@@ -40,6 +44,8 @@ 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
|
||||
@@ -52,6 +58,18 @@ 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
|
||||
@@ -80,6 +98,10 @@ 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:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.35",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+3
-4
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-admin"
|
||||
version = "0.1.17"
|
||||
version = "0.1.21"
|
||||
description = "GovOPlaN generic administration module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.17",
|
||||
"govoplan-access>=0.1.17",
|
||||
"govoplan-core>=0.1.35",
|
||||
"govoplan-access>=0.1.20",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -22,4 +22,3 @@ govoplan_admin = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
admin = "govoplan_admin.backend.manifest:get_manifest"
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -7,13 +9,24 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_admin.backend.governance import create_template, delete_template, update_template
|
||||
from govoplan_admin.backend.governance import (
|
||||
create_template,
|
||||
delete_template,
|
||||
synchronize_templates,
|
||||
update_template,
|
||||
)
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal, audit_operation_context
|
||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
|
||||
from govoplan_core.core.appearance import (
|
||||
appearance_custom_overrides_policy,
|
||||
appearance_settings,
|
||||
update_appearance_custom_overrides_policy,
|
||||
update_appearance_settings,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
@@ -74,11 +87,21 @@ from govoplan_core.core.module_installer_notifications import (
|
||||
installer_notification_subject,
|
||||
)
|
||||
from govoplan_core.core.module_license import module_license_decision, module_license_diagnostics
|
||||
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_module_package_catalog
|
||||
from govoplan_core.core.navigation import (
|
||||
NavigationPreferences,
|
||||
navigation_preferences_from_settings,
|
||||
update_navigation_preferences,
|
||||
)
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
record_module_package_catalog_acceptance,
|
||||
validate_module_package_catalog,
|
||||
validate_official_module_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, MaintenanceMode, saved_maintenance_mode, save_maintenance_mode
|
||||
from govoplan_core.core.lifecycle import ModuleLifecycleManager
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.i18n import (
|
||||
REFERENCE_LANGUAGE_CODE,
|
||||
@@ -102,6 +125,8 @@ from .schemas import (
|
||||
GovernanceTemplateListDeltaResponse,
|
||||
GovernanceTemplateListResponse,
|
||||
GovernanceTemplateUpdateRequest,
|
||||
GovernanceSynchronizationRequest,
|
||||
GovernanceSynchronizationResponse,
|
||||
MaintenanceModeItem,
|
||||
ModuleCatalogItem,
|
||||
ModuleCatalogResponse,
|
||||
@@ -147,6 +172,8 @@ SYSTEM_SETTINGS_SECTIONS = (
|
||||
"languages",
|
||||
"privacy_retention_policy",
|
||||
"maintenance_mode",
|
||||
"navigation",
|
||||
"appearance",
|
||||
"settings",
|
||||
)
|
||||
|
||||
@@ -282,6 +309,8 @@ def _system_settings_item(session: Session) -> SystemSettingsItem:
|
||||
policy = privacy_policy_from_settings(item)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
i18n_payload = system_i18n_payload(item)
|
||||
navigation = navigation_preferences_from_settings(item.settings)
|
||||
appearance_palette, appearance_palette_locked = appearance_settings(item.settings)
|
||||
return SystemSettingsItem(
|
||||
default_locale=item.default_locale,
|
||||
allow_tenant_custom_groups=item.allow_tenant_custom_groups,
|
||||
@@ -292,6 +321,10 @@ def _system_settings_item(session: Session) -> SystemSettingsItem:
|
||||
available_languages=i18n_payload["available_languages"],
|
||||
enabled_language_codes=i18n_payload["enabled_languages"],
|
||||
settings=item.settings or {},
|
||||
navigation=navigation.as_dict() if navigation is not None else None,
|
||||
appearance_palette=appearance_palette or "default",
|
||||
appearance_palette_locked=appearance_palette_locked,
|
||||
appearance_custom_overrides_allowed=appearance_custom_overrides_policy(item.settings) is True,
|
||||
)
|
||||
|
||||
|
||||
@@ -310,6 +343,12 @@ def _system_settings_sections(item: SystemSettingsItem) -> dict[str, Any]:
|
||||
},
|
||||
"privacy_retention_policy": payload["privacy_retention_policy"],
|
||||
"maintenance_mode": payload["maintenance_mode"],
|
||||
"navigation": payload["navigation"],
|
||||
"appearance": {
|
||||
"appearance_palette": payload["appearance_palette"],
|
||||
"appearance_palette_locked": payload["appearance_palette_locked"],
|
||||
"appearance_custom_overrides_allowed": payload["appearance_custom_overrides_allowed"],
|
||||
},
|
||||
"settings": payload["settings"],
|
||||
}
|
||||
|
||||
@@ -589,7 +628,7 @@ def _catalog_plan_item(
|
||||
*,
|
||||
validation: dict[str, object] | None = None,
|
||||
) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
|
||||
result = validation or validate_module_package_catalog()
|
||||
result = validation or _module_package_catalog_validation()
|
||||
_require_valid_module_catalog(result)
|
||||
raw_item = _catalog_install_or_update_item(result, module_id)
|
||||
action = _catalog_plan_action(raw_item, module_id, available_module_ids)
|
||||
@@ -604,6 +643,7 @@ def _catalog_plan_item(
|
||||
python_ref=_catalog_string(raw_item, "python_ref"),
|
||||
webui_package=_catalog_string(raw_item, "webui_package"),
|
||||
webui_ref=_catalog_string(raw_item, "webui_ref"),
|
||||
artifact_integrity=_catalog_mapping(raw_item, "artifact_integrity"),
|
||||
notes=_catalog_plan_notes(raw_item, license_decision),
|
||||
), result
|
||||
|
||||
@@ -616,12 +656,25 @@ def _require_valid_module_catalog(result: dict[str, object]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _module_package_catalog_validation() -> dict[str, object]:
|
||||
configured = validate_module_package_catalog()
|
||||
if configured.get("configured") or configured.get("error"):
|
||||
return configured
|
||||
return validate_official_module_package_catalog()
|
||||
|
||||
|
||||
def _catalog_install_or_update_item(result: dict[str, object], module_id: str) -> dict[str, object]:
|
||||
for raw_item in result.get("modules", []):
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
if raw_item.get("module_id") != module_id or raw_item.get("action") not in {"install", "update"}:
|
||||
continue
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
reason = str(raw_item.get("availability_reason") or "Catalog release has been withdrawn.")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Catalog entry {module_id} cannot be installed: {reason}",
|
||||
)
|
||||
return raw_item
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install/update entry not found: {module_id}")
|
||||
|
||||
@@ -659,6 +712,11 @@ def _catalog_string(raw_item: dict[str, object], field: str) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _catalog_mapping(raw_item: dict[str, object], field: str) -> dict[str, object] | None:
|
||||
value = raw_item.get(field)
|
||||
return dict(value) if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def _catalog_plan_metadata(validation: dict[str, object]) -> dict[str, object]:
|
||||
metadata: dict[str, object] = {
|
||||
"source": validation.get("source") or validation.get("path"),
|
||||
@@ -688,8 +746,24 @@ def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem:
|
||||
def _module_package_catalog_item(
|
||||
raw_item: dict[str, object],
|
||||
available: Mapping[str, Any],
|
||||
catalog_items: list[dict[str, object]] | None = None,
|
||||
) -> ModulePackageCatalogItem:
|
||||
payload = dict(raw_item)
|
||||
module_id = str(raw_item.get("module_id") or "")
|
||||
manifest = available.get(module_id)
|
||||
installed_version = str(getattr(manifest, "version", "") or "") or None
|
||||
target_version = raw_item.get("version")
|
||||
version_comparison = (
|
||||
compare_versions(target_version, installed_version)
|
||||
if installed_version and isinstance(target_version, str)
|
||||
else None
|
||||
)
|
||||
payload["installed"] = manifest is not None
|
||||
payload["installed_version"] = installed_version
|
||||
payload["update_available"] = version_comparison is not None and version_comparison > 0
|
||||
decision = module_license_decision(_catalog_license_features(raw_item))
|
||||
payload["license_allowed"] = bool(decision.get("allowed"))
|
||||
payload["license_enforced"] = bool(decision.get("enforced"))
|
||||
@@ -697,9 +771,157 @@ def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCa
|
||||
payload["license_missing_features"] = [str(item) for item in missing] if isinstance(missing, list) else []
|
||||
reason = decision.get("reason")
|
||||
payload["license_reason"] = reason if isinstance(reason, str) else None
|
||||
compatibility_reasons = _catalog_compatibility_reasons(
|
||||
raw_item,
|
||||
available=available,
|
||||
catalog_items=catalog_items or [raw_item],
|
||||
)
|
||||
if not bool(decision.get("allowed")):
|
||||
compatibility_reasons.append(
|
||||
str(reason or f"The active license policy does not allow {module_id}.")
|
||||
)
|
||||
if raw_item.get("action") not in {"install", "update"}:
|
||||
compatibility_reasons.append("This catalog action cannot be added to an install plan.")
|
||||
compatibility_reasons = list(dict.fromkeys(compatibility_reasons))
|
||||
payload["compatible"] = not compatibility_reasons
|
||||
payload["plan_allowed"] = bool(
|
||||
not compatibility_reasons
|
||||
and raw_item.get("action") in {"install", "update"}
|
||||
and (
|
||||
manifest is None
|
||||
or (version_comparison is not None and version_comparison > 0)
|
||||
or (version_comparison is not None and version_comparison < 0 and raw_item.get("allow_downgrade") is True)
|
||||
or (version_comparison == 0 and raw_item.get("allow_same_version") is True)
|
||||
)
|
||||
)
|
||||
payload["compatibility_reasons"] = compatibility_reasons
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
payload["catalog_state"] = "withdrawn"
|
||||
elif compatibility_reasons:
|
||||
payload["catalog_state"] = "blocked"
|
||||
elif payload["update_available"]:
|
||||
payload["catalog_state"] = "update_available"
|
||||
elif payload["installed"]:
|
||||
payload["catalog_state"] = "installed"
|
||||
else:
|
||||
payload["catalog_state"] = "available"
|
||||
return ModulePackageCatalogItem.model_validate(payload)
|
||||
|
||||
|
||||
def _catalog_compatibility_reasons(
|
||||
raw_item: dict[str, object],
|
||||
*,
|
||||
available: Mapping[str, Any],
|
||||
catalog_items: list[dict[str, object]],
|
||||
) -> list[str]:
|
||||
module_id = str(raw_item.get("module_id") or "")
|
||||
reasons: list[str] = []
|
||||
if raw_item.get("availability") == "withdrawn":
|
||||
reasons.append(
|
||||
str(raw_item.get("availability_reason") or "This catalog release has been withdrawn.")
|
||||
)
|
||||
|
||||
manifest = available.get(module_id)
|
||||
installed_version = str(getattr(manifest, "version", "") or "") or None
|
||||
version_min = _catalog_string(raw_item, "current_version_min")
|
||||
version_max = _catalog_string(raw_item, "current_version_max_exclusive")
|
||||
if installed_version and not version_satisfies_range(
|
||||
installed_version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max,
|
||||
):
|
||||
reasons.append(
|
||||
f"Installed version {installed_version} is outside the supported update window "
|
||||
f"({format_version_range(version_min=version_min, version_max_exclusive=version_max)})."
|
||||
)
|
||||
target_version = _catalog_string(raw_item, "version")
|
||||
if (
|
||||
installed_version
|
||||
and target_version
|
||||
and compare_versions(target_version, installed_version) < 0
|
||||
and raw_item.get("allow_downgrade") is not True
|
||||
):
|
||||
reasons.append(
|
||||
f"Catalog version {target_version} is older than installed version {installed_version}; "
|
||||
"the release does not allow a downgrade."
|
||||
)
|
||||
|
||||
catalog_module_ids = {
|
||||
str(item.get("module_id") or "")
|
||||
for item in catalog_items
|
||||
if item.get("availability") != "withdrawn"
|
||||
}
|
||||
for dependency in _catalog_string_values(raw_item.get("dependencies")):
|
||||
if dependency not in available and dependency not in catalog_module_ids:
|
||||
reasons.append(f"Required module {dependency} is neither installed nor available in this catalog.")
|
||||
|
||||
providers = _catalog_interface_providers(catalog_items, available=available)
|
||||
requirements = raw_item.get("requires_interfaces")
|
||||
if isinstance(requirements, list):
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, Mapping) or requirement.get("optional") is True:
|
||||
continue
|
||||
interface_name = str(requirement.get("name") or "").strip()
|
||||
if not interface_name:
|
||||
continue
|
||||
required_min = _catalog_mapping_string(requirement, "version_min")
|
||||
required_max = _catalog_mapping_string(requirement, "version_max_exclusive")
|
||||
if any(
|
||||
version_satisfies_range(
|
||||
provider_version,
|
||||
version_min=required_min,
|
||||
version_max_exclusive=required_max,
|
||||
)
|
||||
for provider_version in providers.get(interface_name, ())
|
||||
):
|
||||
continue
|
||||
reasons.append(
|
||||
f"Required interface {interface_name} "
|
||||
f"({format_version_range(version_min=required_min, version_max_exclusive=required_max)}) "
|
||||
"has no compatible installed or catalog provider."
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def _catalog_interface_providers(
|
||||
catalog_items: list[dict[str, object]],
|
||||
*,
|
||||
available: Mapping[str, Any],
|
||||
) -> dict[str, set[str]]:
|
||||
providers: dict[str, set[str]] = {}
|
||||
for item in catalog_items:
|
||||
if item.get("availability") == "withdrawn":
|
||||
continue
|
||||
declared = item.get("provides_interfaces")
|
||||
if not isinstance(declared, list):
|
||||
continue
|
||||
for provider in declared:
|
||||
if not isinstance(provider, Mapping):
|
||||
continue
|
||||
name = str(provider.get("name") or "").strip()
|
||||
version = str(provider.get("version") or "").strip()
|
||||
if name and version:
|
||||
providers.setdefault(name, set()).add(version)
|
||||
for manifest in available.values():
|
||||
for provider in getattr(manifest, "provides_interfaces", ()):
|
||||
name = str(getattr(provider, "name", "") or "").strip()
|
||||
version = str(getattr(provider, "version", "") or "").strip()
|
||||
if name and version:
|
||||
providers.setdefault(name, set()).add(version)
|
||||
return providers
|
||||
|
||||
|
||||
def _catalog_string_values(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _catalog_mapping_string(value: Mapping[str, object], field: str) -> str | None:
|
||||
raw = value.get(field)
|
||||
return str(raw).strip() if isinstance(raw, str) and raw.strip() else None
|
||||
|
||||
|
||||
def _catalog_required_license_features(result: dict[str, object]) -> list[str]:
|
||||
required: list[str] = []
|
||||
for raw_item in result.get("modules", []):
|
||||
@@ -1218,13 +1440,24 @@ def retry_module_install_request(
|
||||
|
||||
@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse)
|
||||
def read_module_package_catalog(
|
||||
request: Request,
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
result = validate_module_package_catalog()
|
||||
result = _module_package_catalog_validation()
|
||||
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||
available = (
|
||||
dict(lifecycle.available_modules)
|
||||
if isinstance(lifecycle, ModuleLifecycleManager)
|
||||
else available_module_manifests(ignore_load_errors=True)
|
||||
)
|
||||
catalog_items = [item for item in result["modules"] if isinstance(item, dict)]
|
||||
license_diagnostics = module_license_diagnostics(required_features=_catalog_required_license_features(result))
|
||||
return ModulePackageCatalogResponse(
|
||||
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
|
||||
modules=[
|
||||
_module_package_catalog_item(item, available, catalog_items)
|
||||
for item in catalog_items
|
||||
],
|
||||
configured=bool(result["configured"]),
|
||||
valid=bool(result["valid"]),
|
||||
path=result["path"] if isinstance(result["path"], str) else None,
|
||||
@@ -1574,6 +1807,30 @@ def write_system_settings(
|
||||
before_sections = _system_settings_sections(_system_settings_item(session))
|
||||
before_privacy = privacy_policy_from_settings(item).model_dump(mode="json")
|
||||
before_maintenance = saved_maintenance_mode(session).as_dict()
|
||||
before_appearance_palette, before_appearance_locked = appearance_settings(item.settings)
|
||||
before_custom_overrides_allowed = appearance_custom_overrides_policy(item.settings) is True
|
||||
appearance_palette_changed = (
|
||||
"appearance_palette" in payload.model_fields_set
|
||||
and payload.appearance_palette is not None
|
||||
and payload.appearance_palette != (before_appearance_palette or "default")
|
||||
)
|
||||
custom_overrides_policy_changed = (
|
||||
"appearance_custom_overrides_allowed" in payload.model_fields_set
|
||||
and payload.appearance_custom_overrides_allowed is not None
|
||||
and payload.appearance_custom_overrides_allowed != before_custom_overrides_allowed
|
||||
)
|
||||
if (
|
||||
(
|
||||
"appearance_palette_locked" in payload.model_fields_set
|
||||
and payload.appearance_palette_locked != before_appearance_locked
|
||||
)
|
||||
or (before_appearance_locked and appearance_palette_changed)
|
||||
or custom_overrides_policy_changed
|
||||
) and not has_scope(principal, "admin:policies:write"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Changing the system appearance policy requires admin:policies:write.",
|
||||
)
|
||||
if payload.privacy_retention_policy is not None:
|
||||
privacy_value = payload.privacy_retention_policy.model_dump(mode="json")
|
||||
try:
|
||||
@@ -1610,6 +1867,30 @@ def write_system_settings(
|
||||
available_languages=available_languages,
|
||||
enabled_language_codes=enabled_language_codes,
|
||||
)
|
||||
if {"appearance_palette", "appearance_palette_locked"}.intersection(payload.model_fields_set):
|
||||
current_palette, current_locked = appearance_settings(item.settings)
|
||||
item.settings = update_appearance_settings(
|
||||
item.settings,
|
||||
default_palette=payload.appearance_palette or current_palette or "default",
|
||||
palette_locked=payload.appearance_palette_locked if payload.appearance_palette_locked is not None else current_locked,
|
||||
)
|
||||
if (
|
||||
"appearance_custom_overrides_allowed" in payload.model_fields_set
|
||||
and payload.appearance_custom_overrides_allowed is not None
|
||||
):
|
||||
item.settings = update_appearance_custom_overrides_policy(
|
||||
item.settings,
|
||||
allowed=payload.appearance_custom_overrides_allowed,
|
||||
)
|
||||
if "navigation" in payload.model_fields_set:
|
||||
item.settings = update_navigation_preferences(
|
||||
item.settings,
|
||||
NavigationPreferences(
|
||||
order=tuple(payload.navigation.order),
|
||||
hidden=tuple(payload.navigation.hidden),
|
||||
locked=tuple(payload.navigation.locked),
|
||||
) if payload.navigation is not None else None,
|
||||
)
|
||||
if payload.privacy_retention_policy is not None:
|
||||
set_privacy_policy(item, payload.privacy_retention_policy.model_dump(mode="json"))
|
||||
if payload.maintenance_mode is not None:
|
||||
@@ -1832,6 +2113,71 @@ def create_governance_template(
|
||||
return _governance_template_item(session, item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/system/governance-templates/synchronize",
|
||||
response_model=GovernanceSynchronizationResponse,
|
||||
)
|
||||
def synchronize_governance_templates(
|
||||
payload: GovernanceSynchronizationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
|
||||
):
|
||||
try:
|
||||
result = synchronize_templates(
|
||||
session,
|
||||
template_ids=payload.template_ids,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
except (AdminConflictError, AdminValidationError) as exc:
|
||||
raise _http_admin_error(exc) from exc
|
||||
counts = Counter(item.status for item in result.outcomes)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"governance_template.synchronization_previewed"
|
||||
if payload.dry_run
|
||||
else "governance_template.synchronized"
|
||||
),
|
||||
scope="system",
|
||||
object_type="governance_template_batch",
|
||||
object_id=result.operation_id,
|
||||
details={
|
||||
"version": result.version,
|
||||
"dry_run": result.dry_run,
|
||||
"template_ids": list(dict.fromkeys(payload.template_ids)),
|
||||
"counts": dict(sorted(counts.items())),
|
||||
"blocked_assignment_ids": [
|
||||
item.assignment_id
|
||||
for item in result.outcomes
|
||||
if item.status in {"blocked", "failed"}
|
||||
],
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return GovernanceSynchronizationResponse(
|
||||
version=result.version,
|
||||
operation_id=result.operation_id,
|
||||
dry_run=result.dry_run,
|
||||
outcomes=[
|
||||
{
|
||||
"assignment_id": item.assignment_id,
|
||||
"template_id": item.template_id,
|
||||
"tenant_id": item.tenant_id,
|
||||
"kind": item.kind,
|
||||
"operation": item.operation,
|
||||
"status": item.status,
|
||||
"resource_id": item.resource_id,
|
||||
"blocker_codes": list(item.blocker_codes),
|
||||
"message": item.message,
|
||||
"provenance": dict(item.provenance),
|
||||
}
|
||||
for item in result.outcomes
|
||||
],
|
||||
counts=dict(sorted(counts.items())),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
|
||||
def update_governance_template(
|
||||
template_id: str,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem, NavigationPreferencesPayload
|
||||
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
|
||||
|
||||
@@ -50,6 +50,10 @@ class SystemSettingsItem(BaseModel):
|
||||
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)
|
||||
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):
|
||||
@@ -73,6 +77,10 @@ class SystemSettingsUpdateRequest(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -387,12 +395,61 @@ class ModuleInterfaceRequirementItem(BaseModel):
|
||||
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):
|
||||
module_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
version: str | None = None
|
||||
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"
|
||||
@@ -533,3 +590,31 @@ class GovernanceTemplateUpdateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
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)
|
||||
|
||||
@@ -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,12 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_admin.backend.db.models import GovernanceTemplate, GovernanceTemplateAssignment
|
||||
from govoplan_core.admin.common import AdminConflictError, AdminValidationError, slugify
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
AccessGovernanceMaterializer,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||
AccessGovernanceProjectionV1,
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceProjectionOutcome,
|
||||
GovernanceProjectionResult,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
@@ -15,15 +22,17 @@ from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
TEMPLATE_KINDS = {"group", "role"}
|
||||
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()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER):
|
||||
raise AdminValidationError("Access governance materializer capability is not configured.")
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||
if not isinstance(capability, AccessGovernanceMaterializer):
|
||||
raise AdminValidationError("Access governance materializer capability is invalid.")
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1):
|
||||
raise AdminValidationError("Access governance projection v1 capability is not configured.")
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1)
|
||||
if not isinstance(capability, AccessGovernanceProjectionV1):
|
||||
raise AdminValidationError("Access governance projection v1 capability is invalid.")
|
||||
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]:
|
||||
if kind not in TEMPLATE_KINDS:
|
||||
raise AdminValidationError("Template kind must be group or role.")
|
||||
@@ -107,7 +177,6 @@ def update_template(
|
||||
item.permissions = validate_template(item.kind, permissions)
|
||||
item.is_active = is_active
|
||||
set_template_assignments(session, item, assignments)
|
||||
sync_template(session, item)
|
||||
return item
|
||||
|
||||
|
||||
@@ -115,55 +184,167 @@ def set_template_assignments(
|
||||
session: Session,
|
||||
item: GovernanceTemplate,
|
||||
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] = {}
|
||||
for assignment in assignments:
|
||||
tenant_id = assignment.get("tenant_id", "")
|
||||
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:
|
||||
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
|
||||
|
||||
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 = {
|
||||
row.tenant_id: row
|
||||
for row in session.query(GovernanceTemplateAssignment)
|
||||
.filter(GovernanceTemplateAssignment.template_id == item.id)
|
||||
.all()
|
||||
}
|
||||
for tenant_id, row in list(existing.items()):
|
||||
if tenant_id in desired:
|
||||
row.mode = desired[tenant_id]
|
||||
continue
|
||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=tenant_id))
|
||||
session.delete(row)
|
||||
|
||||
removed = [row for tenant_id, row in existing.items() if tenant_id not in desired]
|
||||
retained: list[GovernanceTemplateAssignment] = []
|
||||
for tenant_id, mode in desired.items():
|
||||
if tenant_id not in existing:
|
||||
session.add(GovernanceTemplateAssignment(template_id=item.id, tenant_id=tenant_id, mode=mode))
|
||||
row = existing.get(tenant_id)
|
||||
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()
|
||||
sync_template(session, item)
|
||||
|
||||
commands = [
|
||||
_command(item, row, operation="remove", source="admin.assignment-reconciliation")
|
||||
for row in removed
|
||||
]
|
||||
commands.extend(
|
||||
_command(item, row, operation="upsert", source="admin.assignment-reconciliation")
|
||||
for row in retained
|
||||
)
|
||||
result = _reconcile(
|
||||
session,
|
||||
commands,
|
||||
source="assignment reconciliation",
|
||||
)
|
||||
_raise_blocked(result)
|
||||
for row in removed:
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return result
|
||||
|
||||
|
||||
def sync_template(session: Session, item: GovernanceTemplate) -> None:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
).all()
|
||||
for assignment in assignments:
|
||||
required = assignment.mode == "required"
|
||||
_governance_materializer().sync_template(
|
||||
session,
|
||||
_materialization(item, tenant_id=assignment.tenant_id, required=required),
|
||||
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."
|
||||
)
|
||||
session.flush()
|
||||
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) -> None:
|
||||
def delete_template(session: Session, item: GovernanceTemplate) -> GovernanceProjectionResult:
|
||||
assignments = session.query(GovernanceTemplateAssignment).filter(
|
||||
GovernanceTemplateAssignment.template_id == item.id
|
||||
).all()
|
||||
for assignment in assignments:
|
||||
_governance_materializer().remove_template(session, _materialization(item, tenant_id=assignment.tenant_id))
|
||||
result = _reconcile(
|
||||
session,
|
||||
(
|
||||
_command(item, assignment, operation="remove", source="admin.template-deletion")
|
||||
for assignment in assignments
|
||||
),
|
||||
source="template deletion",
|
||||
)
|
||||
_raise_blocked(result)
|
||||
session.delete(item)
|
||||
return result
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
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_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
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.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
@@ -15,6 +24,10 @@ from govoplan_core.core.modules import (
|
||||
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_admin.backend.dsar_provider import (
|
||||
ADMIN_DSAR_CAPABILITY,
|
||||
AdminDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
@@ -24,6 +37,10 @@ def _route_factory(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> AdminDsarProvider:
|
||||
return AdminDsarProvider()
|
||||
|
||||
|
||||
ADMIN_PERMISSIONS = (
|
||||
PermissionDefinition(
|
||||
scope="admin:module:read",
|
||||
@@ -64,19 +81,102 @@ ADMIN_ROLE_TEMPLATES = (
|
||||
manifest = ModuleManifest(
|
||||
id="admin",
|
||||
name="Admin",
|
||||
version="0.1.17",
|
||||
version="0.1.21",
|
||||
permissions=ADMIN_PERMISSIONS,
|
||||
role_templates=ADMIN_ROLE_TEMPLATES,
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
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,
|
||||
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.",
|
||||
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.",
|
||||
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": (
|
||||
"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": [
|
||||
@@ -97,6 +197,27 @@ manifest = ModuleManifest(
|
||||
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": [
|
||||
@@ -109,10 +230,29 @@ manifest = ModuleManifest(
|
||||
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. 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.",
|
||||
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": [
|
||||
@@ -121,6 +261,24 @@ manifest = ModuleManifest(
|
||||
"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(
|
||||
@@ -128,32 +286,115 @@ manifest = ModuleManifest(
|
||||
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. 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."
|
||||
"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-settings.locale",
|
||||
"admin.system-settings.languages",
|
||||
"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."
|
||||
),
|
||||
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."
|
||||
),
|
||||
}
|
||||
},
|
||||
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."
|
||||
"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": [
|
||||
@@ -164,24 +405,129 @@ manifest = ModuleManifest(
|
||||
"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-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-modules", module_id="admin", kind="section", label="Modules", order=60),
|
||||
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),
|
||||
@@ -198,13 +544,28 @@ manifest = ModuleManifest(
|
||||
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"),
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
+156
-1
@@ -5,8 +5,12 @@ from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_admin.backend.api.v1.routes import _catalog_plan_item
|
||||
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):
|
||||
@@ -30,6 +34,12 @@ class CatalogPlanItemTests(unittest.TestCase):
|
||||
"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],
|
||||
},
|
||||
@@ -59,6 +69,7 @@ class CatalogPlanItemTests(unittest.TestCase):
|
||||
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")
|
||||
@@ -107,6 +118,150 @@ class CatalogPlanItemTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -6,6 +6,18 @@ 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 = {
|
||||
@@ -16,6 +28,10 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
"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",
|
||||
@@ -36,30 +52,58 @@ class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_admin_contributed_surfaces_remain_declared(self) -> None:
|
||||
surface_ids = {
|
||||
surface.id for surface in manifest.frontend.view_surfaces
|
||||
}
|
||||
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")
|
||||
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)))
|
||||
self.assertEqual(
|
||||
tuple(sorted(permissions)), tuple(sorted(template.permissions))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__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
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/admin-webui",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.17",
|
||||
"@govoplan/core-webui": "^0.1.35",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -27,6 +27,11 @@ for (const source of [changes, packages, templates, modules]) {
|
||||
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/);
|
||||
|
||||
+273
-2
@@ -1,5 +1,5 @@
|
||||
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
import type { ApiSettings, DeltaDeletedItem, NavigationPreferences, PrivacyRetentionPolicy, UserUiPalette } from "@govoplan/core-webui";
|
||||
import { apiDownload, apiFetch, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
@@ -33,6 +33,10 @@ export type SystemSettingsItem = {
|
||||
available_languages: LanguagePackage[];
|
||||
enabled_language_codes: string[];
|
||||
settings: Record<string, unknown>;
|
||||
navigation: NavigationPreferences | null;
|
||||
appearance_palette: UserUiPalette;
|
||||
appearance_palette_locked: boolean;
|
||||
appearance_custom_overrides_allowed: boolean;
|
||||
};
|
||||
|
||||
export type SystemSettingsDeltaSections = Partial<{
|
||||
@@ -42,6 +46,8 @@ export type SystemSettingsDeltaSections = Partial<{
|
||||
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 = {
|
||||
@@ -53,6 +59,10 @@ export type SystemSettingsUpdatePayload = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -119,6 +129,26 @@ export type ConfigurationPackageFragment = {
|
||||
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;
|
||||
@@ -419,12 +449,61 @@ export type ModuleInterfaceRequirementItem = {
|
||||
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 = {
|
||||
module_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
version?: string | null;
|
||||
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";
|
||||
@@ -516,6 +595,123 @@ export type GovernanceTemplateItem = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type GovernanceSynchronizationOutcome = {
|
||||
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 type GovernanceSynchronizationResponse = {
|
||||
version: "1";
|
||||
operation_id: string;
|
||||
dry_run: boolean;
|
||||
outcomes: GovernanceSynchronizationOutcome[];
|
||||
counts: Record<string, number>;
|
||||
};
|
||||
|
||||
export type DataSubjectSelector = {
|
||||
account_id?: string | null;
|
||||
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> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||
}
|
||||
@@ -664,6 +860,79 @@ export function deleteGovernanceTemplate(settings: ApiSettings, templateId: stri
|
||||
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");
|
||||
}
|
||||
@@ -714,6 +983,7 @@ export function applyConfigurationPackage(settings: ApiSettings, payload: Config
|
||||
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",
|
||||
@@ -730,6 +1000,7 @@ export function exportConfigurationPackage(settings: ApiSettings, payload: {
|
||||
fragments: ConfigurationPackageFragment[];
|
||||
data_requirements: ConfigurationPackageRequiredData[];
|
||||
diagnostics: ConfigurationPackageDiagnostic[];
|
||||
provenance?: ConfigurationPackageExportProvenance | null;
|
||||
}> {
|
||||
return apiFetch(settings, "/api/v1/admin/configuration-packages/export", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
|
||||
@@ -33,12 +34,12 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{overview && <>
|
||||
{hasSystemMetrics && <>
|
||||
<div className="admin-overview-section-label">i18n:govoplan-admin.system.bc0792d8</div>
|
||||
<div className="metric-grid">
|
||||
<Metric title="i18n:govoplan-admin.tenants.1f7ae776" value={overview.tenant_count ?? "—"} text="i18n:govoplan-admin.registered_tenant_spaces.61e17d70" />
|
||||
<Metric title="i18n:govoplan-admin.users.57f2b181" value={overview.system_account_count ?? "—"} text="i18n:govoplan-admin.global_login_accounts_across_all_tenants.2aab129d" />
|
||||
<Metric title="i18n:govoplan-admin.central_groups.5c9b5b66" value={overview.system_group_template_count ?? "—"} text="i18n:govoplan-admin.system_governed_group_definitions.de8e5dc9" />
|
||||
<Metric title="i18n:govoplan-admin.tenant_roles.51aca82d" value={overview.system_role_template_count ?? "—"} text="i18n:govoplan-admin.centrally_governed_tenant_roles.797c689b" />
|
||||
</div>
|
||||
<MetricGrid>
|
||||
<MetricCard label="i18n:govoplan-admin.tenants.1f7ae776" value={overview.tenant_count ?? "—"} detail="i18n:govoplan-admin.registered_tenant_spaces.61e17d70" />
|
||||
<MetricCard label="i18n:govoplan-admin.users.57f2b181" value={overview.system_account_count ?? "—"} detail="i18n:govoplan-admin.global_login_accounts_across_all_tenants.2aab129d" />
|
||||
<MetricCard label="i18n:govoplan-admin.central_groups.5c9b5b66" value={overview.system_group_template_count ?? "—"} detail="i18n:govoplan-admin.system_governed_group_definitions.de8e5dc9" />
|
||||
<MetricCard label="i18n:govoplan-admin.tenant_roles.51aca82d" value={overview.system_role_template_count ?? "—"} detail="i18n:govoplan-admin.centrally_governed_tenant_roles.797c689b" />
|
||||
</MetricGrid>
|
||||
</>}
|
||||
|
||||
{hasAnySection(availableSections, platformSectionIds) && <Card title={ADMIN_INTERFACE_I18N.administrationHeading}>
|
||||
@@ -47,6 +48,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
{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-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-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-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-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-audit") && <AreaLink title="i18n:govoplan-admin.audit.fa1703dd" text="i18n:govoplan-admin.system_level_administrative_history.49f76723" onClick={() => onSelect("system-audit")} />}
|
||||
</div>
|
||||
@@ -67,12 +69,12 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
</Card>}
|
||||
|
||||
<div className="admin-overview-section-label">i18n:govoplan-admin.active_tenant.dfadf0aa {overview.active_tenant_name}</div>
|
||||
<div className="metric-grid">
|
||||
<Metric title="i18n:govoplan-admin.tenant_users.cb800b38" value={`${overview.active_user_count}/${overview.user_count}`} text="i18n:govoplan-admin.active_and_total_memberships.c0c20f10" />
|
||||
<Metric title="i18n:govoplan-admin.groups.ae9629f4" value={overview.group_count} text="i18n:govoplan-admin.tenant_local_and_centrally_managed_groups.4a6296b5" />
|
||||
<Metric title="i18n:govoplan-admin.roles.47dcc27d" value={overview.role_count} text="i18n:govoplan-admin.tenant_local_and_centrally_managed_roles.4995a7b2" />
|
||||
<Metric title="i18n:govoplan-admin.api_keys.94fcf3c2" value={overview.active_api_key_count} text="i18n:govoplan-admin.active_tenant_automation_credentials.240c659e" />
|
||||
</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">
|
||||
@@ -112,7 +114,7 @@ export default function AdminOverviewPanel({ settings, onSelect, availableSectio
|
||||
|
||||
}
|
||||
|
||||
const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-configuration-changes", "system-audit"];
|
||||
const platformSectionIds = ["system-modules", "system-tenant-modules", "system-configuration-packages", "system-settings", "system-language-packages", "system-configuration-changes", "system-audit"];
|
||||
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"];
|
||||
@@ -122,10 +124,6 @@ function hasAnySection(sections: ReadonlySet<string>, candidates: readonly strin
|
||||
return candidates.some((section) => sections.has(section));
|
||||
}
|
||||
|
||||
function Metric({ title, value, text }: {title: string;value: string | number;text: string;}) {
|
||||
return <MetricCard label={title} value={value} detail={text} />;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ConfigurationChangeRequest,
|
||||
type ConfigurationPackageDiagnostic,
|
||||
type ConfigurationPackagePlanItem,
|
||||
type ConfigurationPackageRollback,
|
||||
type ConfigurationPackageRequiredData } from
|
||||
"../../api/admin";
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ 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;}) {
|
||||
@@ -74,6 +76,7 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
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);
|
||||
@@ -86,6 +89,22 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
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]
|
||||
@@ -132,6 +151,7 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
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));
|
||||
@@ -182,27 +202,31 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAccessPackage() {
|
||||
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: ["access"],
|
||||
module_ids: moduleIds,
|
||||
scopes: tenantId.trim() ? ["tenant"] : ["system"]
|
||||
});
|
||||
const exported = {
|
||||
package_id: tenantId.trim() ? "govoplan.export.access-tenant" : "govoplan.export.access-system",
|
||||
name: tenantId.trim() ? "i18n:govoplan-admin.exported_tenant_access_configuration.9122c85c" : "i18n:govoplan-admin.exported_system_access_configuration.2e1c6f4c",
|
||||
package_id: `${String(manifest.package_id || "govoplan.configuration")}.export`,
|
||||
name: String(manifest.name || "i18n:govoplan-admin.export.f3e4fadb"),
|
||||
version: "0.1.0",
|
||||
required_modules: [{ module_id: "access" }],
|
||||
required_capabilities: ["configuration.provider", "access.configuration"],
|
||||
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
|
||||
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.access_configuration_exported.098f200d");
|
||||
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 {
|
||||
@@ -210,6 +234,23 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -306,14 +347,19 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
<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 || Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !canRun ? ADMIN_INTERFACE_I18N.validJsonRequired : undefined}><Check size={16} /> i18n:govoplan-admin.apply.cfea419c</Button>
|
||||
<Button onClick={() => void exportAccessPackage()} disabled={Boolean(busy)} disabledReason={busy ? ADMIN_INTERFACE_I18N.busy : undefined}><Download size={16} /> i18n:govoplan-admin.export_access.9a2eb91e</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>}
|
||||
@@ -322,6 +368,7 @@ export default function ConfigurationPackagesPanel({ settings, auth, canWrite }:
|
||||
<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">
|
||||
@@ -354,6 +401,87 @@ function parseObject(text: string): {value: Record<string, unknown> | null;error
|
||||
}
|
||||
}
|
||||
|
||||
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>[] = [
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { DescriptionItem, DescriptionList } 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 { FormGrid, 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,6 +1,7 @@
|
||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Search, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
fetchGovernanceTemplates,
|
||||
fetchPermissionCatalog,
|
||||
fetchTenants,
|
||||
synchronizeGovernanceTemplates,
|
||||
updateGovernanceTemplate,
|
||||
type GovernanceAssignment,
|
||||
type GovernanceTemplateItem,
|
||||
@@ -167,6 +169,23 @@ export default function GovernanceTemplatesPanel({
|
||||
{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>[]>(() => [
|
||||
{
|
||||
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,
|
||||
@@ -191,10 +210,11 @@ export default function GovernanceTemplatesPanel({
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-admin.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ 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) },
|
||||
{ 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) },
|
||||
{ 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" ? "i18n:govoplan-admin.central_groups.5c9b5b66" : "i18n:govoplan-admin.tenant_roles.51aca82d";
|
||||
const description = kind === "group" ?
|
||||
@@ -216,19 +236,19 @@ export default function GovernanceTemplatesPanel({
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
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)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
className=""
|
||||
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="i18n:govoplan-admin.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: 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="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="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">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">
|
||||
<span className="form-label">i18n:govoplan-admin.tenant_availability.067a4f31</span>
|
||||
@@ -239,8 +259,8 @@ export default function GovernanceTemplatesPanel({
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-admin.template_details.d5d75e4d"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-admin.close.bbfa773e</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-admin.kind.e00ac23f</dt><dd>{viewing.kind}</dd></div><div><dt>i18n:govoplan-admin.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-admin.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-admin.tenants.1f7ae776</dt><dd>{viewing.assignments.length || "i18n:govoplan-admin.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-admin.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-admin.permissions.d06d5557</dt><dd>{viewing.permissions.length ? <PermissionDetails scopes={viewing.permissions} permissionsByScope={permissionsByScope} /> : "—"}</dd></div></dl>}
|
||||
<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 && <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>
|
||||
|
||||
<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()} />
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, ConfirmDialog, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ActionToolbar, ApiSettings } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, Button, ConfirmDialog, dispatchPlatformModulesChanged, DocumentationHelpLink, formatDateTime, MetricCard, SegmentedControl, StageRail, StatusBadge, ToggleSwitch, i18nMessage, useUnsavedDraftGuard, type FormatDateTimeOptions } from "@govoplan/core-webui";
|
||||
import { Check, Clock, FileText, Pencil, Send } from "lucide-react";
|
||||
import {
|
||||
cancelModuleInstallerRequest,
|
||||
@@ -52,6 +53,8 @@ const MODULE_INSTALLER_I18N = {
|
||||
resolutionTarget: "i18n:govoplan-admin.where_to_go.f1a20205"
|
||||
} as const;
|
||||
|
||||
type CatalogModuleFilter = "all" | "available" | "updates" | "installed" | "blocked";
|
||||
|
||||
export default function ModuleManagementPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
|
||||
const [catalog, setCatalog] = useState<ModuleCatalogResponse | null>(null);
|
||||
const [installPlan, setInstallPlan] = useState<ModuleInstallPlanResponse | null>(null);
|
||||
@@ -71,6 +74,8 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
const [confirmClearPlan, setConfirmClearPlan] = useState(false);
|
||||
const [confirmMaintenance, setConfirmMaintenance] = useState(false);
|
||||
const [cancelRequestId, setCancelRequestId] = useState("");
|
||||
const [catalogSearch, setCatalogSearch] = useState("");
|
||||
const [catalogFilter, setCatalogFilter] = useState<CatalogModuleFilter>("all");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -108,6 +113,28 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
|
||||
const planValid = planValidationError(draftPlanItems) === "";
|
||||
const latestInstallerRequest = installerRequests?.requests[0] ?? null;
|
||||
const visibleCatalogModules = useMemo(() => {
|
||||
const query = catalogSearch.trim().toLocaleLowerCase();
|
||||
return (packageCatalog?.modules ?? []).filter((item) => {
|
||||
const matchesFilter =
|
||||
catalogFilter === "all" ||
|
||||
(catalogFilter === "available" && item.catalog_state === "available") ||
|
||||
(catalogFilter === "updates" && item.update_available) ||
|
||||
(catalogFilter === "installed" && item.installed) ||
|
||||
(catalogFilter === "blocked" && ["blocked", "withdrawn"].includes(item.catalog_state));
|
||||
if (!matchesFilter) return false;
|
||||
if (!query) return true;
|
||||
return [
|
||||
item.name,
|
||||
item.module_id,
|
||||
item.description ?? "",
|
||||
item.python_package ?? "",
|
||||
item.webui_package ?? "",
|
||||
item.source?.repository ?? "",
|
||||
...item.tags,
|
||||
].join(" ").toLocaleLowerCase().includes(query);
|
||||
});
|
||||
}, [catalogFilter, catalogSearch, packageCatalog]);
|
||||
const currentInstallerRequest = latestInstallerRequest && installerRequestMatchesPlan(
|
||||
installPlan?.updated_at,
|
||||
latestInstallerRequest.created_at
|
||||
@@ -381,13 +408,13 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
actions={<><DocumentationHelpLink reference={MODULE_LIFECYCLE_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()} disabled={saveDisabled} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-admin.working.049ac820" : saveLabel}</Button></>}>
|
||||
|
||||
{catalog && <>
|
||||
<div className="metric-grid module-management-metrics">
|
||||
<MetricGrid columns={5} minimum="compact">
|
||||
<MetricCard label="i18n:govoplan-admin.installed.7bb4405c" value={catalog.modules.length} detail="i18n:govoplan-admin.discovered_packages.660902de" tone="info" />
|
||||
<MetricCard label="i18n:govoplan-admin.active.a733b809" value={activeCount} detail="i18n:govoplan-admin.running_registry.5274b24b" tone="info" />
|
||||
<MetricCard label="i18n:govoplan-admin.saved.c0ae8f6e" value={desiredCount} detail="i18n:govoplan-admin.startup_state.d1300be6" tone="info" />
|
||||
<MetricCard label="i18n:govoplan-admin.drift.4876f7b9" value={pendingCount} detail={pendingCount ? "i18n:govoplan-admin.runtime_differs.43d1fd78" : "i18n:govoplan-admin.runtime_matches.a84afa48"} tone={pendingCount ? "warning" : "good"} />
|
||||
<MetricCard label="i18n:govoplan-admin.maintenance.94de303b" value={maintenanceEnabled ? "i18n:govoplan-admin.on.e0049a66" : "i18n:govoplan-admin.off.e3de5ab0"} detail={canAccessMaintenance ? "i18n:govoplan-admin.bypass_allowed.4e347c27" : "i18n:govoplan-admin.bypass_denied.ab987400"} tone={maintenanceEnabled ? "warning" : "info"} />
|
||||
</div>
|
||||
</MetricGrid>
|
||||
|
||||
<StageRail
|
||||
className="module-installer-stage-rail"
|
||||
@@ -489,19 +516,44 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
</div>
|
||||
{(packageCatalog.source || packageCatalog.path) && <p className="module-package-catalog-description">i18n:govoplan-admin.catalog.4a88d27b {packageCatalog.source_type ?? "source"}: <code>{packageCatalog.source ?? packageCatalog.path}</code>{packageCatalog.cache_used && packageCatalog.cache_path ? <> i18n:govoplan-admin.cached_from.d73c5b2a <code>{packageCatalog.cache_path}</code></> : null}</p>}
|
||||
{(packageCatalog.generated_at || packageCatalog.not_before || packageCatalog.expires_at) && <p className="module-package-catalog-description">i18n:govoplan-admin.generated.a2edf57c {packageCatalog.generated_at ?? "unknown"} i18n:govoplan-admin.valid_after.c615c873 {packageCatalog.not_before ?? "now"} i18n:govoplan-admin.expires.2d21b7de {packageCatalog.expires_at ?? "unknown"}</p>}
|
||||
<LicenseStatus license={packageCatalog.license} />
|
||||
{(packageCatalog.license.configured || packageCatalog.license.required_features.length > 0) && <LicenseStatus license={packageCatalog.license} />}
|
||||
{packageCatalog.error && <p className="alert warning">{packageCatalog.error}</p>}
|
||||
{packageCatalog.warnings.map((warning) => <p key={warning} className="alert warning">{warning}</p>)}
|
||||
{!packageCatalog.configured && <div className="module-install-plan-empty">i18n:govoplan-admin.no_package_catalog_configured_set_govoplan_modul.2c5fceb3</div>}
|
||||
{packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">i18n:govoplan-admin.package_catalog_is_configured_but_contains_no_en.b6b053cb</div>}
|
||||
{packageCatalog.modules.length > 0 && <ActionToolbar className="module-package-catalog-toolbar">
|
||||
<label className="module-package-catalog-search">
|
||||
<span>i18n:govoplan-admin.search_module_directory.7c8f1001</span>
|
||||
<input
|
||||
type="search"
|
||||
value={catalogSearch}
|
||||
onChange={(event) => setCatalogSearch(event.target.value)}
|
||||
placeholder="i18n:govoplan-admin.module_name_package_or_tag.7c8f1002" />
|
||||
|
||||
</label>
|
||||
<SegmentedControl<CatalogModuleFilter>
|
||||
ariaLabel="i18n:govoplan-admin.filter_module_directory.7c8f1003"
|
||||
value={catalogFilter}
|
||||
onChange={setCatalogFilter}
|
||||
options={[
|
||||
{ id: "all", label: "i18n:govoplan-admin.all_catalog_entries.7c8f1004" },
|
||||
{ id: "available", label: "i18n:govoplan-admin.available.7c8f1005" },
|
||||
{ id: "updates", label: "i18n:govoplan-admin.updates.7c8f1006" },
|
||||
{ id: "installed", label: "i18n:govoplan-admin.installed.7bb4405c" },
|
||||
{ id: "blocked", label: "i18n:govoplan-admin.blocked.7c8f1007" },
|
||||
]} />
|
||||
|
||||
</ActionToolbar>}
|
||||
{packageCatalog.modules.length > 0 && visibleCatalogModules.length === 0 && <div className="module-install-plan-empty">i18n:govoplan-admin.no_catalog_entries_match.7c8f1008</div>}
|
||||
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-list">
|
||||
{packageCatalog.modules.map((item) =>
|
||||
{visibleCatalogModules.map((item) =>
|
||||
<div key={`${item.module_id}-${item.version ?? "latest"}`} className="module-package-catalog-row">
|
||||
<div className="module-management-main">
|
||||
<div className="module-management-title">
|
||||
<strong>{item.name}</strong>
|
||||
<code>{item.module_id}</code>
|
||||
{item.version && <span>v{item.version}</span>}
|
||||
<StatusBadge status={catalogStateTone(item)} label={catalogStateLabel(item)} />
|
||||
</div>
|
||||
<div className="module-management-details">
|
||||
{item.python_package && <span>{item.python_package}</span>}
|
||||
@@ -519,13 +571,34 @@ export default function ModuleManagementPanel({ settings, canWrite, canAccessMai
|
||||
{item.provides_interfaces.length > 0 && <span>i18n:govoplan-admin.provides.221b70d9 {providedInterfacesLabel(item)}</span>}
|
||||
{item.requires_interfaces.length > 0 && <span>i18n:govoplan-admin.requires.a4fc9357 {requiredInterfacesLabel(item)}</span>}
|
||||
</div>
|
||||
{item.source && <div className="module-package-catalog-provenance">
|
||||
<span>i18n:govoplan-admin.source.7c8f1009 {item.source.revision_url ? <a href={item.source.revision_url} target="_blank" rel="noreferrer"><code>{item.source.repository}@{item.source.tag}</code></a> : <code>{item.source.repository}@{item.source.tag}</code>}</span>
|
||||
<code title={item.source.commit}>{shortIdentity(item.source.commit)}</code>
|
||||
</div>}
|
||||
{Object.entries(item.artifact_integrity).map(([kind, artifact]) => <div key={kind} className="module-package-catalog-provenance">
|
||||
<span>i18n:govoplan-admin.artifact.7c8f1010 <code>{kind}</code> {artifact.url ? <a href={artifact.url} target="_blank" rel="noreferrer">{artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind}</a> : artifact.filename ?? artifact.registry_identity ?? artifact.ref ?? kind}</span>
|
||||
{artifact.sha256 && <code title={artifact.sha256}>sha256:{shortIdentity(artifact.sha256)}</code>}
|
||||
</div>)}
|
||||
{item.description && <p className="module-package-catalog-description">{item.description}</p>}
|
||||
{item.configuration_requirements.length > 0 && <p className="module-package-catalog-description">i18n:govoplan-admin.configuration_requirements.7c8f1011 {item.configuration_requirements.join(", ")}</p>}
|
||||
{item.permissions.length > 0 && <details className="module-package-catalog-description">
|
||||
<summary>{i18nMessage("i18n:govoplan-admin.declared_permissions.7c8f1015", { value0: item.permissions.length })}</summary>
|
||||
<p className="muted small-note">i18n:govoplan-admin.installing_does_not_grant_permissions.7c8f1016</p>
|
||||
<div className="module-management-details">
|
||||
{permissionCategoryLabels(item).map((label) => <span key={label}>{label}</span>)}
|
||||
</div>
|
||||
<div className="module-management-details">
|
||||
{item.permissions.map((permission) => <span key={permission.scope} title={permission.description}><code>{permission.scope}</code> {permission.label}{permission.deprecated ? " (deprecated)" : ""}</span>)}
|
||||
</div>
|
||||
</details>}
|
||||
{item.migration_notes && <p className="module-package-catalog-description">{item.migration_notes}</p>}
|
||||
{item.bridge_notes && <p className="module-package-catalog-description">{item.bridge_notes}</p>}
|
||||
{item.recovery_notes && <p className="module-package-catalog-description">{item.recovery_notes}</p>}
|
||||
{item.release_notes_url && <p className="module-package-catalog-description"><a href={item.release_notes_url} target="_blank" rel="noreferrer">i18n:govoplan-admin.release_notes.7c8f1012</a></p>}
|
||||
{item.compatibility_reasons.map((compatibilityReason) => <p key={compatibilityReason} className="alert warning">{compatibilityReason}</p>)}
|
||||
{item.license_reason && <p className={`module-package-catalog-description${item.license_allowed ? "" : " alert warning"}`}>{item.license_reason}</p>}
|
||||
</div>
|
||||
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action)} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.license_allowed || !["install", "update"].includes(item.action) ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item, catalog)}</Button>
|
||||
<Button onClick={() => void addCatalogItem(item)} disabled={!canWrite || planBusy || !packageCatalog.valid || !item.plan_allowed} disabledReason={planBusy ? ADMIN_INTERFACE_I18N.busy : !canWrite ? ADMIN_INTERFACE_I18N.writeRequired : !packageCatalog.valid || !item.plan_allowed ? ADMIN_INTERFACE_I18N.catalogBlocked : undefined}>{catalogPlanButtonLabel(item)}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
@@ -892,6 +965,17 @@ function requiredInterfacesLabel(item: ModulePackageCatalogItem): string {
|
||||
}).join(", ");
|
||||
}
|
||||
|
||||
function permissionCategoryLabels(item: ModulePackageCatalogItem): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
item.permissions.forEach((permission) => {
|
||||
const key = `${permission.category} · ${permission.level}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return [...counts.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, count]) => `${key}: ${count}`);
|
||||
}
|
||||
|
||||
function migrationSafetyLabel(value: ModulePackageCatalogItem["migration_safety"] | ModuleInstallTargetItem["migration_safety"]): string {
|
||||
if (value === "requires_review") return "i18n:govoplan-admin.requires_review.8ba0b8c6";
|
||||
if (value === "forward_only") return "i18n:govoplan-admin.forward_only.6c107a46";
|
||||
@@ -951,6 +1035,25 @@ function currentWindowLabel(item: Pick<ModulePackageCatalogItem, "current_versio
|
||||
].filter(Boolean).join(" ") || "any";
|
||||
}
|
||||
|
||||
function catalogStateTone(item: ModulePackageCatalogItem): string {
|
||||
if (item.catalog_state === "withdrawn") return "locked";
|
||||
if (item.catalog_state === "blocked" || item.catalog_state === "update_available") return "warning";
|
||||
if (item.catalog_state === "installed") return "success";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function catalogStateLabel(item: ModulePackageCatalogItem): string {
|
||||
if (item.catalog_state === "withdrawn") return "i18n:govoplan-admin.withdrawn.7c8f1013";
|
||||
if (item.catalog_state === "blocked") return "i18n:govoplan-admin.blocked.7c8f1007";
|
||||
if (item.catalog_state === "update_available") return "i18n:govoplan-admin.update_available.7c8f1014";
|
||||
if (item.catalog_state === "installed") return "i18n:govoplan-admin.installed.7bb4405c";
|
||||
return "i18n:govoplan-admin.available.7c8f1005";
|
||||
}
|
||||
|
||||
function shortIdentity(value: string): string {
|
||||
return value.length > 14 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;desiredEnabled: boolean;}) {
|
||||
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_disable.14bb193a" />;
|
||||
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="i18n:govoplan-admin.will_enable.5d52d70b" />;
|
||||
@@ -958,10 +1061,11 @@ function ModuleStatus({ module, desiredEnabled }: {module: ModuleCatalogItem;des
|
||||
return <StatusBadge status="inactive" label="i18n:govoplan-admin.inactive.09af574c" />;
|
||||
}
|
||||
|
||||
function catalogPlanButtonLabel(item: ModulePackageCatalogItem, catalog: ModuleCatalogResponse | null): string {
|
||||
if (item.action === "update") return "i18n:govoplan-admin.plan_update.86e6857a";
|
||||
const installed = catalog?.modules.some((module) => module.id === item.module_id && module.installed) ?? false;
|
||||
return installed ? "i18n:govoplan-admin.plan_update.86e6857a" : "i18n:govoplan-admin.plan_install.e82bffe6";
|
||||
function catalogPlanButtonLabel(item: ModulePackageCatalogItem): string {
|
||||
if (item.installed && !item.plan_allowed) return "i18n:govoplan-admin.installed.7bb4405c";
|
||||
return item.action === "update" || item.installed
|
||||
? "i18n:govoplan-admin.plan_update.86e6857a"
|
||||
: "i18n:govoplan-admin.plan_install.e82bffe6";
|
||||
}
|
||||
|
||||
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {
|
||||
|
||||
@@ -4,67 +4,30 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { fetchSystemSettingsDelta, updateSystemSettings, type LanguagePackage, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsDeltaSections, type SystemSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, AdminSelectionList, DocumentationHelpLink, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { AppearancePalettePreview, AppearancePaletteSelect } 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 DELTA_KEY = "admin:system-settings";
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const 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: {}
|
||||
};
|
||||
|
||||
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;}) {
|
||||
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
|
||||
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(fallback);
|
||||
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance, canWritePolicy }: {settings: ApiSettings;canWrite: boolean;canAccessMaintenance: boolean;canWritePolicy: boolean;}) {
|
||||
const modules = usePlatformModules();
|
||||
const navigationItems = configurableNavigationItemsForModules(modules);
|
||||
const [draft, setDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [packageDraft, setPackageDraft] = useState({ code: "", label: "", nativeLabel: "" });
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
|
||||
const dirty = systemSettingsDraftKey(draft) !== systemSettingsDraftKey(savedDraft);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => {
|
||||
setDraft(savedDraft);
|
||||
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||
}
|
||||
onDiscard: () => setDraft(savedDraft)
|
||||
});
|
||||
|
||||
async function load() {
|
||||
@@ -101,42 +64,21 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
||||
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
|
||||
allow_tenant_api_keys: draft.allow_tenant_api_keys,
|
||||
maintenance_mode: draft.maintenance_mode,
|
||||
available_languages: draft.available_languages,
|
||||
enabled_language_codes: draft.enabled_language_codes
|
||||
navigation: draft.navigation,
|
||||
appearance_palette: draft.appearance_palette,
|
||||
appearance_palette_locked: draft.appearance_palette_locked,
|
||||
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);}
|
||||
}
|
||||
|
||||
function setEnabledLanguages(selected: string[]) {
|
||||
const enabled = new Set(selected);
|
||||
const nextEnabled = draft.available_languages.map((item) => item.code).filter((item) => enabled.has(item));
|
||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||
}
|
||||
|
||||
function installLanguagePackage() {
|
||||
const code = normalizeLanguageCode(packageDraft.code);
|
||||
if (!code || !packageDraft.label.trim()) return;
|
||||
const nextPackage: LanguagePackage = {
|
||||
code,
|
||||
label: packageDraft.label.trim(),
|
||||
native_label: packageDraft.nativeLabel.trim() || packageDraft.label.trim()
|
||||
};
|
||||
const existing = new Set(draft.available_languages.map((item) => item.code));
|
||||
const available = existing.has(code)
|
||||
? draft.available_languages.map((item) => item.code === code ? nextPackage : item)
|
||||
: [...draft.available_languages, nextPackage];
|
||||
const enabled = draft.enabled_language_codes.includes(code) ? draft.enabled_language_codes : [...draft.enabled_language_codes, code];
|
||||
setDraft({ ...draft, available_languages: available, enabled_language_codes: enabled, default_locale: draft.default_locale || code });
|
||||
setPackageDraft({ code: "", label: "", nativeLabel: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-admin.system_general_settings.7cd662ed"
|
||||
@@ -147,38 +89,6 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
||||
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">
|
||||
<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 || defaultLocaleOptions.length === 0}>
|
||||
{defaultLocaleOptions.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-admin.language_packages">
|
||||
<AdminSelectionList
|
||||
options={draft.available_languages.map((item) => ({ id: item.code, label: item.code.toUpperCase(), description: languageOptionLabel(item), disabled: !canWrite || busy || item.code === draft.default_locale }))}
|
||||
selected={draft.enabled_language_codes}
|
||||
onChange={setEnabledLanguages}
|
||||
/>
|
||||
<div className="form-grid">
|
||||
<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>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={installLanguagePackage} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(normalizeLanguageCode(packageDraft.code) && packageDraft.label.trim()) })}>i18n:govoplan-admin.install_language_package</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-admin.language_packages_help</p>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-admin.tenant_administration_capabilities.5d265972">
|
||||
<div className="settings-list">
|
||||
<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" />
|
||||
@@ -187,6 +97,40 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-admin.these_settings_are_enforced_by_the_backend_centr.8112ed6f</p>
|
||||
</Card>
|
||||
<Card title="System navigation order">
|
||||
<NavigationPreferenceEditor
|
||||
items={navigationItems}
|
||||
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">
|
||||
<ToggleSwitch
|
||||
@@ -212,38 +156,15 @@ export default function SystemSettingsPanel({ settings, canWrite, canAccessMaint
|
||||
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/_/g, "-").split(/[^a-z0-9]+/).filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
function localeOptions(current: string, enabled: string[]): string[] {
|
||||
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
|
||||
}
|
||||
|
||||
function languageOptionLabel(language: LanguagePackage): string {
|
||||
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||
}
|
||||
|
||||
function systemSettingsDraftKey(item: SystemSettingsItem): string {
|
||||
return JSON.stringify({
|
||||
default_locale: item.default_locale,
|
||||
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,
|
||||
available_languages: item.available_languages,
|
||||
enabled_language_codes: item.enabled_language_codes
|
||||
navigation: item.navigation,
|
||||
appearance_palette: item.appearance_palette,
|
||||
appearance_palette_locked: item.appearance_palette_locked,
|
||||
appearance_custom_overrides_allowed: item.appearance_custom_overrides_allowed
|
||||
});
|
||||
}
|
||||
|
||||
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.settings ? { settings: sections.settings } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
@@ -249,12 +250,12 @@ export default function TenantModuleManagementPanel({ settings, scope, canWrite
|
||||
|
||||
{state && draft && (
|
||||
<>
|
||||
<div className="metric-grid module-management-metrics">
|
||||
<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" />
|
||||
</div>
|
||||
</MetricGrid>
|
||||
|
||||
{state.diagnostics.map((diagnostic) => (
|
||||
<DismissibleAlert key={`${diagnostic.code}:${diagnostic.message}`} tone="warning" compact>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -2,6 +2,102 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-admin.package_no_changes_applied.c214a001": "No changes applied",
|
||||
"i18n:govoplan-admin.package_blocked_before_apply.c214a002": "Preflight or the first provider blocked before configuration changed.",
|
||||
"i18n:govoplan-admin.package_no_rollback_needed.c214a003": "No rollback needed",
|
||||
"i18n:govoplan-admin.package_all_fragments_noop.c214a004": "All package fragments were already current and produced no changes.",
|
||||
"i18n:govoplan-admin.package_keep_database_snapshot.c214a005": "Keep the database snapshot",
|
||||
"i18n:govoplan-admin.package_snapshot_is_rollback.c214a006": "Provider-owned configuration changed. Retain the reviewed pre-apply database snapshot until verification is complete.",
|
||||
"i18n:govoplan-admin.package_partial_apply.c214a007": "Partial apply—recovery required",
|
||||
"i18n:govoplan-admin.package_recover_before_retry.c214a008": "A provider blocked after earlier changes committed. Restore the snapshot or complete an explicitly supported compensation before retrying.",
|
||||
"i18n:govoplan-admin.run_preflight_before_apply.c214a009": "Run preflight before applying this package.",
|
||||
"i18n:govoplan-admin.preflight_is_stale.c214a010": "Package, deployment data, or tenant changed. Run preflight again before applying.",
|
||||
"i18n:govoplan-admin.appearance_defaults": "Appearance defaults",
|
||||
"i18n:govoplan-admin.system_palette_default": "System palette default",
|
||||
"i18n:govoplan-admin.system_palette_default_help": "Validated default inherited by tenants and users without an explicit child choice.",
|
||||
"i18n:govoplan-admin.appearance_lock_policy_permission": "Policy-write permission is required to change this lock.",
|
||||
"i18n:govoplan-admin.lock_system_palette": "Lock the system palette",
|
||||
"i18n:govoplan-admin.system_palette_precedence_help": "A system lock suppresses tenant and personal palette choices. Without a lock, explicit child choices take precedence.",
|
||||
"i18n:govoplan-admin.allow_custom_appearance_overrides": "Allow governed personal color overrides",
|
||||
"i18n:govoplan-admin.custom_overrides_policy_help": "Allows tenants to inherit or restrict access to the validated advanced editor. Palette locks still suppress overrides.",
|
||||
"i18n:govoplan-admin.appearance_policy_permission": "Policy-write permission is required to change this appearance policy.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds001": "Data-subject requests",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds002": "Search, export, and govern erasure requests with explicit provider coverage and retained evidence.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds003": "Create request",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds004": "Reload",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds005": "Reference",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds006": "Request type",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds007": "Status",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds008": "Data subject",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds009": "Due",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds010": "Actions",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds011": "Inspect request",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds012": "No data-subject requests have been recorded.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds013": "Records",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds014": "Executable actions",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds015": "Modules without provider",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds016": "Run search",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds017": "Create erasure plan",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds018": "Export JSON",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds019": "Execute selected actions",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds020": "Collected records",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds021": "Erasure plan",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds022": "Provider coverage",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds023": "Select a request to inspect its collected records and decisions.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds024": "Provider",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds025": "Category",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds026": "Evidence handling",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds027": "Decision",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds028": "Rationale",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds029": "Select",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds030": "Create data-subject request",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds031": "Purpose",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds032": "Legal basis",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds033": "Email",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds034": "Account ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds035": "Membership ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds036": "Identity ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds037": "Notes",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds038": "Save",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds039": "Cancel",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds040": "Execute erasure actions",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds041": "These actions may be irreversible. Review retained evidence and provider coverage before continuing.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds042": "Confirmation",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds043": "Enter the exact confirmation phrase",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds044": "Provide at least one subject selector.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds045": "The export is not complete across every enabled module.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds046": "Data-subject request created.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds047": "Provider search completed.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds048": "Erasure plan created.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds049": "Selected erasure actions were processed.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds050": "Data-subject export downloaded.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds051": "Access",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds052": "Erasure",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds053": "Access and erasure",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds054": "Mutable",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds055": "Retained evidence",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds056": "Irreversible",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds057": "Executable",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds058": "Retained / review",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds059": "External references",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds060": "Enter one namespace=reference pair per line for identifiers owned by source systems.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds061": "Each external reference must use namespace=reference on its own line.",
|
||||
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Search module directory",
|
||||
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Module name, package, or tag",
|
||||
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Filter module directory",
|
||||
"i18n:govoplan-admin.all_catalog_entries.7c8f1004": "All",
|
||||
"i18n:govoplan-admin.available.7c8f1005": "Available",
|
||||
"i18n:govoplan-admin.updates.7c8f1006": "Updates",
|
||||
"i18n:govoplan-admin.blocked.7c8f1007": "Blocked",
|
||||
"i18n:govoplan-admin.no_catalog_entries_match.7c8f1008": "No catalog entries match the current search and filter.",
|
||||
"i18n:govoplan-admin.source.7c8f1009": "Source:",
|
||||
"i18n:govoplan-admin.artifact.7c8f1010": "Artifact:",
|
||||
"i18n:govoplan-admin.configuration_requirements.7c8f1011": "Configuration requirements:",
|
||||
"i18n:govoplan-admin.release_notes.7c8f1012": "Release notes",
|
||||
"i18n:govoplan-admin.withdrawn.7c8f1013": "Withdrawn",
|
||||
"i18n:govoplan-admin.update_available.7c8f1014": "Update available",
|
||||
"i18n:govoplan-admin.declared_permissions.7c8f1015": "Declared permissions ({value0})",
|
||||
"i18n:govoplan-admin.installing_does_not_grant_permissions.7c8f1016": "Installing this module does not grant these permissions.",
|
||||
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administration data is loading.",
|
||||
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "An administration operation is in progress.",
|
||||
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "System administration write permission is required.",
|
||||
@@ -142,6 +238,20 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.language_name_placeholder": "French",
|
||||
"i18n:govoplan-admin.language_packages": "Language packages",
|
||||
"i18n:govoplan-admin.language_packages_help": "Installed packages define which language codes can be enabled. A package only becomes fully translated when matching module translation catalogs are present.",
|
||||
"i18n:govoplan-admin.language_package_administration.lp001": "Language packages and tenant defaults",
|
||||
"i18n:govoplan-admin.language_package_administration_description.lp001": "Install and activate language packages, choose the default for new tenants, and inspect exact module-version compatibility.",
|
||||
"i18n:govoplan-admin.language_package_administration_overview.lp001": "Package lifecycle, default tenant language, and module-version compatibility.",
|
||||
"i18n:govoplan-admin.language_package_settings_saved.lp001": "Language package settings saved.",
|
||||
"i18n:govoplan-admin.default_language_inheritance_help.lp001": "New tenants inherit this active language; tenant and user preferences can select only languages enabled above them.",
|
||||
"i18n:govoplan-admin.compatible.lp001": "Compatible",
|
||||
"i18n:govoplan-admin.incompatible.lp001": "Incompatible",
|
||||
"i18n:govoplan-admin.uninstallable.lp001": "Uninstallable",
|
||||
"i18n:govoplan-admin.module_version_catalogs.lp001": "{value0} module-version catalogs provide this language.",
|
||||
"i18n:govoplan-admin.activate.lp001": "Activate",
|
||||
"i18n:govoplan-admin.deactivate.lp001": "Deactivate",
|
||||
"i18n:govoplan-admin.default_language_must_remain_active.lp001": "The default language must remain active.",
|
||||
"i18n:govoplan-admin.module_version_compatibility.lp001": "Module-version compatibility",
|
||||
"i18n:govoplan-admin.no_module_language_catalogs.lp001": "No installed module language catalogs are available. Package and default-language controls remain usable.",
|
||||
"i18n:govoplan-admin.native_language_name": "Native language name",
|
||||
"i18n:govoplan-admin.native_language_name_placeholder": "Francais",
|
||||
"i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Defaults for newly created tenants",
|
||||
@@ -417,6 +527,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.users.57f2b181": "Users",
|
||||
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
||||
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
||||
"i18n:govoplan-admin.sync.905f6309": "Sync",
|
||||
"i18n:govoplan-admin.valid.a4aefa35": "Valid",
|
||||
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
||||
"i18n:govoplan-admin.value_deleted.3c4bf574": "{value0} deleted.",
|
||||
@@ -438,6 +549,102 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.working.049ac820": "Working..."
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-admin.package_no_changes_applied.c214a001": "Keine Änderungen angewendet",
|
||||
"i18n:govoplan-admin.package_blocked_before_apply.c214a002": "Die Vorprüfung oder der erste Provider hat blockiert, bevor Konfiguration geändert wurde.",
|
||||
"i18n:govoplan-admin.package_no_rollback_needed.c214a003": "Keine Rücksetzung erforderlich",
|
||||
"i18n:govoplan-admin.package_all_fragments_noop.c214a004": "Alle Paketfragmente waren bereits aktuell und haben nichts geändert.",
|
||||
"i18n:govoplan-admin.package_keep_database_snapshot.c214a005": "Datenbank-Snapshot aufbewahren",
|
||||
"i18n:govoplan-admin.package_snapshot_is_rollback.c214a006": "Modul-eigene Konfiguration wurde geändert. Der geprüfte Datenbank-Snapshot vor der Anwendung muss bis zum Abschluss der Verifikation erhalten bleiben.",
|
||||
"i18n:govoplan-admin.package_partial_apply.c214a007": "Teilanwendung – Wiederherstellung erforderlich",
|
||||
"i18n:govoplan-admin.package_recover_before_retry.c214a008": "Ein Provider hat nach bereits festgeschriebenen Änderungen blockiert. Vor einem neuen Versuch muss der Snapshot wiederhergestellt oder eine ausdrücklich unterstützte Kompensation abgeschlossen werden.",
|
||||
"i18n:govoplan-admin.run_preflight_before_apply.c214a009": "Vor dem Anwenden dieses Pakets muss die Vorprüfung ausgeführt werden.",
|
||||
"i18n:govoplan-admin.preflight_is_stale.c214a010": "Paket, Einsatzdaten oder Mandant wurden geändert. Vor dem Anwenden muss die Vorprüfung erneut ausgeführt werden.",
|
||||
"i18n:govoplan-admin.appearance_defaults": "Darstellungsstandards",
|
||||
"i18n:govoplan-admin.system_palette_default": "Systemstandard für die Farbpalette",
|
||||
"i18n:govoplan-admin.system_palette_default_help": "Geprüfter Standard für Mandanten und Benutzer ohne eigene Auswahl.",
|
||||
"i18n:govoplan-admin.appearance_lock_policy_permission": "Zum Ändern dieser Sperre ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
|
||||
"i18n:govoplan-admin.lock_system_palette": "Systempalette verbindlich festlegen",
|
||||
"i18n:govoplan-admin.system_palette_precedence_help": "Eine Systemsperre unterdrückt Mandanten- und Benutzerauswahlen. Ohne Sperre haben ausdrückliche untergeordnete Auswahlen Vorrang.",
|
||||
"i18n:govoplan-admin.allow_custom_appearance_overrides": "Gesteuerte persönliche Farbanpassungen zulassen",
|
||||
"i18n:govoplan-admin.custom_overrides_policy_help": "Mandanten können den Zugang zum geprüften erweiterten Editor erben oder einschränken. Palettensperren unterdrücken Anpassungen weiterhin.",
|
||||
"i18n:govoplan-admin.appearance_policy_permission": "Zum Ändern dieser Darstellungsrichtlinie ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds001": "Betroffenenanfragen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds002": "Auskunfts- und Löschanfragen mit expliziter Anbieterabdeckung und Aufbewahrungsnachweisen suchen, exportieren und steuern.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds003": "Anfrage anlegen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds004": "Neu laden",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds005": "Aktenzeichen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds006": "Anfrageart",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds007": "Status",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds008": "Betroffene Person",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds009": "Fällig",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds010": "Aktionen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds011": "Anfrage prüfen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds012": "Es wurden noch keine Betroffenenanfragen erfasst.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds013": "Datensätze",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds014": "Ausführbare Aktionen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds015": "Module ohne Anbieter",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds016": "Suche ausführen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds017": "Löschplan erstellen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds018": "JSON exportieren",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds019": "Ausgewählte Aktionen ausführen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds020": "Gefundene Datensätze",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds021": "Löschplan",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds022": "Anbieterabdeckung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds023": "Wählen Sie eine Anfrage aus, um Datensätze und Entscheidungen zu prüfen.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds024": "Anbieter",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds025": "Kategorie",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds026": "Nachweisbehandlung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds027": "Entscheidung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds028": "Begründung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds029": "Auswählen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds030": "Betroffenenanfrage anlegen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds031": "Zweck",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds032": "Rechtsgrundlage",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds033": "E-Mail",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds034": "Konto-ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds035": "Mitgliedschafts-ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds036": "Identitäts-ID",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds037": "Notizen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds038": "Speichern",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds039": "Abbrechen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds040": "Löschaktionen ausführen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds041": "Diese Aktionen können unumkehrbar sein. Prüfen Sie Aufbewahrungsnachweise und Anbieterabdeckung, bevor Sie fortfahren.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds042": "Bestätigung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds043": "Geben Sie die Bestätigungsphrase exakt ein",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds044": "Geben Sie mindestens ein Merkmal der betroffenen Person an.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds045": "Der Export deckt nicht alle aktivierten Module vollständig ab.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds046": "Betroffenenanfrage wurde angelegt.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds047": "Anbietersuche wurde abgeschlossen.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds048": "Löschplan wurde erstellt.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds049": "Ausgewählte Löschaktionen wurden verarbeitet.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds050": "Betroffenenauskunft wurde heruntergeladen.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds051": "Auskunft",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds052": "Löschung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds053": "Auskunft und Löschung",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds054": "Veränderbar",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds055": "Aufzubewahrender Nachweis",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds056": "Nicht umkehrbar",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds057": "Ausführbar",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds058": "Aufbewahren / prüfen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds059": "Externe Referenzen",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds060": "Je Zeile ein Paar Namensraum=Referenz für Kennungen aus Quellsystemen eingeben.",
|
||||
"i18n:govoplan-admin.data_subject_requests.ds061": "Jede externe Referenz muss in einer eigenen Zeile als Namensraum=Referenz angegeben werden.",
|
||||
"i18n:govoplan-admin.search_module_directory.7c8f1001": "Modulverzeichnis durchsuchen",
|
||||
"i18n:govoplan-admin.module_name_package_or_tag.7c8f1002": "Modulname, Paket oder Schlagwort",
|
||||
"i18n:govoplan-admin.filter_module_directory.7c8f1003": "Modulverzeichnis filtern",
|
||||
"i18n:govoplan-admin.all_catalog_entries.7c8f1004": "Alle",
|
||||
"i18n:govoplan-admin.available.7c8f1005": "Verfügbar",
|
||||
"i18n:govoplan-admin.updates.7c8f1006": "Aktualisierungen",
|
||||
"i18n:govoplan-admin.blocked.7c8f1007": "Blockiert",
|
||||
"i18n:govoplan-admin.no_catalog_entries_match.7c8f1008": "Keine Katalogeinträge entsprechen der aktuellen Suche und dem Filter.",
|
||||
"i18n:govoplan-admin.source.7c8f1009": "Quelle:",
|
||||
"i18n:govoplan-admin.artifact.7c8f1010": "Artefakt:",
|
||||
"i18n:govoplan-admin.configuration_requirements.7c8f1011": "Konfigurationsvoraussetzungen:",
|
||||
"i18n:govoplan-admin.release_notes.7c8f1012": "Versionshinweise",
|
||||
"i18n:govoplan-admin.withdrawn.7c8f1013": "Zurückgezogen",
|
||||
"i18n:govoplan-admin.update_available.7c8f1014": "Aktualisierung verfügbar",
|
||||
"i18n:govoplan-admin.declared_permissions.7c8f1015": "Deklarierte Berechtigungen ({value0})",
|
||||
"i18n:govoplan-admin.installing_does_not_grant_permissions.7c8f1016": "Die Installation dieses Moduls vergibt diese Berechtigungen nicht.",
|
||||
"i18n:govoplan-admin.administration_data_is_loading.6bf3c001": "Administrationsdaten werden geladen.",
|
||||
"i18n:govoplan-admin.an_administration_operation_is_in_progress.6bf3c002": "Eine Administrationsaktion wird gerade ausgeführt.",
|
||||
"i18n:govoplan-admin.system_administration_write_permission_is_required.6bf3c003": "Eine Schreibberechtigung für die Systemadministration ist erforderlich.",
|
||||
@@ -578,6 +785,20 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.language_name_placeholder": "Franzoesisch",
|
||||
"i18n:govoplan-admin.language_packages": "Sprachpakete",
|
||||
"i18n:govoplan-admin.language_packages_help": "Installierte Pakete legen fest, welche Sprachcodes aktiviert werden koennen. Ein Paket ist erst vollstaendig uebersetzt, wenn passende Uebersetzungskataloge der Module vorhanden sind.",
|
||||
"i18n:govoplan-admin.language_package_administration.lp001": "Sprachpakete und Mandantenstandards",
|
||||
"i18n:govoplan-admin.language_package_administration_description.lp001": "Sprachpakete installieren und aktivieren, den Standard fuer neue Mandanten waehlen und die exakte Modulversions-Kompatibilitaet pruefen.",
|
||||
"i18n:govoplan-admin.language_package_administration_overview.lp001": "Paketlebenszyklus, Standard-Mandantensprache und Modulversions-Kompatibilitaet.",
|
||||
"i18n:govoplan-admin.language_package_settings_saved.lp001": "Sprachpaket-Einstellungen gespeichert.",
|
||||
"i18n:govoplan-admin.default_language_inheritance_help.lp001": "Neue Mandanten erben diese aktive Sprache; Mandanten- und Benutzereinstellungen koennen nur oberhalb aktivierte Sprachen waehlen.",
|
||||
"i18n:govoplan-admin.compatible.lp001": "Kompatibel",
|
||||
"i18n:govoplan-admin.incompatible.lp001": "Inkompatibel",
|
||||
"i18n:govoplan-admin.uninstallable.lp001": "Deinstallierbar",
|
||||
"i18n:govoplan-admin.module_version_catalogs.lp001": "{value0} Modulversions-Kataloge stellen diese Sprache bereit.",
|
||||
"i18n:govoplan-admin.activate.lp001": "Aktivieren",
|
||||
"i18n:govoplan-admin.deactivate.lp001": "Deaktivieren",
|
||||
"i18n:govoplan-admin.default_language_must_remain_active.lp001": "Die Standardsprache muss aktiv bleiben.",
|
||||
"i18n:govoplan-admin.module_version_compatibility.lp001": "Modulversions-Kompatibilitaet",
|
||||
"i18n:govoplan-admin.no_module_language_catalogs.lp001": "Es sind keine Sprachkataloge installierter Module verfuegbar. Paket- und Standardspracheinstellungen bleiben nutzbar.",
|
||||
"i18n:govoplan-admin.native_language_name": "Nativer Sprachname",
|
||||
"i18n:govoplan-admin.native_language_name_placeholder": "Francais",
|
||||
"i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd": "Standards für neu erstellte Mandanten",
|
||||
@@ -852,6 +1073,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-admin.user_retention_policy_limits": "Aufbewahrungslimits fuer benutzereigene Datensaetze.",
|
||||
"i18n:govoplan-admin.users.57f2b181": "Benutzer",
|
||||
"i18n:govoplan-admin.valid_after.c615c873": "· Valid after:",
|
||||
"i18n:govoplan-admin.sync.905f6309": "Synchronisieren",
|
||||
"i18n:govoplan-admin.valid_and_trusted.73cf89bc": "Valid and trusted",
|
||||
"i18n:govoplan-admin.valid.a4aefa35": "Gültig",
|
||||
"i18n:govoplan-admin.valid.b374b8f9": "Valid:",
|
||||
|
||||
@@ -11,4 +11,5 @@ export {
|
||||
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 LanguagePackagesPanel } from "./features/admin/LanguagePackagesPanel";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
|
||||
+36
-1
@@ -11,6 +11,8 @@ const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceT
|
||||
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
|
||||
const TenantModuleManagementPanel = lazy(() => import("./features/admin/TenantModuleManagementPanel"));
|
||||
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,
|
||||
@@ -46,7 +48,22 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
render: ({ settings, auth }) => createElement(SystemSettingsPanel, {
|
||||
settings,
|
||||
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")
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -124,6 +141,22 @@ const adminSections: AdminSectionsUiCapability = {
|
||||
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",
|
||||
@@ -167,12 +200,14 @@ export const adminModule: PlatformWebModule = {
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user