12 Commits

14 changed files with 790 additions and 135 deletions

View File

@@ -22,6 +22,14 @@ This repository owns:
Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior. Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior.
While Docs is installed, its managed `docs_reader` tenant role is an automatic
authenticated-member baseline. Access derives its narrow read grant from the
active Docs manifest without per-user assignments or authorization-time writes,
so ordinary users can open their configured handbook without an administrator
assigning documentation access one account at a time.
Administrative documentation remains separately protected by
`docs:documentation:admin` or the applicable administration scope.
## Documentation model ## Documentation model
The docs module should render documentation in three layers: The docs module should render documentation in three layers:

View File

@@ -26,7 +26,7 @@ It should not start from a full product manual. The current tenant, installed
modules, enabled routes, permissions, and configuration decide what is shown as modules, enabled routes, permissions, and configuration decide what is shown as
the default path. the default path.
Tracking issue: `add-ideas/govoplan-docs#15`. Tracking issue: `GovOPlaN/govoplan-docs#15`.
## Editorial Pillars ## Editorial Pillars
@@ -255,6 +255,9 @@ Workflow topics:
- keep each step actionable - keep each step actionable
- mention blockers where the user would encounter them - mention blockers where the user would encounter them
- link to field/reference topics instead of repeating field tables - link to field/reference topics instead of repeating field tables
- for every user-facing workflow, declare one or more conditions and put
`required_scopes` or `any_scopes` on every condition alternative; the release
gate rejects an unscoped alternative
Reference topics: Reference topics:

View File

@@ -100,17 +100,17 @@ database session when available. The module can then describe the effective
state without the docs module importing feature internals. state without the docs module importing feature internals.
For example, `govoplan-mail` contributes static documentation for reusable mail For example, `govoplan-mail` contributes static documentation for reusable mail
profiles and a runtime tenant policy topic. If the effective tenant mail policy profiles and runtime effective-policy topics. The provider evaluates the
limits sending to approved profile ids and disables user, group, and current actor and scope. A custom-profile task appears only when that actor has
campaign-local profiles, the runtime docs state that users can choose approved the relevant authority and the effective user policy permits it; the task then
profiles but cannot bring arbitrary SMTP or IMAP servers at lower scopes. If the states the host/domain bounds and separate credential authority needed to
policy allows lower scopes, the same topic explains which scopes may define complete it.
profiles and whether credential inheritance is locked.
Runtime providers must avoid leaking secrets. They should summarize posture, Runtime providers must avoid leaking secrets. They should summarize posture,
counts, source provenance, and enabling conditions rather than exposing counts, source provenance, and enabling conditions. A provider may disclose a
credentials, hostnames, profile ids, or raw policy payloads unless the route is host or domain pattern only when the actor is authorized and that exact value is
explicitly intended for that level of detail. necessary to complete the documented task. Credentials, usernames, profile
ids, source ids, unrelated topology, and raw policy payloads remain excluded.
### Conditions And Unlocks ### Conditions And Unlocks
@@ -123,9 +123,10 @@ Documentation topics can declare conditions:
- required scopes or one-of scope alternatives - required scopes or one-of scope alternatives
- configuration keys that influence the topic - configuration keys that influence the topic
The docs API classifies satisfied topics into their requested layer. Unsatisfied The docs API classifies satisfied topics into their requested layer.
topics remain visible as available or evidence documentation when safe, with a Unsatisfied topics can remain visible in the administrator projection when
reason such as a missing module, capability, or scope. safe. The user projection omits them so a protected body or metadata payload is
not disclosed merely because a condition failed.
Topics can also declare related modules and unlock notes. This lets a module Topics can also declare related modules and unlock notes. This lets a module
state that additional behavior becomes available when another module is state that additional behavior becomes available when another module is
@@ -136,7 +137,7 @@ then to public module documentation for broader examples.
GovOPlaN has two documentation presentations over the same source model. GovOPlaN has two documentation presentations over the same source model.
Admin documentation is allowed to be technical. It can expose installed module Admin documentation is a separately authorized technical projection. It can expose installed module
ids, route contributions, API paths, permissions, capability names, server ids, route contributions, API paths, permissions, capability names, server
configuration keys, policy source chains, migration notes, and operator configuration keys, policy source chains, migration notes, and operator
evidence. This is the right place to explain exactly why a setting is available, evidence. This is the right place to explain exactly why a setting is available,
@@ -157,8 +158,10 @@ whether the user may choose an approved mail profile or add a local mail server.
The first presentation rule is: The first presentation rule is:
- admin docs show technical context and evidence tables - admin docs require `docs:documentation:admin` or the compatible settings
- user docs show plain-language topics, examples, limits, and escalation paths administration authority and show technical context and evidence tables
- user docs are the default and show only active plain-language topics,
examples, limits, and safe escalation paths
- both presentations are filtered by installed modules, active configuration, - both presentations are filtered by installed modules, active configuration,
permissions, and safe disclosure rules permissions, and safe disclosure rules

View File

@@ -23,9 +23,24 @@ The documentation context is built from:
| `available` | Installed features hidden by missing permissions or unmet conditions. | | `available` | Installed features hidden by missing permissions or unmet conditions. |
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. | | `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
Normal user documentation should emphasize `always` and `configured` content. Normal user documentation returns only active, safely projected topics. Admin
Admin documentation may show all layers plus route, permission, module, and documentation may show all layers plus route, permission, module, and capability
capability diagnostics. diagnostics, but it requires the separate administrative documentation
authority.
The managed `docs_reader` role grants `docs:documentation:read` automatically
to every authenticated tenant membership while Docs is installed. This only
opens the user projection: each contributed workflow still needs its own
module, capability, permission, and runtime-policy conditions so baseline Help
Center access does not imply authority to perform every documented task.
This permission binding is a strict source contract. Every user workflow topic
must declare at least one `DocumentationCondition`, and every alternative in
its `conditions` tuple must include `required_scopes` or `any_scopes`. Module,
capability, or configuration conditions alone are not sufficient because topic
conditions are alternatives: one unscoped alternative would bypass all scoped
ones. Manifest validation blocks a release containing such a topic, and Docs
omits a non-compliant runtime-provider topic from the user projection.
## Conditions ## Conditions
@@ -37,7 +52,11 @@ Documentation topics can declare:
- required capabilities - required capabilities
- required scopes - required scopes
- any-of scope sets - any-of scope sets
- related configuration keys - related configuration keys as technical provenance
Configuration keys are descriptive metadata; they are not evaluated as
conditions. Configuration-dependent guidance belongs in an owning module's
runtime provider.
The API returns both a human-readable `reason` and structured `blockers`: The API returns both a human-readable `reason` and structured `blockers`:
@@ -52,10 +71,27 @@ The API returns both a human-readable `reason` and structured `blockers`:
} }
``` ```
The WebUI must display the reason and, when present, the blocker lists. This Raw blockers and scope names are part of the administrator projection. The user
prevents dead-end instructions: users see whether a feature is unavailable projection omits inactive topics instead of returning their protected body,
because a module is missing, a capability is not registered, or their current metadata, and blocker identifiers. A module can contribute a separate, safe
permissions do not expose it. plain-language escalation topic when users need to know that an administrator
must enable something.
## Task metadata
Workflow topics can supply `outcome`, `prerequisites`, `steps`, `result`, and
`verification`. Runtime providers can additionally supply:
- `current_configuration`: bounded plain-language facts for this actor and
installation;
- `limitations`: bounded, actionable caveats; and
- `constraints`: records with `id`, `label`, `description`, and optional
user-safe `values` needed to complete the task.
The Docs API validates and whitelists those fields for the user projection.
Modules remain responsible for authorization and for preserving policy
semantics. Constraints must never contain secrets, internal policy-source ids,
unrelated topology, or raw policy payloads.
## Module Guidance ## Module Guidance
@@ -90,9 +126,9 @@ Runtime docs may link to issues when an unavailable feature is planned or a
known limitation is relevant, but the UI must label that link as active work. known limitation is relevant, but the UI must label that link as active work.
It must not treat open issues as shipped behavior. It must not treat open issues as shipped behavior.
Safe disclosure is evaluated before a topic is returned. Missing permission, Safe disclosure is evaluated before a topic is returned. User docs contain
missing module, and missing capability explanations should be useful but only active topics, runtime and HTTPS public links, and a bounded metadata
minimal. User docs explain the blocker and who can resolve it. Admin docs can whitelist. Admin docs can
include route ids, scopes, capability names, configuration keys, and policy include route ids, scopes, capability names, configuration keys, and policy
provenance only when the actor has permission to inspect those details. Secrets, provenance only when the actor has permission to inspect those details. Secrets,
tokens, private keys, credentials, raw policy payloads, and unrelated personal tokens, private keys, credentials, raw policy payloads, and unrelated personal

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.8", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.8" version = "0.1.10"
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"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.10",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.10",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

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

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
@@ -15,11 +16,12 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
user_workflow_scope_condition_issues,
) )
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,65 +31,183 @@ 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_route_items = [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)
documentation_count = sum(len(layer) for layer in documentation_layers.values()) if documentation_type == "admin":
catalog = _admin_documentation_catalog(
registry,
principal,
route_items=route_items,
visible_route_items=visible_route_items,
)
else:
catalog = _user_documentation_catalog(
registry,
visible_route_items=visible_route_items,
documentation_layers=documentation_layers,
)
topic_groups = _documentation_topic_groups(documentation_layers) topic_groups = _documentation_topic_groups(documentation_layers)
return { return {
"actor": { "actor": _documentation_actor(
"tenant_id": principal.tenant_id, principal,
"user_id": principal.user.id, documentation_type=documentation_type,
"scope_count": len(principal.scopes), locale=resolved_locale,
"documentation_type": documentation_type, can_read_admin=can_read_admin_documentation,
"locale": resolved_locale, ),
}, "summary": _documentation_summary(
"summary": { catalog,
"module_count": len(modules), documentation_layers=documentation_layers,
"visible_route_count": len(visible_routes), topic_groups=topic_groups,
"available_route_count": len(available_routes), ),
"permission_count": len(permissions),
"granted_permission_count": sum(1 for item in permissions if item["granted"]),
"optional_module_count": len(optional_modules),
"documentation_topic_count": documentation_count,
"configured_documentation_topic_count": len(documentation_layers["configured"]),
"workflow_topic_count": len(topic_groups["workflow"]),
"reference_topic_count": len(topic_groups["reference"]),
"pattern_topic_count": len(topic_groups["pattern"]),
"system_topic_count": len(topic_groups["system"]),
},
"topic_groups": topic_groups, "topic_groups": topic_groups,
"layers": { "layers": _documentation_layer_payload(
"always": { catalog,
"documentation": documentation_layers["always"], documentation_layers=documentation_layers,
}, include_evidence_sources=documentation_type == "admin",
"configured": { ),
"modules": modules, }
"routes": visible_routes,
"permissions": permissions,
"documentation": documentation_layers["configured"], def _admin_documentation_catalog(
}, registry: PlatformRegistry,
"available": { principal: ApiPrincipal,
"routes": available_routes, *,
"permissions": [item for item in permissions if not item["granted"]], route_items: list[dict[str, Any]],
"documentation": documentation_layers["available"], visible_route_items: list[dict[str, Any]],
}, ) -> dict[str, list[dict[str, Any]]]:
"evidence": { return {
"optional_modules": optional_modules, "modules": [
"sources": _evidence_sources(), _module_payload(manifest, technical=True)
"documentation": documentation_layers["evidence"], for manifest in registry.manifests()
}, ],
"permissions": [
_permission_payload(permission, principal)
for permission in registry.permissions()
],
"visible_routes": visible_route_items,
"available_routes": [item for item in route_items if not item["visible"]],
"optional_modules": _optional_module_evidence(registry.manifests()),
}
def _user_documentation_catalog(
registry: PlatformRegistry,
*,
visible_route_items: list[dict[str, Any]],
documentation_layers: Mapping[str, list[dict[str, Any]]],
) -> dict[str, list[dict[str, Any]]]:
visible_module_ids = {
*(str(item["module_id"]) for item in visible_route_items),
*(
str(topic["source_module_id"])
for topic in _all_documentation_topics(documentation_layers)
),
}
return {
"modules": [
_module_payload(manifest, technical=False)
for manifest in registry.manifests()
if manifest.id in visible_module_ids
],
"permissions": [],
"visible_routes": _user_route_payloads(visible_route_items),
"available_routes": [],
"optional_modules": [],
}
def _documentation_actor(
principal: ApiPrincipal,
*,
documentation_type: DocumentationType,
locale: str,
can_read_admin: bool,
) -> dict[str, Any]:
actor: dict[str, Any] = {
"documentation_type": documentation_type,
"locale": locale,
"available_documentation_types": [
"user",
*(["admin"] if can_read_admin else []),
],
}
if documentation_type == "admin":
actor.update(
tenant_id=principal.tenant_id,
user_id=principal.user.id,
scope_count=len(principal.scopes),
)
return actor
def _documentation_summary(
catalog: Mapping[str, list[dict[str, Any]]],
*,
documentation_layers: Mapping[str, list[dict[str, Any]]],
topic_groups: Mapping[str, list[dict[str, Any]]],
) -> dict[str, int]:
permissions = catalog["permissions"]
return {
"module_count": len(catalog["modules"]),
"visible_route_count": len(catalog["visible_routes"]),
"available_route_count": len(catalog["available_routes"]),
"permission_count": len(permissions),
"granted_permission_count": sum(
1 for item in permissions if item["granted"]
),
"optional_module_count": len(catalog["optional_modules"]),
"documentation_topic_count": sum(
len(layer) for layer in documentation_layers.values()
),
"configured_documentation_topic_count": len(
documentation_layers["configured"]
),
"workflow_topic_count": len(topic_groups["workflow"]),
"reference_topic_count": len(topic_groups["reference"]),
"pattern_topic_count": len(topic_groups["pattern"]),
"system_topic_count": len(topic_groups["system"]),
}
def _documentation_layer_payload(
catalog: Mapping[str, list[dict[str, Any]]],
*,
documentation_layers: Mapping[str, list[dict[str, Any]]],
include_evidence_sources: bool,
) -> dict[str, dict[str, object]]:
permissions = catalog["permissions"]
return {
"always": {
"documentation": documentation_layers["always"],
},
"configured": {
"modules": catalog["modules"],
"routes": catalog["visible_routes"],
"permissions": permissions,
"documentation": documentation_layers["configured"],
},
"available": {
"routes": catalog["available_routes"],
"permissions": [item for item in permissions if not item["granted"]],
"documentation": documentation_layers["available"],
},
"evidence": {
"optional_modules": catalog["optional_modules"],
"sources": _evidence_sources() if include_evidence_sources else [],
"documentation": documentation_layers["evidence"],
}, },
} }
@@ -99,28 +219,57 @@ 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",
}
def _user_route_payloads(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for item in items:
key = (str(item["module_id"]), str(item["path"]))
if key in seen:
continue
seen.add(key)
result.append(_user_route_payload(item))
return result
def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]: def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]:
return { return {
"scope": permission.scope, "scope": permission.scope,
@@ -260,11 +409,21 @@ def _classify_documentation(
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []} layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
installed = {manifest.id for manifest in registry.manifests()} installed = {manifest.id for manifest in registry.manifests()}
visible_runtime_paths = frozenset([
"/settings", # Authenticated shell route, not contributed by a module manifest.
*(
str(item["path"])
for item in _route_items(registry.manifests(), principal)
if item["visible"]
),
]) if documentation_type == "user" else frozenset()
topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale) topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale)
for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)): for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)):
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 +435,8 @@ def _classify_documentation(
target_layer=target_layer, target_layer=target_layer,
blockers=blockers, blockers=blockers,
locale=locale, locale=locale,
documentation_type=documentation_type,
visible_runtime_paths=visible_runtime_paths,
)) ))
return layers return layers
@@ -292,7 +453,8 @@ def _collect_documentation_topics(
topics: list[tuple[str, DocumentationTopic]] = [] topics: list[tuple[str, DocumentationTopic]] = []
for manifest in registry.manifests(): for manifest in registry.manifests():
for topic in manifest.documentation: for topic in manifest.documentation:
topics.append((manifest.id, topic)) if not user_workflow_scope_condition_issues(topic):
topics.append((manifest.id, topic))
if not manifest.documentation_providers: if not manifest.documentation_providers:
continue continue
context = DocumentationContext( context = DocumentationContext(
@@ -308,6 +470,8 @@ def _collect_documentation_topics(
try: try:
provided_topics = tuple(provider(context)) provided_topics = tuple(provider(context))
except Exception as exc: except Exception as exc:
if documentation_type != "admin":
continue
provided_topics = ( provided_topics = (
DocumentationTopic( DocumentationTopic(
id=f"{manifest.id}.runtime-documentation-unavailable", id=f"{manifest.id}.runtime-documentation-unavailable",
@@ -321,7 +485,8 @@ def _collect_documentation_topics(
), ),
) )
for topic in provided_topics: for topic in provided_topics:
topics.append((manifest.id, topic)) if not user_workflow_scope_condition_issues(topic):
topics.append((manifest.id, topic))
return topics return topics
@@ -488,11 +653,13 @@ 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,
visible_runtime_paths: frozenset[str],
) -> 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 +689,134 @@ 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, visible_runtime_paths=visible_runtime_paths)
],
"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", "help_contexts")
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, *, visible_runtime_paths: frozenset[str]) -> bool:
href = link.href.strip()
if link.kind == "runtime":
if not href.startswith("/") or href.startswith("//") or "\\" in href:
return False
try:
parsed = urlsplit(href)
except ValueError:
return False
return not parsed.scheme and not parsed.netloc and parsed.path in visible_runtime_paths
if link.kind != "public":
return False
try:
parsed = urlsplit(href)
except ValueError:
return False
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

@@ -2,6 +2,7 @@ from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -14,7 +15,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,22 +44,34 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.8", version="0.1.10",
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=(
RoleTemplate( RoleTemplate(
slug="docs_reader", slug="docs_reader",
name="Documentation reader", name="Documentation reader",
description="Read the configured-system documentation browser.", description="Read the configured-system documentation browser. This role is granted automatically to every authenticated tenant member while Docs is installed.",
permissions=(DOCS_READ_SCOPE,), permissions=(DOCS_READ_SCOPE,),
default_authenticated=True,
),
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,
@@ -155,6 +170,11 @@ manifest = ModuleManifest(
audience=("tenant_admin", "access_admin", "operator", "user"), audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("organizations", "identity", "idm", "access"), related_modules=("organizations", "identity", "idm", "access"),
order=21, order=21,
conditions=(
DocumentationCondition(
required_modules=("organizations", "identity", "idm", "access"),
),
),
links=( links=(
DocumentationLink(label="Organizations", href="/organizations", kind="runtime"), DocumentationLink(label="Organizations", href="/organizations", kind="runtime"),
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"), DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),

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, NavItem
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
@@ -28,6 +33,18 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase): class DocsContextTests(unittest.TestCase):
def test_docs_reader_is_the_managed_authenticated_tenant_default(self) -> None:
manifest = get_docs_manifest()
roles = {template.slug: template for template in manifest.role_templates}
self.assertTrue(roles["docs_reader"].default_authenticated)
self.assertTrue(roles["docs_reader"].managed)
self.assertEqual(roles["docs_reader"].level, "tenant")
self.assertEqual(
roles["docs_reader"].permissions,
("docs:documentation:read",),
)
def test_topic_groups_preserve_layer_classification(self) -> None: def test_topic_groups_preserve_layer_classification(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(get_tenancy_manifest()) registry.register(get_tenancy_manifest())
@@ -60,7 +77,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 +93,207 @@ 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_fails_closed_for_unscoped_workflow_topics(self) -> None:
unscoped_static_topic = DocumentationTopic(
id="example.workflow.unscoped-static",
title="Unscoped static task",
summary="This topic must not be rendered.",
documentation_types=("user",),
metadata={"kind": "workflow"},
)
unscoped_runtime_topic = DocumentationTopic(
id="example.workflow.unscoped-runtime",
title="Unscoped runtime task",
summary="This provider topic must not be rendered.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_modules=("example",)),),
metadata={"kind": "workflow"},
)
scoped_runtime_topic = DocumentationTopic(
id="example.workflow.scoped-runtime",
title="Scoped runtime task",
summary="This provider topic remains visible.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
metadata={"kind": "workflow"},
)
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="9.9.9",
documentation=(unscoped_static_topic,),
documentation_providers=(
lambda context: (unscoped_runtime_topic, scoped_runtime_topic),
),
))
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="user",
locale="en",
)
self.assertEqual(
[topic["id"] for topic in _documentation_topic_groups(layers)["workflow"]],
["example.workflow.scoped-runtime"],
)
def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="9.9.9",
nav_items=(NavItem(path="/example", label="Example", required_any=("docs:documentation:read",)),),
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="Backslash runtime", href="/\\evil.invalid", kind="runtime"),
DocumentationLink(label="Invisible runtime", href="/hidden", kind="runtime"),
DocumentationLink(label="Shell settings", href="/settings?section=example", kind="runtime"),
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
DocumentationLink(label="Malformed public", href="https://[", 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."],
"help_contexts": ["example.list"],
"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", "/settings?section=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"]["help_contexts"], ["example.list"])
self.assertEqual(topic["metadata"]["constraints"][0], {
"id": "domain",
"label": "Domain",
"description": "Use an approved domain.",
"values": ["*.example.invalid"],
})
def test_runtime_provider_failure_is_diagnostic_only(self) -> None:
def failing_provider(_context):
raise RuntimeError("sensitive provider detail")
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="private-module",
name="Private module",
version="1.0.0",
documentation_providers=(failing_provider,),
))
principal = FakePrincipal({"docs:documentation:read"})
user_layers = _classify_documentation(
registry,
principal,
settings=None,
session=None,
documentation_type="user",
locale="en",
)
admin_layers = _classify_documentation(
registry,
principal,
settings=None,
session=None,
documentation_type="admin",
locale="en",
)
self.assertFalse(any(user_layers.values()))
self.assertEqual(
[topic["id"] for topic in admin_layers["evidence"]],
["private-module.runtime-documentation-unavailable"],
)
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"], [])
self.assertEqual(payload["layers"]["evidence"]["sources"], [])
self.assertEqual(
[(item["module_id"], item["path"]) for item in payload["layers"]["configured"]["routes"]],
[("docs", "/docs")],
)
self.assertEqual(payload["summary"]["visible_route_count"], 1)
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.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,11 +13,11 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"

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

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react"; import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { import {
@@ -67,20 +67,29 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
const [context, setContext] = useState<DocsContext | null>(null); const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const loadSequence = useRef(0);
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search)); const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set()); const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
const locale = localeFromSearch(location.search) ?? language; const locale = localeFromSearch(location.search) ?? language;
const adminDocs = documentationType === "admin"; const adminDocs = documentationType === "admin";
async function load() { async function load() {
const sequence = ++loadSequence.current;
setLoading(true); setLoading(true);
setError(""); setError("");
setContext(null);
try { try {
setContext(await fetchDocsContext(settings, { documentationType, locale })); const nextContext = await fetchDocsContext(settings, { documentationType, locale });
if (sequence !== loadSequence.current) return;
if (nextContext.actor.documentation_type !== documentationType) {
throw new Error("Documentation response type did not match the requested projection.");
}
setContext(nextContext);
} catch (err) { } catch (err) {
if (sequence !== loadSequence.current) return;
setError(adminErrorMessage(err)); setError(adminErrorMessage(err));
} finally { } finally {
setLoading(false); if (sequence === loadSequence.current) setLoading(false);
} }
} }
@@ -123,7 +132,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 +224,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 +240,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" }
]}
/> />
); );
} }
@@ -353,10 +369,26 @@ function DocumentationTopicList({ topics, emptyText, showTechnical = false, docu
} }
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) { function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
if (topic.kind === "workflow") return <WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} />; const configuredDetails = <ConfiguredDetails topic={topic} />;
if (topic.kind === "reference") return <ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />; if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
if (topic.kind === "pattern") return <PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />; if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
return <RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />; if (topic.kind === "pattern") return <>{configuredDetails}<PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
return <>{configuredDetails}<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /></>;
}
function ConfiguredDetails({ topic }: { topic: DocsDocumentationTopic }) {
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
const limitations = metadataList(topic.metadata, "limitations");
const constraints = metadataRecords(topic.metadata, "constraints");
const prefix = topicAnchorId(topic);
if (!currentConfiguration.length && !limitations.length && !constraints.length) return null;
return (
<div className="docs-topic-details">
{!!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} />}
</div>
);
} }
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) { function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
@@ -385,6 +417,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");
@@ -757,8 +813,25 @@ function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null { function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
if (!pages.length) return null; if (!pages.length) return null;
const requested = new URLSearchParams(search).get("topic") || ""; const params = new URLSearchParams(search);
return pages.find((page) => page.id === requested) ?? pages[0]; const requested = params.get("topic") || "";
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
const helpContext = params.get("context") || "";
if (helpContext) {
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
if (exact) return exact;
const moduleId = helpContextModuleId(helpContext);
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
if (moduleTopic) return moduleTopic;
}
return pages[0];
}
function helpContextModuleId(value: string): string {
const prefix = value.split(".", 1)[0];
if (prefix === "campaign") return "campaigns";
if (prefix === "address-book") return "addresses";
return prefix;
} }
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] { function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
@@ -781,6 +854,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
const prefix = topicAnchorId(topic); const prefix = topicAnchorId(topic);
const items: OutlineItem[] = [{ id: prefix, label: topic.title }]; const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
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 (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 (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, "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" });
@@ -800,7 +876,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.10",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"], optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
translations, translations,