Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
commit 2defb89bc1
55 changed files with 3361 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/admin
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/admin
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/admin
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/admin
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/admin
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# GovOPlaN Admin
`govoplan-admin` owns generic system administration API and WebUI contributions
during the GovOPlaN module split.
This repository owns the live `governance_templates` and
`governance_template_assignments` tables while preserving their historical
table names. It contributes the stable
`/api/v1/admin/system/governance-templates` routes and owns governance-template
CRUD plus materialization into access-owned tenant groups and roles.
## WebUI Package
The repository root and `webui/` directory both expose the package
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
itself; `govoplan-access` owns the route shell. Instead, admin contributes
section entries through core's `admin.sections` UI capability:
- overview
- system settings
- governance template tenant roles
- governance template groups
The route shell in access collects those sections at runtime, applies their
scope requirements, and renders them without importing admin package internals.

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@govoplan/admin-webui",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

25
pyproject.toml Normal file
View File

@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-admin"
version = "0.1.5"
description = "GovOPlaN generic administration module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.5",
"govoplan-access>=0.1.5",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_admin = ["py.typed"]
[project.entry-points."govoplan.modules"]
admin = "govoplan_admin.backend.manifest:get_manifest"

View File

@@ -0,0 +1,35 @@
Metadata-Version: 2.4
Name: govoplan-admin
Version: 0.1.4
Summary: GovOPlaN generic administration module.
Author: GovOPlaN
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: govoplan-core>=0.1.4
Requires-Dist: govoplan-access>=0.1.4
# GovOPlaN Admin
`govoplan-admin` owns generic system administration API and WebUI contributions
during the GovOPlaN module split.
This repository owns the live `governance_templates` and
`governance_template_assignments` tables while preserving their historical
table names. It contributes the stable
`/api/v1/admin/system/governance-templates` routes and owns governance-template
CRUD plus materialization into access-owned tenant groups and roles.
## WebUI Package
The repository root and `webui/` directory both expose the package
`@govoplan/admin-webui`. The package does not contribute the `/admin` route
itself; `govoplan-access` owns the route shell. Instead, admin contributes
section entries through core's `admin.sections` UI capability:
- overview
- system settings
- governance template tenant roles
- governance template groups
The route shell in access collects those sections at runtime, applies their
scope requirements, and renders them without importing admin package internals.

View File

@@ -0,0 +1,19 @@
README.md
pyproject.toml
src/govoplan_admin/__init__.py
src/govoplan_admin/py.typed
src/govoplan_admin.egg-info/PKG-INFO
src/govoplan_admin.egg-info/SOURCES.txt
src/govoplan_admin.egg-info/dependency_links.txt
src/govoplan_admin.egg-info/entry_points.txt
src/govoplan_admin.egg-info/requires.txt
src/govoplan_admin.egg-info/top_level.txt
src/govoplan_admin/backend/__init__.py
src/govoplan_admin/backend/governance.py
src/govoplan_admin/backend/manifest.py
src/govoplan_admin/backend/api/__init__.py
src/govoplan_admin/backend/api/v1/__init__.py
src/govoplan_admin/backend/api/v1/routes.py
src/govoplan_admin/backend/api/v1/schemas.py
src/govoplan_admin/backend/db/__init__.py
src/govoplan_admin/backend/db/models.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
[govoplan.modules]
admin = govoplan_admin.backend.manifest:get_manifest

View File

@@ -0,0 +1,2 @@
govoplan-core>=0.1.4
govoplan-access>=0.1.4

View File

@@ -0,0 +1 @@
govoplan_admin

View File

@@ -0,0 +1,2 @@
"""GovOPlaN generic administration module."""

View File

@@ -0,0 +1,2 @@
"""Backend integration for the GovOPlaN admin module."""

View File

@@ -0,0 +1,840 @@
from __future__ import annotations
import os
from pathlib import Path
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_core.admin.settings import get_system_settings
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
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.module_management import (
PROTECTED_MODULES,
ModuleInstallPlanItem,
ModuleManagementError,
configured_enabled_modules,
module_install_plan_commands,
module_dependents,
module_uninstall_plan_item,
plan_desired_enabled_modules,
save_desired_enabled_modules,
save_module_install_plan,
saved_desired_enabled_modules,
saved_module_install_plan,
)
from govoplan_core.core.module_installer import (
ModuleInstallerError,
cancel_module_installer_request,
default_installer_runtime_dir,
list_module_installer_runs,
list_module_installer_requests,
module_installer_daemon_status,
module_install_preflight,
module_installer_lock_status,
queue_module_installer_request,
read_module_installer_run,
read_module_installer_request,
retry_module_installer_request,
)
from govoplan_core.core.module_license import module_license_decision
from govoplan_core.core.module_package_catalog import record_module_package_catalog_acceptance, validate_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.db.session import get_session
from govoplan_core.privacy.retention import privacy_policy_from_settings, set_privacy_policy
from govoplan_core.security.permissions import ALL_PERMISSIONS, effective_permission_count
from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings as core_settings
from govoplan_core.tenancy.service import tenant_counts
from govoplan_tenancy.backend.db.models import Tenant
from .schemas import (
AdminOverviewResponse,
GovernanceTemplateCreateRequest,
GovernanceTemplateItem,
GovernanceTemplateListResponse,
GovernanceTemplateUpdateRequest,
MaintenanceModeItem,
ModuleCatalogItem,
ModuleCatalogResponse,
ModuleInstallPreflightResponse,
ModuleInstallerLockStatus,
ModuleInstallerDaemonStatus,
ModuleInstallerRequestCreateRequest,
ModuleInstallerRequestItem,
ModuleInstallerRequestListResponse,
ModuleInstallerRunListResponse,
ModuleInstallerRunSummary,
ModuleInstallPlanResponse,
ModuleInstallPlanUpdateRequest,
ModulePackageCatalogItem,
ModulePackageCatalogResponse,
ModuleStateUpdateRequest,
PrivacyRetentionPolicyItem,
SystemSettingsItem,
SystemSettingsUpdateRequest,
)
router = APIRouter(prefix="/admin", tags=["admin"])
def _access_administration() -> AccessAdministration:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION)
if not isinstance(capability, AccessAdministration):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access administration capability is invalid")
return capability
def _request_registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured")
return registry
def _request_lifecycle(request: Request) -> ModuleLifecycleManager:
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
if not isinstance(lifecycle, ModuleLifecycleManager):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module lifecycle manager is not configured")
return lifecycle
def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _governance_template_item(session: Session, item: GovernanceTemplate) -> GovernanceTemplateItem:
assignments = session.query(GovernanceTemplateAssignment).filter(
GovernanceTemplateAssignment.template_id == item.id
).order_by(GovernanceTemplateAssignment.tenant_id.asc()).all()
return GovernanceTemplateItem(
id=item.id,
kind=item.kind,
slug=item.slug,
name=item.name,
description=item.description,
permissions=item.permissions or [],
effective_permission_count=effective_permission_count(item.permissions or [], level="tenant") if item.kind == "role" else 0,
is_active=item.is_active,
assignments=[{"tenant_id": row.tenant_id, "mode": row.mode} for row in assignments],
created_at=item.created_at,
updated_at=item.updated_at,
)
def _module_catalog_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleCatalogResponse:
registry = _request_registry(request)
current_enabled = [manifest.id for manifest in registry.manifests()]
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
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)
dependents = module_dependents(available)
current_set = set(current_enabled)
desired_set = set(desired_enabled)
protected_set = set(PROTECTED_MODULES)
items: list[ModuleCatalogItem] = []
for module_id, manifest in sorted(available.items(), key=lambda item: item[0]):
migration = manifest.migration_spec
frontend = manifest.frontend
items.append(ModuleCatalogItem(
id=manifest.id,
name=manifest.name,
version=manifest.version,
installed=True,
current_enabled=manifest.id in current_set,
desired_enabled=manifest.id in desired_set,
protected=manifest.id in protected_set,
restart_required=(manifest.id in current_set) != (manifest.id in desired_set),
dependencies=list(manifest.dependencies),
optional_dependencies=list(manifest.optional_dependencies),
dependents=list(dependents.get(manifest.id, ())),
permissions_count=len(manifest.permissions),
role_templates_count=len(manifest.role_templates),
route_contributed=manifest.route_factory is not None,
nav_count=len(manifest.nav_items),
frontend_package=frontend.package_name if frontend else None,
migration_module_id=migration.module_id if migration else None,
migration_script_location=migration.script_location if migration else None,
runtime_toggle_supported=True,
install_uninstall_supported=manifest.id not in protected_set,
))
restart_required = current_set != desired_set
maintenance_mode = saved_maintenance_mode(session)
return ModuleCatalogResponse(
modules=items,
current_enabled=current_enabled,
desired_enabled=desired_enabled,
configured_enabled=configured_enabled,
protected_modules=list(PROTECTED_MODULES),
restart_required=restart_required,
runtime_toggle_supported=True,
install_uninstall_supported=True,
install_plan_supported=True,
package_mutation_supported=False,
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
notes=notes or [
"Enable/disable changes are applied to the running server and saved as startup state.",
"Installing or uninstalling Python/WebUI packages is planned here and applied by the separate installer daemon during maintenance mode.",
],
)
def _module_install_plan_response(session: Session, request: Request, *, notes: list[str] | None = None) -> ModuleInstallPlanResponse:
plan = saved_module_install_plan(session)
maintenance_mode = saved_maintenance_mode(session)
registry = _request_registry(request)
current_enabled = [manifest.id for manifest in registry.manifests()]
configured_enabled = list(configured_enabled_modules(core_settings.enabled_modules))
desired_enabled = list(saved_desired_enabled_modules(session, configured_enabled))
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)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=current_enabled,
desired_enabled=desired_enabled,
maintenance_mode=maintenance_mode.enabled,
session=session,
webui_root=_webui_root(),
runtime_dir=default_installer_runtime_dir(core_settings.database_url),
)
default_notes = [
"The running server does not install or remove packages inside the FastAPI request.",
"Use govoplan-module-installer from an operator shell while maintenance mode is active.",
]
if not maintenance_mode.enabled:
default_notes.append("Maintenance mode is currently off.")
return ModuleInstallPlanResponse(
items=[item.as_dict() for item in plan.items],
updated_at=plan.updated_at,
commands=list(module_install_plan_commands(plan)),
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
preflight=ModuleInstallPreflightResponse.model_validate(preflight.as_dict()),
notes=notes or default_notes,
)
def _webui_root() -> Path | None:
configured = os.getenv("GOVOPLAN_WEBUI_ROOT")
if configured:
return Path(configured).expanduser().resolve()
candidate = Path.cwd() / "webui"
return candidate if candidate.exists() else None
def _upsert_install_plan_item(session: Session, item: ModuleInstallPlanItem):
existing = saved_module_install_plan(session)
retained = [
current
for current in existing.items
if not (current.status == "planned" and current.module_id == item.module_id)
]
return save_module_install_plan(session, [*retained, item])
def _catalog_plan_item(module_id: str) -> tuple[ModuleInstallPlanItem, dict[str, object]]:
result = validate_module_package_catalog()
if not result.get("valid"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(result.get("error") or "Module package catalog is invalid."),
)
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") != "install":
continue
license_decision = module_license_decision(_catalog_license_features(raw_item))
if not license_decision.get("allowed"):
missing = license_decision.get("missing_features")
missing_text = ", ".join(str(item) for item in missing) if isinstance(missing, list) else "required feature"
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"License does not allow installing {module_id}: {missing_text}.",
)
notes = raw_item.get("notes") if isinstance(raw_item.get("notes"), str) else None
if license_decision.get("reason") and license_decision.get("missing_features"):
prefix = f"{notes}\n" if notes else ""
notes = f"{prefix}License warning: {license_decision['reason']}"
return ModuleInstallPlanItem(
module_id=str(raw_item["module_id"]),
action="install",
python_package=raw_item.get("python_package") if isinstance(raw_item.get("python_package"), str) else None,
python_ref=raw_item.get("python_ref") if isinstance(raw_item.get("python_ref"), str) else None,
webui_package=raw_item.get("webui_package") if isinstance(raw_item.get("webui_package"), str) else None,
webui_ref=raw_item.get("webui_ref") if isinstance(raw_item.get("webui_ref"), str) else None,
notes=notes,
), result
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catalog install entry not found: {module_id}")
def _catalog_license_features(raw_item: dict[str, object]) -> list[str]:
value = raw_item.get("license_features")
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _module_package_catalog_item(raw_item: dict[str, object]) -> ModulePackageCatalogItem:
payload = dict(raw_item)
decision = module_license_decision(_catalog_license_features(raw_item))
payload["license_allowed"] = bool(decision.get("allowed"))
payload["license_enforced"] = bool(decision.get("enforced"))
missing = decision.get("missing_features")
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
return ModulePackageCatalogItem.model_validate(payload)
@router.get("/overview", response_model=AdminOverviewResponse)
def admin_overview(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(
"admin:users:read", "admin:groups:read", "admin:roles:read", "admin:settings:read",
"admin:api_keys:read", "system:tenants:read", "system:accounts:read", "system:roles:read", "system:access:read",
"system:settings:read", "system:governance:read", "system:audit:read",
)),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
counts = tenant_counts(session, tenant.id)
access_admin = _access_administration()
capabilities = [item.scope for item in ALL_PERMISSIONS if has_scope(principal, item.scope)]
return AdminOverviewResponse(
active_tenant_id=tenant.id,
active_tenant_name=tenant.name,
tenant_count=session.query(Tenant).count() if has_scope(principal, "system:tenants:read") else None,
system_account_count=access_admin.system_account_count(session) if has_scope(principal, "system:accounts:read") or has_scope(principal, "system:access:read") else None,
system_group_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "group").count() if has_scope(principal, "system:governance:read") else None,
system_role_template_count=session.query(GovernanceTemplate).filter(GovernanceTemplate.kind == "role").count() if has_scope(principal, "system:governance:read") else None,
user_count=counts["users"],
active_user_count=counts["active_users"],
group_count=counts["groups"],
role_count=access_admin.role_count_for_tenant(session, tenant.id),
active_api_key_count=access_admin.active_api_key_count_for_tenant(session, tenant.id),
capabilities=capabilities,
)
@router.get("/system/modules", response_model=ModuleCatalogResponse)
def list_system_modules(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return _module_catalog_response(session, request)
@router.get("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def read_module_install_plan(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return _module_install_plan_response(session, request)
@router.get("/system/modules/install-runs", response_model=ModuleInstallerRunListResponse)
def list_module_install_runs(
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
return ModuleInstallerRunListResponse(
runs=[
ModuleInstallerRunSummary.model_validate(item)
for item in list_module_installer_runs(runtime_dir=runtime_dir)
],
lock=ModuleInstallerLockStatus.model_validate(module_installer_lock_status(runtime_dir=runtime_dir)),
)
@router.get("/system/modules/install-runs/{run_id}")
def read_module_install_run_detail(
run_id: str,
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
try:
return read_module_installer_run(runtime_dir=runtime_dir, run_id=run_id)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/system/modules/install-requests", response_model=ModuleInstallerRequestListResponse)
def list_module_install_requests(
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
return ModuleInstallerRequestListResponse(
requests=[
ModuleInstallerRequestItem.model_validate(item)
for item in list_module_installer_requests(runtime_dir=runtime_dir)
],
daemon=ModuleInstallerDaemonStatus.model_validate(module_installer_daemon_status(runtime_dir=runtime_dir)),
)
@router.get("/system/modules/install-requests/{request_id}")
def read_module_install_request_detail(
request_id: str,
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
try:
return read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/system/modules/install-requests", response_model=ModuleInstallerRequestItem)
def create_module_install_request(
payload: ModuleInstallerRequestCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
maintenance_mode = saved_maintenance_mode(session)
if not maintenance_mode.enabled:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before queuing installer requests.")
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Queuing installer requests requires maintenance access.")
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by=principal.user.id,
options=payload.options.model_dump(exclude_none=True),
)
audit_from_principal(
session,
principal,
action="system_modules.install_request_queued",
scope="system",
object_type="module_install_request",
object_id=str(request["request_id"]),
details={"options": payload.options.model_dump(exclude_none=True)},
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@router.post("/system/modules/install-requests/{request_id}/cancel", response_model=ModuleInstallerRequestItem)
def cancel_module_install_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
maintenance_mode = saved_maintenance_mode(session)
if not maintenance_mode.enabled:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
try:
request = cancel_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
cancelled_by=principal.user.id,
)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
action="system_modules.install_request_cancelled",
scope="system",
object_type="module_install_request",
object_id=request_id,
details={},
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@router.post("/system/modules/install-requests/{request_id}/retry", response_model=ModuleInstallerRequestItem)
def retry_module_install_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
maintenance_mode = saved_maintenance_mode(session)
if not maintenance_mode.enabled:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Maintenance mode must be enabled before changing installer requests.")
if not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Changing installer requests requires maintenance access.")
runtime_dir = default_installer_runtime_dir(core_settings.database_url)
try:
request = retry_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
requested_by=principal.user.id,
)
except ModuleInstallerError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
action="system_modules.install_request_retried",
scope="system",
object_type="module_install_request",
object_id=request_id,
details={"new_request_id": request["request_id"]},
)
session.commit()
return ModuleInstallerRequestItem.model_validate(request)
@router.get("/system/modules/package-catalog", response_model=ModulePackageCatalogResponse)
def read_module_package_catalog(
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
result = validate_module_package_catalog()
return ModulePackageCatalogResponse(
modules=[_module_package_catalog_item(item) for item in result["modules"] if isinstance(item, dict)],
configured=bool(result["configured"]),
valid=bool(result["valid"]),
path=result["path"] if isinstance(result["path"], str) else None,
source=result["source"] if isinstance(result.get("source"), str) else None,
source_type=result["source_type"] if isinstance(result.get("source_type"), str) else None,
channel=result["channel"] if isinstance(result["channel"], str) else None,
sequence=result["sequence"] if isinstance(result.get("sequence"), int) else None,
generated_at=result["generated_at"] if isinstance(result.get("generated_at"), str) else None,
expires_at=result["expires_at"] if isinstance(result.get("expires_at"), str) else None,
signed=bool(result["signed"]),
trusted=bool(result["trusted"]),
key_id=result["key_id"] if isinstance(result["key_id"], str) else None,
warnings=[str(item) for item in result["warnings"]] if isinstance(result["warnings"], list) else [],
error=result["error"] if isinstance(result["error"], str) else None,
)
@router.post("/system/modules/install-plan/catalog/{module_id}", response_model=ModuleInstallPlanResponse)
def plan_module_install_from_catalog(
module_id: str,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
try:
item, validation = _catalog_plan_item(module_id)
plan = _upsert_install_plan_item(session, item)
record_module_package_catalog_acceptance(validation)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
action="system_modules.install_plan_catalog_added",
scope="system",
object_type="module_install_plan",
object_id=module_id,
details={"items": [item.as_dict() for item in plan.items]},
)
session.commit()
return _module_install_plan_response(session, request, notes=[f"Catalog install entry planned for {module_id}."])
@router.post("/system/modules/{module_id}/uninstall-plan", response_model=ModuleInstallPlanResponse)
def plan_module_uninstall(
module_id: str,
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
registry = _request_registry(request)
lifecycle = _request_lifecycle(request)
current_enabled = {manifest.id for manifest in registry.manifests()}
configured_enabled = configured_enabled_modules(core_settings.enabled_modules)
desired_enabled = set(saved_desired_enabled_modules(session, configured_enabled))
if module_id in PROTECTED_MODULES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Protected platform modules cannot be uninstalled.")
if module_id in current_enabled or module_id in desired_enabled:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Disable the module before planning package uninstall.")
try:
item = module_uninstall_plan_item(module_id, lifecycle.available_modules)
plan = _upsert_install_plan_item(session, item)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
action="system_modules.uninstall_plan_added",
scope="system",
object_type="module_install_plan",
object_id=module_id,
details={"items": [item.as_dict() for item in plan.items]},
)
session.commit()
return _module_install_plan_response(session, request, notes=[f"Package uninstall planned for {module_id}."])
@router.put("/system/modules", response_model=ModuleCatalogResponse)
def update_system_modules(
request: Request,
payload: ModuleStateUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
lifecycle = _request_lifecycle(request)
available = lifecycle.available_modules
try:
plan = plan_desired_enabled_modules(payload.enabled_modules, available)
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
try:
result = lifecycle.apply_enabled_modules(plan.enabled_modules, protected_modules=PROTECTED_MODULES)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
desired = save_desired_enabled_modules(session, result.enabled_modules)
audit_from_principal(
session,
principal,
action="system_modules.desired_state_updated",
scope="system",
object_type="module_state",
object_id="global",
details={
"desired_enabled": list(desired),
"added_dependencies": list(plan.added_dependencies),
"activated": list(result.activated_modules),
"deactivated": list(result.deactivated_modules),
"mounted": list(result.mounted_modules),
},
)
session.commit()
notes = ["Module state saved and applied to the running server."]
if plan.added_dependencies:
notes.append("Required dependencies added automatically: " + ", ".join(plan.added_dependencies))
if result.activated_modules:
notes.append("Activated: " + ", ".join(result.activated_modules))
if result.deactivated_modules:
notes.append("Deactivated: " + ", ".join(result.deactivated_modules))
return _module_catalog_response(session, request, notes=notes)
@router.put("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def update_module_install_plan(
request: Request,
payload: ModuleInstallPlanUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
try:
plan = save_module_install_plan(session, [item.model_dump() for item in payload.items])
except ModuleManagementError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
audit_from_principal(
session,
principal,
action="system_modules.install_plan_updated",
scope="system",
object_type="module_install_plan",
object_id="global",
details={"items": [item.as_dict() for item in plan.items]},
)
session.commit()
return _module_install_plan_response(session, request, notes=["Module package install plan saved."])
@router.delete("/system/modules/install-plan", response_model=ModuleInstallPlanResponse)
def clear_module_install_plan(
request: Request,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
plan = save_module_install_plan(session, ())
audit_from_principal(
session,
principal,
action="system_modules.install_plan_cleared",
scope="system",
object_type="module_install_plan",
object_id="global",
details={"items": [item.as_dict() for item in plan.items]},
)
session.commit()
return _module_install_plan_response(session, request, notes=["Module package install plan cleared."])
@router.get("/system/settings", response_model=SystemSettingsItem)
def read_system_settings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
item = get_system_settings(session)
session.commit()
policy = privacy_policy_from_settings(item)
maintenance_mode = saved_maintenance_mode(session)
return SystemSettingsItem(
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,
privacy_retention_policy=PrivacyRetentionPolicyItem.model_validate(policy.model_dump(mode="json")),
maintenance_mode=MaintenanceModeItem.model_validate(maintenance_mode.as_dict()),
settings=item.settings or {},
)
@router.patch("/system/settings", response_model=SystemSettingsItem)
def write_system_settings(
payload: SystemSettingsUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
item = get_system_settings(session)
item.default_locale = payload.default_locale.strip() or "en"
item.allow_tenant_custom_groups = payload.allow_tenant_custom_groups
item.allow_tenant_custom_roles = payload.allow_tenant_custom_roles
item.allow_tenant_api_keys = payload.allow_tenant_api_keys
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:
current = saved_maintenance_mode(session)
next_mode = MaintenanceMode(
enabled=payload.maintenance_mode.enabled,
message=payload.maintenance_mode.message.strip() if payload.maintenance_mode.message else None,
)
if current.enabled != next_mode.enabled and not has_scope(principal, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Changing maintenance mode requires {MAINTENANCE_ACCESS_SCOPE}.",
)
save_maintenance_mode(session, next_mode)
session.add(item)
audit_from_principal(
session, principal, action="system_settings.updated", scope="system", object_type="system_settings", object_id=item.id,
details=payload.model_dump(),
)
session.commit()
return read_system_settings(session=session, principal=principal)
@router.get("/system/governance-templates", response_model=GovernanceTemplateListResponse)
def list_governance_templates(
kind: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:read")),
):
del principal
query = session.query(GovernanceTemplate)
if kind:
query = query.filter(GovernanceTemplate.kind == kind)
items = query.order_by(GovernanceTemplate.kind.asc(), GovernanceTemplate.name.asc()).all()
return GovernanceTemplateListResponse(templates=[_governance_template_item(session, item) for item in items])
@router.post("/system/governance-templates", response_model=GovernanceTemplateItem, status_code=status.HTTP_201_CREATED)
def create_governance_template(
payload: GovernanceTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
):
try:
item = create_template(
session,
kind=payload.kind,
slug=payload.slug,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
is_active=payload.is_active,
assignments=[assignment.model_dump() for assignment in payload.assignments],
)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session,
principal,
action="governance_template.created",
scope="system",
object_type=f"{payload.kind}_template",
object_id=item.id,
details={"slug": item.slug, "tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
)
session.commit()
return _governance_template_item(session, item)
@router.patch("/system/governance-templates/{template_id}", response_model=GovernanceTemplateItem)
def update_governance_template(
template_id: str,
payload: GovernanceTemplateUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
):
item = session.get(GovernanceTemplate, template_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
try:
update_template(
session,
item,
name=payload.name,
description=payload.description,
permissions=payload.permissions,
is_active=payload.is_active,
assignments=[assignment.model_dump() for assignment in payload.assignments],
)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session,
principal,
action="governance_template.updated",
scope="system",
object_type=f"{item.kind}_template",
object_id=item.id,
details={"tenant_ids": [assignment.tenant_id for assignment in payload.assignments]},
)
session.commit()
return _governance_template_item(session, item)
@router.delete("/system/governance-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_governance_template(
template_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:governance:write")),
):
item = session.get(GovernanceTemplate, template_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Governance template not found")
kind = item.kind
try:
delete_template(session, item)
except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc
audit_from_principal(
session,
principal,
action="governance_template.deleted",
scope="system",
object_type=f"{kind}_template",
object_id=template_id,
details={},
)
session.commit()
return None

View File

@@ -0,0 +1,360 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class MaintenanceModeItem(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = False
message: str | None = Field(default=None, max_length=500)
class AdminOverviewResponse(BaseModel):
active_tenant_id: str
active_tenant_name: str
tenant_count: int | None = None
system_account_count: int | None = None
system_group_template_count: int | None = None
system_role_template_count: int | None = None
user_count: int
active_user_count: int
group_count: int
role_count: int
active_api_key_count: int
capabilities: list[str] = Field(default_factory=list)
class SystemSettingsItem(BaseModel):
default_locale: str = "en"
allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
settings: dict[str, Any] = Field(default_factory=dict)
class SystemSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
allow_tenant_custom_groups: bool
allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
maintenance_mode: MaintenanceModeItem | None = None
class ModuleCatalogItem(BaseModel):
id: str
name: str
version: str
installed: bool = True
current_enabled: bool = False
desired_enabled: bool = False
protected: bool = False
restart_required: bool = False
dependencies: list[str] = Field(default_factory=list)
optional_dependencies: list[str] = Field(default_factory=list)
dependents: list[str] = Field(default_factory=list)
permissions_count: int = 0
role_templates_count: int = 0
route_contributed: bool = False
nav_count: int = 0
frontend_package: str | None = None
migration_module_id: str | None = None
migration_script_location: str | None = None
runtime_toggle_supported: bool = False
install_uninstall_supported: bool = False
class ModuleCatalogResponse(BaseModel):
modules: list[ModuleCatalogItem]
current_enabled: list[str]
desired_enabled: list[str]
configured_enabled: list[str]
protected_modules: list[str]
restart_required: bool = False
runtime_toggle_supported: bool = False
install_uninstall_supported: bool = False
install_plan_supported: bool = False
package_mutation_supported: bool = False
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
notes: list[str] = Field(default_factory=list)
class ModuleStateUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_modules: list[str] = Field(default_factory=list)
class ModuleInstallPlanItem(BaseModel):
model_config = ConfigDict(extra="forbid")
module_id: str = Field(min_length=1, max_length=120)
action: Literal["install", "uninstall"]
python_package: str | None = Field(default=None, max_length=200)
python_ref: str | None = Field(default=None, max_length=1000)
webui_package: str | None = Field(default=None, max_length=200)
webui_ref: str | None = Field(default=None, max_length=1000)
destroy_data: bool = False
status: Literal["planned", "applied", "blocked"] = "planned"
notes: str | None = Field(default=None, max_length=1000)
class ModuleInstallPreflightIssue(BaseModel):
severity: Literal["blocker", "warning", "info"]
code: str
message: str
module_id: str | None = None
class ModuleInstallChecklistItem(BaseModel):
id: str
label: str
status: Literal["done", "pending", "blocked", "warning"]
detail: str | None = None
class ModuleInstallPreflightResponse(BaseModel):
allowed: bool
maintenance_mode: bool
frontend_rebuild_required: bool
restart_required: bool
commands: list[str] = Field(default_factory=list)
rollback_commands: list[str] = Field(default_factory=list)
issues: list[ModuleInstallPreflightIssue] = Field(default_factory=list)
checklist: list[ModuleInstallChecklistItem] = Field(default_factory=list)
class ModuleInstallPlanResponse(BaseModel):
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
updated_at: str | None = None
commands: list[str] = Field(default_factory=list)
maintenance_mode: MaintenanceModeItem = Field(default_factory=MaintenanceModeItem)
preflight: ModuleInstallPreflightResponse | None = None
notes: list[str] = Field(default_factory=list)
class ModuleInstallerLockStatus(BaseModel):
model_config = ConfigDict(extra="allow")
locked: bool = False
path: str
class ModuleInstallerDaemonStatus(BaseModel):
model_config = ConfigDict(extra="allow")
running: bool = False
status: str = "unknown"
path: str
class ModuleInstallerRunSummary(BaseModel):
run_id: str
status: str
started_at: str | None = None
finished_at: str | None = None
rollback_status: str | None = None
supervisor_status: str | None = None
error: str | None = None
record_path: str
commands_count: int = 0
planned_modules: list[str] = Field(default_factory=list)
class ModuleInstallerRunListResponse(BaseModel):
runs: list[ModuleInstallerRunSummary] = Field(default_factory=list)
lock: ModuleInstallerLockStatus
class ModuleInstallerRequestOptions(BaseModel):
model_config = ConfigDict(extra="forbid")
build_webui: bool = False
migrate_database: bool = True
webui_root: str | None = Field(default=None, max_length=1000)
npm_bin: str | None = Field(default=None, max_length=500)
database_backup_command: str | None = Field(default=None, max_length=4000)
database_restore_command: str | None = Field(default=None, max_length=4000)
database_restore_check_command: str | None = Field(default=None, max_length=4000)
activate_installed_modules: bool = True
remove_uninstalled_modules_from_desired: bool = True
restart_commands: list[str] = Field(default_factory=list)
health_urls: list[str] = Field(default_factory=list)
health_timeout_seconds: float = Field(default=60.0, ge=1, le=3600)
health_interval_seconds: float = Field(default=2.0, ge=0.2, le=120)
class ModuleInstallerRequestItem(BaseModel):
model_config = ConfigDict(extra="allow")
request_id: str
status: str
created_at: str
requested_by: str | None = None
started_at: str | None = None
finished_at: str | None = None
cancelled_at: str | None = None
cancelled_by: str | None = None
retry_of: str | None = None
error: str | None = None
record_path: str | None = None
options: dict[str, Any] = Field(default_factory=dict)
class ModuleInstallerRequestListResponse(BaseModel):
requests: list[ModuleInstallerRequestItem] = Field(default_factory=list)
daemon: ModuleInstallerDaemonStatus
class ModuleInstallerRequestCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
options: ModuleInstallerRequestOptions = Field(default_factory=ModuleInstallerRequestOptions)
class ModulePackageCatalogItem(BaseModel):
module_id: str
name: str
description: str | None = None
version: str | None = None
action: Literal["install", "uninstall"] = "install"
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
license_features: list[str] = Field(default_factory=list)
license_allowed: bool = True
license_enforced: bool = False
license_missing_features: list[str] = Field(default_factory=list)
license_reason: str | None = None
notes: str | None = None
tags: list[str] = Field(default_factory=list)
class ModulePackageCatalogResponse(BaseModel):
modules: list[ModulePackageCatalogItem] = Field(default_factory=list)
configured: bool = False
valid: bool = True
path: str | None = None
source: str | None = None
source_type: str | None = None
channel: str | None = None
sequence: int | None = None
generated_at: str | None = None
expires_at: str | None = None
signed: bool = False
trusted: bool = False
key_id: str | None = None
warnings: list[str] = Field(default_factory=list)
error: str | None = None
class ModuleInstallPlanUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[ModuleInstallPlanItem] = Field(default_factory=list)
class GovernanceAssignment(BaseModel):
tenant_id: str
mode: Literal["available", "required"] = "available"
class GovernanceTemplateItem(BaseModel):
id: str
kind: Literal["group", "role"]
slug: str
name: str
description: str | None = None
permissions: list[str] = Field(default_factory=list)
effective_permission_count: int = 0
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class GovernanceTemplateListResponse(BaseModel):
templates: list[GovernanceTemplateItem]
class GovernanceTemplateCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["group", "role"]
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)
class GovernanceTemplateUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_active: bool = True
assignments: list[GovernanceAssignment] = Field(default_factory=list)

View File

@@ -0,0 +1,2 @@
"""Admin-owned SQLAlchemy metadata and models."""

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class GovernanceTemplate(Base, TimestampMixin):
__tablename__ = "governance_templates"
__table_args__ = (UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class GovernanceTemplateAssignment(Base, TimestampMixin):
__tablename__ = "governance_template_assignments"
__table_args__ = (UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
template_id: Mapped[str] = mapped_column(ForeignKey("governance_templates.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
mode: Mapped[str] = mapped_column(String(20), default="available", nullable=False)
__all__ = ["GovernanceTemplate", "GovernanceTemplateAssignment", "new_uuid"]

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
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,
GovernanceTemplateMaterialization,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.security.permissions import validate_tenant_permissions
from govoplan_tenancy.backend.db.models import Tenant
TEMPLATE_KINDS = {"group", "role"}
ASSIGNMENT_MODES = {"available", "required"}
def _governance_materializer() -> AccessGovernanceMaterializer:
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.")
return capability
def _materialization(
item: GovernanceTemplate,
*,
tenant_id: str,
required: bool = False,
) -> GovernanceTemplateMaterialization:
return GovernanceTemplateMaterialization(
template_id=item.id,
kind=item.kind, # type: ignore[arg-type]
tenant_id=tenant_id,
slug=item.slug,
name=item.name,
description=item.description,
permissions=tuple(item.permissions or []),
is_active=item.is_active,
required=required,
)
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.")
if kind == "group":
if permissions:
raise AdminValidationError("Group templates do not contain permissions; assign roles to groups inside each tenant.")
return []
try:
return validate_tenant_permissions(permissions)
except ValueError as exc:
raise AdminValidationError(str(exc)) from exc
def create_template(
session: Session,
*,
kind: str,
slug: str,
name: str,
description: str | None,
permissions: list[str],
is_active: bool,
assignments: list[dict[str, str]],
) -> GovernanceTemplate:
normalized_slug = slugify(slug)
permissions = validate_template(kind, permissions)
exists = session.query(GovernanceTemplate).filter(
GovernanceTemplate.kind == kind,
GovernanceTemplate.slug == normalized_slug,
).first()
if exists:
raise AdminConflictError(f"A {kind} template with slug {normalized_slug!r} already exists.")
item = GovernanceTemplate(
kind=kind,
slug=normalized_slug,
name=name.strip(),
description=description,
permissions=permissions,
is_active=is_active,
)
session.add(item)
session.flush()
set_template_assignments(session, item, assignments)
return item
def update_template(
session: Session,
item: GovernanceTemplate,
*,
name: str,
description: str | None,
permissions: list[str],
is_active: bool,
assignments: list[dict[str, str]],
) -> GovernanceTemplate:
item.name = name.strip()
item.description = description
item.permissions = validate_template(item.kind, permissions)
item.is_active = is_active
set_template_assignments(session, item, assignments)
sync_template(session, item)
return item
def set_template_assignments(
session: Session,
item: GovernanceTemplate,
assignments: list[dict[str, str]],
) -> None:
desired: dict[str, str] = {}
for assignment in assignments:
tenant_id = assignment.get("tenant_id", "")
mode = assignment.get("mode", "available")
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
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)
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))
session.flush()
sync_template(session, item)
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),
)
session.flush()
def delete_template(session: Session, item: GovernanceTemplate) -> None:
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))
session.delete(item)

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from govoplan_admin.backend.db import models as admin_models # noqa: F401 - populate Admin ORM metadata
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
from govoplan_core.db.base import Base
def _route_factory(context: ModuleContext):
del context
from govoplan_admin.backend.api.v1.routes import router
return router
manifest = ModuleManifest(
id="admin",
name="Admin",
version="0.1.5",
dependencies=("access",),
route_factory=_route_factory,
migration_spec=MigrationSpec(module_id="admin", metadata=Base.metadata),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
admin_models.GovernanceTemplate,
admin_models.GovernanceTemplateAssignment,
label="Admin",
),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@

30
webui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "@govoplan/admin-webui",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

403
webui/src/api/admin.ts Normal file
View File

@@ -0,0 +1,403 @@
import type { ApiSettings } from "@govoplan/core-webui";
import { apiFetch } from "@govoplan/core-webui";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type MaintenanceMode = {
enabled: boolean;
message?: string | null;
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
maintenance_mode: MaintenanceMode;
settings: Record<string, unknown>;
};
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
maintenance_mode?: MaintenanceMode | null;
};
export type ModuleCatalogItem = {
id: string;
name: string;
version: string;
installed: boolean;
current_enabled: boolean;
desired_enabled: boolean;
protected: boolean;
restart_required: boolean;
dependencies: string[];
optional_dependencies: string[];
dependents: string[];
permissions_count: number;
role_templates_count: number;
route_contributed: boolean;
nav_count: number;
frontend_package?: string | null;
migration_module_id?: string | null;
migration_script_location?: string | null;
runtime_toggle_supported: boolean;
install_uninstall_supported: boolean;
};
export type ModuleCatalogResponse = {
modules: ModuleCatalogItem[];
current_enabled: string[];
desired_enabled: string[];
configured_enabled: string[];
protected_modules: string[];
restart_required: boolean;
runtime_toggle_supported: boolean;
install_uninstall_supported: boolean;
install_plan_supported: boolean;
package_mutation_supported: boolean;
maintenance_mode: MaintenanceMode;
notes: string[];
};
export type ModuleInstallPlanItem = {
module_id: string;
action: "install" | "uninstall";
python_package?: string | null;
python_ref?: string | null;
webui_package?: string | null;
webui_ref?: string | null;
destroy_data: boolean;
status: "planned" | "applied" | "blocked";
notes?: string | null;
};
export type ModuleInstallPreflightIssue = {
severity: "blocker" | "warning" | "info";
code: string;
message: string;
module_id?: string | null;
};
export type ModuleInstallChecklistItem = {
id: string;
label: string;
status: "done" | "pending" | "blocked" | "warning";
detail?: string | null;
};
export type ModuleInstallPreflight = {
allowed: boolean;
maintenance_mode: boolean;
frontend_rebuild_required: boolean;
restart_required: boolean;
commands: string[];
rollback_commands: string[];
issues: ModuleInstallPreflightIssue[];
checklist: ModuleInstallChecklistItem[];
};
export type ModuleInstallPlanResponse = {
items: ModuleInstallPlanItem[];
updated_at?: string | null;
commands: string[];
maintenance_mode: MaintenanceMode;
preflight?: ModuleInstallPreflight | null;
notes: string[];
};
export type ModuleInstallerLockStatus = {
locked: boolean;
path: string;
[key: string]: unknown;
};
export type ModuleInstallerDaemonStatus = {
running: boolean;
status: string;
path: string;
[key: string]: unknown;
};
export type ModuleInstallerRunSummary = {
run_id: string;
status: string;
started_at?: string | null;
finished_at?: string | null;
rollback_status?: string | null;
supervisor_status?: string | null;
error?: string | null;
record_path: string;
commands_count: number;
planned_modules: string[];
};
export type ModuleInstallerRunListResponse = {
runs: ModuleInstallerRunSummary[];
lock: ModuleInstallerLockStatus;
};
export type ModuleInstallerRequestOptions = {
build_webui: boolean;
migrate_database: boolean;
webui_root?: string | null;
npm_bin?: string | null;
database_backup_command?: string | null;
database_restore_command?: string | null;
database_restore_check_command?: string | null;
activate_installed_modules: boolean;
remove_uninstalled_modules_from_desired: boolean;
restart_commands: string[];
health_urls: string[];
health_timeout_seconds: number;
health_interval_seconds: number;
};
export type ModuleInstallerRequestItem = {
request_id: string;
status: string;
created_at: string;
requested_by?: string | null;
started_at?: string | null;
finished_at?: string | null;
cancelled_at?: string | null;
cancelled_by?: string | null;
retry_of?: string | null;
error?: string | null;
record_path?: string | null;
options: Record<string, unknown>;
};
export type ModuleInstallerRequestListResponse = {
requests: ModuleInstallerRequestItem[];
daemon: ModuleInstallerDaemonStatus;
};
export type ModulePackageCatalogItem = {
module_id: string;
name: string;
description?: string | null;
version?: string | null;
action: "install" | "uninstall";
python_package?: string | null;
python_ref?: string | null;
webui_package?: string | null;
webui_ref?: string | null;
license_features: string[];
license_allowed: boolean;
license_enforced: boolean;
license_missing_features: string[];
license_reason?: string | null;
notes?: string | null;
tags: string[];
};
export type ModulePackageCatalogResponse = {
modules: ModulePackageCatalogItem[];
configured: boolean;
valid: boolean;
path?: string | null;
source?: string | null;
source_type?: string | null;
channel?: string | null;
sequence?: number | null;
generated_at?: string | null;
expires_at?: string | null;
signed: boolean;
trusted: boolean;
key_id?: string | null;
warnings: string[];
error?: string | null;
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function fetchModuleCatalog(settings: ApiSettings): Promise<ModuleCatalogResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules");
}
export function updateModuleState(settings: ApiSettings, enabledModules: string[]): Promise<ModuleCatalogResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules", {
method: "PUT",
body: JSON.stringify({ enabled_modules: enabledModules })
});
}
export function fetchModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan");
}
export function fetchModuleInstallerRuns(settings: ApiSettings): Promise<ModuleInstallerRunListResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-runs");
}
export function fetchModuleInstallerRequests(settings: ApiSettings): Promise<ModuleInstallerRequestListResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-requests");
}
export function createModuleInstallerRequest(settings: ApiSettings, options: ModuleInstallerRequestOptions): Promise<ModuleInstallerRequestItem> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-requests", {
method: "POST",
body: JSON.stringify({ options })
});
}
export function cancelModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise<ModuleInstallerRequestItem> {
return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/cancel`, { method: "POST" });
}
export function retryModuleInstallerRequest(settings: ApiSettings, requestId: string): Promise<ModuleInstallerRequestItem> {
return apiFetch(settings, `/api/v1/admin/system/modules/install-requests/${requestId}/retry`, { method: "POST" });
}
export function fetchModulePackageCatalog(settings: ApiSettings): Promise<ModulePackageCatalogResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/package-catalog");
}
export function planModuleInstallFromCatalog(settings: ApiSettings, moduleId: string): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, `/api/v1/admin/system/modules/install-plan/catalog/${encodeURIComponent(moduleId)}`, { method: "POST" });
}
export function planModuleUninstall(settings: ApiSettings, moduleId: string): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, `/api/v1/admin/system/modules/${encodeURIComponent(moduleId)}/uninstall-plan`, { method: "POST" });
}
export function updateModuleInstallPlan(settings: ApiSettings, items: ModuleInstallPlanItem[]): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", {
method: "PUT",
body: JSON.stringify({ items })
});
}
export function clearModuleInstallPlan(settings: ApiSettings): Promise<ModuleInstallPlanResponse> {
return apiFetch(settings, "/api/v1/admin/system/modules/install-plan", { method: "DELETE" });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
}

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchAdminOverview, type AdminOverview } from "../../api/admin";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
export default function AdminOverviewPanel({ settings, onSelect, availableSections }: { settings: ApiSettings; onSelect: (section: string) => void; availableSections: ReadonlySet<string> }) {
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setError("");
try { setOverview(await fetchAdminOverview(settings)); }
catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
const hasSystemMetrics = Boolean(overview && [
overview.tenant_count,
overview.system_account_count,
overview.system_group_template_count,
overview.system_role_template_count
].some((value) => value !== null && value !== undefined));
return (
<AdminPageLayout title="Administration" description="System-wide governance and tenant-local access management, separated by scope and enforced by the backend." loading={loading} error={error} actions={<Button onClick={() => void load()} disabled={loading}>Reload</Button>}>
{overview && <>
{hasSystemMetrics && <>
<div className="admin-overview-section-label">System</div>
<div className="metric-grid">
<Metric title="Tenants" value={overview.tenant_count ?? "—"} text="Registered tenant spaces." />
<Metric title="Users" value={overview.system_account_count ?? "—"} text="Global login accounts across all tenants." />
<Metric title="Central groups" value={overview.system_group_template_count ?? "—"} text="System-governed group definitions." />
<Metric title="Tenant roles" value={overview.system_role_template_count ?? "—"} text="Centrally governed tenant roles." />
</div>
</>}
{hasSystemArea(availableSections) && <Card title="System administration">
<div className="admin-overview-grid">
{availableSections.has("system-settings") && <AreaLink title="General" text="Instance defaults and tenant governance capabilities." onClick={() => onSelect("system-settings")} />}
{availableSections.has("system-tenants") && <AreaLink title="Tenants" text="Create, suspend and govern tenant spaces." onClick={() => onSelect("system-tenants")} />}
{availableSections.has("system-roles") && <AreaLink title="System roles" text="Instance-wide roles assigned directly to global accounts." onClick={() => onSelect("system-roles")} />}
{availableSections.has("system-role-templates") && <AreaLink title="Tenant roles" text="Centrally governed tenant roles and their availability across tenants." onClick={() => onSelect("system-role-templates")} />}
{availableSections.has("system-groups") && <AreaLink title="Groups" text="Group definitions made available or required in selected tenants." onClick={() => onSelect("system-groups")} />}
{availableSections.has("system-users") && <AreaLink title="Users" text="Global accounts, tenant memberships and system-role assignments." onClick={() => onSelect("system-users")} />}
{availableSections.has("system-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("system-mail-servers")} />}
{availableSections.has("system-retention") && <AreaLink title="Retention" text="Instance privacy retention policy and lower-level override permissions." onClick={() => onSelect("system-retention")} />}
{availableSections.has("system-modules") && <AreaLink title="Modules" text="Installed modules, runtime state and startup state." onClick={() => onSelect("system-modules")} />}
{availableSections.has("system-audit") && <AreaLink title="Audit" text="System-level administrative history." onClick={() => onSelect("system-audit")} />}
</div>
</Card>}
<div className="admin-overview-section-label">Active tenant: {overview.active_tenant_name}</div>
<div className="metric-grid">
<Metric title="Tenant users" value={`${overview.active_user_count}/${overview.user_count}`} text="Active and total memberships." />
<Metric title="Groups" value={overview.group_count} text="Tenant-local and centrally managed groups." />
<Metric title="Roles" value={overview.role_count} text="Tenant-local and centrally managed roles." />
<Metric title="API keys" value={overview.active_api_key_count} text="Active tenant automation credentials." />
</div>
<Card title="Tenant administration">
<div className="admin-overview-grid">
{availableSections.has("tenant-settings") && <AreaLink title="General" text="Tenant locale and tenant-specific settings." onClick={() => onSelect("tenant-settings")} />}
{availableSections.has("tenant-roles") && <AreaLink title="Roles" text="Tenant permission bundles and system-managed role copies." onClick={() => onSelect("tenant-roles")} />}
{availableSections.has("tenant-groups") && <AreaLink title="Groups" text="Tenant memberships and inherited roles." onClick={() => onSelect("tenant-groups")} />}
{availableSections.has("tenant-users") && <AreaLink title="Users" text="Membership status, groups and direct roles in the active tenant." onClick={() => onSelect("tenant-users")} />}
{availableSections.has("tenant-mail-servers") && <AreaLink title="Mail servers" text="Reusable encrypted SMTP/IMAP profiles and mail policy." onClick={() => onSelect("tenant-mail-servers")} />}
{availableSections.has("tenant-retention") && <AreaLink title="Retention" text="Tenant-level privacy retention limits inherited by owned objects." onClick={() => onSelect("tenant-retention")} />}
{availableSections.has("tenant-api-keys") && <AreaLink title="API keys" text="Scoped automation credentials capped by owner permissions." onClick={() => onSelect("tenant-api-keys")} />}
{availableSections.has("tenant-audit") && <AreaLink title="Audit" text="Tenant-level administrative history only." onClick={() => onSelect("tenant-audit")} />}
</div>
</Card>
</>}
</AdminPageLayout>
);
}
function hasSystemArea(sections: ReadonlySet<string>): boolean {
return ["system-settings", "system-tenants", "system-roles", "system-role-templates", "system-groups", "system-users", "system-mail-servers", "system-retention", "system-modules", "system-audit"].some((section) => sections.has(section));
}
function Metric({ title, value, text }: { title: string; value: string | number; text: string }) {
return <Card title={title}><strong className="module-big-number">{value}</strong><p className="muted">{text}</p></Card>;
}
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>;
}

View File

@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
import type { 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";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import {
createGovernanceTemplate,
deleteGovernanceTemplate,
fetchGovernanceTemplates,
fetchPermissionCatalog,
fetchTenants,
updateGovernanceTemplate,
type GovernanceAssignment,
type GovernanceTemplateItem,
type PermissionItem,
type TenantAdminItem
} from "../../api/admin";
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
const emptyDraft = {
slug: "",
name: "",
description: "",
isActive: true,
permissions: [] as string[],
assignments: [] as GovernanceAssignment[]
};
export default function GovernanceTemplatesPanel({
settings,
kind,
canWrite,
onAuthRefresh
}: {
settings: ApiSettings;
kind: "group" | "role";
canWrite: boolean;
onAuthRefresh: () => Promise<void>;
}) {
const [items, setItems] = useState<GovernanceTemplateItem[]>([]);
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
const [editing, setEditing] = useState<GovernanceTemplateItem | "new" | null>(null);
const [viewing, setViewing] = useState<GovernanceTemplateItem | null>(null);
const [deleting, setDeleting] = useState<GovernanceTemplateItem | null>(null);
const [draft, setDraft] = useState(emptyDraft);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [nextItems, nextTenants, nextPermissions] = await Promise.all([
fetchGovernanceTemplates(settings, kind),
fetchTenants(settings),
kind === "role" ? fetchPermissionCatalog(settings) : Promise.resolve([])
]);
setItems(nextItems);
setTenants(nextTenants);
setPermissions(nextPermissions.filter((item) => item.level === "tenant"));
} catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, kind]);
function openCreate() {
setDraft(emptyDraft);
setEditing("new");
}
function openEdit(item: GovernanceTemplateItem) {
setDraft({
slug: item.slug,
name: item.name,
description: item.description || "",
isActive: item.is_active,
permissions: item.permissions,
assignments: item.assignments
});
setEditing(item);
}
function assignmentMode(tenantId: string): "none" | "available" | "required" {
return draft.assignments.find((item) => item.tenant_id === tenantId)?.mode ?? "none";
}
function setAssignment(tenantId: string, mode: "none" | "available" | "required") {
setDraft((current) => ({
...current,
assignments: mode === "none"
? current.assignments.filter((item) => item.tenant_id !== tenantId)
: [...current.assignments.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, mode }]
}));
}
async function save() {
setBusy(true);
setError("");
try {
if (editing === "new") {
await createGovernanceTemplate(settings, {
kind,
slug: draft.slug,
name: draft.name,
description: draft.description || null,
permissions: kind === "role" ? draft.permissions : [],
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${kind === "group" ? "Group" : "Role"} template created.`);
} else if (editing) {
await updateGovernanceTemplate(settings, editing.id, {
name: draft.name,
description: draft.description || null,
permissions: kind === "role" ? draft.permissions : [],
is_active: draft.isActive,
assignments: draft.assignments
});
setSuccess(`${draft.name} updated and synchronized to assigned tenants.`);
}
setEditing(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!deleting) return;
setBusy(true);
setError("");
try {
await deleteGovernanceTemplate(settings, deleting.id);
setSuccess(`${deleting.name} deleted.`);
setDeleting(null);
await load();
await onAuthRefresh();
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
const columns = useMemo<DataGridColumn<GovernanceTemplateItem>[]>(() => [
{
id: "template", header: kind === "group" ? "Group template" : "Tenant role", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true,
value: (row) => `${row.name} ${row.slug}`,
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
},
{
id: "tenants", header: "Tenant availability", width: 320, minWidth: 210, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true,
value: (row) => row.assignments.map((assignment) => tenants.find((tenant) => tenant.id === assignment.tenant_id)?.name || assignment.tenant_id).join(", ") || "—",
render: (row) => row.assignments.length ? row.assignments.map((assignment) => {
const tenant = tenants.find((item) => item.id === assignment.tenant_id);
return `${tenant?.name || assignment.tenant_id} (${assignment.mode})`;
}).join(", ") : "—"
},
...(kind === "role" ? [{
id: "permissions", header: "Permissions", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer" as const,
value: (row: GovernanceTemplateItem) => row.effective_permission_count
}] : []),
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{
id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right",
render: (row) => <div className="admin-icon-actions">
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
</div>
}
], [canWrite, kind, tenants]);
const title = kind === "group" ? "Central groups" : "Tenant roles";
const description = kind === "group"
? "Centrally defined group identities that are provisioned into selected tenants as available or required definitions. Membership remains tenant-local."
: "Centrally governed tenant roles that are provisioned into selected tenants as available or required definitions.";
return (
<>
<AdminPageLayout
title={title}
description={description}
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label={kind === "group" ? "Add group template" : "Add tenant role"} icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
>
<div className="admin-table-surface">
<DataGrid id={`admin-system-${kind}-templates-v3`} rows={items} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText={`No central ${kind} templates found.`} />
</div>
</AdminPageLayout>
<Dialog
open={editing !== null}
title={editing === "new" ? (kind === "group" ? "Create group template" : "Create tenant role") : (kind === "group" ? "Edit group template" : "Edit tenant role")}
onClose={() => !busy && setEditing(null)}
className="admin-dialog admin-dialog-wide"
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : kind === "group" ? "Save template" : "Save tenant role"}</Button></>}
>
<div className="admin-form-grid two-columns">
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
</div>
{kind === "role" && <div className="form-field"><span className="form-label">Tenant permissions</span><AdminSelectionList options={permissions.map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))} selected={draft.permissions} onChange={(next) => setDraft({ ...draft, permissions: next })} /></div>}
<div className="form-field">
<span className="form-label">Tenant availability</span>
<div className="admin-selection-list admin-governance-mode">
{tenants.map((tenant) => <div className="admin-tenant-assignment-row" key={tenant.id}><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span><select value={assignmentMode(tenant.id)} onChange={(event) => setAssignment(tenant.id, event.target.value as "none" | "available" | "required")}><option value="none">Not available</option><option value="available">Available</option><option value="required">Required</option></select></div>)}
</div>
<p className="muted small-note">Required means the definition must remain present and system-controlled. It does not automatically assign users or grant permissions.</p>
</div>
</Dialog>
<Dialog open={Boolean(viewing)} title={viewing?.name || "Template details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
{viewing && <dl className="admin-details-grid"><div><dt>Kind</dt><dd>{viewing.kind}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Tenants</dt><dd>{viewing.assignments.length || "None"}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Permissions</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
</Dialog>
<ConfirmDialog open={Boolean(deleting)} title={kind === "group" ? "Delete group template" : "Delete tenant role"} message={`Delete ${deleting?.name}? Removal is blocked while a materialized tenant definition still has members or assignments.`} confirmLabel={kind === "group" ? "Delete template" : "Delete tenant role"} tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
</>
);
}

View File

@@ -0,0 +1,638 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { AdminPageLayout, adminErrorMessage, Button, dispatchPlatformModulesChanged, StatusBadge, ToggleSwitch } from "@govoplan/core-webui";
import {
cancelModuleInstallerRequest,
clearModuleInstallPlan,
createModuleInstallerRequest,
fetchModuleCatalog,
fetchModuleInstallPlan,
fetchModuleInstallerRequests,
fetchModuleInstallerRuns,
fetchModulePackageCatalog,
planModuleInstallFromCatalog,
planModuleUninstall,
retryModuleInstallerRequest,
updateModuleInstallPlan,
updateModuleState,
type ModuleCatalogItem,
type ModuleCatalogResponse,
type ModuleInstallerRequestListResponse,
type ModuleInstallerRequestOptions,
type ModuleInstallerRunListResponse,
type ModuleInstallPlanItem,
type ModuleInstallPlanResponse,
type ModulePackageCatalogResponse,
type ModulePackageCatalogItem
} from "../../api/admin";
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);
const [installerRuns, setInstallerRuns] = useState<ModuleInstallerRunListResponse | null>(null);
const [installerRequests, setInstallerRequests] = useState<ModuleInstallerRequestListResponse | null>(null);
const [packageCatalog, setPackageCatalog] = useState<ModulePackageCatalogResponse | null>(null);
const [draftEnabled, setDraftEnabled] = useState<Set<string> | null>(null);
const [draftPlanItems, setDraftPlanItems] = useState<ModuleInstallPlanItem[]>([]);
const [requestOptions, setRequestOptions] = useState<InstallerRequestFormOptions>(() => defaultInstallerRequestOptions());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [planBusy, setPlanBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [loaded, loadedPlan, loadedRuns, loadedRequests, loadedPackageCatalog] = await Promise.all([
fetchModuleCatalog(settings),
fetchModuleInstallPlan(settings),
fetchModuleInstallerRuns(settings),
fetchModuleInstallerRequests(settings),
fetchModulePackageCatalog(settings)
]);
setCatalog(loaded);
setInstallPlan(loadedPlan);
setInstallerRuns(loadedRuns);
setInstallerRequests(loadedRequests);
setPackageCatalog(loadedPackageCatalog);
setDraftEnabled(new Set(loaded.desired_enabled));
setDraftPlanItems(loadedPlan.items);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
const desired = draftEnabled ?? new Set(catalog?.desired_enabled ?? []);
const dirty = Boolean(catalog && !sameSet(desired, new Set(catalog.desired_enabled)));
const pendingCount = catalog?.modules.filter((module) => module.current_enabled !== desired.has(module.id)).length ?? 0;
const activeCount = catalog?.modules.filter((module) => module.current_enabled).length ?? 0;
const desiredCount = desired.size;
const maintenanceEnabled = Boolean(catalog?.maintenance_mode.enabled || installPlan?.maintenance_mode.enabled);
const planDirty = Boolean(installPlan && JSON.stringify(normalizePlanItems(draftPlanItems)) !== JSON.stringify(normalizePlanItems(installPlan.items)));
const planValid = planValidationError(draftPlanItems) === "";
function setModuleDesired(module: ModuleCatalogItem, enabled: boolean) {
if (module.protected) return;
setSuccess("");
setDraftEnabled((current) => {
const next = new Set(current ?? catalog?.desired_enabled ?? []);
if (enabled) next.add(module.id);
else next.delete(module.id);
return next;
});
}
async function save() {
if (!draftEnabled) return;
setBusy(true);
setError("");
setSuccess("");
try {
const updated = await updateModuleState(settings, Array.from(draftEnabled).sort());
setCatalog(updated);
setDraftEnabled(new Set(updated.desired_enabled));
setSuccess(updated.notes[0] ?? "Module state saved.");
dispatchPlatformModulesChanged();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy(false);
}
}
async function savePlan() {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await updateModuleInstallPlan(settings, normalizePlanItems(draftPlanItems));
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Module install plan saved.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function clearPlan() {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await clearModuleInstallPlan(settings);
setInstallPlan(updated);
setDraftPlanItems([]);
setSuccess(updated.notes[0] ?? "Module install plan cleared.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
function updatePlanItem(index: number, patch: Partial<ModuleInstallPlanItem>) {
setSuccess("");
setDraftPlanItems((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function addPlanItem() {
setDraftPlanItems((items) => [...items, emptyPlanItem()]);
}
async function addCatalogItem(item: ModulePackageCatalogItem) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await planModuleInstallFromCatalog(settings, item.module_id);
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Catalog install entry planned.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function addUninstallPlan(module: ModuleCatalogItem) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const updated = await planModuleUninstall(settings, module.id);
setInstallPlan(updated);
setDraftPlanItems(updated.items);
setSuccess(updated.notes[0] ?? "Package uninstall planned.");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function queueInstallerRequest() {
if (!installPlan) return;
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await createModuleInstallerRequest(settings, normalizeRequestOptions(requestOptions, installPlan));
setSuccess(`Installer request queued: ${request.request_id}`);
const [loadedRuns, loadedRequests] = await Promise.all([
fetchModuleInstallerRuns(settings),
fetchModuleInstallerRequests(settings)
]);
setInstallerRuns(loadedRuns);
setInstallerRequests(loadedRequests);
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function cancelInstallerRequest(requestId: string) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await cancelModuleInstallerRequest(settings, requestId);
setSuccess(`Installer request cancelled: ${request.request_id}`);
setInstallerRequests(await fetchModuleInstallerRequests(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
async function retryInstallerRequest(requestId: string) {
setPlanBusy(true);
setError("");
setSuccess("");
try {
const request = await retryModuleInstallerRequest(settings, requestId);
setSuccess(`Installer request queued: ${request.request_id}`);
setInstallerRequests(await fetchModuleInstallerRequests(settings));
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setPlanBusy(false);
}
}
return (
<AdminPageLayout
title="System modules"
description="Installed modules, active runtime state, and saved startup state."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading || busy}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || !dirty || busy}>{busy ? "Applying..." : "Save and apply"}</Button></>}
>
{catalog && <>
<div className="metric-grid module-management-metrics">
<ModuleMetric label="Installed" value={catalog.modules.length} detail="Discovered packages" />
<ModuleMetric label="Active" value={activeCount} detail="Running registry" />
<ModuleMetric label="Saved" value={desiredCount} detail="Startup state" />
<ModuleMetric label="Drift" value={pendingCount} detail={pendingCount ? "Runtime differs" : "Runtime matches"} tone={pendingCount ? "warning" : "good"} />
<ModuleMetric label="Maintenance" value={maintenanceEnabled ? "On" : "Off"} detail={canAccessMaintenance ? "Bypass allowed" : "Bypass denied"} tone={maintenanceEnabled ? "warning" : "info"} />
</div>
<div className="module-management-list">
{catalog.modules.map((module) => {
const desiredEnabled = desired.has(module.id);
return (
<div key={module.id} className={`module-management-row${module.restart_required || module.current_enabled !== desiredEnabled ? " pending" : ""}`}>
<div className="module-management-main">
<div className="module-management-title">
<strong>{module.name}</strong>
<code>{module.id}</code>
<span>v{module.version}</span>
</div>
<div className="module-management-meta">
<ModuleStatus module={module} desiredEnabled={desiredEnabled} />
{module.protected && <StatusBadge status="locked" label="Locked" />}
{module.frontend_package && <span>{module.frontend_package}</span>}
{module.migration_module_id && <span>DB: {module.migration_module_id}</span>}
</div>
<div className="module-management-details">
<span>Requires: {module.dependencies.length ? module.dependencies.join(", ") : "none"}</span>
<span>Optional: {module.optional_dependencies.length ? module.optional_dependencies.join(", ") : "none"}</span>
<span>Dependents: {module.dependents.length ? module.dependents.join(", ") : "none"}</span>
</div>
</div>
<div className="module-management-toggle">
<ToggleSwitch
label={desiredEnabled ? "Enabled" : "Disabled"}
checked={desiredEnabled}
disabled={!canWrite || module.protected || busy}
onChange={(checked) => setModuleDesired(module, checked)}
/>
{module.install_uninstall_supported && <Button onClick={() => void addUninstallPlan(module)} disabled={!canWrite || planBusy || module.current_enabled || module.desired_enabled}>Plan uninstall</Button>}
</div>
</div>
);
})}
</div>
{catalog.notes.length > 0 && <div className="module-management-notes">
{catalog.notes.map((note) => <p key={note}>{note}</p>)}
</div>}
{packageCatalog && <div className="module-package-catalog">
<div className="module-install-plan-heading">
<div>
<h2>Package catalog</h2>
<p>Approved package references that can be added to the operator install plan.</p>
</div>
<div className="button-row compact-actions">
{packageCatalog.channel && <StatusBadge status="inactive" label={`Channel: ${packageCatalog.channel}`} />}
{packageCatalog.sequence !== null && packageCatalog.sequence !== undefined && <StatusBadge status="inactive" label={`Seq ${packageCatalog.sequence}`} />}
<StatusBadge
status={packageCatalog.trusted ? "success" : packageCatalog.signed ? "warning" : "inactive"}
label={packageCatalog.trusted ? "Signed and trusted" : packageCatalog.signed ? "Signed, untrusted" : "Unsigned"}
/>
</div>
</div>
{(packageCatalog.source || packageCatalog.path) && <p className="module-package-catalog-description">Catalog {packageCatalog.source_type ?? "source"}: <code>{packageCatalog.source ?? packageCatalog.path}</code></p>}
{(packageCatalog.generated_at || packageCatalog.expires_at) && <p className="module-package-catalog-description">Generated: {packageCatalog.generated_at ?? "unknown"} · Expires: {packageCatalog.expires_at ?? "unknown"}</p>}
{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">No package catalog configured. Set GOVOPLAN_MODULE_PACKAGE_CATALOG or GOVOPLAN_MODULE_PACKAGE_CATALOG_URL to enable curated install entries.</div>}
{packageCatalog.configured && packageCatalog.modules.length === 0 && !packageCatalog.error && <div className="module-install-plan-empty">Package catalog is configured but contains no entries.</div>}
{packageCatalog.modules.length > 0 && <div className="module-package-catalog-list">
{packageCatalog.modules.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>}
</div>
<div className="module-management-details">
{item.python_package && <span>{item.python_package}</span>}
{item.webui_package && <span>{item.webui_package}</span>}
{item.tags.length > 0 && <span>{item.tags.join(", ")}</span>}
{item.license_features.length > 0 && <span>License: {item.license_features.join(", ")}</span>}
{item.license_missing_features.length > 0 && <span>Missing: {item.license_missing_features.join(", ")}</span>}
</div>
{item.description && <p className="module-package-catalog-description">{item.description}</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 || item.action !== "install"}>Plan install</Button>
</div>
))}
</div>}
</div>}
{installPlan && <div className="module-install-plan-panel">
<div className="module-install-plan-heading">
<div>
<h2>Operator install plan</h2>
<p>Plan package installs and removals here, then apply the rendered commands from an operator shell during maintenance mode.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={addPlanItem} disabled={!canWrite || planBusy}>Add plan item</Button>
<Button onClick={() => void clearPlan()} disabled={!canWrite || planBusy || draftPlanItems.length === 0}>Clear</Button>
<Button variant="primary" onClick={() => void savePlan()} disabled={!canWrite || planBusy || !planDirty || !planValid}>{planBusy ? "Saving..." : "Save plan"}</Button>
</div>
</div>
{planValidationError(draftPlanItems) && <p className="alert warning">{planValidationError(draftPlanItems)}</p>}
{!maintenanceEnabled && <p className="alert warning">Maintenance mode is off. Package changes should be applied only after enabling maintenance mode.</p>}
{installPlan.preflight && <div className={`module-install-preflight ${installPlan.preflight.allowed ? "is-allowed" : "is-blocked"}`}>
<div className="module-install-preflight-title">
<strong>{installPlan.preflight.allowed ? "Installer preflight passed" : "Installer preflight blocked"}</strong>
<span>{installPlan.preflight.restart_required ? "Restart/reload required after package changes" : "No package restart pending"}</span>
{installPlan.preflight.frontend_rebuild_required && <span>WebUI rebuild required</span>}
</div>
{installPlan.preflight.issues.length > 0 && <div className="module-install-preflight-issues">
{installPlan.preflight.issues.map((issue, index) => (
<div key={`${issue.code}-${issue.module_id ?? "global"}-${index}`} className={`module-install-preflight-issue severity-${issue.severity}`}>
<strong>{issue.severity}</strong>
<span>{issue.module_id ? `${issue.module_id}: ` : ""}{issue.message}</span>
</div>
))}
</div>}
{installPlan.preflight.checklist.length > 0 && <div className="module-install-checklist">
{installPlan.preflight.checklist.map((item) => (
<div key={item.id} className={`module-install-checklist-item status-${item.status}`}>
<strong>{item.label}</strong>
<span>{item.status}</span>
{item.detail && <p>{item.detail}</p>}
</div>
))}
</div>}
</div>}
<div className="module-install-plan-list">
{draftPlanItems.length === 0 && <div className="module-install-plan-empty">No package changes planned.</div>}
{draftPlanItems.map((item, index) => (
<div className="module-install-plan-row" key={`${item.module_id}-${index}`}>
<label><span>Action</span><select value={item.action} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { action: event.target.value as ModuleInstallPlanItem["action"] })}><option value="install">Install</option><option value="uninstall">Uninstall</option></select></label>
<label><span>Module</span><input value={item.module_id} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { module_id: event.target.value })} placeholder="files" /></label>
<label><span>Python package</span><input value={item.python_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { python_package: event.target.value })} placeholder="govoplan-files" /></label>
<ToggleSwitch label="Destroy data" checked={Boolean(item.destroy_data)} onChange={(checked) => updatePlanItem(index, { destroy_data: checked })} disabled={!canWrite || planBusy || item.action !== "uninstall"} />
<label className="wide"><span>Python ref</span><input value={item.python_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { python_ref: event.target.value })} placeholder="govoplan-files @ git+ssh://...@v0.1.4" /></label>
<label><span>WebUI package</span><input value={item.webui_package ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { webui_package: event.target.value })} placeholder="@govoplan/files-webui" /></label>
<label className="wide"><span>WebUI ref</span><input value={item.webui_ref ?? ""} disabled={!canWrite || planBusy || item.action === "uninstall"} onChange={(event) => updatePlanItem(index, { webui_ref: event.target.value })} placeholder="git+ssh://...#v0.1.4" /></label>
<label><span>Status</span><select value={item.status} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { status: event.target.value as ModuleInstallPlanItem["status"] })}><option value="planned">Planned</option><option value="applied">Applied</option><option value="blocked">Blocked</option></select></label>
<label className="wide"><span>Notes</span><input value={item.notes ?? ""} disabled={!canWrite || planBusy} onChange={(event) => updatePlanItem(index, { notes: event.target.value })} /></label>
<div className="module-install-plan-actions"><Button onClick={() => setDraftPlanItems((items) => items.filter((_, itemIndex) => itemIndex !== index))} disabled={!canWrite || planBusy}>Remove</Button></div>
</div>
))}
</div>
<pre className="code-panel module-install-plan-commands">{installerCommand(installPlan.preflight?.frontend_rebuild_required ?? false)}</pre>
{installPlan.preflight?.commands.length ? <pre className="code-panel module-install-plan-commands">{installPlan.preflight.commands.join("\n")}</pre> : installPlan.commands.length > 0 && <pre className="code-panel module-install-plan-commands">{installPlan.commands.join("\n")}</pre>}
{installPlan.notes.length > 0 && <div className="module-management-notes">{installPlan.notes.map((note) => <p key={note}>{note}</p>)}</div>}
<div className="module-installer-request-panel">
<div className="module-install-plan-heading">
<div>
<h2>Daemon execution</h2>
<p>Queue the saved plan for a separate installer daemon process.</p>
</div>
<Button variant="primary" onClick={() => void queueInstallerRequest()} disabled={!canWrite || !canAccessMaintenance || planBusy || planDirty || !maintenanceEnabled || !installPlan.preflight?.allowed}>
Queue supervised run
</Button>
</div>
{planDirty && <p className="alert warning">Save the install plan before queueing a daemon request.</p>}
{!maintenanceEnabled && <p className="alert warning">Maintenance mode must be enabled before queueing a daemon request.</p>}
{!canAccessMaintenance && <p className="alert warning">Queueing installer requests requires maintenance access.</p>}
<div className="module-installer-request-grid">
<ToggleSwitch label="Run migrations" checked={requestOptions.migrateDatabase} onChange={(checked) => setRequestOptions((current) => ({ ...current, migrateDatabase: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Build WebUI" checked={requestOptions.buildWebui} onChange={(checked) => setRequestOptions((current) => ({ ...current, buildWebui: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Activate installs" checked={requestOptions.activateInstalledModules} onChange={(checked) => setRequestOptions((current) => ({ ...current, activateInstalledModules: checked }))} disabled={!canWrite || planBusy} />
<ToggleSwitch label="Drop uninstalls from startup" checked={requestOptions.removeUninstalledModulesFromDesired} onChange={(checked) => setRequestOptions((current) => ({ ...current, removeUninstalledModulesFromDesired: checked }))} disabled={!canWrite || planBusy} />
<label><span>Health URLs</span><textarea value={requestOptions.healthUrls} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, healthUrls: event.target.value }))} placeholder="http://127.0.0.1:8000/health" /></label>
<label><span>Restart commands</span><textarea value={requestOptions.restartCommands} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, restartCommands: event.target.value }))} placeholder="systemctl restart govoplan.service" /></label>
<label><span>DB backup command</span><textarea value={requestOptions.databaseBackupCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseBackupCommand: event.target.value }))} placeholder={"pg_dump --format=custom \"$GOVOPLAN_DATABASE_URL\" > \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
<label><span>DB restore command</span><textarea value={requestOptions.databaseRestoreCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCommand: event.target.value }))} placeholder={"pg_restore --clean --if-exists --dbname \"$GOVOPLAN_DATABASE_URL\" \"$GOVOPLAN_DATABASE_BACKUP_PATH\""} /></label>
<label><span>DB restore-check command</span><textarea value={requestOptions.databaseRestoreCheckCommand} disabled={!canWrite || planBusy} onChange={(event) => setRequestOptions((current) => ({ ...current, databaseRestoreCheckCommand: event.target.value }))} placeholder={"pg_restore --list \"$GOVOPLAN_DATABASE_BACKUP_PATH\" >/dev/null"} /></label>
</div>
</div>
</div>}
{installerRequests && <div className="module-installer-runs">
<div className="module-installer-runs-heading">
<div>
<h2>Installer requests</h2>
<p>Queued daemon handoffs created from the admin UI or CLI.</p>
</div>
<StatusBadge
status={installerRequests.daemon.running ? statusTone(installerRequests.daemon.status) : "inactive"}
label={installerRequests.daemon.running ? `Daemon: ${installerRequests.daemon.status}` : "Daemon offline"}
/>
</div>
{installerRequests.requests.length === 0 && <div className="module-install-plan-empty">No installer requests recorded yet.</div>}
{installerRequests.requests.length > 0 && <div className="module-installer-run-list">
{installerRequests.requests.map((request) => (
<div key={request.request_id} className="module-installer-run-row">
<div className="module-installer-run-main">
<div className="module-installer-run-title">
<strong>{request.request_id}</strong>
<StatusBadge status={statusTone(request.status)} label={request.status} />
</div>
<div className="module-management-details">
<span>Created: {formatDateTime(request.created_at)}</span>
<span>Started: {formatDateTime(request.started_at)}</span>
<span>Finished: {formatDateTime(request.finished_at)}</span>
{request.cancelled_at && <span>Cancelled: {formatDateTime(request.cancelled_at)}</span>}
{request.retry_of && <span>Retry of: {request.retry_of}</span>}
</div>
{request.error && <p className="module-installer-run-error">{request.error}</p>}
</div>
<div className="module-installer-run-actions">
{request.status === "queued" && <Button onClick={() => void cancelInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Cancel</Button>}
{(request.status === "failed" || request.status === "cancelled") && <Button onClick={() => void retryInstallerRequest(request.request_id)} disabled={!canWrite || !canAccessMaintenance || !maintenanceEnabled || planBusy}>Retry</Button>}
{request.record_path && <code>{request.record_path}</code>}
</div>
</div>
))}
</div>}
</div>}
{installerRuns && <div className="module-installer-runs">
<div className="module-installer-runs-heading">
<div>
<h2>Installer runs</h2>
<p>Recent supervised installer records and the current package-install lock.</p>
</div>
<StatusBadge status={installerRuns.lock.locked ? "warning" : "success"} label={installerRuns.lock.locked ? "Locked" : "Unlocked"} />
</div>
{installerRuns.runs.length === 0 && <div className="module-install-plan-empty">No installer runs recorded yet.</div>}
{installerRuns.runs.length > 0 && <div className="module-installer-run-list">
{installerRuns.runs.map((run) => (
<div key={run.run_id} className="module-installer-run-row">
<div className="module-installer-run-main">
<div className="module-installer-run-title">
<strong>{run.run_id}</strong>
<StatusBadge status={statusTone(run.status)} label={run.status} />
{run.supervisor_status && <StatusBadge status={statusTone(run.supervisor_status)} label={`Supervisor: ${run.supervisor_status}`} />}
{run.rollback_status && <StatusBadge status={statusTone(run.rollback_status)} label={`Rollback: ${run.rollback_status}`} />}
</div>
<div className="module-management-details">
<span>Modules: {run.planned_modules.length ? run.planned_modules.join(", ") : "none"}</span>
<span>Commands: {run.commands_count}</span>
<span>Started: {formatDateTime(run.started_at)}</span>
<span>Finished: {formatDateTime(run.finished_at)}</span>
</div>
{run.error && <p className="module-installer-run-error">{run.error}</p>}
</div>
<code>{run.record_path}</code>
</div>
))}
</div>}
</div>}
</>}
</AdminPageLayout>
);
}
function ModuleMetric({ label, value, detail, tone = "info" }: { label: string; value: string | number; detail: string; tone?: "info" | "good" | "warning" }) {
return <div className={`metric-card metric-${tone}`}><div className="metric-label">{label}</div><div className="metric-value">{value}</div><div className="metric-detail">{detail}</div></div>;
}
function ModuleStatus({ module, desiredEnabled }: { module: ModuleCatalogItem; desiredEnabled: boolean }) {
if (module.current_enabled && !desiredEnabled) return <StatusBadge status="warning" label="Will disable" />;
if (!module.current_enabled && desiredEnabled) return <StatusBadge status="warning" label="Will enable" />;
if (module.current_enabled) return <StatusBadge status="success" label="Active" />;
return <StatusBadge status="inactive" label="Inactive" />;
}
function sameSet(left: ReadonlySet<string>, right: ReadonlySet<string>) {
if (left.size !== right.size) return false;
for (const item of left) {
if (!right.has(item)) return false;
}
return true;
}
function emptyPlanItem(): ModuleInstallPlanItem {
return {
module_id: "",
action: "install",
python_package: "",
python_ref: "",
webui_package: "",
webui_ref: "",
destroy_data: false,
status: "planned",
notes: ""
};
}
function normalizePlanItems(items: ModuleInstallPlanItem[]): ModuleInstallPlanItem[] {
return items
.map((item) => ({
module_id: item.module_id.trim(),
action: item.action,
python_package: cleanOptional(item.python_package),
python_ref: item.action === "install" ? cleanOptional(item.python_ref) : null,
webui_package: cleanOptional(item.webui_package),
webui_ref: item.action === "install" ? cleanOptional(item.webui_ref) : null,
destroy_data: item.action === "uninstall" && Boolean(item.destroy_data),
status: item.status,
notes: cleanOptional(item.notes)
}))
.filter((item) => item.module_id || item.python_package || item.python_ref || item.webui_package || item.webui_ref || item.notes);
}
function cleanOptional(value?: string | null): string | null {
const cleaned = (value ?? "").trim();
return cleaned || null;
}
function planValidationError(items: ModuleInstallPlanItem[]): string {
for (const item of normalizePlanItems(items)) {
if (!item.module_id) return "Every plan item needs a module id.";
if (item.action === "install" && !item.python_ref) return `${item.module_id} needs a Python package reference.`;
if (item.action === "install" && item.python_ref && !item.python_package) return `${item.module_id} needs a Python package name for rollback.`;
if (item.action === "uninstall" && !item.python_package) return `${item.module_id} needs a Python package name for uninstall.`;
if (item.action === "install" && Boolean(item.webui_package) !== Boolean(item.webui_ref)) return `${item.module_id} needs both WebUI package and WebUI reference, or neither.`;
if (item.action === "uninstall" && item.webui_ref && !item.webui_package) return `${item.module_id} has a WebUI reference but no WebUI package.`;
}
return "";
}
function statusTone(status: string): string {
const normalized = status.toLowerCase();
if (normalized.includes("fail") || normalized.includes("blocked")) return "error";
if (normalized.includes("rollback") || normalized.includes("running") || normalized.includes("dry")) return "warning";
if (normalized.includes("applied") || normalized.includes("ok") || normalized.includes("unlock")) return "success";
return "inactive";
}
function formatDateTime(value?: string | null): string {
if (!value) return "not recorded";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function installerCommand(frontendRebuildRequired: boolean): string {
const buildFlag = frontendRebuildRequired ? " --build-webui" : "";
return [
"govoplan-module-installer --format shell",
`govoplan-module-installer --daemon --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`,
`govoplan-module-installer --enqueue-supervised --migrate${buildFlag} --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
].join("\n");
}
type InstallerRequestFormOptions = {
buildWebui: boolean;
migrateDatabase: boolean;
healthUrls: string;
restartCommands: string;
databaseBackupCommand: string;
databaseRestoreCommand: string;
databaseRestoreCheckCommand: string;
activateInstalledModules: boolean;
removeUninstalledModulesFromDesired: boolean;
};
function defaultInstallerRequestOptions(): InstallerRequestFormOptions {
return {
buildWebui: false,
migrateDatabase: true,
healthUrls: "http://127.0.0.1:8000/health",
restartCommands: "",
databaseBackupCommand: "",
databaseRestoreCommand: "",
databaseRestoreCheckCommand: "",
activateInstalledModules: true,
removeUninstalledModulesFromDesired: true
};
}
function normalizeRequestOptions(form: InstallerRequestFormOptions, installPlan: ModuleInstallPlanResponse): ModuleInstallerRequestOptions {
return {
build_webui: form.buildWebui || Boolean(installPlan.preflight?.frontend_rebuild_required),
migrate_database: form.migrateDatabase,
restart_commands: splitLines(form.restartCommands),
health_urls: splitLines(form.healthUrls),
database_backup_command: cleanOptional(form.databaseBackupCommand),
database_restore_command: cleanOptional(form.databaseRestoreCommand),
database_restore_check_command: cleanOptional(form.databaseRestoreCheckCommand),
activate_installed_modules: form.activateInstalledModules,
remove_uninstalled_modules_from_desired: form.removeUninstalledModulesFromDesired,
health_timeout_seconds: 60,
health_interval_seconds: 2
};
}
function splitLines(value: string): string[] {
return value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
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 { fetchSystemSettings, updateSystemSettings, type PrivacyRetentionLimitPermissions, type PrivacyRetentionPolicy, type SystemSettingsItem } from "../../api/admin";
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
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: "en",
allow_tenant_custom_groups: true,
allow_tenant_custom_roles: true,
allow_tenant_api_keys: true,
privacy_retention_policy: defaultPrivacyPolicy,
maintenance_mode: { enabled: false, message: "" },
settings: {}
};
export default function SystemSettingsPanel({ settings, canWrite, canAccessMaintenance }: { settings: ApiSettings; canWrite: boolean; canAccessMaintenance: boolean }) {
const [draft, setDraft] = useState<SystemSettingsItem>(fallback);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const loaded = await fetchSystemSettings(settings);
setDraft(loaded);
}
catch (err) { setError(adminErrorMessage(err)); }
finally { setLoading(false); }
}
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
async function save() {
setBusy(true);
setError("");
try {
setDraft(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,
maintenance_mode: draft.maintenance_mode
}));
setSuccess("System settings saved.");
} catch (err) { setError(adminErrorMessage(err)); }
finally { setBusy(false); }
}
return (
<AdminPageLayout
title="System general settings"
description="Instance-wide defaults and tenant governance capabilities. Retention policy management has its own system section."
loading={loading}
error={error}
success={success}
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy}>{busy ? "Saving…" : "Save settings"}</Button></>}
>
<div className="admin-settings-form">
<Card title="Defaults for newly created tenants">
<FormField label="Default locale"><input value={draft.default_locale} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} /></FormField>
</Card>
<Card title="Tenant administration capabilities">
<div className="settings-list">
<ToggleSwitch checked={draft.allow_tenant_custom_groups} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_groups: checked })} label="Allow tenant-defined groups by default" />
<ToggleSwitch checked={draft.allow_tenant_custom_roles} onChange={(checked) => setDraft({ ...draft, allow_tenant_custom_roles: checked })} label="Allow tenant-defined roles by default" />
<ToggleSwitch checked={draft.allow_tenant_api_keys} onChange={(checked) => setDraft({ ...draft, allow_tenant_api_keys: checked })} label="Allow tenant API keys by default" />
</div>
<p className="muted small-note">These settings are enforced by the backend. Central groups and tenant roles remain available even when local creation is disabled.</p>
</Card>
<Card title="Maintenance mode">
<div className="settings-list">
<ToggleSwitch
checked={draft.maintenance_mode.enabled}
disabled={!canWrite || !canAccessMaintenance}
onChange={(checked) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, enabled: checked } })}
label="Restrict authenticated API access to maintenance operators"
/>
</div>
<FormField label="Maintenance message">
<textarea
rows={3}
value={draft.maintenance_mode.message ?? ""}
disabled={!canWrite}
onChange={(event) => setDraft({ ...draft, maintenance_mode: { ...draft.maintenance_mode, message: event.target.value } })}
/>
</FormField>
<p className="muted small-note">Changing the maintenance-mode flag requires system:maintenance:access. Login remains available so an operator can sign in during maintenance.</p>
</Card>
</div>
</AdminPageLayout>
);
}

7
webui/src/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export { default } from "./module";
export * from "./module";
export * from "./api/admin";
export { default as AdminOverviewPanel } from "./features/admin/AdminOverviewPanel";
export { default as GovernanceTemplatesPanel } from "./features/admin/GovernanceTemplatesPanel";
export { default as SystemSettingsPanel } from "./features/admin/SystemSettingsPanel";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

87
webui/src/module.ts Normal file
View File

@@ -0,0 +1,87 @@
import { createElement, lazy } from "react";
import type { AdminSectionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { adminReadScopes, hasScope } from "@govoplan/core-webui";
const AdminOverviewPanel = lazy(() => import("./features/admin/AdminOverviewPanel"));
const GovernanceTemplatesPanel = lazy(() => import("./features/admin/GovernanceTemplatesPanel"));
const ModuleManagementPanel = lazy(() => import("./features/admin/ModuleManagementPanel"));
const SystemSettingsPanel = lazy(() => import("./features/admin/SystemSettingsPanel"));
const adminSections: AdminSectionsUiCapability = {
sections: [
{
id: "overview",
label: "Overview",
group: "ROOT",
order: 0,
anyOf: adminReadScopes,
render: ({ settings, availableSections, selectSection }) => createElement(AdminOverviewPanel, {
settings,
availableSections,
onSelect: selectSection
})
},
{
id: "system-settings",
label: "General",
group: "SYSTEM",
order: 10,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(SystemSettingsPanel, {
settings,
canWrite: hasScope(auth, "system:settings:write"),
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
})
},
{
id: "system-role-templates",
label: "Tenant roles",
group: "SYSTEM",
order: 40,
allOf: ["system:governance:read"],
render: ({ settings, auth, refreshAuth }) => createElement(GovernanceTemplatesPanel, {
settings,
kind: "role",
canWrite: hasScope(auth, "system:governance:write"),
onAuthRefresh: refreshAuth
})
},
{
id: "system-modules",
label: "Modules",
group: "SYSTEM",
order: 85,
allOf: ["system:settings:read"],
render: ({ settings, auth }) => createElement(ModuleManagementPanel, {
settings,
canWrite: hasScope(auth, "system:settings:write"),
canAccessMaintenance: hasScope(auth, "system:maintenance:access")
})
},
{
id: "system-groups",
label: "Groups",
group: "SYSTEM",
order: 50,
allOf: ["system:governance:read"],
render: ({ settings, auth, refreshAuth }) => createElement(GovernanceTemplatesPanel, {
settings,
kind: "group",
canWrite: hasScope(auth, "system:governance:write"),
onAuthRefresh: refreshAuth
})
}
]
};
export const adminModule: PlatformWebModule = {
id: "admin",
label: "Admin",
version: "1.0.0",
dependencies: ["access"],
uiCapabilities: {
"admin.sections": adminSections
}
};
export default adminModule;