Add governed module and interface controls
This commit is contained in:
@@ -834,6 +834,24 @@ the shared loading and retryable error state around route rendering. The
|
|||||||
initial static import closure and largest asynchronous chunk are enforced by
|
initial static import closure and largest asynchronous chunk are enforced by
|
||||||
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
|
Every public platform interface has a stable declaration identity. Backend
|
||||||
|
routes, capabilities, interfaces, search providers/sources, permissions,
|
||||||
|
frontend routes/navigation, and View surfaces derive that identity from typed
|
||||||
|
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
|
||||||
|
admin sections, widgets, search contexts, and extension actions. Shared form
|
||||||
|
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
|
||||||
|
values when another contract, documentation topic, or automated check must
|
||||||
|
refer to the control across source changes. The static inventory assigns a
|
||||||
|
line-independent source anchor when an explicit ID is absent and reports that
|
||||||
|
fact for later review.
|
||||||
|
|
||||||
|
Core exposes the sanitized runtime declaration set at
|
||||||
|
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
|
||||||
|
`admin:module:read` or `system:settings:read`, and includes only modules
|
||||||
|
effective in the caller's active tenant context. It never serializes factories,
|
||||||
|
credentials, executable callbacks, or mutable module state. Registry validation
|
||||||
|
rejects conflicting declaration IDs before startup.
|
||||||
|
|
||||||
WebUI modules receive only the core route context:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
@@ -1239,6 +1257,40 @@ devserver, development bootstrap, background worker registry, and migration
|
|||||||
metadata plan all read the saved desired state from `system_settings` before
|
metadata plan all read the saved desired state from `system_settings` before
|
||||||
building their module registry.
|
building their module registry.
|
||||||
|
|
||||||
|
### Tenant entitlement and personal visibility
|
||||||
|
|
||||||
|
Deployment activation remains process-wide: one installed and active registry
|
||||||
|
is shared by every tenant served by that process. Tenant module selection is a
|
||||||
|
separate entitlement document in `core_scopes.settings.module_entitlements`:
|
||||||
|
|
||||||
|
- a system policy marks each installed module `unavailable`, `available`, or
|
||||||
|
`forced` for one tenant;
|
||||||
|
- the tenant selection may enable or disable only available modules;
|
||||||
|
- protected platform modules, forced modules, and transitive dependencies stay
|
||||||
|
effective;
|
||||||
|
- malformed explicit entitlement fails closed to protected modules, while an
|
||||||
|
absent document preserves the pre-entitlement behavior for upgraded tenants;
|
||||||
|
- an optimistic revision prevents concurrent system and tenant administrators
|
||||||
|
from silently replacing each other's changes.
|
||||||
|
|
||||||
|
The authenticated platform metadata and module route guard intersect global
|
||||||
|
runtime activation with the active tenant's effective entitlement. Entitlement
|
||||||
|
does not grant a permission. Access authorization must still allow every API
|
||||||
|
operation and resource.
|
||||||
|
|
||||||
|
Users and groups do not own another module-runtime state. Every WebUI module
|
||||||
|
already contributes a root `<module>.module` View surface, so personal and
|
||||||
|
group module visibility is expressed through Views. View policy controls who
|
||||||
|
may select, assign, edit, derive, or workflow-activate those projections;
|
||||||
|
required View assignments can retain required UI. Thus tenant entitlement owns
|
||||||
|
operational availability, Views own presentation, and Access owns authority.
|
||||||
|
|
||||||
|
Capability-style modules such as Encryption must keep activation separate from
|
||||||
|
domain data state. Making Encryption effective only exposes its capability and
|
||||||
|
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
|
||||||
|
an explicit versioned protection-policy operation owned by Encryption and the
|
||||||
|
module that owns the data.
|
||||||
|
|
||||||
Hot enable/disable is a core design principle for every module:
|
Hot enable/disable is a core design principle for every module:
|
||||||
|
|
||||||
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||||
|
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
|
||||||
|
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
|
||||||
|
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
|
||||||
|
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
|
||||||
|
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ def get_api_principal(
|
|||||||
authorization: str | None = Header(default=None),
|
authorization: str | None = Header(default=None),
|
||||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
) -> ApiPrincipal:
|
) -> ApiPrincipal:
|
||||||
|
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||||
|
if isinstance(cached, ApiPrincipal):
|
||||||
|
return cached
|
||||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||||
request,
|
request,
|
||||||
session,
|
session,
|
||||||
@@ -143,6 +146,7 @@ def get_api_principal(
|
|||||||
)
|
)
|
||||||
if not isinstance(principal, ApiPrincipal):
|
if not isinstance(principal, ApiPrincipal):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||||
|
request.state.govoplan_api_principal = principal
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ from collections.abc import Mapping, Sequence
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
||||||
|
from govoplan_core.core.module_entitlements import tenant_module_entitlement_state
|
||||||
from govoplan_core.core.module_lifecycle_recovery import (
|
from govoplan_core.core.module_lifecycle_recovery import (
|
||||||
ModuleLifecycleRecovery,
|
ModuleLifecycleRecovery,
|
||||||
begin_runtime_graph_recovery,
|
begin_runtime_graph_recovery,
|
||||||
@@ -18,7 +22,9 @@ from govoplan_core.core.runtime import configure_runtime
|
|||||||
from govoplan_core.core.workflows import (
|
from govoplan_core.core.workflows import (
|
||||||
workflow_definition_contribution_provider,
|
workflow_definition_contribution_provider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -31,12 +37,61 @@ class ModuleLifecycleResult:
|
|||||||
|
|
||||||
|
|
||||||
def require_module_active(module_id: str):
|
def require_module_active(module_id: str):
|
||||||
def dependency(request: Request) -> None:
|
def dependency(
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
|
) -> None:
|
||||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||||
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
|
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
|
||||||
return
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||||
|
|
||||||
|
# Public module routes must remain reachable without Access. When an
|
||||||
|
# authenticated request is present, cache its principal and apply the
|
||||||
|
# active tenant's module entitlement before the owning route executes.
|
||||||
|
if not authorization and not x_api_key and not request.cookies:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
principal = get_api_principal(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
authorization=authorization,
|
||||||
|
x_api_key=x_api_key,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code in {
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
}:
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tenant = session.get(Tenant, principal.principal.tenant_id)
|
||||||
|
except (RuntimeError, SQLAlchemyError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Tenant module entitlement could not be resolved.",
|
||||||
|
) from exc
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="The active tenant is unavailable.",
|
||||||
|
)
|
||||||
|
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
||||||
|
entitlement = tenant_module_entitlement_state(
|
||||||
|
tenant.settings or {},
|
||||||
|
manifests,
|
||||||
|
runtime_active_modules=manifests,
|
||||||
|
)
|
||||||
|
if module_id not in entitlement.effective_modules:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||||
|
)
|
||||||
|
|
||||||
return dependency
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
|
||||||
|
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
|
||||||
|
TENANT_PROTECTED_MODULES = ("access", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleEntitlementError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleEntitlementConflict(ModuleEntitlementError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantModuleItem:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
dependencies: tuple[str, ...]
|
||||||
|
runtime_active: bool
|
||||||
|
availability: str
|
||||||
|
selected: bool
|
||||||
|
effective: bool
|
||||||
|
forced: bool
|
||||||
|
derived_dependency: bool
|
||||||
|
tenant_can_toggle: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantModuleEntitlementState:
|
||||||
|
revision: int
|
||||||
|
configured: bool
|
||||||
|
available_modules: tuple[str, ...]
|
||||||
|
forced_modules: tuple[str, ...]
|
||||||
|
selected_modules: tuple[str, ...]
|
||||||
|
effective_modules: tuple[str, ...]
|
||||||
|
derived_dependencies: tuple[str, ...]
|
||||||
|
modules: tuple[TenantModuleItem, ...]
|
||||||
|
diagnostics: tuple[dict[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_module_entitlement_state(
|
||||||
|
settings: Mapping[str, object] | None,
|
||||||
|
manifests: Mapping[str, ModuleManifest],
|
||||||
|
*,
|
||||||
|
runtime_active_modules: Iterable[str] | None = None,
|
||||||
|
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||||
|
) -> TenantModuleEntitlementState:
|
||||||
|
module_ids = tuple(sorted(manifests))
|
||||||
|
known = set(module_ids)
|
||||||
|
runtime_active = (
|
||||||
|
known
|
||||||
|
if runtime_active_modules is None
|
||||||
|
else known.intersection(_normalized_ids(runtime_active_modules))
|
||||||
|
)
|
||||||
|
protected = known.intersection(_normalized_ids(protected_modules))
|
||||||
|
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
|
||||||
|
configured = isinstance(raw_document, Mapping)
|
||||||
|
diagnostics: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
if not configured:
|
||||||
|
revision = 0
|
||||||
|
requested_available = set(known)
|
||||||
|
requested_forced = set(protected)
|
||||||
|
requested_selected = set(known)
|
||||||
|
else:
|
||||||
|
document = raw_document
|
||||||
|
revision = _revision(document.get("revision"), diagnostics)
|
||||||
|
system_policy = document.get("system_policy")
|
||||||
|
tenant_selection = document.get("tenant_selection")
|
||||||
|
if not isinstance(system_policy, Mapping) or not isinstance(
|
||||||
|
tenant_selection, Mapping
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"module_entitlements.invalid_document",
|
||||||
|
"The tenant module entitlement document is malformed and was restricted to protected modules.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
requested_available = set(protected)
|
||||||
|
requested_forced = set(protected)
|
||||||
|
requested_selected = set()
|
||||||
|
else:
|
||||||
|
requested_available = _configured_ids(
|
||||||
|
system_policy.get("available_modules"),
|
||||||
|
field="system_policy.available_modules",
|
||||||
|
known=known,
|
||||||
|
fallback=protected,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
requested_forced = _configured_ids(
|
||||||
|
system_policy.get("forced_modules"),
|
||||||
|
field="system_policy.forced_modules",
|
||||||
|
known=known,
|
||||||
|
fallback=protected,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
requested_selected = _configured_ids(
|
||||||
|
tenant_selection.get("enabled_modules"),
|
||||||
|
field="tenant_selection.enabled_modules",
|
||||||
|
known=known,
|
||||||
|
fallback=(),
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
available, missing_available = _dependency_closure(
|
||||||
|
requested_available | requested_forced | protected,
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
forced, missing_forced = _dependency_closure(
|
||||||
|
requested_forced | protected,
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
selected = requested_selected.intersection(available)
|
||||||
|
effective_candidates, missing_selected = _dependency_closure(
|
||||||
|
selected | forced,
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
effective_candidates.intersection_update(available)
|
||||||
|
effective = effective_candidates.intersection(runtime_active)
|
||||||
|
derived = effective_candidates - selected - forced
|
||||||
|
|
||||||
|
for module_id in sorted(
|
||||||
|
missing_available | missing_forced | missing_selected
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"module_entitlements.missing_dependency",
|
||||||
|
f"A selected module requires unavailable dependency {module_id}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
items: list[TenantModuleItem] = []
|
||||||
|
for module_id in module_ids:
|
||||||
|
manifest = manifests[module_id]
|
||||||
|
is_available = module_id in available
|
||||||
|
is_forced = module_id in forced
|
||||||
|
is_selected = module_id in selected
|
||||||
|
is_derived = module_id in derived
|
||||||
|
is_runtime_active = module_id in runtime_active
|
||||||
|
is_effective = module_id in effective
|
||||||
|
reason: str | None = None
|
||||||
|
if not is_available:
|
||||||
|
reason = "Unavailable by system policy."
|
||||||
|
elif is_forced:
|
||||||
|
reason = "Required by system policy or a protected platform dependency."
|
||||||
|
elif is_derived:
|
||||||
|
reason = "Required by another selected module."
|
||||||
|
elif not is_runtime_active and (is_selected or is_forced):
|
||||||
|
reason = "Selected for this tenant, but the module is not active in the deployment."
|
||||||
|
items.append(
|
||||||
|
TenantModuleItem(
|
||||||
|
id=module_id,
|
||||||
|
name=manifest.name,
|
||||||
|
dependencies=tuple(manifest.dependencies),
|
||||||
|
runtime_active=is_runtime_active,
|
||||||
|
availability=(
|
||||||
|
"forced" if is_forced else "available" if is_available else "unavailable"
|
||||||
|
),
|
||||||
|
selected=is_selected,
|
||||||
|
effective=is_effective,
|
||||||
|
forced=is_forced,
|
||||||
|
derived_dependency=is_derived,
|
||||||
|
tenant_can_toggle=is_available and not is_forced and not is_derived,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TenantModuleEntitlementState(
|
||||||
|
revision=revision,
|
||||||
|
configured=configured,
|
||||||
|
available_modules=tuple(sorted(available)),
|
||||||
|
forced_modules=tuple(sorted(forced)),
|
||||||
|
selected_modules=tuple(sorted(selected)),
|
||||||
|
effective_modules=tuple(sorted(effective)),
|
||||||
|
derived_dependencies=tuple(sorted(derived)),
|
||||||
|
modules=tuple(items),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_system_tenant_module_policy(
|
||||||
|
settings: Mapping[str, object] | None,
|
||||||
|
manifests: Mapping[str, ModuleManifest],
|
||||||
|
*,
|
||||||
|
available_modules: Iterable[str],
|
||||||
|
forced_modules: Iterable[str],
|
||||||
|
enabled_modules: Iterable[str],
|
||||||
|
expected_revision: int | None,
|
||||||
|
runtime_active_modules: Iterable[str] | None = None,
|
||||||
|
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||||
|
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||||
|
current = tenant_module_entitlement_state(
|
||||||
|
settings,
|
||||||
|
manifests,
|
||||||
|
runtime_active_modules=runtime_active_modules,
|
||||||
|
protected_modules=protected_modules,
|
||||||
|
)
|
||||||
|
_check_revision(current.revision, expected_revision)
|
||||||
|
known = set(manifests)
|
||||||
|
available_requested = _validated_requested_ids(
|
||||||
|
available_modules, known=known, field="available_modules"
|
||||||
|
)
|
||||||
|
forced_requested = _validated_requested_ids(
|
||||||
|
forced_modules, known=known, field="forced_modules"
|
||||||
|
)
|
||||||
|
enabled_requested = _validated_requested_ids(
|
||||||
|
enabled_modules, known=known, field="enabled_modules"
|
||||||
|
)
|
||||||
|
protected = known.intersection(_normalized_ids(protected_modules))
|
||||||
|
available, missing = _dependency_closure(
|
||||||
|
available_requested | forced_requested | protected,
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
forced, forced_missing = _dependency_closure(
|
||||||
|
forced_requested | protected,
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
if missing or forced_missing:
|
||||||
|
missing_text = ", ".join(sorted(missing | forced_missing))
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
f"Module policy references dependencies that are not installed: {missing_text}"
|
||||||
|
)
|
||||||
|
unavailable_enabled = enabled_requested - available
|
||||||
|
if unavailable_enabled:
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
"Tenant selection contains modules unavailable by system policy: "
|
||||||
|
+ ", ".join(sorted(unavailable_enabled))
|
||||||
|
)
|
||||||
|
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
|
||||||
|
updated = _write_document(
|
||||||
|
settings,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
available_modules=available,
|
||||||
|
forced_modules=forced,
|
||||||
|
enabled_modules=enabled_requested,
|
||||||
|
)
|
||||||
|
return updated, tenant_module_entitlement_state(
|
||||||
|
updated,
|
||||||
|
manifests,
|
||||||
|
runtime_active_modules=runtime_active_modules,
|
||||||
|
protected_modules=protected_modules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_tenant_module_selection(
|
||||||
|
settings: Mapping[str, object] | None,
|
||||||
|
manifests: Mapping[str, ModuleManifest],
|
||||||
|
*,
|
||||||
|
enabled_modules: Iterable[str],
|
||||||
|
expected_revision: int | None,
|
||||||
|
runtime_active_modules: Iterable[str] | None = None,
|
||||||
|
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||||
|
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||||
|
current = tenant_module_entitlement_state(
|
||||||
|
settings,
|
||||||
|
manifests,
|
||||||
|
runtime_active_modules=runtime_active_modules,
|
||||||
|
protected_modules=protected_modules,
|
||||||
|
)
|
||||||
|
_check_revision(current.revision, expected_revision)
|
||||||
|
enabled = _validated_requested_ids(
|
||||||
|
enabled_modules,
|
||||||
|
known=set(manifests),
|
||||||
|
field="enabled_modules",
|
||||||
|
)
|
||||||
|
unavailable = enabled - set(current.available_modules)
|
||||||
|
if unavailable:
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
"Tenant selection contains modules unavailable by system policy: "
|
||||||
|
+ ", ".join(sorted(unavailable))
|
||||||
|
)
|
||||||
|
_validate_enabled_dependencies(
|
||||||
|
enabled | set(current.forced_modules),
|
||||||
|
set(current.available_modules),
|
||||||
|
manifests,
|
||||||
|
)
|
||||||
|
updated = _write_document(
|
||||||
|
settings,
|
||||||
|
revision=current.revision + 1,
|
||||||
|
available_modules=current.available_modules,
|
||||||
|
forced_modules=current.forced_modules,
|
||||||
|
enabled_modules=enabled,
|
||||||
|
)
|
||||||
|
return updated, tenant_module_entitlement_state(
|
||||||
|
updated,
|
||||||
|
manifests,
|
||||||
|
runtime_active_modules=runtime_active_modules,
|
||||||
|
protected_modules=protected_modules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def module_entitlement_payload(
|
||||||
|
tenant_id: str,
|
||||||
|
state: TenantModuleEntitlementState,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"revision": state.revision,
|
||||||
|
"configured": state.configured,
|
||||||
|
"available_modules": list(state.available_modules),
|
||||||
|
"forced_modules": list(state.forced_modules),
|
||||||
|
"selected_modules": list(state.selected_modules),
|
||||||
|
"effective_modules": list(state.effective_modules),
|
||||||
|
"derived_dependencies": list(state.derived_dependencies),
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"dependencies": list(item.dependencies),
|
||||||
|
"runtime_active": item.runtime_active,
|
||||||
|
"availability": item.availability,
|
||||||
|
"selected": item.selected,
|
||||||
|
"effective": item.effective,
|
||||||
|
"forced": item.forced,
|
||||||
|
"derived_dependency": item.derived_dependency,
|
||||||
|
"tenant_can_toggle": item.tenant_can_toggle,
|
||||||
|
"reason": item.reason,
|
||||||
|
}
|
||||||
|
for item in state.modules
|
||||||
|
],
|
||||||
|
"diagnostics": [dict(item) for item in state.diagnostics],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_document(
|
||||||
|
settings: Mapping[str, object] | None,
|
||||||
|
*,
|
||||||
|
revision: int,
|
||||||
|
available_modules: Iterable[str],
|
||||||
|
forced_modules: Iterable[str],
|
||||||
|
enabled_modules: Iterable[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
updated = dict(settings or {})
|
||||||
|
updated[MODULE_ENTITLEMENTS_KEY] = {
|
||||||
|
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
|
||||||
|
"revision": revision,
|
||||||
|
"system_policy": {
|
||||||
|
"available_modules": sorted(set(available_modules)),
|
||||||
|
"forced_modules": sorted(set(forced_modules)),
|
||||||
|
},
|
||||||
|
"tenant_selection": {
|
||||||
|
"enabled_modules": sorted(set(enabled_modules)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enabled_dependencies(
|
||||||
|
enabled: set[str],
|
||||||
|
available: set[str],
|
||||||
|
manifests: Mapping[str, ModuleManifest],
|
||||||
|
) -> None:
|
||||||
|
closure, missing = _dependency_closure(enabled, manifests)
|
||||||
|
if missing:
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
"Selected modules require dependencies that are not installed: "
|
||||||
|
+ ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
unavailable = closure - available
|
||||||
|
if unavailable:
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
"Selected modules require dependencies unavailable by system policy: "
|
||||||
|
+ ", ".join(sorted(unavailable))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_closure(
|
||||||
|
requested: Iterable[str],
|
||||||
|
manifests: Mapping[str, ModuleManifest],
|
||||||
|
) -> tuple[set[str], set[str]]:
|
||||||
|
closure: set[str] = set()
|
||||||
|
missing: set[str] = set()
|
||||||
|
pending = list(_normalized_ids(requested))
|
||||||
|
while pending:
|
||||||
|
module_id = pending.pop()
|
||||||
|
if module_id in closure:
|
||||||
|
continue
|
||||||
|
manifest = manifests.get(module_id)
|
||||||
|
if manifest is None:
|
||||||
|
missing.add(module_id)
|
||||||
|
continue
|
||||||
|
closure.add(module_id)
|
||||||
|
pending.extend(manifest.dependencies)
|
||||||
|
return closure, missing
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_ids(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
known: set[str],
|
||||||
|
fallback: Iterable[str],
|
||||||
|
diagnostics: list[dict[str, str]],
|
||||||
|
) -> set[str]:
|
||||||
|
if not isinstance(value, list | tuple):
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"module_entitlements.invalid_field",
|
||||||
|
f"{field} is malformed and was evaluated with a restrictive fallback.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return set(fallback)
|
||||||
|
values = _normalized_ids(value)
|
||||||
|
unknown = values - known
|
||||||
|
if unknown:
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"module_entitlements.unknown_module",
|
||||||
|
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return values.intersection(known)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_requested_ids(
|
||||||
|
values: Iterable[str],
|
||||||
|
*,
|
||||||
|
known: set[str],
|
||||||
|
field: str,
|
||||||
|
) -> set[str]:
|
||||||
|
normalized = _normalized_ids(values)
|
||||||
|
unknown = normalized - known
|
||||||
|
if unknown:
|
||||||
|
raise ModuleEntitlementError(
|
||||||
|
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_ids(values: Iterable[object]) -> set[str]:
|
||||||
|
return {
|
||||||
|
clean
|
||||||
|
for value in values
|
||||||
|
if (clean := str(value).strip())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
|
||||||
|
if isinstance(value, int) and value >= 0:
|
||||||
|
return value
|
||||||
|
diagnostics.append(
|
||||||
|
_diagnostic(
|
||||||
|
"module_entitlements.invalid_revision",
|
||||||
|
"The module entitlement revision is invalid; concurrent updates will require a reload.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _check_revision(current: int, expected: int | None) -> None:
|
||||||
|
if expected is not None and expected != current:
|
||||||
|
raise ModuleEntitlementConflict(
|
||||||
|
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnostic(code: str, message: str) -> dict[str, str]:
|
||||||
|
return {"code": code, "message": message, "severity": "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MODULE_ENTITLEMENTS_KEY",
|
||||||
|
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
|
||||||
|
"TENANT_PROTECTED_MODULES",
|
||||||
|
"ModuleEntitlementConflict",
|
||||||
|
"ModuleEntitlementError",
|
||||||
|
"TenantModuleEntitlementState",
|
||||||
|
"TenantModuleItem",
|
||||||
|
"module_entitlement_payload",
|
||||||
|
"tenant_module_entitlement_state",
|
||||||
|
"update_system_tenant_module_policy",
|
||||||
|
"update_tenant_module_selection",
|
||||||
|
]
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Literal, Mapping, Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
PlatformInterfaceKind = Literal[
|
||||||
|
"backend_capability",
|
||||||
|
"frontend_route",
|
||||||
|
"navigation",
|
||||||
|
"permission",
|
||||||
|
"provided_interface",
|
||||||
|
"public_route",
|
||||||
|
"search_provider",
|
||||||
|
"search_source",
|
||||||
|
"settings_route",
|
||||||
|
"view_surface",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlatformInterfaceDeclaration:
|
||||||
|
"""A sanitized, stable declaration from a module manifest.
|
||||||
|
|
||||||
|
The declaration contains identifiers and authorization metadata only. It
|
||||||
|
deliberately excludes factories, executable callbacks, credentials, and
|
||||||
|
mutable module state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
kind: PlatformInterfaceKind
|
||||||
|
label: str | None = None
|
||||||
|
path: str | None = None
|
||||||
|
required_all: tuple[str, ...] = ()
|
||||||
|
required_any: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
return f"{self.kind}:{self.id}"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"id": self.id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"label": self.label,
|
||||||
|
"path": self.path,
|
||||||
|
"required_all": list(self.required_all),
|
||||||
|
"required_any": list(self.required_any),
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_interface_declarations(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
) -> tuple[PlatformInterfaceDeclaration, ...]:
|
||||||
|
"""Normalize the typed public declarations owned by one module manifest."""
|
||||||
|
|
||||||
|
declarations: list[PlatformInterfaceDeclaration] = []
|
||||||
|
|
||||||
|
for capability_name in sorted(manifest.capability_factories):
|
||||||
|
documentation = manifest.capability_documentation.get(capability_name)
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=capability_name,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="backend_capability",
|
||||||
|
label=documentation.label if documentation is not None else None,
|
||||||
|
metadata={
|
||||||
|
"contract_version": (
|
||||||
|
documentation.contract_version
|
||||||
|
if documentation is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for interface in manifest.provides_interfaces:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=interface.name,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="provided_interface",
|
||||||
|
metadata={"version": interface.version},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for permission in manifest.permissions:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=permission.scope,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="permission",
|
||||||
|
label=permission.label,
|
||||||
|
metadata={
|
||||||
|
"category": permission.category,
|
||||||
|
"level": permission.level,
|
||||||
|
"deprecated": permission.deprecated,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for registration in manifest.search_providers:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=registration.id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="search_provider",
|
||||||
|
metadata={
|
||||||
|
"role": "provider",
|
||||||
|
"resource_types": list(registration.resource_types),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for registration in manifest.search_sources:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=registration.id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="search_source",
|
||||||
|
metadata={"role": "source"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is not None:
|
||||||
|
for route in frontend.routes:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=route_view_surface_id(manifest.id, route.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="frontend_route",
|
||||||
|
path=route.path,
|
||||||
|
required_all=route.required_all,
|
||||||
|
required_any=route.required_any,
|
||||||
|
metadata={
|
||||||
|
"component": route.component,
|
||||||
|
"order": route.order,
|
||||||
|
"surface_id": route.surface_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for route in frontend.public_routes:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=f"{manifest.id}.public.{_path_slug(route.path)}",
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="public_route",
|
||||||
|
path=route.path,
|
||||||
|
metadata={"component": route.component, "order": route.order},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for route in frontend.settings_routes:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=route_view_surface_id(manifest.id, route.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="settings_route",
|
||||||
|
path=route.path,
|
||||||
|
required_all=route.required_all,
|
||||||
|
required_any=route.required_any,
|
||||||
|
metadata={
|
||||||
|
"component": route.component,
|
||||||
|
"order": route.order,
|
||||||
|
"surface_id": route.surface_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in frontend.nav_items:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=navigation_view_surface_id(manifest.id, item.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="navigation",
|
||||||
|
label=item.label,
|
||||||
|
path=item.path,
|
||||||
|
required_all=item.required_all,
|
||||||
|
required_any=item.required_any,
|
||||||
|
metadata={
|
||||||
|
"icon": item.icon,
|
||||||
|
"section": item.section,
|
||||||
|
"order": item.order,
|
||||||
|
"surface_id": item.surface_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=surface.id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="view_surface",
|
||||||
|
label=surface.label,
|
||||||
|
metadata={
|
||||||
|
"surface_kind": surface.kind,
|
||||||
|
"parent_id": surface.parent_id,
|
||||||
|
"description": surface.description,
|
||||||
|
"order": surface.order,
|
||||||
|
"default_visible": surface.default_visible,
|
||||||
|
"required": surface.required,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
frontend_navigation = {
|
||||||
|
declaration.id: declaration
|
||||||
|
for declaration in declarations
|
||||||
|
if declaration.kind == "navigation"
|
||||||
|
}
|
||||||
|
for item in manifest.nav_items:
|
||||||
|
declaration = PlatformInterfaceDeclaration(
|
||||||
|
id=navigation_view_surface_id(manifest.id, item.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="navigation",
|
||||||
|
label=item.label,
|
||||||
|
path=item.path,
|
||||||
|
required_all=item.required_all,
|
||||||
|
required_any=item.required_any,
|
||||||
|
metadata={
|
||||||
|
"icon": item.icon,
|
||||||
|
"section": item.section,
|
||||||
|
"order": item.order,
|
||||||
|
"surface_id": item.surface_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
frontend_declaration = frontend_navigation.get(declaration.id)
|
||||||
|
if frontend_declaration is not None and frontend_declaration == declaration:
|
||||||
|
continue
|
||||||
|
declarations.append(declaration)
|
||||||
|
|
||||||
|
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for declaration in manifest_interface_declarations(manifest):
|
||||||
|
if declaration.key in seen:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate platform interface "
|
||||||
|
f"{declaration.key!r}"
|
||||||
|
)
|
||||||
|
seen.add(declaration.key)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
|
||||||
|
declarations = manifest_interface_declarations(manifest)
|
||||||
|
serialized = [item.to_dict() for item in declarations]
|
||||||
|
canonical = json.dumps(
|
||||||
|
serialized,
|
||||||
|
ensure_ascii=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return {
|
||||||
|
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||||
|
"module_id": manifest.id,
|
||||||
|
"module_version": manifest.version,
|
||||||
|
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
|
||||||
|
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
|
||||||
|
"declarations": serialized,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def platform_interface_catalog(
|
||||||
|
manifests: Sequence[ModuleManifest],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
|
||||||
|
return {
|
||||||
|
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||||
|
"modules": modules,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_slug(path: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
|
||||||
|
return slug or "root"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PLATFORM_INTERFACE_CONTRACT_VERSION",
|
||||||
|
"PlatformInterfaceDeclaration",
|
||||||
|
"PlatformInterfaceKind",
|
||||||
|
"manifest_interface_catalog",
|
||||||
|
"manifest_interface_declarations",
|
||||||
|
"platform_interface_catalog",
|
||||||
|
"validate_manifest_interface_declarations",
|
||||||
|
]
|
||||||
@@ -29,6 +29,9 @@ from govoplan_core.core.ownership import (
|
|||||||
OwnershipProviderRegistration,
|
OwnershipProviderRegistration,
|
||||||
ResourceOwnershipProvider,
|
ResourceOwnershipProvider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.platform_interfaces import (
|
||||||
|
validate_manifest_interface_declarations,
|
||||||
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ExternalProviderDeclaration,
|
ExternalProviderDeclaration,
|
||||||
ExternalProviderStateProviderRegistration,
|
ExternalProviderStateProviderRegistration,
|
||||||
@@ -646,6 +649,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
_validate_manifest_overlaps(manifest)
|
_validate_manifest_overlaps(manifest)
|
||||||
_validate_manifest_migration_spec(manifest)
|
_validate_manifest_migration_spec(manifest)
|
||||||
_validate_manifest_frontend(manifest)
|
_validate_manifest_frontend(manifest)
|
||||||
|
try:
|
||||||
|
validate_manifest_interface_declarations(manifest)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RegistryError(str(exc)) from exc
|
||||||
for item in manifest.nav_items:
|
for item in manifest.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
for topic in manifest.documentation:
|
for topic in manifest.documentation:
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
|||||||
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
|
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
|
||||||
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
|
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
|
||||||
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
|
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
|
||||||
|
PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"),
|
||||||
|
PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"),
|
||||||
)
|
)
|
||||||
|
|
||||||
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
|
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
|
from govoplan_core.core.module_entitlements import (
|
||||||
|
module_entitlement_payload,
|
||||||
|
tenant_module_entitlement_state,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||||
|
from govoplan_core.core.platform_interfaces import (
|
||||||
|
manifest_interface_catalog,
|
||||||
|
platform_interface_catalog,
|
||||||
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||||
from govoplan_core.core.views import (
|
from govoplan_core.core.views import (
|
||||||
VIEW_SURFACE_CONTRACT_VERSION,
|
VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
@@ -17,6 +25,7 @@ from govoplan_core.core.views import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
|
|
||||||
def _registry(request: Request) -> PlatformRegistry:
|
def _registry(request: Request) -> PlatformRegistry:
|
||||||
@@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry:
|
|||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_manifest_state(
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]:
|
||||||
|
"""Resolve only manifests available in the principal's active context."""
|
||||||
|
|
||||||
|
registry = _registry(request)
|
||||||
|
manifests = tuple(registry.manifests())
|
||||||
|
entitlement = None
|
||||||
|
principal_ref = getattr(principal, "principal", None)
|
||||||
|
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||||
|
if tenant_id is not None:
|
||||||
|
try:
|
||||||
|
with get_database().session() as session:
|
||||||
|
tenant = session.get(Tenant, tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="The active tenant is unavailable.",
|
||||||
|
)
|
||||||
|
manifest_map = {manifest.id: manifest for manifest in manifests}
|
||||||
|
entitlement = tenant_module_entitlement_state(
|
||||||
|
tenant.settings or {},
|
||||||
|
manifest_map,
|
||||||
|
runtime_active_modules=manifest_map,
|
||||||
|
)
|
||||||
|
except (RuntimeError, SQLAlchemyError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Tenant module entitlement could not be resolved.",
|
||||||
|
) from exc
|
||||||
|
effective_ids = (
|
||||||
|
set(entitlement.effective_modules)
|
||||||
|
if entitlement is not None
|
||||||
|
else {manifest.id for manifest in manifests}
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
registry,
|
||||||
|
tuple(manifest for manifest in manifests if manifest.id in effective_ids),
|
||||||
|
entitlement,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"path": item.path,
|
"path": item.path,
|
||||||
@@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
@router.get("/modules")
|
@router.get("/modules")
|
||||||
def modules(
|
def modules(
|
||||||
request: Request,
|
request: Request,
|
||||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
):
|
):
|
||||||
registry = _registry(request)
|
registry, manifests, entitlement = _effective_manifest_state(
|
||||||
|
request,
|
||||||
|
principal,
|
||||||
|
)
|
||||||
|
principal_ref = getattr(principal, "principal", None)
|
||||||
|
tenant_id = getattr(principal_ref, "tenant_id", None)
|
||||||
return {
|
return {
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
@@ -178,13 +235,36 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
for declaration in manifest.external_providers
|
for declaration in manifest.external_providers
|
||||||
],
|
],
|
||||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||||
|
"interface_catalog": {
|
||||||
|
key: value
|
||||||
|
for key, value in manifest_interface_catalog(manifest).items()
|
||||||
|
if key != "declarations"
|
||||||
|
},
|
||||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||||
"frontend": _frontend_payload(manifest),
|
"frontend": _frontend_payload(manifest),
|
||||||
}
|
}
|
||||||
for manifest in registry.manifests()
|
for manifest in manifests
|
||||||
]
|
],
|
||||||
|
"module_entitlement": (
|
||||||
|
module_entitlement_payload(tenant_id, entitlement)
|
||||||
|
if tenant_id is not None and entitlement is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/interface-catalog")
|
||||||
|
def interface_catalog(
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("admin:module:read", "system:settings:read")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_registry_item, manifests, _entitlement = _effective_manifest_state(
|
||||||
|
request,
|
||||||
|
principal,
|
||||||
|
)
|
||||||
|
return platform_interface_catalog(manifests)
|
||||||
|
|
||||||
@router.get("/public-modules")
|
@router.get("/public-modules")
|
||||||
def public_modules(request: Request):
|
def public_modules(request: Request):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.lifecycle import require_module_active
|
||||||
|
from govoplan_core.core.module_entitlements import (
|
||||||
|
ModuleEntitlementConflict,
|
||||||
|
ModuleEntitlementError,
|
||||||
|
tenant_module_entitlement_state,
|
||||||
|
update_system_tenant_module_policy,
|
||||||
|
update_tenant_module_selection,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleEntitlementTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.manifests = {
|
||||||
|
"access": ModuleManifest(id="access", name="Access", version="test"),
|
||||||
|
"admin": ModuleManifest(
|
||||||
|
id="admin",
|
||||||
|
name="Admin",
|
||||||
|
version="test",
|
||||||
|
dependencies=("access",),
|
||||||
|
),
|
||||||
|
"files": ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="test",
|
||||||
|
dependencies=("access",),
|
||||||
|
),
|
||||||
|
"campaigns": ModuleManifest(
|
||||||
|
id="campaigns",
|
||||||
|
name="Campaigns",
|
||||||
|
version="test",
|
||||||
|
dependencies=("access", "files"),
|
||||||
|
),
|
||||||
|
"encryption": ModuleManifest(
|
||||||
|
id="encryption",
|
||||||
|
name="Encryption",
|
||||||
|
version="test",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None:
|
||||||
|
state = tenant_module_entitlement_state({}, self.manifests)
|
||||||
|
|
||||||
|
self.assertFalse(state.configured)
|
||||||
|
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||||
|
self.assertEqual({"access", "admin"}, set(state.forced_modules))
|
||||||
|
|
||||||
|
def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None:
|
||||||
|
settings, state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
self.manifests,
|
||||||
|
available_modules=("campaigns", "encryption"),
|
||||||
|
forced_modules=("campaigns",),
|
||||||
|
enabled_modules=("encryption",),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, state.revision)
|
||||||
|
self.assertEqual(
|
||||||
|
{"access", "admin", "files", "campaigns", "encryption"},
|
||||||
|
set(state.available_modules),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"access", "admin", "files", "campaigns"},
|
||||||
|
set(state.forced_modules),
|
||||||
|
)
|
||||||
|
self.assertEqual({"encryption"}, set(state.selected_modules))
|
||||||
|
self.assertEqual(set(self.manifests), set(state.effective_modules))
|
||||||
|
self.assertIn("module_entitlements", settings)
|
||||||
|
|
||||||
|
def test_tenant_cannot_enable_system_unavailable_module(self) -> None:
|
||||||
|
settings, _state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
self.manifests,
|
||||||
|
available_modules=("files",),
|
||||||
|
forced_modules=(),
|
||||||
|
enabled_modules=(),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ModuleEntitlementError,
|
||||||
|
"unavailable by system policy: encryption",
|
||||||
|
):
|
||||||
|
update_tenant_module_selection(
|
||||||
|
settings,
|
||||||
|
self.manifests,
|
||||||
|
enabled_modules=("encryption",),
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None:
|
||||||
|
settings, _state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
self.manifests,
|
||||||
|
available_modules=("campaigns",),
|
||||||
|
forced_modules=("campaigns",),
|
||||||
|
enabled_modules=(),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
_settings, state = update_tenant_module_selection(
|
||||||
|
settings,
|
||||||
|
self.manifests,
|
||||||
|
enabled_modules=(),
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"access", "admin", "files", "campaigns"},
|
||||||
|
set(state.effective_modules),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
not item.tenant_can_toggle
|
||||||
|
for item in state.modules
|
||||||
|
if item.id in state.forced_modules
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None:
|
||||||
|
settings, state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
self.manifests,
|
||||||
|
available_modules=("files", "encryption"),
|
||||||
|
forced_modules=(),
|
||||||
|
enabled_modules=("encryption",),
|
||||||
|
expected_revision=0,
|
||||||
|
runtime_active_modules=("access", "admin", "files"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("encryption", state.selected_modules)
|
||||||
|
self.assertNotIn("encryption", state.effective_modules)
|
||||||
|
encryption = next(item for item in state.modules if item.id == "encryption")
|
||||||
|
self.assertIn("not active in the deployment", encryption.reason or "")
|
||||||
|
self.assertIn("module_entitlements", settings)
|
||||||
|
|
||||||
|
def test_stale_revision_is_rejected(self) -> None:
|
||||||
|
settings, _state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
self.manifests,
|
||||||
|
available_modules=("files",),
|
||||||
|
forced_modules=(),
|
||||||
|
enabled_modules=("files",),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ModuleEntitlementConflict):
|
||||||
|
update_tenant_module_selection(
|
||||||
|
settings,
|
||||||
|
self.manifests,
|
||||||
|
enabled_modules=(),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_document_fails_closed_to_protected_modules(self) -> None:
|
||||||
|
state = tenant_module_entitlement_state(
|
||||||
|
{"module_entitlements": {"revision": "invalid"}},
|
||||||
|
self.manifests,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({"access", "admin"}, set(state.effective_modules))
|
||||||
|
self.assertTrue(state.diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
|
||||||
|
configure_database(f"sqlite:///{root / 'test.db'}")
|
||||||
|
create_scope_tables(get_database().engine)
|
||||||
|
self.manifests = (
|
||||||
|
ModuleManifest(id="access", name="Access", version="test"),
|
||||||
|
ModuleManifest(
|
||||||
|
id="admin",
|
||||||
|
name="Admin",
|
||||||
|
version="test",
|
||||||
|
dependencies=("access",),
|
||||||
|
),
|
||||||
|
ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="test",
|
||||||
|
dependencies=("access",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.registry = PlatformRegistry()
|
||||||
|
for manifest in self.manifests:
|
||||||
|
self.registry.register(manifest)
|
||||||
|
settings, _state = update_system_tenant_module_policy(
|
||||||
|
{},
|
||||||
|
{manifest.id: manifest for manifest in self.manifests},
|
||||||
|
available_modules=(),
|
||||||
|
forced_modules=(),
|
||||||
|
enabled_modules=(),
|
||||||
|
expected_revision=0,
|
||||||
|
)
|
||||||
|
with get_database().session() as session:
|
||||||
|
session.add(
|
||||||
|
Tenant(
|
||||||
|
id="tenant-1",
|
||||||
|
slug="tenant-1",
|
||||||
|
name="Tenant 1",
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = self.registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: self.principal
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
{"access", "admin"},
|
||||||
|
{item["id"] for item in response.json()["modules"]},
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"files",
|
||||||
|
response.json()["module_entitlement"]["effective_modules"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = self.registry
|
||||||
|
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||||
|
|
||||||
|
@guarded.get("/files")
|
||||||
|
def files_route():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
app.include_router(guarded)
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.lifecycle.get_api_principal",
|
||||||
|
return_value=self.principal,
|
||||||
|
), TestClient(app) as client:
|
||||||
|
response = client.get(
|
||||||
|
"/files",
|
||||||
|
headers={"Authorization": "Bearer test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(404, response.status_code, response.text)
|
||||||
|
self.assertEqual(
|
||||||
|
"Module is unavailable in the active tenant: files",
|
||||||
|
response.json()["detail"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = self.registry
|
||||||
|
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
|
||||||
|
|
||||||
|
@guarded.get("/public-files")
|
||||||
|
def public_files_route():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
app.include_router(guarded)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/public-files")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.platform_interfaces import (
|
||||||
|
manifest_interface_catalog,
|
||||||
|
manifest_interface_declarations,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(*scopes: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id=None,
|
||||||
|
tenant_id=None,
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest() -> ModuleManifest:
|
||||||
|
navigation = NavItem(path="/example", label="Example", icon="box")
|
||||||
|
return ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1.2.3",
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="example.reader", version="1.0.0"),
|
||||||
|
),
|
||||||
|
capability_factories={"example.reader": lambda _context: object()},
|
||||||
|
nav_items=(navigation,),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(path="/example", component="ExamplePage"),
|
||||||
|
),
|
||||||
|
nav_items=(navigation,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformInterfaceCatalogTests(unittest.TestCase):
|
||||||
|
def test_manifest_declarations_have_stable_typed_keys(self) -> None:
|
||||||
|
declarations = manifest_interface_declarations(_manifest())
|
||||||
|
keys = {item.key for item in declarations}
|
||||||
|
|
||||||
|
self.assertIn("backend_capability:example.reader", keys)
|
||||||
|
self.assertIn("provided_interface:example.reader", keys)
|
||||||
|
self.assertIn("frontend_route:example.route.example", keys)
|
||||||
|
self.assertIn("navigation:example.nav.example", keys)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
sum(item.key == "navigation:example.nav.example" for item in declarations),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_digest_is_deterministic(self) -> None:
|
||||||
|
first = manifest_interface_catalog(_manifest())
|
||||||
|
second = manifest_interface_catalog(_manifest())
|
||||||
|
|
||||||
|
self.assertEqual(first["digest"], second["digest"])
|
||||||
|
self.assertEqual("1", first["contract_version"])
|
||||||
|
|
||||||
|
def test_registry_rejects_conflicting_duplicate_navigation(self) -> None:
|
||||||
|
manifest = _manifest()
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=manifest.id,
|
||||||
|
name=manifest.name,
|
||||||
|
version=manifest.version,
|
||||||
|
nav_items=manifest.nav_items,
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
nav_items=(NavItem(path="/example", label="Other"),),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(manifest)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "duplicate platform interface"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_read_only_endpoint_requires_administrator_scope(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(_manifest())
|
||||||
|
registry.validate()
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: _principal()
|
||||||
|
with TestClient(app) as client:
|
||||||
|
denied = client.get("/api/v1/platform/interface-catalog")
|
||||||
|
self.assertEqual(403, denied.status_code)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: _principal(
|
||||||
|
"admin:module:read"
|
||||||
|
)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/interface-catalog")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual("1", payload["contract_version"])
|
||||||
|
self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]])
|
||||||
|
self.assertIn(
|
||||||
|
"frontend_route:example.route.example",
|
||||||
|
{
|
||||||
|
item["key"]
|
||||||
|
for item in payload["modules"][0]["declarations"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -40,7 +40,7 @@ function scanStructuralSourcePositions(roots) {
|
|||||||
}
|
}
|
||||||
if (ts.isPropertyAssignment(node)) {
|
if (ts.isPropertyAssignment(node)) {
|
||||||
const name = nodeName(node.name);
|
const name = nodeName(node.name);
|
||||||
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`);
|
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer) && !isPresentationalBlockerProperty(node)) report(node, `object ${name}`);
|
||||||
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
|
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
|
||||||
}
|
}
|
||||||
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
|
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
|
||||||
@@ -221,6 +221,16 @@ function isStructuralJsxValue(node) {
|
|||||||
return parent.tagName.getText() === "option";
|
return parent.tagName.getText() === "option";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPresentationalBlockerProperty(node) {
|
||||||
|
if (nodeName(node.name) !== "target" || !ts.isObjectLiteralExpression(node.parent)) return false;
|
||||||
|
const propertyNames = new Set(
|
||||||
|
node.parent.properties
|
||||||
|
.filter((property) => ts.isPropertyAssignment(property))
|
||||||
|
.map((property) => nodeName(property.name))
|
||||||
|
);
|
||||||
|
return propertyNames.has("actor") && propertyNames.has("requiredAction");
|
||||||
|
}
|
||||||
|
|
||||||
function hasAncestorVariable(node, variableName) {
|
function hasAncestorVariable(node, variableName) {
|
||||||
let current = node.parent;
|
let current = node.parent;
|
||||||
while (current) {
|
while (current) {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
|
|||||||
const settings = read("src/features/settings/SettingsPage.tsx");
|
const settings = read("src/features/settings/SettingsPage.tsx");
|
||||||
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
|
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
|
||||||
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||||
|
const iconRail = read("src/layout/IconRail.tsx");
|
||||||
|
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
|
||||||
|
const titlebar = read("src/layout/Titlebar.tsx");
|
||||||
|
const layoutStyles = read("src/styles/layout.css");
|
||||||
|
|
||||||
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
||||||
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
|
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
|
||||||
@@ -24,4 +28,18 @@ assert.match(credentials, /contextId: "access\.credentials"/, "credentials expos
|
|||||||
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
|
||||||
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
||||||
|
|
||||||
|
assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport");
|
||||||
|
assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls");
|
||||||
|
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
|
||||||
|
assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed");
|
||||||
|
|
||||||
|
assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern");
|
||||||
|
assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell");
|
||||||
|
assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state");
|
||||||
|
assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position");
|
||||||
|
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows");
|
||||||
|
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
|
||||||
|
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
|
||||||
|
assert.match(moduleLoadBoundary, /<DismissibleAlert tone="danger" compact/, "module failures use the compact shared alert");
|
||||||
|
|
||||||
console.log("Core interface-pattern contracts passed.");
|
console.log("Core interface-pattern contracts passed.");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
import type { PlatformInterfaceCatalog, PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||||
@@ -50,3 +50,7 @@ export async function fetchPlatformStatus(settings: ApiSettings): Promise<Platfo
|
|||||||
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
|
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
|
||||||
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
|
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformInterfaceCatalog(settings: ApiSettings): Promise<PlatformInterfaceCatalog> {
|
||||||
|
return apiFetch<PlatformInterfaceCatalog>(settings, "/api/v1/platform/interface-catalog");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import DisabledActionTooltip from "./DisabledActionTooltip";
|
import DisabledActionTooltip from "./DisabledActionTooltip";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
|
||||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInterfaceIdentityProps & {
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
disabledReason?: ReactNode;
|
disabledReason?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
|
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
|
||||||
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
|
||||||
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
|
||||||
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
import useOutsideDismiss from "../hooks/useOutsideDismiss";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
|
||||||
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & {
|
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & PlatformInterfaceIdentityProps & {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
min?: string;
|
min?: string;
|
||||||
@@ -49,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
|
|||||||
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
return `${date || dateString(new Date())}T${time || "00:00"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) {
|
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||||
const selectedDate = parseDate(value);
|
const selectedDate = parseDate(value);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
|
||||||
@@ -93,7 +94,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={`date-field ${className}`.trim()}>
|
<div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<input
|
<input
|
||||||
{...props}
|
{...props}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -145,7 +146,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) {
|
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
@@ -158,7 +159,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
|||||||
}, [value, min, max]);
|
}, [value, min, max]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`time-field ${className}`.trim()}>
|
<div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<input
|
<input
|
||||||
{...props}
|
{...props}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -174,7 +175,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) {
|
export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
|
||||||
const parts = datePartsFromDateTime(value);
|
const parts = datePartsFromDateTime(value);
|
||||||
const minParts = datePartsFromDateTime(min || "");
|
const minParts = datePartsFromDateTime(min || "");
|
||||||
const maxParts = datePartsFromDateTime(max || "");
|
const maxParts = datePartsFromDateTime(max || "");
|
||||||
@@ -188,7 +189,7 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`date-time-field ${className}`.trim()}>
|
<div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<DateField
|
<DateField
|
||||||
{...props}
|
{...props}
|
||||||
value={parts.date}
|
value={parts.date}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import FieldLabel from "./help/FieldLabel";
|
|||||||
import type { DocumentationHelpReference } from "./help/documentationHelp";
|
import type { DocumentationHelpReference } from "./help/documentationHelp";
|
||||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
|
||||||
export default function FormField({ label, help, documentation, children }: { label: ReactNode; help?: ReactNode; documentation?: DocumentationHelpReference; children: ReactNode }) {
|
type FormFieldProps = PlatformInterfaceIdentityProps & {
|
||||||
|
label: ReactNode;
|
||||||
|
help?: ReactNode;
|
||||||
|
documentation?: DocumentationHelpReference;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||||
return (
|
return (
|
||||||
<label className="form-field">
|
<label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
|
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default class ModuleLoadBoundary extends Component<
|
|||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return (
|
return (
|
||||||
<div className="content-pad module-load-error">
|
<div className="content-pad module-load-error">
|
||||||
<DismissibleAlert tone="danger" dismissible={false}>
|
<DismissibleAlert tone="danger" compact resetKey={`${this.props.resetKey}:${this.state.error.message}`}>
|
||||||
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
|
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
|
||||||
<Button type="button" onClick={() => window.location.reload()}>
|
<Button type="button" onClick={() => window.location.reload()}>
|
||||||
i18n:govoplan-core.reload.cce71553
|
i18n:govoplan-core.reload.cce71553
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { ChevronDown, Search } from "lucide-react";
|
import { ChevronDown, Search } from "lucide-react";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
|
||||||
export type SearchableSelectOption = {
|
export type SearchableSelectOption = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -27,7 +28,7 @@ export type SearchableSelectCreateCustomOption = (
|
|||||||
value: string
|
value: string
|
||||||
) => SearchableSelectOption | null;
|
) => SearchableSelectOption | null;
|
||||||
|
|
||||||
export type SearchableSelectProps = {
|
export type SearchableSelectProps = PlatformInterfaceIdentityProps & {
|
||||||
id?: string;
|
id?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (
|
onChange: (
|
||||||
@@ -95,7 +96,9 @@ export default function SearchableSelect({
|
|||||||
minQueryLength = 0,
|
minQueryLength = 0,
|
||||||
searchLimit = 50,
|
searchLimit = 50,
|
||||||
debounceMs = 200,
|
debounceMs = 200,
|
||||||
className = ""
|
className = "",
|
||||||
|
interfaceId,
|
||||||
|
helpTopicId
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||||
@@ -294,6 +297,8 @@ export default function SearchableSelect({
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={rootClassName}
|
className={rootClassName}
|
||||||
|
data-interface-id={interfaceId}
|
||||||
|
data-help-topic-id={helpTopicId}
|
||||||
onBlur={closeOnFocusLeave}
|
onBlur={closeOnFocusLeave}
|
||||||
>
|
>
|
||||||
<div className="searchable-select-control">
|
<div className="searchable-select-control">
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import type { ReactNode } from "react";
|
|||||||
import FieldLabel from "./help/FieldLabel";
|
import FieldLabel from "./help/FieldLabel";
|
||||||
import { helpForFieldLabel } from "../utils/fieldHelp";
|
import { helpForFieldLabel } from "../utils/fieldHelp";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
|
|
||||||
type ToggleSwitchProps = {
|
type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
activeLabel?: ReactNode;
|
activeLabel?: ReactNode;
|
||||||
inactiveLabel?: ReactNode;
|
inactiveLabel?: ReactNode;
|
||||||
@@ -13,7 +14,7 @@ type ToggleSwitchProps = {
|
|||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
|
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
|
||||||
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
const renderedLabel = typeof label === "string" ? translateText(label) : label;
|
||||||
@@ -21,7 +22,7 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
|
|||||||
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
|
||||||
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
|
||||||
return (
|
return (
|
||||||
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
|
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<input
|
<input
|
||||||
className="toggle-switch-input"
|
className="toggle-switch-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, KeyboardEvent } from "react";
|
import type { CSSProperties, KeyboardEvent } from "react";
|
||||||
|
import type { PlatformInterfaceIdentityProps } from "../../types";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
type MailboxAddress } from
|
type MailboxAddress } from
|
||||||
"../../utils/emailAddresses";
|
"../../utils/emailAddresses";
|
||||||
|
|
||||||
type EmailAddressInputProps = {
|
type EmailAddressInputProps = PlatformInterfaceIdentityProps & {
|
||||||
value: MailboxAddress[];
|
value: MailboxAddress[];
|
||||||
onChange?: (value: MailboxAddress[]) => void;
|
onChange?: (value: MailboxAddress[]) => void;
|
||||||
onAddressAdded?: (address: MailboxAddress) => void;
|
onAddressAdded?: (address: MailboxAddress) => void;
|
||||||
@@ -43,7 +44,9 @@ export default function EmailAddressInput({
|
|||||||
emailPlaceholder = "email@example.org",
|
emailPlaceholder = "email@example.org",
|
||||||
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
|
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
|
||||||
compact = false,
|
compact = false,
|
||||||
showAddButton
|
showAddButton,
|
||||||
|
interfaceId,
|
||||||
|
helpTopicId
|
||||||
}: EmailAddressInputProps) {
|
}: EmailAddressInputProps) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
@@ -199,7 +202,7 @@ export default function EmailAddressInput({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
|
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
|
||||||
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
|
||||||
<div className="email-chip-list" aria-live="polite">
|
<div className="email-chip-list" aria-live="polite">
|
||||||
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
|
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
|
||||||
|
|||||||
@@ -66,21 +66,29 @@ export default function IconRail({
|
|||||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!compact &&
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
|
<div className="icon-rail-scroll">
|
||||||
<nav className="icon-nav">
|
<nav className="icon-nav">
|
||||||
{items.map(({ to, label, icon: Icon }) => {
|
{items.map(({ to, label, icon: Icon }) => {
|
||||||
const target = rememberedTargets[to] ?? to;
|
const target = rememberedTargets[to] ?? to;
|
||||||
const active = modulePathActive(location.pathname, to);
|
const active = modulePathActive(location.pathname, to);
|
||||||
const renderedLabel = translateText(label);
|
const renderedLabel = translateText(label);
|
||||||
return (
|
return (
|
||||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={target}
|
||||||
|
className={`icon-nav-item ${active ? "active" : ""}`}
|
||||||
|
title={renderedLabel}
|
||||||
|
onClick={(event) => handleNavClick(event, target)}
|
||||||
|
>
|
||||||
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
||||||
<span className="icon-nav-label">{renderedLabel}</span>
|
<span className="icon-nav-label">{renderedLabel}</span>
|
||||||
</NavLink>);
|
</NavLink>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
<div className="icon-rail-bottom">
|
<div className="icon-rail-bottom">
|
||||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
||||||
<Settings size={20} />
|
<Settings size={20} />
|
||||||
@@ -97,8 +105,9 @@ export default function IconRail({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</aside>);
|
</aside>
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react";
|
||||||
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||||
import HelpMenu from "./HelpMenu";
|
import HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
@@ -75,6 +75,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
0 :
|
0 :
|
||||||
Math.max(0, Number(notificationSummary?.unread) || 0);
|
Math.max(0, Number(notificationSummary?.unread) || 0);
|
||||||
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
|
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
|
||||||
|
const titlebarState = !backendReachable ? "offline" : maintenanceMode?.enabled ? "maintenance" : null;
|
||||||
|
const titlebarStateLabel = titlebarState === "offline"
|
||||||
|
? "System not reachable / offline!"
|
||||||
|
: translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onPointerDown(event: MouseEvent) {
|
function onPointerDown(event: MouseEvent) {
|
||||||
@@ -148,27 +152,34 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}`}>
|
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
|
||||||
{!backendReachable ?
|
{titlebarState &&
|
||||||
|
<div className="titlebar-status-pattern" aria-hidden="true">
|
||||||
|
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className="titlebar-leading">
|
||||||
|
{titlebarState === "offline" &&
|
||||||
<div
|
<div
|
||||||
className="backend-offline-topbar-alert"
|
className="backend-offline-topbar-alert"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
title="System not reachable / offline!">
|
aria-label={titlebarStateLabel}
|
||||||
System not reachable / offline!
|
title={titlebarStateLabel}>
|
||||||
</div> :
|
<WifiOff size={18} aria-hidden="true" />
|
||||||
maintenanceMode?.enabled &&
|
</div>
|
||||||
|
}
|
||||||
|
{titlebarState === "maintenance" &&
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="maintenance-topbar-link"
|
className="maintenance-topbar-link"
|
||||||
title={maintenanceMode.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||||
|
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
|
||||||
onClick={openMaintenanceSettings}>
|
onClick={openMaintenanceSettings}>
|
||||||
|
<TriangleAlert size={18} aria-hidden="true" />
|
||||||
{translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d")}
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="titlebar-leading">
|
|
||||||
{auth && showContextSelectors &&
|
{auth && showContextSelectors &&
|
||||||
<div className="titlebar-context-selectors">
|
<div className="titlebar-context-selectors">
|
||||||
{activeTenant && showTenantControl &&
|
{activeTenant && showTenantControl &&
|
||||||
@@ -237,7 +248,13 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div className="context-menu-wrap" ref={accountRef}>
|
<div className="context-menu-wrap" ref={accountRef}>
|
||||||
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
<button
|
||||||
|
className="account-pill"
|
||||||
|
aria-label={displayUserName}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={accountOpen}
|
||||||
|
title={displayUserName}
|
||||||
|
onClick={() => setAccountOpen(!accountOpen)}>
|
||||||
<UserCircle size={22} />
|
<UserCircle size={22} />
|
||||||
<span>{displayUserName}</span>
|
<span>{displayUserName}</span>
|
||||||
<span className="tenant-caret">▾</span>
|
<span className="tenant-caret">▾</span>
|
||||||
|
|||||||
@@ -2235,7 +2235,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.module-load-error .alert {
|
.module-load-error .alert {
|
||||||
|
width: min(640px, 100%);
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-load-error .alert-message {
|
.module-load-error .alert-message {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
|
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
|
||||||
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
|
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
|
||||||
.icon-rail.expanded { width: 208px; align-items: stretch; }
|
.icon-rail.expanded { width: 208px; align-items: stretch; }
|
||||||
.icon-rail-header { width: 100%; min-height: 63px; display: flex; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
|
.icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
|
||||||
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
|
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
|
||||||
.brand-mark::after { position: absolute;
|
.brand-mark::after { position: absolute;
|
||||||
top: 9px;
|
top: 9px;
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
.icon-rail-toggle:hover,
|
.icon-rail-toggle:hover,
|
||||||
.icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
|
.icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
|
||||||
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
|
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
|
||||||
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
|
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
|
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
|
||||||
.icon-nav-item svg,
|
.icon-nav-item svg,
|
||||||
@@ -27,6 +28,12 @@
|
|||||||
.icon-rail.compact { width: 58px; }
|
.icon-rail.compact { width: 58px; }
|
||||||
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
|
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
|
||||||
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
|
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
|
||||||
|
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
|
||||||
|
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
|
||||||
|
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
|
||||||
|
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
|
||||||
|
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
|
||||||
|
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
|
||||||
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
|
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
|
||||||
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
|
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
|
||||||
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
|
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
|
||||||
@@ -50,10 +57,10 @@
|
|||||||
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
|
||||||
.account-pill { color: var(--text); }
|
.account-pill { color: var(--text); }
|
||||||
.maintenance-topbar-link,
|
.maintenance-topbar-link,
|
||||||
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; }
|
.backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); }
|
||||||
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; }
|
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
|
||||||
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
|
||||||
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); }
|
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); }
|
||||||
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
|
||||||
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.language-menu { min-width: 210px; }
|
.language-menu { min-width: 210px; }
|
||||||
@@ -322,7 +329,7 @@
|
|||||||
/* Side rail: settings lives as the bottom utility entry. */
|
/* Side rail: settings lives as the bottom utility entry. */
|
||||||
.icon-rail-bottom {
|
.icon-rail-bottom {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: auto;
|
flex: 0 0 auto;
|
||||||
padding: 12px 0 20px;
|
padding: 12px 0 20px;
|
||||||
}
|
}
|
||||||
.icon-rail-bottom .icon-nav-item {
|
.icon-rail-bottom .icon-nav-item {
|
||||||
@@ -331,3 +338,90 @@
|
|||||||
.icon-rail-bottom .icon-rail-toggle {
|
.icon-rail-bottom .icon-rail-toggle {
|
||||||
border-top: 1px solid var(--rail-bg-active);
|
border-top: 1px solid var(--rail-bg-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-main {
|
||||||
|
grid-template-rows: 104px 51px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar,
|
||||||
|
.titlebar.has-global-search {
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
grid-template-rows: 42px 42px;
|
||||||
|
column-gap: 6px;
|
||||||
|
row-gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-leading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-context-selectors {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
gap: 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-global-search {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-actions,
|
||||||
|
.titlebar.has-global-search .titlebar-actions {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar:not(.has-global-search) .titlebar-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-menu-button {
|
||||||
|
min-width: 40px;
|
||||||
|
padding-inline: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-actions > .context-menu-wrap:not(.language-menu-wrap) > .titlebar-link {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-pill {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-pill span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-bar {
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-pad {
|
||||||
|
padding: 18px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -385,6 +385,13 @@ export type PlatformPublicRouteContribution = {
|
|||||||
|
|
||||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type PlatformInterfaceIdentityProps = {
|
||||||
|
/** Stable control-plane identity; use a module-namespaced value. */
|
||||||
|
interfaceId?: string;
|
||||||
|
/** Optional stable documentation/help topic associated with the control. */
|
||||||
|
helpTopicId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformTranslationDictionary = Record<string, string>;
|
export type PlatformTranslationDictionary = Record<string, string>;
|
||||||
|
|
||||||
export type PlatformTranslations = Record<string, PlatformTranslationDictionary>;
|
export type PlatformTranslations = Record<string, PlatformTranslationDictionary>;
|
||||||
@@ -1050,6 +1057,32 @@ export type PlatformPublicModuleInfo = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformInterfaceDeclarationInfo = {
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
kind: string;
|
||||||
|
label?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
required_all: string[];
|
||||||
|
required_any: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformModuleInterfaceCatalog = {
|
||||||
|
contract_version: string;
|
||||||
|
module_id: string;
|
||||||
|
module_version: string;
|
||||||
|
digest: string;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
declarations: PlatformInterfaceDeclarationInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlatformInterfaceCatalog = {
|
||||||
|
contract_version: string;
|
||||||
|
modules: PlatformModuleInterfaceCatalog[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformModuleInfo = {
|
export type PlatformModuleInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1083,6 +1116,7 @@ export type PlatformModuleInfo = {
|
|||||||
behavior: Record<string, unknown>;
|
behavior: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
runtime_ui_capabilities?: string[];
|
runtime_ui_capabilities?: string[];
|
||||||
|
interface_catalog?: Omit<PlatformModuleInterfaceCatalog, "declarations">;
|
||||||
nav: Array<{
|
nav: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export const adminReadScopes = [
|
|||||||
"admin:api_keys:read",
|
"admin:api_keys:read",
|
||||||
"admin:settings:read",
|
"admin:settings:read",
|
||||||
"admin:policies:read",
|
"admin:policies:read",
|
||||||
|
"admin:module:read",
|
||||||
|
"admin:module:write",
|
||||||
"mail:profile:read",
|
"mail:profile:read",
|
||||||
"mail_servers:read",
|
"mail_servers:read",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
@@ -163,6 +165,8 @@ export const adminReadScopes = [
|
|||||||
"access:system_setting:read",
|
"access:system_setting:read",
|
||||||
"access:system_credential:read",
|
"access:system_credential:read",
|
||||||
"access:credential:read",
|
"access:credential:read",
|
||||||
|
"access:service_account:read",
|
||||||
|
"approvals:workspace:admin",
|
||||||
"access:governance:read",
|
"access:governance:read",
|
||||||
"views:definition:read",
|
"views:definition:read",
|
||||||
"views:assignment:read",
|
"views:assignment:read",
|
||||||
|
|||||||
Reference in New Issue
Block a user