15 Commits

14 changed files with 848 additions and 185 deletions

View File

@@ -18,8 +18,8 @@ cd /mnt/DATA/git/govoplan-core
For combined checks once implementation starts, run: For combined checks once implementation starts, run:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan
./scripts/check-focused.sh tools/checks/check-focused.sh
``` ```
## Working Rules ## Working Rules

View File

@@ -1,5 +1,9 @@
# govoplan-docs # govoplan-docs
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems. GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems.
It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities. It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities.
@@ -18,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

@@ -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
@@ -210,7 +213,50 @@ The UI should make the current context explicit enough to avoid confusion, but i
## Governance ## Governance
Durable context belongs in repository docs and synced wiki pages. Active work belongs in Gitea issues. Runtime documentation should link both where helpful, but it should distinguish stable explanation from changing backlog state. ### Source Ownership
Documentation ownership follows behavior ownership:
- runtime documentation topics belong to the module that owns the route,
policy, workflow, capability, or data model being explained
- repository docs belong to the repository that owns the implementation or
durable architecture decision
- synced Gitea wiki pages are a publication surface for durable context, not a
separate source of truth
- active Gitea issues are the source of truth for current work state,
acceptance criteria, blockers, and triage decisions
The docs module renders and classifies documentation. It should not become the
owner of feature-module explanations, and it should not copy backlog state into
runtime documentation as if it were stable product behavior. When runtime docs
link to an issue, they must present it as changing work state. When runtime docs
link to repository docs or wiki pages, they may present the linked material as
durable context if the owning repository treats it that way.
Durable context belongs in repository docs and synced wiki pages. Active work
belongs in Gitea issues. Runtime documentation should link both where helpful,
but it should distinguish stable explanation from changing backlog state.
### Privacy And Permission Boundaries
Documentation is still a governed interface. Role-aware documentation must never
use help text as a side channel for data, configuration, or capability details
that the actor could not otherwise see.
User-facing topics may explain that a feature is unavailable and identify the
kind of blocker, such as missing permission, disabled module, locked policy, or
administrator configuration. They should not expose internal module ids, raw
scope names, policy payloads, hostnames, tenant identifiers, profile ids, or
other operational details unless the actor is already allowed to inspect that
information.
Admin-facing topics may expose technical provenance, route ids, API paths,
capabilities, configuration keys, policy source chains, and migration notes when
the actor has the relevant administrative permission. Even then, runtime
providers must summarize secrets and sensitive values as posture, counts, or
source provenance. Credentials, tokens, private keys, raw payloads, and
person-specific data stay out of documentation responses unless a dedicated
audited administration route explicitly provides them.
Documentation sources should be auditable when they affect compliance, operator procedure, or policy explanation. Configuration-derived documentation should identify the source configuration package or policy source where possible. Documentation sources should be auditable when they affect compliance, operator procedure, or policy explanation. Configuration-derived documentation should identify the source configuration package or policy source where possible.

View File

@@ -23,9 +23,16 @@ 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.
## Conditions ## Conditions
@@ -37,7 +44,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 +63,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
@@ -68,3 +96,32 @@ should contribute conditional topics such as:
The docs module remains the renderer. Feature modules own their subject matter The docs module remains the renderer. Feature modules own their subject matter
and describe unlocks through manifest metadata. and describe unlocks through manifest metadata.
## Ownership And Disclosure Rules
The ownership rule is the same for all documentation layers: the module or
repository that owns the behavior owns the durable explanation. The docs module
owns classification, filtering, search, route contribution, and rendering. It
does not own feature-module business rules, policy semantics, or current issue
state.
Use these sources for these purposes:
| Source | Purpose |
| --- | --- |
| Runtime docs providers | Effective, actor-aware explanation of the configured system. |
| Repository docs | Durable architecture, module contracts, runbooks, and governance decisions. |
| Synced Gitea wiki pages | Published copy of durable documentation for browsing and linking. |
| Gitea issues | Active backlog state, acceptance criteria, blockers, and closure evidence. |
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.
It must not treat open issues as shipped behavior.
Safe disclosure is evaluated before a topic is returned. User docs contain
only active topics, runtime and HTTPS public links, and a bounded metadata
whitelist. Admin docs can
include route ids, scopes, capability names, configuration keys, and policy
provenance only when the actor has permission to inspect those details. Secrets,
tokens, private keys, credentials, raw policy payloads, and unrelated personal
data are never returned as documentation content.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.6", "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.6", "@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",
@@ -32,4 +32,3 @@
} }
} }
} }

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.6" 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.6", "govoplan-core>=0.1.10",
"govoplan-access>=0.1.6", "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.6" __version__ = "0.1.10"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any 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,58 @@ 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)
if documentation_type == "admin":
visible_routes = visible_route_items
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_route_items),
*(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_payloads(visible_route_items)
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),
@@ -85,7 +114,7 @@ def docs_context(
}, },
"evidence": { "evidence": {
"optional_modules": optional_modules, "optional_modules": optional_modules,
"sources": _evidence_sources(), "sources": _evidence_sources() if documentation_type == "admin" else [],
"documentation": documentation_layers["evidence"], "documentation": documentation_layers["evidence"],
}, },
}, },
@@ -99,28 +128,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 +318,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 +344,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
@@ -308,6 +378,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",
@@ -356,42 +428,117 @@ def _condition_visibility(
registry: PlatformRegistry, registry: PlatformRegistry,
principal: ApiPrincipal, principal: ApiPrincipal,
) -> tuple[bool, str, dict[str, list[str]]]: ) -> tuple[bool, str, dict[str, list[str]]]:
blockers = {"modules": [], "capabilities": [], "scopes": []} missing_required_modules = _missing_required_modules(condition, installed)
missing_required_modules = [module_id for module_id in condition.required_modules if module_id not in installed] unsatisfied_any_modules = _unsatisfied_any_modules(condition, installed)
if missing_required_modules: conflicting_modules = _conflicting_modules(condition, installed)
blockers["modules"].extend(missing_required_modules) missing_capabilities = _missing_required_capabilities(condition, registry)
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules): missing_scopes = _missing_required_scopes(condition, principal)
blockers["modules"].extend(module_id for module_id in condition.any_modules if module_id not in blockers["modules"]) unsatisfied_any_scopes = _unsatisfied_any_scopes(condition, principal)
conflicting_modules = [module_id for module_id in condition.missing_modules if module_id in installed] blockers = _condition_blockers(
if conflicting_modules: missing_required_modules=missing_required_modules,
blockers["modules"].extend(conflicting_modules) unsatisfied_any_modules=unsatisfied_any_modules,
missing_capabilities = [name for name in condition.required_capabilities if not registry.has_capability(name)] conflicting_modules=conflicting_modules,
if missing_capabilities: missing_capabilities=missing_capabilities,
blockers["capabilities"].extend(missing_capabilities) missing_scopes=missing_scopes,
missing_scopes = [scope for scope in condition.required_scopes if not has_scope(principal, scope)] unsatisfied_any_scopes=unsatisfied_any_scopes,
missing_any_scopes = list(condition.any_scopes) if condition.any_scopes and not any(has_scope(principal, scope) for scope in condition.any_scopes) else [] )
if missing_scopes:
blockers["scopes"].extend(missing_scopes)
if missing_any_scopes:
blockers["scopes"].extend(scope for scope in missing_any_scopes if scope not in blockers["scopes"])
if not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]: if _condition_has_no_blockers(blockers):
return True, "conditions satisfied", blockers return True, "conditions satisfied", blockers
reason = _condition_blocker_reason(
missing_required_modules=missing_required_modules,
unsatisfied_any_modules=unsatisfied_any_modules,
conflicting_modules=conflicting_modules,
missing_capabilities=missing_capabilities,
missing_scopes=missing_scopes,
unsatisfied_any_scopes=unsatisfied_any_scopes,
)
return False, reason, blockers
def _missing_required_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
return [module_id for module_id in condition.required_modules if module_id not in installed]
def _unsatisfied_any_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
if not condition.any_modules:
return []
if any(module_id in installed for module_id in condition.any_modules):
return []
return list(condition.any_modules)
def _conflicting_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
return [module_id for module_id in condition.missing_modules if module_id in installed]
def _missing_required_capabilities(condition: DocumentationCondition, registry: PlatformRegistry) -> list[str]:
return [name for name in condition.required_capabilities if not registry.has_capability(name)]
def _missing_required_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
return [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
def _unsatisfied_any_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
if not condition.any_scopes:
return []
if any(has_scope(principal, scope) for scope in condition.any_scopes):
return []
return list(condition.any_scopes)
def _condition_blockers(
*,
missing_required_modules: list[str],
unsatisfied_any_modules: list[str],
conflicting_modules: list[str],
missing_capabilities: list[str],
missing_scopes: list[str],
unsatisfied_any_scopes: list[str],
) -> dict[str, list[str]]:
blockers = {"modules": [], "capabilities": [], "scopes": []}
_extend_unique(blockers["modules"], missing_required_modules)
_extend_unique(blockers["modules"], unsatisfied_any_modules)
_extend_unique(blockers["modules"], conflicting_modules)
_extend_unique(blockers["capabilities"], missing_capabilities)
_extend_unique(blockers["scopes"], missing_scopes)
_extend_unique(blockers["scopes"], unsatisfied_any_scopes)
return blockers
def _extend_unique(target: list[str], values: list[str]) -> None:
target.extend(value for value in values if value not in target)
def _condition_has_no_blockers(blockers: dict[str, list[str]]) -> bool:
return not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]
def _condition_blocker_reason(
*,
missing_required_modules: list[str],
unsatisfied_any_modules: list[str],
conflicting_modules: list[str],
missing_capabilities: list[str],
missing_scopes: list[str],
unsatisfied_any_scopes: list[str],
) -> str:
parts: list[str] = [] parts: list[str] = []
if missing_required_modules: if missing_required_modules:
parts.append("missing modules: " + ", ".join(missing_required_modules)) parts.append("missing modules: " + ", ".join(missing_required_modules))
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules): if unsatisfied_any_modules:
parts.append("requires one installed module from: " + ", ".join(condition.any_modules)) parts.append("requires one installed module from: " + ", ".join(unsatisfied_any_modules))
if conflicting_modules: if conflicting_modules:
parts.append("not active when installed: " + ", ".join(conflicting_modules)) parts.append("not active when installed: " + ", ".join(conflicting_modules))
if missing_capabilities: if missing_capabilities:
parts.append("missing capabilities: " + ", ".join(missing_capabilities)) parts.append("missing capabilities: " + ", ".join(missing_capabilities))
if missing_scopes: if missing_scopes:
parts.append("missing scopes: " + ", ".join(missing_scopes)) parts.append("missing scopes: " + ", ".join(missing_scopes))
if missing_any_scopes: if unsatisfied_any_scopes:
parts.append("requires one scope from: " + ", ".join(missing_any_scopes)) parts.append("requires one scope from: " + ", ".join(unsatisfied_any_scopes))
return False, "; ".join(parts), blockers return "; ".join(parts)
def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str: def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str:
@@ -413,11 +560,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,
@@ -447,6 +596,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.6", 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,15 +1,22 @@
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, 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 (
_classify_documentation, _classify_documentation,
_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
@@ -26,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())
@@ -58,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())
@@ -74,11 +93,158 @@ 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",
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(
@@ -86,6 +252,49 @@ class DocsContextTests(unittest.TestCase):
"docs-topic-access-access-workflow-grant-user-access", "docs-topic-access-access-workflow-grant-user-access",
) )
def test_condition_visibility_reports_module_capability_and_scope_blockers(self) -> None:
registry = PlatformRegistry()
condition = DocumentationCondition(
required_modules=("mail",),
any_modules=("files", "campaigns"),
missing_modules=("legacy",),
required_capabilities=("mail.delivery",),
required_scopes=("docs:documentation:read",),
any_scopes=("mail:profile:read", "admin:policies:read"),
)
principal = FakePrincipal({"docs:documentation:read"})
active, reason, blockers = _condition_visibility(condition, {"legacy"}, registry, principal)
self.assertFalse(active)
self.assertEqual(
reason,
"missing modules: mail; "
"requires one installed module from: files, campaigns; "
"not active when installed: legacy; "
"missing capabilities: mail.delivery; "
"requires one scope from: mail:profile:read, admin:policies:read",
)
self.assertEqual(blockers["modules"], ["mail", "files", "campaigns", "legacy"])
self.assertEqual(blockers["capabilities"], ["mail.delivery"])
self.assertEqual(blockers["scopes"], ["mail:profile:read", "admin:policies:read"])
def test_condition_visibility_accepts_any_module_scope_and_capability(self) -> None:
registry = PlatformRegistry()
registry.register_capability_factory("mail", "mail.delivery", lambda context: object())
condition = DocumentationCondition(
any_modules=("files", "campaigns"),
required_capabilities=("mail.delivery",),
any_scopes=("mail:profile:read", "admin:policies:read"),
)
principal = FakePrincipal({"admin:policies:read"})
active, reason, blockers = _condition_visibility(condition, {"campaigns"}, registry, principal)
self.assertTrue(active)
self.assertEqual(reason, "conditions satisfied")
self.assertEqual(blockers, {"modules": [], "capabilities": [], "scopes": []})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.6", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,7 +13,7 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@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

@@ -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,8 +1,9 @@
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 {
Button, Button,
DataGrid,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
LoadingFrame, LoadingFrame,
@@ -12,7 +13,8 @@ import {
adminErrorMessage, adminErrorMessage,
useGuardedNavigate, useGuardedNavigate,
usePlatformLanguage, usePlatformLanguage,
type ApiSettings type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
fetchDocsContext, fetchDocsContext,
@@ -65,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);
} }
} }
@@ -121,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
@@ -209,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"
@@ -219,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" }
]}
/> />
); );
} }
@@ -351,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> }) {
@@ -383,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");
@@ -404,34 +462,18 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
} }
function ReferenceFieldTable({ id, fields, showTechnical }: { id: string; fields: Record<string, unknown>[]; showTechnical: boolean }) { function ReferenceFieldTable({ id, fields, showTechnical }: { id: string; fields: Record<string, unknown>[]; showTechnical: boolean }) {
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "field", header: "i18n:govoplan-docs.field.7558c082", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (field) => metadataString(field, "label"), render: (field) => <strong>{metadataString(field, "label")}</strong> },
{ id: "meaning", header: "i18n:govoplan-docs.meaning.584d8aa0", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, filterable: true, value: (field) => [metadataString(field, "user_description"), metadataString(field, "admin_description"), metadataString(field, "provenance")].join(" "), render: (field) => <div>{metadataString(field, "user_description")}{showTechnical && metadataString(field, "admin_description") && <span className="muted block">{metadataString(field, "admin_description")}</span>}{showTechnical && metadataString(field, "provenance") && <span className="muted block">{metadataString(field, "provenance")}</span>}</div> },
...(showTechnical ? [
{ id: "api", header: "i18n:govoplan-docs.api_mapping.e969f6f7", width: "minmax(190px, .8fr)", minWidth: 170, resizable: true, filterable: true, value: (field: Record<string, unknown>) => `${metadataString(field, "api_path")} ${metadataString(field, "api_field")}`, render: (field: Record<string, unknown>) => <div>{metadataString(field, "api_path")}<span className="muted block">{metadataString(field, "api_field")}</span></div> },
{ id: "permission", header: "i18n:govoplan-docs.permission.d71c9448", width: 190, filterable: true, value: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-", render: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-" }
] satisfies DataGridColumn<Record<string, unknown>>[] : []),
{ id: "validation", header: "i18n:govoplan-docs.validation.ef7f6d9c", width: "minmax(180px, .7fr)", minWidth: 160, resizable: true, filterable: true, value: (field) => metadataString(field, "validation") || "-", render: (field) => metadataString(field, "validation") || "-" }
];
return ( return (
<div className="admin-table-wrap" id={id}> <div id={id}>
<table className="admin-table"> <DataGrid id={`${id}-grid`} rows={fields} columns={columns} getRowKey={(field) => metadataString(field, "field_id") || metadataString(field, "label")} />
<thead>
<tr>
<th>i18n:govoplan-docs.field.7558c082</th>
<th>i18n:govoplan-docs.meaning.584d8aa0</th>
{showTechnical && <th>i18n:govoplan-docs.api_mapping.e969f6f7</th>}
{showTechnical && <th>i18n:govoplan-docs.permission.d71c9448</th>}
<th>i18n:govoplan-docs.validation.ef7f6d9c</th>
</tr>
</thead>
<tbody>
{fields.map((field) =>
<tr key={metadataString(field, "field_id") || metadataString(field, "label")}>
<td><strong>{metadataString(field, "label")}</strong></td>
<td>
{metadataString(field, "user_description")}
{showTechnical && metadataString(field, "admin_description") && <span className="muted block">{metadataString(field, "admin_description")}</span>}
{showTechnical && metadataString(field, "provenance") && <span className="muted block">{metadataString(field, "provenance")}</span>}
</td>
{showTechnical && <td>{metadataString(field, "api_path")}<span className="muted block">{metadataString(field, "api_field")}</span></td>}
{showTechnical && <td>{metadataString(field, "permission_scope") || "-"}</td>}
<td>{metadataString(field, "validation") || "-"}</td>
</tr>
)}
</tbody>
</table>
</div> </div>
); );
} }
@@ -771,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[] {
@@ -795,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" });
@@ -814,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 {
@@ -861,49 +923,29 @@ function metadataRecords(metadata: Record<string, unknown>, key: string): Record
function ModuleTable({ modules }: { modules: DocsModule[] }) { function ModuleTable({ modules }: { modules: DocsModule[] }) {
if (!modules.length) return <p className="muted">i18n:govoplan-docs.no_configured_modules_found.f6f9ce24</p>; if (!modules.length) return <p className="muted">i18n:govoplan-docs.no_configured_modules_found.f6f9ce24</p>;
const columns: DataGridColumn<DocsModule>[] = [
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (module) => `${module.name} ${module.id} ${module.version}`, render: (module) => <div><strong>{module.name}</strong><span className="muted block">{module.id} {module.version}</span></div> },
{ id: "routes", header: "i18n:govoplan-docs.routes.03730e58", width: 170, sortable: true, value: (module) => module.route_count, render: (module) => <>{module.route_count} i18n:govoplan-docs.route.200e2a66 {module.nav_count} nav</> },
{ id: "permissions", header: "i18n:govoplan-docs.permissions.d06d5557", width: 130, sortable: true, value: (module) => module.permission_count },
{ id: "frontend", header: "i18n:govoplan-docs.frontend.152d1cf2", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (module) => module.frontend_package || "-", render: (module) => module.frontend_package || "-" },
{ id: "capabilities", header: "i18n:govoplan-docs.capabilities.ca09c54b", width: "minmax(260px, 1.2fr)", minWidth: 220, resizable: true, filterable: true, value: (module) => module.capabilities.join(" "), render: (module) => <div>{module.capabilities.length ? module.capabilities.join(", ") : "-"}{module.documentation_count || module.documentation_provider_count ? <span className="muted block">{module.documentation_count} i18n:govoplan-docs.docs.5dc1e9b8 {module.documentation_provider_count} provider</span> : null}</div> }
];
return ( return (
<div className="admin-table-wrap"> <DataGrid id="docs-configured-modules" rows={modules} columns={columns} getRowKey={(module) => module.id} />
<table className="admin-table">
<thead>
<tr><th>i18n:govoplan-docs.module.b8ff0289</th><th>i18n:govoplan-docs.routes.03730e58</th><th>i18n:govoplan-docs.permissions.d06d5557</th><th>i18n:govoplan-docs.frontend.152d1cf2</th><th>i18n:govoplan-docs.capabilities.ca09c54b</th></tr>
</thead>
<tbody>
{modules.map((module) =>
<tr key={module.id}>
<td><strong>{module.name}</strong><span className="muted block">{module.id} {module.version}</span></td>
<td>{module.route_count} i18n:govoplan-docs.route.200e2a66 {module.nav_count} nav</td>
<td>{module.permission_count}</td>
<td>{module.frontend_package || "-"}</td>
<td>{module.capabilities.length ? module.capabilities.join(", ") : "-"}{module.documentation_count || module.documentation_provider_count ? <span className="muted block">{module.documentation_count} i18n:govoplan-docs.docs.5dc1e9b8 {module.documentation_provider_count} provider</span> : null}</td>
</tr>
)}
</tbody>
</table>
</div>
); );
} }
function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) { function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) {
if (!routes.length) return <p className="muted">{emptyText}</p>; if (!routes.length) return <p className="muted">{emptyText}</p>;
const columns: DataGridColumn<DocsRoute>[] = [
{ id: "route", header: "i18n:govoplan-docs.route.4999528e", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, sortable: true, filterable: true, value: (route) => `${route.label} ${route.path}`, render: (route) => <div><strong>{route.label}</strong><span className="muted block">{route.path}</span></div> },
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: 160, sortable: true, filterable: true, value: (route) => route.module_id },
{ id: "source", header: "i18n:govoplan-docs.source.6da13add", width: 160, sortable: true, filterable: true, value: (route) => route.source },
{ id: "requirements", header: "i18n:govoplan-docs.requirements.09a428f9", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (route) => routeRequirements(route) },
{ id: "status", header: "i18n:govoplan-docs.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (route) => route.visible ? "visible" : route.reason, render: (route) => <StatusBadge status={route.visible ? "success" : "warning"} label={route.visible ? "visible" : route.reason} /> }
];
return ( return (
<div className="admin-table-wrap"> <DataGrid id={`docs-routes-${emptyText}`} rows={routes} columns={columns} getRowKey={(route) => `${route.source}-${route.module_id}-${route.path}`} />
<table className="admin-table">
<thead>
<tr><th>i18n:govoplan-docs.route.4999528e</th><th>i18n:govoplan-docs.module.b8ff0289</th><th>i18n:govoplan-docs.source.6da13add</th><th>i18n:govoplan-docs.requirements.09a428f9</th><th>i18n:govoplan-docs.status.bae7d5be</th></tr>
</thead>
<tbody>
{routes.map((route) =>
<tr key={`${route.source}-${route.module_id}-${route.path}`}>
<td><strong>{route.label}</strong><span className="muted block">{route.path}</span></td>
<td>{route.module_id}</td>
<td>{route.source}</td>
<td>{routeRequirements(route)}</td>
<td><StatusBadge status={route.visible ? "success" : "warning"} label={route.visible ? "visible" : route.reason} /></td>
</tr>
)}
</tbody>
</table>
</div>
); );
} }

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,
@@ -24,4 +24,4 @@ export const docsModule: PlatformWebModule = {
}; };
export default docsModule; export default docsModule;