fix(docs): secure adaptive handbook projections

This commit is contained in:
2026-07-21 18:39:06 +02:00
parent 0c9997593c
commit 0f41e02428
10 changed files with 376 additions and 54 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.8" version = "0.1.9"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.8" __version__ = "0.1.9"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Mapping from typing import Any, Mapping
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 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.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database 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"]) router = APIRouter(prefix="/docs", tags=["docs"])
@@ -29,30 +30,57 @@ TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
@router.get("/context") @router.get("/context")
def docs_context( def docs_context(
request: Request, 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), locale: str | None = Query(default=None, min_length=2, max_length=20),
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)), principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
) -> dict[str, Any]: ) -> 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) registry = _registry(request)
resolved_locale = _preferred_locale(request, locale) 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) route_items = _route_items(registry.manifests(), principal)
visible_routes = [item for item in route_items if item["visible"]] visible_routes = [item for item in route_items if item["visible"]]
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) 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())
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()) documentation_count = sum(len(layer) for layer in documentation_layers.values())
topic_groups = _documentation_topic_groups(documentation_layers) topic_groups = _documentation_topic_groups(documentation_layers)
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 { return {
"actor": { "actor": actor_payload,
"tenant_id": principal.tenant_id,
"user_id": principal.user.id,
"scope_count": len(principal.scopes),
"documentation_type": documentation_type,
"locale": resolved_locale,
},
"summary": { "summary": {
"module_count": len(modules), "module_count": len(modules),
"visible_route_count": len(visible_routes), "visible_route_count": len(visible_routes),
@@ -99,25 +127,42 @@ def _registry(request: Request) -> PlatformRegistry:
return registry return registry
def _module_payload(manifest: ModuleManifest) -> dict[str, Any]: def _module_payload(manifest: ModuleManifest, *, technical: bool) -> dict[str, Any]:
frontend = manifest.frontend frontend = manifest.frontend
migration = manifest.migration_spec migration = manifest.migration_spec
return { return {
"id": manifest.id, "id": manifest.id,
"name": manifest.name, "name": manifest.name,
"version": manifest.version, "version": manifest.version if technical else "",
"dependencies": list(manifest.dependencies), "dependencies": list(manifest.dependencies) if technical else [],
"optional_dependencies": list(manifest.optional_dependencies), "optional_dependencies": list(manifest.optional_dependencies) if technical else [],
"permission_count": len(manifest.permissions), "permission_count": len(manifest.permissions) if technical else 0,
"role_template_count": len(manifest.role_templates), "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), "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), "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 else None, "frontend_package": frontend.package_name if frontend and technical else None,
"backend_route_contributed": manifest.route_factory is not None, "backend_route_contributed": manifest.route_factory is not None if technical else False,
"migration_module_id": migration.module_id if migration else None, "migration_module_id": migration.module_id if migration and technical else None,
"capabilities": sorted(manifest.capability_factories), "capabilities": sorted(manifest.capability_factories) if technical else [],
"documentation_count": len(manifest.documentation), "documentation_count": len(manifest.documentation) if technical else 0,
"documentation_provider_count": len(manifest.documentation_providers), "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): if not _topic_matches_documentation_type(topic, documentation_type):
continue continue
active, reason, blockers = _documentation_visibility(topic, installed, registry, principal) 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) target_layer = _documentation_target_layer(topic, active, blockers)
if target_layer not in layers: if target_layer not in layers:
target_layer = "evidence" target_layer = "evidence"
@@ -276,6 +323,7 @@ def _classify_documentation(
target_layer=target_layer, target_layer=target_layer,
blockers=blockers, blockers=blockers,
locale=locale, locale=locale,
documentation_type=documentation_type,
)) ))
return layers return layers
@@ -488,11 +536,12 @@ def _documentation_topic_payload(
target_layer: str, target_layer: str,
blockers: dict[str, list[str]], blockers: dict[str, list[str]],
locale: str, locale: str,
documentation_type: DocumentationType,
) -> dict[str, Any]: ) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale) translation_locale, translation = _translation_for_locale(topic, locale)
kind = _documentation_topic_kind(topic) kind = _documentation_topic_kind(topic)
return { payload = {
"id": topic.id, "id": topic.id,
"source_module_id": module_id, "source_module_id": module_id,
"kind": kind, "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)}), "configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
"metadata": dict(topic.metadata), "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: def _documentation_topic_kind(topic: DocumentationTopic) -> str:

View File

@@ -14,7 +14,9 @@ from govoplan_core.core.modules import (
) )
DOCS_READ_SCOPE = "docs:documentation:read" 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: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -41,14 +43,19 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.8", version="0.1.9",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("policy", "audit", "ops", "workflow", "search"), optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
permissions=( permissions=(
_permission( _permission(
DOCS_READ_SCOPE, DOCS_READ_SCOPE,
"View configured documentation", "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=( role_templates=(
@@ -58,6 +65,12 @@ manifest = ModuleManifest(
description="Read the configured-system documentation browser.", description="Read the configured-system documentation browser.",
permissions=(DOCS_READ_SCOPE,), 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, route_factory=_route_factory,
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),), nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),

View File

@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from inspect import signature
from types import SimpleNamespace 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_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_core.core.registry import PlatformRegistry
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
from govoplan_docs.backend.api.v1.routes import ( from govoplan_docs.backend.api.v1.routes import (
@@ -12,6 +16,7 @@ from govoplan_docs.backend.api.v1.routes import (
_condition_visibility, _condition_visibility,
_documentation_topic_anchor, _documentation_topic_anchor,
_documentation_topic_groups, _documentation_topic_groups,
docs_context,
) )
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest 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("access.workflow.grant-user-access", configured_ids)
self.assertIn("docs.pattern.field-help", always_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 = PlatformRegistry()
registry.register(get_tenancy_manifest()) registry.register(get_tenancy_manifest())
registry.register(get_access_manifest()) registry.register(get_access_manifest())
@@ -76,11 +81,106 @@ class DocsContextTests(unittest.TestCase):
locale="en", locale="en",
) )
groups = _documentation_topic_groups(layers) 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.assertNotIn("access.workflow.grant-user-access", workflow_ids)
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"]) self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
self.assertIn("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: def test_topic_anchor_is_stable(self) -> None:
self.assertEqual( self.assertEqual(

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",

View File

@@ -110,11 +110,12 @@ export type DocsDocumentationTopic = {
export type DocsContext = { export type DocsContext = {
actor: { actor: {
tenant_id: string; tenant_id?: string;
user_id: string; user_id?: string;
scope_count: number; scope_count?: number;
documentation_type: "admin" | "user"; documentation_type: "admin" | "user";
locale: string; locale: string;
available_documentation_types: Array<"admin" | "user">;
}; };
summary: { summary: {
module_count: number; module_count: number;

View File

@@ -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"> <aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<div className="docs-sidebar-header"> <div className="docs-sidebar-header">
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div> <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> </div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99"> <nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree <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 ( return (
<SegmentedControl <SegmentedControl
className="docs-audience-toggle" className="docs-audience-toggle"
@@ -221,10 +231,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c" ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
value={selected} value={selected}
onChange={onSelect} onChange={onSelect}
options={[ options={options}
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
]}
/> />
); );
} }
@@ -365,10 +372,16 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
const outcome = metadataString(topic.metadata, "outcome"); const outcome = metadataString(topic.metadata, "outcome");
const result = metadataString(topic.metadata, "result"); const result = metadataString(topic.metadata, "result");
const verification = metadataString(topic.metadata, "verification"); 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); const prefix = topicAnchorId(topic);
return ( return (
<div className="docs-topic-details"> <div className="docs-topic-details">
{outcome && <DetailBlock id={`${prefix}-outcome`} title="i18n:govoplan-docs.outcome.10172bd3" value={outcome} />} {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} />} {!!prerequisites.length && <DetailList id={`${prefix}-prerequisites`} title="i18n:govoplan-docs.prerequisites.fdf2407f" items={prerequisites} />}
{!!steps.length && {!!steps.length &&
<div className="docs-detail-block" id={`${prefix}-steps`}> <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> }) { function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const route = metadataString(topic.metadata, "route"); const route = metadataString(topic.metadata, "route");
const screen = metadataString(topic.metadata, "screen"); 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.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
if (topic.kind === "workflow") { if (topic.kind === "workflow") {
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" }); 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, "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 (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" }); 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 { 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 { function localeFromSearch(search: string): string | null {

View File

@@ -4,7 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
const DocsPage = lazy(() => import("./features/docs/DocsPage")); 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 = { const translations = {
en: generatedTranslations.en, en: generatedTranslations.en,
@@ -14,7 +14,7 @@ const translations = {
export const docsModule: PlatformWebModule = { export const docsModule: PlatformWebModule = {
id: "docs", id: "docs",
label: "i18n:govoplan-docs.docs.68a41942", label: "i18n:govoplan-docs.docs.68a41942",
version: "1.0.0", version: "0.1.9",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"], optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
translations, translations,