Compare commits
12 Commits
0c9997593c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 511930ea56 | |||
| 4d36ab2747 | |||
| 94c000f351 | |||
| be52b716ca | |||
| 901baf8352 | |||
| 7cdd1e1c78 | |||
| e9f3106657 | |||
| 9dab1c842a | |||
| 2f912ff619 | |||
| a08bc7c204 | |||
| bbe3c7d163 | |||
| 0f41e02428 |
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.8"
|
__version__ = "0.1.10"
|
||||||
|
|||||||
@@ -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,66 +31,184 @@ 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,
|
||||||
|
can_read_admin=can_read_admin_documentation,
|
||||||
|
),
|
||||||
|
"summary": _documentation_summary(
|
||||||
|
catalog,
|
||||||
|
documentation_layers=documentation_layers,
|
||||||
|
topic_groups=topic_groups,
|
||||||
|
),
|
||||||
|
"topic_groups": topic_groups,
|
||||||
|
"layers": _documentation_layer_payload(
|
||||||
|
catalog,
|
||||||
|
documentation_layers=documentation_layers,
|
||||||
|
include_evidence_sources=documentation_type == "admin",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_documentation_catalog(
|
||||||
|
registry: PlatformRegistry,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
route_items: list[dict[str, Any]],
|
||||||
|
visible_route_items: list[dict[str, Any]],
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
return {
|
||||||
|
"modules": [
|
||||||
|
_module_payload(manifest, technical=True)
|
||||||
|
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,
|
"documentation_type": documentation_type,
|
||||||
"locale": resolved_locale,
|
"locale": locale,
|
||||||
},
|
"available_documentation_types": [
|
||||||
"summary": {
|
"user",
|
||||||
"module_count": len(modules),
|
*(["admin"] if can_read_admin else []),
|
||||||
"visible_route_count": len(visible_routes),
|
],
|
||||||
"available_route_count": len(available_routes),
|
}
|
||||||
|
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),
|
"permission_count": len(permissions),
|
||||||
"granted_permission_count": sum(1 for item in permissions if item["granted"]),
|
"granted_permission_count": sum(
|
||||||
"optional_module_count": len(optional_modules),
|
1 for item in permissions if item["granted"]
|
||||||
"documentation_topic_count": documentation_count,
|
),
|
||||||
"configured_documentation_topic_count": len(documentation_layers["configured"]),
|
"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"]),
|
"workflow_topic_count": len(topic_groups["workflow"]),
|
||||||
"reference_topic_count": len(topic_groups["reference"]),
|
"reference_topic_count": len(topic_groups["reference"]),
|
||||||
"pattern_topic_count": len(topic_groups["pattern"]),
|
"pattern_topic_count": len(topic_groups["pattern"]),
|
||||||
"system_topic_count": len(topic_groups["system"]),
|
"system_topic_count": len(topic_groups["system"]),
|
||||||
},
|
}
|
||||||
"topic_groups": topic_groups,
|
|
||||||
"layers": {
|
|
||||||
|
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": {
|
"always": {
|
||||||
"documentation": documentation_layers["always"],
|
"documentation": documentation_layers["always"],
|
||||||
},
|
},
|
||||||
"configured": {
|
"configured": {
|
||||||
"modules": modules,
|
"modules": catalog["modules"],
|
||||||
"routes": visible_routes,
|
"routes": catalog["visible_routes"],
|
||||||
"permissions": permissions,
|
"permissions": permissions,
|
||||||
"documentation": documentation_layers["configured"],
|
"documentation": documentation_layers["configured"],
|
||||||
},
|
},
|
||||||
"available": {
|
"available": {
|
||||||
"routes": available_routes,
|
"routes": catalog["available_routes"],
|
||||||
"permissions": [item for item in permissions if not item["granted"]],
|
"permissions": [item for item in permissions if not item["granted"]],
|
||||||
"documentation": documentation_layers["available"],
|
"documentation": documentation_layers["available"],
|
||||||
},
|
},
|
||||||
"evidence": {
|
"evidence": {
|
||||||
"optional_modules": optional_modules,
|
"optional_modules": catalog["optional_modules"],
|
||||||
"sources": _evidence_sources(),
|
"sources": _evidence_sources() if include_evidence_sources else [],
|
||||||
"documentation": documentation_layers["evidence"],
|
"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,6 +453,7 @@ 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:
|
||||||
|
if not user_workflow_scope_condition_issues(topic):
|
||||||
topics.append((manifest.id, topic))
|
topics.append((manifest.id, topic))
|
||||||
if not manifest.documentation_providers:
|
if not manifest.documentation_providers:
|
||||||
continue
|
continue
|
||||||
@@ -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,6 +485,7 @@ def _collect_documentation_topics(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
for topic in provided_topics:
|
for topic in provided_topics:
|
||||||
|
if not user_workflow_scope_condition_issues(topic):
|
||||||
topics.append((manifest.id, 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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user