fix(docs): secure adaptive handbook projections
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
|
||||
@@ -19,7 +20,7 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
from govoplan_docs.backend.manifest import DOCS_READ_SCOPES
|
||||
from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES
|
||||
|
||||
router = APIRouter(prefix="/docs", tags=["docs"])
|
||||
|
||||
@@ -29,30 +30,57 @@ TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
|
||||
@router.get("/context")
|
||||
def docs_context(
|
||||
request: Request,
|
||||
documentation_type: DocumentationType = Query(default="admin", alias="type", pattern="^(admin|user)$"),
|
||||
documentation_type: DocumentationType = Query(default="user", alias="type", pattern="^(admin|user)$"),
|
||||
locale: str | None = Query(default=None, min_length=2, max_length=20),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
|
||||
) -> dict[str, Any]:
|
||||
can_read_admin_documentation = _has_any_scope(principal, DOCS_ADMIN_READ_SCOPES)
|
||||
if documentation_type == "admin" and not can_read_admin_documentation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Administrative documentation requires documentation-administrator authority",
|
||||
)
|
||||
registry = _registry(request)
|
||||
resolved_locale = _preferred_locale(request, locale)
|
||||
modules = [_module_payload(manifest) for manifest in registry.manifests()]
|
||||
permissions = [_permission_payload(permission, principal) for permission in registry.permissions()]
|
||||
route_items = _route_items(registry.manifests(), principal)
|
||||
visible_routes = [item for item in route_items if item["visible"]]
|
||||
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
|
||||
if documentation_type == "admin":
|
||||
modules = [_module_payload(manifest, technical=True) for manifest in registry.manifests()]
|
||||
permissions = [_permission_payload(permission, principal) for permission in registry.permissions()]
|
||||
available_routes = [item for item in route_items if not item["visible"]]
|
||||
optional_modules = _optional_module_evidence(registry.manifests())
|
||||
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
|
||||
else:
|
||||
visible_module_ids = {
|
||||
*(str(item["module_id"]) for item in visible_routes),
|
||||
*(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)),
|
||||
}
|
||||
modules = [
|
||||
_module_payload(manifest, technical=False)
|
||||
for manifest in registry.manifests()
|
||||
if manifest.id in visible_module_ids
|
||||
]
|
||||
permissions = []
|
||||
visible_routes = [_user_route_payload(item) for item in visible_routes]
|
||||
available_routes = []
|
||||
optional_modules = []
|
||||
documentation_count = sum(len(layer) for layer in documentation_layers.values())
|
||||
topic_groups = _documentation_topic_groups(documentation_layers)
|
||||
|
||||
return {
|
||||
"actor": {
|
||||
"tenant_id": principal.tenant_id,
|
||||
"user_id": principal.user.id,
|
||||
"scope_count": len(principal.scopes),
|
||||
actor_payload: dict[str, Any] = {
|
||||
"documentation_type": documentation_type,
|
||||
"locale": resolved_locale,
|
||||
},
|
||||
"available_documentation_types": ["user", *(["admin"] if can_read_admin_documentation else [])],
|
||||
}
|
||||
if documentation_type == "admin":
|
||||
actor_payload.update(
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
scope_count=len(principal.scopes),
|
||||
)
|
||||
|
||||
return {
|
||||
"actor": actor_payload,
|
||||
"summary": {
|
||||
"module_count": len(modules),
|
||||
"visible_route_count": len(visible_routes),
|
||||
@@ -99,25 +127,42 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _module_payload(manifest: ModuleManifest) -> dict[str, Any]:
|
||||
def _module_payload(manifest: ModuleManifest, *, technical: bool) -> dict[str, Any]:
|
||||
frontend = manifest.frontend
|
||||
migration = manifest.migration_spec
|
||||
return {
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"dependencies": list(manifest.dependencies),
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"permission_count": len(manifest.permissions),
|
||||
"role_template_count": len(manifest.role_templates),
|
||||
"nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0),
|
||||
"route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0),
|
||||
"frontend_package": frontend.package_name if frontend else None,
|
||||
"backend_route_contributed": manifest.route_factory is not None,
|
||||
"migration_module_id": migration.module_id if migration else None,
|
||||
"capabilities": sorted(manifest.capability_factories),
|
||||
"documentation_count": len(manifest.documentation),
|
||||
"documentation_provider_count": len(manifest.documentation_providers),
|
||||
"version": manifest.version if technical else "",
|
||||
"dependencies": list(manifest.dependencies) if technical else [],
|
||||
"optional_dependencies": list(manifest.optional_dependencies) if technical else [],
|
||||
"permission_count": len(manifest.permissions) if technical else 0,
|
||||
"role_template_count": len(manifest.role_templates) if technical else 0,
|
||||
"nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0) if technical else 0,
|
||||
"route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0) if technical else 0,
|
||||
"frontend_package": frontend.package_name if frontend and technical else None,
|
||||
"backend_route_contributed": manifest.route_factory is not None if technical else False,
|
||||
"migration_module_id": migration.module_id if migration and technical else None,
|
||||
"capabilities": sorted(manifest.capability_factories) if technical else [],
|
||||
"documentation_count": len(manifest.documentation) if technical else 0,
|
||||
"documentation_provider_count": len(manifest.documentation_providers) if technical else 0,
|
||||
}
|
||||
|
||||
|
||||
def _user_route_payload(item: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"module_id": item["module_id"],
|
||||
"path": item["path"],
|
||||
"label": item["label"],
|
||||
"icon": item["icon"],
|
||||
"section": item["section"],
|
||||
"source": "visible",
|
||||
"component": None,
|
||||
"required_all": [],
|
||||
"required_any": [],
|
||||
"order": item["order"],
|
||||
"visible": True,
|
||||
"reason": "visible",
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +310,8 @@ def _classify_documentation(
|
||||
if not _topic_matches_documentation_type(topic, documentation_type):
|
||||
continue
|
||||
active, reason, blockers = _documentation_visibility(topic, installed, registry, principal)
|
||||
if documentation_type == "user" and not active:
|
||||
continue
|
||||
target_layer = _documentation_target_layer(topic, active, blockers)
|
||||
if target_layer not in layers:
|
||||
target_layer = "evidence"
|
||||
@@ -276,6 +323,7 @@ def _classify_documentation(
|
||||
target_layer=target_layer,
|
||||
blockers=blockers,
|
||||
locale=locale,
|
||||
documentation_type=documentation_type,
|
||||
))
|
||||
return layers
|
||||
|
||||
@@ -488,11 +536,12 @@ def _documentation_topic_payload(
|
||||
target_layer: str,
|
||||
blockers: dict[str, list[str]],
|
||||
locale: str,
|
||||
documentation_type: DocumentationType,
|
||||
) -> dict[str, Any]:
|
||||
module_id = topic.source_module_id or source_module_id
|
||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||
kind = _documentation_topic_kind(topic)
|
||||
return {
|
||||
payload = {
|
||||
"id": topic.id,
|
||||
"source_module_id": module_id,
|
||||
"kind": kind,
|
||||
@@ -522,6 +571,125 @@ def _documentation_topic_payload(
|
||||
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
||||
"metadata": dict(topic.metadata),
|
||||
}
|
||||
if documentation_type == "admin":
|
||||
return payload
|
||||
return {
|
||||
"id": payload["id"],
|
||||
"source_module_id": payload["source_module_id"],
|
||||
"kind": payload["kind"],
|
||||
"anchor_id": payload["anchor_id"],
|
||||
"title": payload["title"],
|
||||
"summary": payload["summary"],
|
||||
"body": payload["body"],
|
||||
"layer": payload["layer"],
|
||||
"target_layer": payload["target_layer"],
|
||||
"documentation_types": ["user"],
|
||||
"active": True,
|
||||
"reason": "documented",
|
||||
"blockers": {"modules": [], "capabilities": [], "scopes": []},
|
||||
"audience": [],
|
||||
"order": payload["order"],
|
||||
"i18n_key": "",
|
||||
"locale": locale,
|
||||
"translation_locale": payload["translation_locale"],
|
||||
"conditions": [],
|
||||
"links": [
|
||||
_documentation_link_payload(link)
|
||||
for link in topic.links
|
||||
if _user_link_allowed(link)
|
||||
],
|
||||
"related_modules": [],
|
||||
"unlocks": list(topic.unlocks),
|
||||
"configuration_keys": [],
|
||||
"metadata": _user_topic_metadata(kind, topic.metadata),
|
||||
}
|
||||
|
||||
|
||||
def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, Any]:
|
||||
projected: dict[str, Any] = {}
|
||||
scalar_keys = ("kind", "screen", "section", "outcome", "result", "verification", "purpose", "when_used", "user_explanation")
|
||||
list_keys = ("prerequisites", "steps", "current_configuration", "limitations", "related_topic_ids")
|
||||
id_keys = ("tree_parent_id", "parent_topic_id", "parent_id")
|
||||
for key in scalar_keys:
|
||||
value = _bounded_string(metadata.get(key), maximum=2_000)
|
||||
if value:
|
||||
projected[key] = value
|
||||
for key in list_keys:
|
||||
values = _bounded_string_list(metadata.get(key), maximum_items=64, maximum_length=2_000)
|
||||
if values:
|
||||
projected[key] = values
|
||||
for key in id_keys:
|
||||
value = _bounded_string(metadata.get(key), maximum=255)
|
||||
if value:
|
||||
projected[key] = value
|
||||
if kind == "reference" and isinstance(metadata.get("fields"), list):
|
||||
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
|
||||
if fields:
|
||||
projected["fields"] = [field for field in fields if field]
|
||||
constraints = _user_constraints(metadata.get("constraints"))
|
||||
if constraints:
|
||||
projected["constraints"] = constraints
|
||||
return projected
|
||||
|
||||
|
||||
def _user_field_metadata(field: Mapping[str, Any]) -> dict[str, Any]:
|
||||
allowed = ("field_id", "label", "user_description", "validation")
|
||||
return {
|
||||
key: value
|
||||
for key in allowed
|
||||
if (value := _bounded_string(field.get(key), maximum=2_000))
|
||||
}
|
||||
|
||||
|
||||
def _user_constraints(value: object) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
constraints: list[dict[str, Any]] = []
|
||||
for item in value[:32]:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
constraint = {
|
||||
key: text
|
||||
for key in ("id", "label", "description")
|
||||
if (text := _bounded_string(item.get(key), maximum=2_000 if key == "description" else 255))
|
||||
}
|
||||
values = _bounded_string_list(item.get("values"), maximum_items=64, maximum_length=500)
|
||||
if values:
|
||||
constraint["values"] = values
|
||||
if constraint.get("label") and constraint.get("description"):
|
||||
constraints.append(constraint)
|
||||
return constraints
|
||||
|
||||
|
||||
def _bounded_string(value: object, *, maximum: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
clean = value.strip()
|
||||
return clean if len(clean) <= maximum else ""
|
||||
|
||||
|
||||
def _bounded_string_list(value: object, *, maximum_items: int, maximum_length: int) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
clean
|
||||
for item in value[:maximum_items]
|
||||
if (clean := _bounded_string(item, maximum=maximum_length))
|
||||
]
|
||||
|
||||
|
||||
def _user_link_allowed(link: DocumentationLink) -> bool:
|
||||
href = link.href.strip()
|
||||
if link.kind == "runtime":
|
||||
return href.startswith("/") and not href.startswith("//")
|
||||
if link.kind != "public":
|
||||
return False
|
||||
parsed = urlsplit(href)
|
||||
return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password
|
||||
|
||||
|
||||
def _has_any_scope(principal: ApiPrincipal, scopes: tuple[str, ...]) -> bool:
|
||||
return any(has_scope(principal, scope) for scope in scopes)
|
||||
|
||||
|
||||
def _documentation_topic_kind(topic: DocumentationTopic) -> str:
|
||||
|
||||
@@ -14,7 +14,9 @@ from govoplan_core.core.modules import (
|
||||
)
|
||||
|
||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin"
|
||||
DOCS_ADMIN_READ_SCOPES = (DOCS_ADMIN_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
@@ -41,14 +43,19 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.8",
|
||||
version="0.1.9",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
||||
permissions=(
|
||||
_permission(
|
||||
DOCS_READ_SCOPE,
|
||||
"View configured documentation",
|
||||
"Read documentation generated from installed modules, visible routes, permissions, and evidence sources.",
|
||||
"Read user documentation generated for the current actor from installed modules and effective configuration.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
"View administrative documentation",
|
||||
"Read technical module, route, permission, configuration, and evidence documentation.",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
@@ -58,6 +65,12 @@ manifest = ModuleManifest(
|
||||
description="Read the configured-system documentation browser.",
|
||||
permissions=(DOCS_READ_SCOPE,),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="docs_admin",
|
||||
name="Documentation administrator",
|
||||
description="Read user guidance and the technical configured-system documentation projection.",
|
||||
permissions=(DOCS_READ_SCOPE, DOCS_ADMIN_READ_SCOPE),
|
||||
),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from inspect import signature
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
|
||||
from govoplan_core.core.modules import DocumentationCondition
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
||||
from govoplan_docs.backend.api.v1.routes import (
|
||||
@@ -12,6 +16,7 @@ from govoplan_docs.backend.api.v1.routes import (
|
||||
_condition_visibility,
|
||||
_documentation_topic_anchor,
|
||||
_documentation_topic_groups,
|
||||
docs_context,
|
||||
)
|
||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||
|
||||
@@ -60,7 +65,7 @@ class DocsContextTests(unittest.TestCase):
|
||||
self.assertIn("access.workflow.grant-user-access", configured_ids)
|
||||
self.assertIn("docs.pattern.field-help", always_ids)
|
||||
|
||||
def test_unavailable_topic_still_appears_in_kind_group(self) -> None:
|
||||
def test_user_projection_omits_topics_without_required_access(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_tenancy_manifest())
|
||||
registry.register(get_access_manifest())
|
||||
@@ -76,11 +81,106 @@ class DocsContextTests(unittest.TestCase):
|
||||
locale="en",
|
||||
)
|
||||
groups = _documentation_topic_groups(layers)
|
||||
workflows = {topic["id"]: topic for topic in groups["workflow"]}
|
||||
workflow_ids = {topic["id"] for topic in groups["workflow"]}
|
||||
|
||||
self.assertIn("access.workflow.grant-user-access", workflows)
|
||||
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"])
|
||||
self.assertIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||
self.assertNotIn("access.workflow.grant-user-access", workflow_ids)
|
||||
self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||
|
||||
def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="9.9.9",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.workflow.safe",
|
||||
title="Safe task",
|
||||
summary="A task the actor may perform.",
|
||||
documentation_types=("user",),
|
||||
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
|
||||
links=(
|
||||
DocumentationLink(label="Open task", href="/example", kind="runtime"),
|
||||
DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"),
|
||||
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
|
||||
DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"),
|
||||
DocumentationLink(label="API", href="/api/v1/example", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"screen": "Example",
|
||||
"steps": ["Do the safe thing."],
|
||||
"current_configuration": ["The bounded option is enabled."],
|
||||
"constraints": [{
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
"secret": "must-not-pass",
|
||||
}],
|
||||
"raw_policy": {"secret": "must-not-pass"},
|
||||
"api_path": "/api/v1/internal",
|
||||
},
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
topic = layers["configured"][0]
|
||||
|
||||
self.assertEqual([link["href"] for link in topic["links"]], ["/example", "https://example.invalid/help"])
|
||||
self.assertEqual(topic["conditions"], [])
|
||||
self.assertEqual(topic["configuration_keys"], [])
|
||||
self.assertEqual(topic["blockers"], {"modules": [], "capabilities": [], "scopes": []})
|
||||
self.assertNotIn("raw_policy", topic["metadata"])
|
||||
self.assertNotIn("api_path", topic["metadata"])
|
||||
self.assertEqual(topic["metadata"]["constraints"][0], {
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
})
|
||||
|
||||
def test_docs_default_to_user_and_admin_projection_requires_admin_authority(self) -> None:
|
||||
query_default = signature(docs_context).parameters["documentation_type"].default
|
||||
self.assertEqual(query_default.default, "user")
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
docs_context(
|
||||
SimpleNamespace(),
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
|
||||
def test_user_actor_projection_does_not_disclose_identity_or_scope_count(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_docs_manifest())
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(govoplan_registry=registry)))
|
||||
empty_layers = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
|
||||
with patch("govoplan_docs.backend.api.v1.routes._documentation_layers", return_value=empty_layers):
|
||||
payload = docs_context(
|
||||
request,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["actor"]["available_documentation_types"], ["user"])
|
||||
self.assertNotIn("tenant_id", payload["actor"])
|
||||
self.assertNotIn("user_id", payload["actor"])
|
||||
self.assertNotIn("scope_count", payload["actor"])
|
||||
self.assertEqual(payload["layers"]["configured"]["permissions"], [])
|
||||
self.assertEqual(payload["layers"]["available"]["routes"], [])
|
||||
|
||||
def test_topic_anchor_is_stable(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -110,11 +110,12 @@ export type DocsDocumentationTopic = {
|
||||
|
||||
export type DocsContext = {
|
||||
actor: {
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
scope_count: number;
|
||||
tenant_id?: string;
|
||||
user_id?: string;
|
||||
scope_count?: number;
|
||||
documentation_type: "admin" | "user";
|
||||
locale: string;
|
||||
available_documentation_types: Array<"admin" | "user">;
|
||||
};
|
||||
summary: {
|
||||
module_count: number;
|
||||
|
||||
@@ -123,7 +123,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<div className="docs-sidebar-header">
|
||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||
<AudienceToggle selected={documentationType} onSelect={selectDocumentationType} />
|
||||
<AudienceToggle
|
||||
selected={documentationType}
|
||||
onSelect={selectDocumentationType}
|
||||
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<ExplorerTree
|
||||
@@ -211,7 +215,13 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}
|
||||
}
|
||||
|
||||
function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void }) {
|
||||
function AudienceToggle({ selected, onSelect, canViewAdmin }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void; canViewAdmin: boolean }) {
|
||||
const options: Array<{ id: DocumentationType; label: string }> = [
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
];
|
||||
if (canViewAdmin) {
|
||||
options.unshift({ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" });
|
||||
}
|
||||
return (
|
||||
<SegmentedControl
|
||||
className="docs-audience-toggle"
|
||||
@@ -221,10 +231,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
|
||||
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
|
||||
value={selected}
|
||||
onChange={onSelect}
|
||||
options={[
|
||||
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
]}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -365,10 +372,16 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
|
||||
const outcome = metadataString(topic.metadata, "outcome");
|
||||
const result = metadataString(topic.metadata, "result");
|
||||
const verification = metadataString(topic.metadata, "verification");
|
||||
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
|
||||
const limitations = metadataList(topic.metadata, "limitations");
|
||||
const constraints = metadataRecords(topic.metadata, "constraints");
|
||||
const prefix = topicAnchorId(topic);
|
||||
return (
|
||||
<div className="docs-topic-details">
|
||||
{outcome && <DetailBlock id={`${prefix}-outcome`} title="i18n:govoplan-docs.outcome.10172bd3" value={outcome} />}
|
||||
{!!currentConfiguration.length && <DetailList id={`${prefix}-current-configuration`} title="i18n:govoplan-docs.this_system.b13a51ad" items={currentConfiguration} />}
|
||||
{!!constraints.length && <ConstraintDetails id={`${prefix}-constraints`} constraints={constraints} />}
|
||||
{!!limitations.length && <DetailList id={`${prefix}-limitations`} title="i18n:govoplan-docs.details.a6b3c45f" items={limitations} />}
|
||||
{!!prerequisites.length && <DetailList id={`${prefix}-prerequisites`} title="i18n:govoplan-docs.prerequisites.fdf2407f" items={prerequisites} />}
|
||||
{!!steps.length &&
|
||||
<div className="docs-detail-block" id={`${prefix}-steps`}>
|
||||
@@ -385,6 +398,30 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintDetails({ id, constraints }: { id: string; constraints: Record<string, unknown>[] }) {
|
||||
return (
|
||||
<div className="docs-detail-block" id={id}>
|
||||
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
||||
<dl className="detail-list compact">
|
||||
{constraints.map((constraint, index) => {
|
||||
const label = metadataString(constraint, "label");
|
||||
const description = metadataString(constraint, "description");
|
||||
const values = metadataList(constraint, "values");
|
||||
return (
|
||||
<div key={metadataString(constraint, "id") || `${label}-${index}`}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
{description}
|
||||
{!!values.length && <span className="muted block">{values.join(", ")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
const route = metadataString(topic.metadata, "route");
|
||||
const screen = metadataString(topic.metadata, "screen");
|
||||
@@ -783,6 +820,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
|
||||
if (topic.kind === "workflow") {
|
||||
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
|
||||
if (metadataList(topic.metadata, "current_configuration").length) items.push({ id: `${prefix}-current-configuration`, label: "i18n:govoplan-docs.this_system.b13a51ad" });
|
||||
if (metadataRecords(topic.metadata, "constraints").length) items.push({ id: `${prefix}-constraints`, label: "i18n:govoplan-docs.requirements.09a428f9" });
|
||||
if (metadataList(topic.metadata, "limitations").length) items.push({ id: `${prefix}-limitations`, label: "i18n:govoplan-docs.details.a6b3c45f" });
|
||||
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
|
||||
if (metadataList(topic.metadata, "steps").length) items.push({ id: `${prefix}-steps`, label: "i18n:govoplan-docs.steps.6041435e" });
|
||||
if (metadataString(topic.metadata, "result")) items.push({ id: `${prefix}-result`, label: "i18n:govoplan-docs.result.1f4fbf43" });
|
||||
@@ -800,7 +840,7 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
}
|
||||
|
||||
function documentationTypeFromSearch(search: string): DocumentationType {
|
||||
return new URLSearchParams(search).get("type") === "user" ? "user" : "admin";
|
||||
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
function localeFromSearch(search: string): string | null {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
||||
|
||||
const docsReadScopes = ["docs:documentation:read", "system:settings:read", "admin:settings:read"];
|
||||
const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"];
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
@@ -14,7 +14,7 @@ const translations = {
|
||||
export const docsModule: PlatformWebModule = {
|
||||
id: "docs",
|
||||
label: "i18n:govoplan-docs.docs.68a41942",
|
||||
version: "1.0.0",
|
||||
version: "0.1.9",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
||||
translations,
|
||||
|
||||
Reference in New Issue
Block a user