feat: implement governed service directory

This commit is contained in:
2026-08-01 17:48:38 +02:00
parent cadac23162
commit ff44ed5f4a
17 changed files with 1948 additions and 0 deletions
+27
View File
@@ -3,3 +3,30 @@
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
Portal owns service discovery, presentation, and channel entry. Its
`portal.service_directory` capability projects provider-owned, versioned
service definitions into available, explainably unavailable, or undiscoverable
entries. It does not persist the institutional service promise or import Cases,
Forms, Workflow, Access, or Policy tables.
The capability works in a reduced composition without a Services provider by
returning an empty directory. When a provider is present, provider-level access
filtering remains authoritative and Portal adds only presentation-oriented
availability checks for publication, effective time, audience, module, and
capability requirements.
The tenant-scoped `/api/v1/portal/services` endpoint and `/portal` WebUI expose
the projection. Audience visibility is derived from trusted principal and
active function-assignment state on the server.
`POST /api/v1/portal/services/{service_id}/launch` re-fetches and re-evaluates
the exact Service revision before any effect. URL entries return a validated
redirect. Case, form, and workflow bindings delegate through the optional
`cases.service_launcher`, `forms_runtime.service_launcher`, and
`workflow_engine.service_launcher` contracts; each is tenant-bound and
replay-safe. A missing owner launcher makes the entry explainably unavailable.
Form launch resolves an exact published `<form-id>/<revision>` and returns the
owner's Form-instance route rather than persisting values in Portal.
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
+68
View File
@@ -4,6 +4,12 @@ GovOPlaN Portal should expose a role-aware service directory. The directory is
not a marketing catalogue; it is the user-facing map of what a person can do in
the configured institution.
Portal owns presentation, discovery, entry, and channel-specific availability.
It must not become the long-term owner of the institutional service definition.
A shared Services contract should own the versioned promise independently so
Cases, Forms, Workflow Engine, Reporting, external publication, and more than
one portal surface can consume the same definition.
## Purpose
The service directory should help users find the right administrative action
@@ -34,11 +40,22 @@ A service entry should describe:
- policy and availability blockers
- user-facing explanation and required documents
- audit and evidence expectations
- version and effective interval
- legal or organizational basis, deadlines, fees, remedies, and service-level
expectations
- responsible organization/function, mandate/jurisdiction, result/decision,
publication, and records bindings
The portal should request service contributions through core-mediated
capability and UI contribution contracts. It must not import domain module
internals.
The first implementation may consume module-contributed service definitions.
Create a separate `govoplan-services` repository only when definitions require
independent persistence/lifecycle and are consumed outside Portal by at least
one other procedure or publication surface. Extraction must keep stable service
keys and compatibility for existing Portal links.
Service visibility must consume access semantics through kernel capabilities:
- `access.semanticDirectory` resolves the actor's identity, account,
@@ -56,3 +73,54 @@ The directory should explain unavailable services when the reason is useful:
missing role, disabled module, tenant policy, missing connector, maintenance
mode, or unavailable external provider. It should hide only services that are
irrelevant or intentionally undiscoverable by policy.
## Implemented Slice
`portal.service_directory` consumes the optional `services.definitions`
capability. It returns only tenant-scoped definitions and distinguishes
`available`, `unavailable`, and `hidden` states. Draft, retired,
outside-effective-time, and audience-inapplicable services are hidden. A
suspended service or missing required module/capability remains discoverable
with stable reason codes and its configured explanation reference.
The definition provider remains responsible for access and policy filtering.
Portal cannot widen its result and returns an empty directory when no provider
is installed. No service definition is persisted in Portal.
Tenant/package specializations are derived before presentation through Core's
restrictive Service rule. They carry their parent version and may narrow
audience, channels, effective time, and publication or add prerequisites,
required evidence, legal bases, and bindings. They cannot silently loosen the
system/package definition.
Policy, Mandate, connector, maintenance, and configuration requirements are
evaluated through the optional `services.availability` capability. Module and
capability requirements are checked against the active registry, and audience
requirements against the supplied actor projection. Unknown requirements fail
closed. Each requirement declares whether failure remains visible with an
explanation or makes the entry undiscoverable.
`GET /api/v1/portal/services` and the `/portal` WebUI expose this projection.
The API derives audience tokens from the authenticated principal instead of
accepting caller-provided audience claims. Generic `public` and `authenticated`
tokens are combined with stable account, identity, group, role, active function,
and organization-unit tokens. Function slugs are included only after the
optional Access semantic directory confirms an active, effective, same-tenant
assignment. Missing or failing directory providers narrow the result.
The WebUI provides bounded search, availability filtering, explanations, and
one launch action. `POST /api/v1/portal/services/{service_id}/launch` resolves
the entry again at launch time so a stale browser projection cannot bypass a
new audience, publication, effective-time, module, or capability restriction.
Direct URL entries return only validated local or credential-free HTTP(S)
redirects.
Case, form, and workflow bindings delegate to owner capabilities. Their launch result
must retain the exact Service reference, tenant, binding kind, target reference,
and replay-safe idempotency key. Portal never writes their tables. Missing
launchers produce `service.launcher.missing:<kind>` and make the entry visibly
unavailable. Forms Runtime now provides a definition-aware launcher when Forms
and Forms Runtime are active. It resolves an exact published
`<form-id>/<revision>`, validates launch values, and retains Service/binding
provenance. Reduced installations still fail closed rather than simulating a
submission in Portal.
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-portal"
version = "0.1.8"
description = "GovOPlaN service discovery and public portal module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.8"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_portal = ["py.typed"]
[project.entry-points."govoplan.modules"]
"portal" = "govoplan_portal.backend.manifest:get_manifest"
+5
View File
@@ -0,0 +1,5 @@
"""GovOPlaN Portal module."""
from govoplan_portal.backend.manifest import get_manifest
__all__ = ["get_manifest"]
+1
View File
@@ -0,0 +1 @@
"""Portal backend contracts."""
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY,
CAPABILITY_SERVICE_DEFINITIONS,
service_launch_capability,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_portal.backend.service_directory import (
CAPABILITY_PORTAL_SERVICE_DIRECTORY,
PortalServiceDirectory,
)
from govoplan_core.core.views import ViewSurface
MODULE_ID = "portal"
MODULE_VERSION = "0.1.8"
READ_SCOPE = "portal:service:read"
SERVICE_LAUNCH_CAPABILITIES = tuple(
service_launch_capability(kind) for kind in ("case", "form", "workflow")
)
def _service_directory(context: ModuleContext) -> PortalServiceDirectory:
return PortalServiceDirectory(context.registry)
def _router(context: ModuleContext):
from govoplan_portal.backend.router import configure_registry, router
configure_registry(context.registry)
return router
manifest = ModuleManifest(
id=MODULE_ID,
name="Portal",
version=MODULE_VERSION,
optional_dependencies=(
"access",
"services",
"cases",
"forms",
"forms_runtime",
"workflow_engine",
),
optional_capabilities=(
CAPABILITY_SERVICE_DEFINITIONS,
CAPABILITY_SERVICE_AVAILABILITY,
*SERVICE_LAUNCH_CAPABILITIES,
),
permissions=(
PermissionDefinition(
scope=READ_SCOPE,
label="View service directory",
description="Discover services available to the current account and function assignments.",
category="Portal",
level="tenant",
module_id=MODULE_ID,
resource="service",
action="read",
),
),
role_templates=(
RoleTemplate(
slug="portal_user",
name="Portal user",
description="Discover and enter available institutional services.",
permissions=(READ_SCOPE,),
default_authenticated=True,
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="portal.service_directory", version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="services.availability", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
*(
ModuleInterfaceRequirement(
name=capability,
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
)
for capability in SERVICE_LAUNCH_CAPABILITIES
),
),
capability_factories={
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
},
route_factory=_router,
nav_items=(
NavItem(
path="/portal",
label="Services",
icon="landmark",
required_any=(READ_SCOPE,),
order=25,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/portal-webui",
routes=(
FrontendRoute(
path="/portal",
component="PortalPage",
required_any=(READ_SCOPE,),
order=25,
),
),
nav_items=(
NavItem(
path="/portal",
label="Services",
icon="landmark",
required_any=(READ_SCOPE,),
order=25,
),
),
view_surfaces=(
ViewSurface(
id="portal.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Services navigation",
order=10,
),
ViewSurface(
id="portal.directory",
module_id=MODULE_ID,
kind="route",
label="Service directory",
order=20,
),
),
),
capability_documentation={
CAPABILITY_PORTAL_SERVICE_DIRECTORY: CapabilityDocumentation(
label="Portal service directory",
summary="Projects governed service definitions into role-aware availability entries.",
contract_version="0.1.0",
),
},
documentation=(
DocumentationTopic(
id="portal.service-directory",
title="Service directory",
summary="Find services available in the configured institution and understand relevant availability limits.",
body=(
"Portal presents provider-owned, versioned service definitions. "
"Published services may be available, unavailable with a reason, "
"or undiscoverable when they do not apply to the current audience. "
"Opening a service re-evaluates that exact revision and delegates case, "
"form, or workflow startup to an installed owner capability."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin"),
links=(
DocumentationLink(
label="Service directory architecture",
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
kind="repository",
),
),
),
),
architecture=ModuleArchitectureDeclaration(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_service_directory.py",
summary="Proves provider-neutral service discovery and explained availability.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/SERVICE_DIRECTORY_CONCEPT.md",
summary="Defines Portal presentation and Services ownership boundaries.",
),
),
known_limits=(
"Portal does not persist service definitions; the Services provider remains authoritative.",
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
),
owned_concepts=("service discovery", "service presentation", "channel entry"),
non_owned_concepts=("institutional service definition", "case lifecycle"),
reference_packages=("product.service-to-decision",),
documentation=ModuleArchitectureDocumentation(
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
operations=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
),
),
)
def get_manifest() -> ModuleManifest:
return manifest
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import (
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_core.db.session import get_session
from govoplan_portal.backend.schemas import (
PortalServiceLaunchRequest,
PortalServiceLaunchResponse,
PortalServiceListResponse,
)
from govoplan_portal.backend.service_directory import (
PortalServiceDirectory,
PortalServiceLaunchError,
)
READ_SCOPE = "portal:service:read"
router = APIRouter(prefix="/portal", tags=["portal"])
_registry: object | None = None
def configure_registry(registry: object | None) -> None:
global _registry
_registry = registry
@router.get("/services", response_model=PortalServiceListResponse)
def api_list_portal_services(
q: str = Query(default="", max_length=200),
include_unavailable: bool = True,
effective_at: datetime | None = None,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PortalServiceListResponse:
if not has_scope(principal, READ_SCOPE):
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
observed_at = effective_at or datetime.now(tz=UTC)
if observed_at.tzinfo is None:
raise HTTPException(
status_code=400,
detail="Portal service effective_at must include a timezone.",
)
try:
entries = PortalServiceDirectory(_registry).list_entries(
session,
principal,
tenant_id=principal.tenant_id,
effective_at=observed_at,
query=q.strip(),
limit=limit,
include_unavailable=include_unavailable,
)
except InstitutionalContextError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return PortalServiceListResponse(
services=[entry.to_dict() for entry in entries]
)
@router.post(
"/services/{service_id}/launch",
response_model=PortalServiceLaunchResponse,
)
def api_launch_portal_service(
service_id: str,
payload: PortalServiceLaunchRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PortalServiceLaunchResponse:
if not has_scope(principal, READ_SCOPE):
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
if payload.requested_at.tzinfo is None:
raise HTTPException(
status_code=400,
detail="Portal service launch requested_at must include a timezone.",
)
reference = InstitutionalReference(
kind="service",
owner_module="services",
object_id=service_id,
tenant_id=principal.tenant_id,
version=payload.service_version,
valid_at=payload.requested_at,
)
try:
result = PortalServiceDirectory(_registry).launch_service(
session,
principal,
reference=reference,
requested_at=payload.requested_at,
idempotency_key=payload.idempotency_key,
parameters=payload.parameters,
)
session.commit()
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PortalServiceLaunchError as exc:
session.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (InstitutionalContextError, ValueError) as exc:
session.rollback()
status_code = 409 if "conflict" in str(exc).casefold() else 400
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
return PortalServiceLaunchResponse(**result.to_dict())
__all__ = ["READ_SCOPE", "configure_registry", "router"]
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class PortalServiceEntryResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
definition: dict[str, Any]
state: str
reason_codes: list[str] = Field(default_factory=list)
entry_binding: dict[str, Any] | None = None
availability_evidence: list[dict[str, Any]] = Field(default_factory=list)
class PortalServiceListResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
services: list[PortalServiceEntryResponse]
class PortalServiceLaunchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
service_version: str = Field(min_length=1, max_length=120)
idempotency_key: str = Field(min_length=1, max_length=255)
requested_at: datetime
parameters: dict[str, Any] = Field(default_factory=dict, max_length=100)
class PortalServiceLaunchResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
service_ref: dict[str, Any]
binding: dict[str, Any]
state: str
target_ref: dict[str, Any] | None = None
href: str | None = None
replayed: bool = False
evidence: list[dict[str, Any]] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
__all__ = [
"PortalServiceEntryResponse",
"PortalServiceLaunchRequest",
"PortalServiceLaunchResponse",
"PortalServiceListResponse",
]
@@ -0,0 +1,501 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
from govoplan_core.core.access import (
AccessSemanticDirectory,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
PrincipalRef,
)
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY,
CAPABILITY_SERVICE_DEFINITIONS,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
ServiceBinding,
ServiceAvailabilityAssessment,
ServiceAvailabilityEvaluator,
ServiceDefinition,
ServiceDefinitionProvider,
ServiceLaunchRequest,
ServiceLaunchResult,
ServiceLauncher,
service_launch_capability,
)
CAPABILITY_PORTAL_SERVICE_DIRECTORY = "portal.service_directory"
ServiceDiscoveryState = Literal["available", "unavailable", "hidden"]
@dataclass(frozen=True, slots=True)
class PortalServiceEntry:
definition: ServiceDefinition
state: ServiceDiscoveryState
reason_codes: tuple[str, ...] = ()
entry_binding: ServiceBinding | None = None
availability_evidence: tuple[EvidenceReference, ...] = ()
@property
def discoverable(self) -> bool:
return self.state != "hidden"
@property
def available(self) -> bool:
return self.state == "available"
def to_dict(self) -> dict[str, object]:
return {
"definition": self.definition.to_dict(include_inspection=False),
"state": self.state,
"reason_codes": list(self.reason_codes),
"entry_binding": (
self.entry_binding.to_dict() if self.entry_binding else None
),
"availability_evidence": [
item.to_dict(include_inspection=False)
for item in self.availability_evidence
],
}
class PortalServiceDirectory:
"""Role-aware projection over a provider-owned service definition catalogue."""
def __init__(self, registry: object | None = None) -> None:
self._registry = registry
def list_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
effective_at: datetime,
audiences: tuple[str, ...] | None = None,
query: str = "",
limit: int = 100,
include_unavailable: bool = True,
) -> tuple[PortalServiceEntry, ...]:
if not tenant_id.strip():
raise InstitutionalContextError("Portal service tenant id is required.")
if not 1 <= limit <= 200:
raise InstitutionalContextError(
"Portal service directory limit must be between 1 and 200."
)
effective_audiences = (
audiences
if audiences is not None
else principal_audiences(
self._registry,
session,
principal,
tenant_id=tenant_id,
effective_at=effective_at,
)
)
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
if not isinstance(provider, ServiceDefinitionProvider):
return ()
definitions = provider.list_service_definitions(
session,
principal,
tenant_id=tenant_id,
query=query,
limit=limit,
)
evaluator = _capability(
self._registry,
CAPABILITY_SERVICE_AVAILABILITY,
)
entries: list[PortalServiceEntry] = []
for definition in definitions[:limit]:
assessment = None
if isinstance(evaluator, ServiceAvailabilityEvaluator):
try:
assessment = evaluator.evaluate_service_availability(
session,
principal,
definition=definition,
effective_at=effective_at,
)
except Exception: # noqa: BLE001 - isolate optional evaluators.
assessment = ServiceAvailabilityAssessment(
requirement_states={},
reason_codes=("service.availability.evaluator_failed",),
)
entries.append(
self.project(
definition,
tenant_id=tenant_id,
effective_at=effective_at,
audiences=effective_audiences,
assessment=assessment,
)
)
return tuple(
entry
for entry in entries
if entry.discoverable
and (include_unavailable or entry.available)
)
def get_entry(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
effective_at: datetime,
) -> PortalServiceEntry | None:
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
if not isinstance(provider, ServiceDefinitionProvider):
return None
definition = provider.get_service_definition(
session,
principal,
reference=reference,
effective_at=effective_at,
)
if definition is None:
return None
if definition.reference != reference:
raise InstitutionalContextError(
"Service provider did not return the requested exact revision."
)
evaluator = _capability(
self._registry,
CAPABILITY_SERVICE_AVAILABILITY,
)
assessment = None
if isinstance(evaluator, ServiceAvailabilityEvaluator):
try:
assessment = evaluator.evaluate_service_availability(
session,
principal,
definition=definition,
effective_at=effective_at,
)
except Exception: # noqa: BLE001 - optional evaluator isolation.
assessment = ServiceAvailabilityAssessment(
requirement_states={},
reason_codes=("service.availability.evaluator_failed",),
)
return self.project(
definition,
tenant_id=reference.tenant_id,
effective_at=effective_at,
audiences=principal_audiences(
self._registry,
session,
principal,
tenant_id=reference.tenant_id,
effective_at=effective_at,
),
assessment=assessment,
)
def launch_service(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
requested_at: datetime,
idempotency_key: str,
parameters: dict[str, object],
) -> ServiceLaunchResult:
entry = self.get_entry(
session,
principal,
reference=reference,
effective_at=requested_at,
)
if entry is None or not entry.discoverable:
raise LookupError("Service not found.")
if not entry.available:
raise PortalServiceLaunchError(
"Service is currently unavailable: "
+ ", ".join(entry.reason_codes)
)
binding = entry.entry_binding
if binding is None:
raise PortalServiceLaunchError(
"Service has no supported entry binding."
)
request = ServiceLaunchRequest(
service_ref=entry.definition.reference,
binding=binding,
idempotency_key=idempotency_key,
requested_at=requested_at,
parameters=parameters,
)
if binding.kind in {"url", "external"}:
return ServiceLaunchResult(
service_ref=entry.definition.reference,
binding=binding,
state="redirect",
href=binding.reference,
metadata={},
)
capability_name = service_launch_capability(binding.kind)
launcher = _capability(self._registry, capability_name)
if not isinstance(launcher, ServiceLauncher):
raise PortalServiceLaunchError(
f"Service launcher is unavailable: {capability_name}."
)
result = launcher.launch_service(
session,
principal,
definition=entry.definition,
request=request,
)
if (
result.service_ref != entry.definition.reference
or result.binding != binding
):
raise InstitutionalContextError(
"Service launcher returned a result for another definition or binding."
)
return result
def project(
self,
definition: ServiceDefinition,
*,
tenant_id: str,
effective_at: datetime,
audiences: tuple[str, ...] = (),
assessment: ServiceAvailabilityAssessment | None = None,
) -> PortalServiceEntry:
if definition.reference.tenant_id != tenant_id:
raise InstitutionalContextError(
"Portal cannot project a service from another tenant."
)
if assessment is not None and any(
item.tenant_id != tenant_id for item in assessment.evidence
):
raise InstitutionalContextError(
"Service availability evidence belongs to another tenant."
)
hidden_reasons: list[str] = []
unavailable_reasons: list[str] = []
if definition.publication_state in {"draft", "retired"}:
hidden_reasons.append(
f"service.publication.{definition.publication_state}"
)
elif definition.publication_state == "suspended":
unavailable_reasons.append("service.publication.suspended")
if not definition.temporal.effective_at(effective_at):
hidden_reasons.append("service.outside_effective_interval")
if not set(audiences).intersection(definition.audience):
hidden_reasons.append("service.audience.not_applicable")
for binding in definition.bindings:
if not binding.required:
continue
if binding.kind == "module" and not _has_module(
self._registry, binding.reference
):
unavailable_reasons.append(
f"service.required_module.missing:{binding.reference}"
)
elif binding.kind == "capability" and _capability(
self._registry, binding.reference
) is None:
unavailable_reasons.append(
f"service.required_capability.missing:{binding.reference}"
)
assessment_states = (
assessment.requirement_states if assessment is not None else {}
)
for requirement in definition.availability_requirements:
if requirement.kind == "module":
resolved: bool | None = _has_module(
self._registry,
requirement.reference,
)
elif requirement.kind == "capability":
resolved = (
_capability(self._registry, requirement.reference) is not None
)
elif requirement.kind == "audience":
resolved = requirement.reference in audiences
else:
resolved = assessment_states.get(requirement.key)
if resolved is True:
continue
reason = (
f"service.requirement.failed:{requirement.key}"
if resolved is False
else f"service.requirement.unknown:{requirement.key}"
)
reasons = (
hidden_reasons
if requirement.failure_state == "hidden"
else unavailable_reasons
)
reasons.append(reason)
if requirement.explanation_ref:
reasons.append(
f"service.explanation:{requirement.explanation_ref}"
)
if assessment is not None and (
hidden_reasons or unavailable_reasons
):
unavailable_reasons.extend(assessment.reason_codes)
if definition.availability_explanation_ref and unavailable_reasons:
unavailable_reasons.append(
f"service.explanation:{definition.availability_explanation_ref}"
)
entry_binding = next(
(
binding
for binding in definition.bindings
if binding.kind in {"form", "case", "workflow", "external", "url"}
),
None,
)
if entry_binding is not None and entry_binding.kind in {
"form",
"case",
"workflow",
}:
capability_name = service_launch_capability(entry_binding.kind)
if _capability(self._registry, capability_name) is None:
unavailable_reasons.append(
f"service.launcher.missing:{capability_name}"
)
state: ServiceDiscoveryState = (
"hidden"
if hidden_reasons
else "unavailable"
if unavailable_reasons
else "available"
)
return PortalServiceEntry(
definition=definition,
state=state,
reason_codes=tuple(
dict.fromkeys((*hidden_reasons, *unavailable_reasons))
),
entry_binding=entry_binding,
availability_evidence=(assessment.evidence if assessment else ()),
)
class PortalServiceLaunchError(RuntimeError):
pass
def principal_audiences(
registry: object | None,
_session: object,
principal: object,
*,
tenant_id: str,
effective_at: datetime,
) -> tuple[str, ...]:
"""Build bounded audience tokens from trusted principal state.
Definitions can target generic authenticated users and stable account,
identity, group, role, organization-unit, or function identifiers. Function
slugs are included only after resolving an active tenant-bound assignment.
Provider failures fail closed to the tokens already present on the signed-in
principal.
"""
ref = _principal_ref(principal)
if ref is None or ref.tenant_id != tenant_id:
return ("public",)
tokens = {"public", "authenticated", f"account:{ref.account_id}"}
if ref.identity_id:
tokens.add(f"identity:{ref.identity_id}")
tokens.update(f"group:{item}" for item in ref.group_ids)
tokens.update(f"role:{item}" for item in ref.role_ids)
tokens.update(
f"function-assignment:{item}" for item in ref.function_assignment_ids
)
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
if not isinstance(directory, AccessSemanticDirectory):
return tuple(sorted(tokens))
for assignment_id in sorted(ref.function_assignment_ids):
try:
assignment = directory.get_function_assignment(assignment_id)
if (
assignment is None
or assignment.tenant_id != tenant_id
or assignment.account_id != ref.account_id
or assignment.status != "active"
or (
assignment.valid_from is not None
and effective_at < assignment.valid_from
)
or (
assignment.valid_until is not None
and effective_at >= assignment.valid_until
)
):
continue
function = directory.get_function(assignment.function_id)
if (
function is None
or function.tenant_id != tenant_id
or function.status != "active"
):
continue
except Exception: # noqa: BLE001 - optional directory failures fail closed.
continue
tokens.update(
{
f"function:{function.id}",
f"function:{function.slug}",
f"organization-unit:{assignment.organization_unit_id}",
}
)
return tuple(sorted(tokens))
def _principal_ref(principal: object) -> PrincipalRef | None:
if isinstance(principal, PrincipalRef):
return principal
converter = getattr(principal, "to_platform_principal", None)
if not callable(converter):
return None
try:
value = converter()
except Exception: # noqa: BLE001 - untrusted provider object.
return None
return value if isinstance(value, PrincipalRef) else None
def _has_module(registry: object | None, module_id: str) -> bool:
return bool(
registry is not None
and hasattr(registry, "has")
and registry.has(module_id)
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_PORTAL_SERVICE_DIRECTORY",
"PortalServiceDirectory",
"PortalServiceEntry",
"PortalServiceLaunchError",
"ServiceDiscoveryState",
"principal_audiences",
]
+1
View File
@@ -0,0 +1 @@
+391
View File
@@ -0,0 +1,391 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_AVAILABILITY,
CAPABILITY_SERVICE_DEFINITIONS,
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
ServiceBinding,
ServiceAvailabilityAssessment,
ServiceAvailabilityRequirement,
ServiceDefinition,
ServiceLaunchResult,
TemporalRevision,
)
from govoplan_core.core.access import (
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
FunctionAssignmentRef,
FunctionRef,
PrincipalRef,
)
from govoplan_portal.backend.manifest import get_manifest
from govoplan_portal.backend.service_directory import (
PortalServiceDirectory,
principal_audiences,
)
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
def service(
*,
state: str = "published",
requirements: tuple[ServiceAvailabilityRequirement, ...] = (),
) -> ServiceDefinition:
return ServiceDefinition(
reference=InstitutionalReference(
kind="service",
owner_module="portal",
object_id="permit",
tenant_id="tenant-1",
version="3",
),
key="permit.apply",
temporal=TemporalRevision(
revision="3",
valid_from=NOW - timedelta(days=1),
valid_to=NOW + timedelta(days=1),
recorded_at=NOW - timedelta(days=2),
),
title="Apply for a permit",
audience=("resident",),
bindings=(
ServiceBinding("capability", "cases.service_intake"),
ServiceBinding("case", "permit-application"),
),
availability_requirements=requirements,
publication_state=state, # type: ignore[arg-type]
availability_explanation_ref="docs:permit-requirements",
)
class Provider:
def __init__(self, definition: ServiceDefinition | None = None) -> None:
self.definition = definition or service()
def get_service_definition(self, session, principal, *, reference, effective_at=None):
return self.definition
def list_service_definitions(self, session, principal, *, tenant_id, query="", limit=100):
return (self.definition,)
class Launcher:
def launch_service(self, session, principal, *, definition, request):
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=InstitutionalReference(
kind="case",
owner_module="cases",
object_id="case-1",
tenant_id="tenant-1",
version="1",
),
href="/cases/case-1",
metadata={},
)
class Registry:
def __init__(
self,
*,
intake: bool = False,
evaluator: object | None = None,
definition: ServiceDefinition | None = None,
) -> None:
self.capabilities = {
CAPABILITY_SERVICE_DEFINITIONS: Provider(definition)
}
if intake:
self.capabilities["cases.service_intake"] = object()
self.capabilities["cases.service_launcher"] = Launcher()
if evaluator is not None:
self.capabilities[CAPABILITY_SERVICE_AVAILABILITY] = evaluator
def has(self, module_id: str) -> bool:
return False
def has_capability(self, name: str) -> bool:
return name in self.capabilities
def capability(self, name: str) -> object:
return self.capabilities[name]
class SemanticDirectory:
def get_account(self, account_id):
return None
def get_user(self, user_id):
return None
def get_users(self, user_ids):
return {}
def users_for_tenant(self, tenant_id):
return ()
def get_group(self, group_id):
return None
def get_groups(self, group_ids):
return {}
def groups_for_tenant(self, tenant_id):
return ()
def groups_for_user(self, user_id, *, tenant_id):
return ()
def display_label(self, subject):
return None
def get_identity(self, identity_id):
return None
def accounts_for_identity(self, identity_id):
return ()
def get_organization_unit(self, organization_unit_id):
return None
def organization_units_for_tenant(self, tenant_id):
return ()
def get_function(self, function_id):
return FunctionRef(
id=function_id,
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="permit-clerk",
name="Permit clerk",
)
def functions_for_organization_unit(
self, organization_unit_id, *, include_subunits=False
):
return ()
def get_function_assignment(self, assignment_id):
return FunctionAssignmentRef(
id=assignment_id,
tenant_id="tenant-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
)
def function_assignments_for_account(self, account_id, *, tenant_id=None):
return ()
class PortalServiceDirectoryTests(unittest.TestCase):
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
None,
None,
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
)
self.assertEqual(1, len(entries))
self.assertTrue(entries[0].available)
self.assertEqual("case", entries[0].entry_binding.kind)
def test_missing_capability_is_explained_and_filterable(self) -> None:
directory = PortalServiceDirectory(Registry())
entry = directory.project(
service(),
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
)
self.assertEqual("unavailable", entry.state)
self.assertIn(
"service.required_capability.missing:cases.service_intake",
entry.reason_codes,
)
self.assertEqual(
(),
directory.list_entries(
None,
None,
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
include_unavailable=False,
),
)
def test_exact_available_service_launch_uses_owner_launcher(self) -> None:
definition = replace(service(), audience=("public",))
result = PortalServiceDirectory(
Registry(intake=True, definition=definition)
).launch_service(
None,
None,
reference=definition.reference,
requested_at=NOW,
idempotency_key="launch-1",
parameters={},
)
self.assertEqual("started", result.state)
self.assertEqual("case-1", result.target_ref.object_id)
def test_non_applicable_or_unpublished_service_is_hidden(self) -> None:
directory = PortalServiceDirectory(Registry(intake=True))
self.assertEqual(
"hidden",
directory.project(
service(state="draft"),
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
).state,
)
self.assertEqual(
"hidden",
directory.project(
service(),
tenant_id="tenant-1",
effective_at=NOW,
audiences=("operator",),
).state,
)
def test_cross_tenant_projection_fails_closed(self) -> None:
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
PortalServiceDirectory().project(
service(),
tenant_id="tenant-2",
effective_at=NOW,
)
def test_governed_requirements_explain_or_conceal_failures(self) -> None:
definition = service(
requirements=(
ServiceAvailabilityRequirement(
"policy",
"permit-access",
explanation_ref="docs:permit-policy",
),
ServiceAvailabilityRequirement(
"maintenance",
"permit-backend",
failure_state="hidden",
),
)
)
assessment = ServiceAvailabilityAssessment(
requirement_states={"policy:permit-access": False},
reason_codes=("policy.denied",),
)
entry = PortalServiceDirectory(Registry(intake=True)).project(
definition,
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
assessment=assessment,
)
self.assertEqual("hidden", entry.state)
self.assertIn(
"service.requirement.failed:policy:permit-access",
entry.reason_codes,
)
self.assertIn(
"service.requirement.unknown:maintenance:permit-backend",
entry.reason_codes,
)
self.assertIn("policy.denied", entry.reason_codes)
def test_availability_evidence_cannot_cross_tenants(self) -> None:
assessment = ServiceAvailabilityAssessment(
requirement_states={},
evidence=(
EvidenceReference(
kind="record",
owner_module="policy",
evidence_id="decision-1",
tenant_id="tenant-2",
),
),
)
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
PortalServiceDirectory(Registry(intake=True)).project(
service(),
tenant_id="tenant-1",
effective_at=NOW,
audiences=("resident",),
assessment=assessment,
)
def test_principal_audiences_resolve_active_function_semantics(self) -> None:
registry = Registry(intake=True)
registry.capabilities[CAPABILITY_ACCESS_SEMANTIC_DIRECTORY] = (
SemanticDirectory()
)
principal = PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
identity_id="identity-1",
group_ids=frozenset({"group-1"}),
role_ids=frozenset({"role-1"}),
function_assignment_ids=frozenset({"assignment-1"}),
)
audiences = principal_audiences(
registry,
None,
principal,
tenant_id="tenant-1",
effective_at=NOW,
)
self.assertIn("authenticated", audiences)
self.assertIn("function:permit-clerk", audiences)
self.assertIn("organization-unit:unit-1", audiences)
self.assertIn("role:role-1", audiences)
def test_empty_audience_projection_fails_closed(self) -> None:
entry = PortalServiceDirectory(Registry(intake=True)).project(
service(),
tenant_id="tenant-1",
effective_at=NOW,
)
self.assertEqual("hidden", entry.state)
self.assertIn("service.audience.not_applicable", entry.reason_codes)
def test_manifest_exposes_api_and_service_directory_webui(self) -> None:
manifest = get_manifest()
self.assertEqual("portal", manifest.id)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.frontend)
self.assertEqual("@govoplan/portal-webui", manifest.frontend.package_name)
self.assertIn(
"portal:service:read",
{item.scope for item in manifest.permissions},
)
self.assertIn("portal.service_directory", manifest.capability_factories)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/portal-webui",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/portal.css": "./src/styles/portal.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type PortalServiceBinding = {
kind: string;
reference: string;
required: boolean;
};
export type PortalServiceDefinition = {
reference: {
object_id: string;
version?: string | null;
};
key: string;
title: string;
audience: string[];
prerequisites: string[];
required_evidence_types: string[];
fee_refs: string[];
deadline_refs: string[];
channels: string[];
publication_state: string;
};
export type PortalServiceEntry = {
definition: PortalServiceDefinition;
state: "available" | "unavailable";
reason_codes: string[];
entry_binding?: PortalServiceBinding | null;
availability_evidence: Array<Record<string, unknown>>;
};
export type PortalServiceListResponse = {
services: PortalServiceEntry[];
};
export type PortalServiceLaunchResult = {
service_ref: PortalServiceDefinition["reference"];
binding: PortalServiceBinding;
state: "started" | "redirect";
target_ref?: Record<string, unknown> | null;
href?: string | null;
replayed: boolean;
evidence: Array<Record<string, unknown>>;
metadata: Record<string, unknown>;
};
export function listPortalServices(
settings: ApiSettings,
options: {
query?: string;
includeUnavailable?: boolean;
limit?: number;
},
signal?: AbortSignal
): Promise<PortalServiceListResponse> {
return apiFetch<PortalServiceListResponse>(
settings,
apiPath("/api/v1/portal/services", {
q: options.query,
include_unavailable: options.includeUnavailable,
limit: options.limit
}),
{ signal }
);
}
export function launchPortalService(
settings: ApiSettings,
serviceId: string,
payload: {
service_version: string;
idempotency_key: string;
requested_at: string;
parameters?: Record<string, unknown>;
}
): Promise<PortalServiceLaunchResult> {
return apiFetch<PortalServiceLaunchResult>(
settings,
`/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`,
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
+231
View File
@@ -0,0 +1,231 @@
import { ArrowUpRight, Search } from "lucide-react";
import {
useEffect,
useMemo,
useRef,
useState,
type FormEvent
} from "react";
import {
DismissibleAlert,
Button,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
launchPortalService,
listPortalServices,
type PortalServiceEntry
} from "../../api/portal";
export default function PortalPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [includeUnavailable, setIncludeUnavailable] = useState(true);
const [services, setServices] = useState<PortalServiceEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [launchingId, setLaunchingId] = useState("");
const launchAttempts = useRef(new Map<string, {
idempotencyKey: string;
requestedAt: string;
}>());
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
listPortalServices(
settings,
{
query: submittedQuery,
includeUnavailable,
limit: 200
},
controller.signal
).
then((response) => setServices(response.services)).
catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Services could not be loaded.");
}
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [includeUnavailable, settings, submittedQuery]);
const counts = useMemo(() => ({
available: services.filter((entry) => entry.state === "available").length,
unavailable: services.filter((entry) => entry.state === "unavailable").length
}), [services]);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
async function launch(entry: PortalServiceEntry) {
const serviceId = entry.definition.reference.object_id;
const version = entry.definition.reference.version;
if (!version) {
setError("This service has no exact launchable revision.");
return;
}
const attemptKey = `${serviceId}:${version}`;
const attempt = launchAttempts.current.get(attemptKey) ?? {
idempotencyKey: crypto.randomUUID(),
requestedAt: new Date().toISOString()
};
launchAttempts.current.set(attemptKey, attempt);
setLaunchingId(attemptKey);
setError("");
try {
const result = await launchPortalService(settings, serviceId, {
service_version: version,
idempotency_key: attempt.idempotencyKey,
requested_at: attempt.requestedAt
});
launchAttempts.current.delete(attemptKey);
if (!result.href) {
throw new Error("The service started without returning a destination.");
}
if (result.href.startsWith("/") && !result.href.startsWith("//")) {
navigate(result.href);
} else {
window.location.assign(result.href);
}
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Service could not be started.");
} finally {
setLaunchingId("");
}
}
return (
<main className="portal-page">
<div className="portal-shell">
<div className="portal-toolbar">
<form className="portal-search" onSubmit={submit}>
<Search size={17} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label="Search services"
placeholder="Search services"
/>
<button type="submit" className="btn btn-primary">Search</button>
</form>
<ToggleSwitch
label="Show unavailable services"
checked={includeUnavailable}
onChange={setIncludeUnavailable}
/>
</div>
<div className="portal-result-summary" aria-live="polite">
<strong>{counts.available}</strong> available
{includeUnavailable && <><span aria-hidden="true">/</span><strong>{counts.unavailable}</strong> unavailable</>}
</div>
<PageScrollViewport className="portal-results">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading services" />}
{!loading && !error && services.length === 0 &&
<div className="portal-empty">No matching services.</div>
}
{!loading && services.length > 0 &&
<div className="portal-service-list">
{services.map((entry) =>
<ServiceEntry
key={`${entry.definition.reference.object_id}:${entry.definition.reference.version ?? "current"}`}
entry={entry}
launching={launchingId === `${entry.definition.reference.object_id}:${entry.definition.reference.version ?? ""}`}
onLaunch={() => void launch(entry)}
/>
)}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function ServiceEntry({
entry,
launching,
onLaunch
}: {
entry: PortalServiceEntry;
launching: boolean;
onLaunch: () => void;
}) {
const reasons = userFacingReasons(entry.reason_codes);
return (
<article className={`portal-service-entry is-${entry.state}`}>
<div className="portal-service-heading">
<div>
<h2>{entry.definition.title}</h2>
<span className="portal-service-key">{entry.definition.key}</span>
</div>
<StatusBadge
status={entry.state === "available" ? "active" : "warning"}
label={entry.state === "available" ? "Available" : "Unavailable"}
/>
</div>
<div className="portal-service-metadata">
{entry.definition.channels.map((channel) =>
<span key={channel}>{humanize(channel)}</span>
)}
{entry.definition.required_evidence_types.map((evidence) =>
<span key={evidence}>{humanize(evidence)}</span>
)}
</div>
{reasons.length > 0 &&
<ul className="portal-service-reasons">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
}
<div className="portal-service-actions">
{entry.entry_binding && entry.state === "available" ?
<Button variant="primary" disabled={launching} onClick={onLaunch}>
{launching ? "Starting" : "Open"}
<ArrowUpRight size={15} aria-hidden="true" />
</Button> :
entry.entry_binding &&
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
}
</div>
</article>
);
}
function userFacingReasons(codes: string[]): string[] {
const values = codes.
filter((code) => !code.startsWith("service.explanation:")).
map((code) => {
if (code === "service.publication.suspended") return "This service is temporarily suspended.";
if (code.includes("required_module.missing") || code.includes("required_capability.missing")) {
return "A required system component is unavailable.";
}
if (code.includes("evaluator_failed")) return "Availability could not be confirmed.";
if (code.includes("requirement.failed")) return "An availability requirement is not met.";
if (code.includes("requirement.unknown")) return "An availability requirement could not be confirmed.";
return "This service is currently unavailable.";
});
return [...new Set(values)];
}
function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, portalModule } from "./module";
export * from "./api/portal";
+50
View File
@@ -0,0 +1,50 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/portal.css";
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
export const portalModule: PlatformWebModule = {
id: "portal",
label: "Services",
version: "0.1.8",
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
routes: [
{
path: "/portal",
anyOf: ["portal:service:read"],
order: 25,
surfaceId: "portal.directory",
render: (context) => createElement(PortalPage, context)
}
],
navItems: [
{
to: "/portal",
label: "Services",
iconName: "landmark",
anyOf: ["portal:service:read"],
order: 25,
surfaceId: "portal.navigation"
}
],
viewSurfaces: [
{
id: "portal.navigation",
moduleId: "portal",
kind: "navigation",
label: "Services navigation",
order: 10
},
{
id: "portal.directory",
moduleId: "portal",
kind: "route",
label: "Service directory",
order: 20
}
]
};
export default portalModule;
+146
View File
@@ -0,0 +1,146 @@
.portal-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.portal-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--surface);
}
.portal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.portal-search {
display: flex;
align-items: center;
gap: 8px;
width: min(620px, 100%);
}
.portal-search input {
min-width: 120px;
flex: 1;
}
.portal-result-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
color: var(--text-soft);
border-bottom: 1px solid var(--border);
}
.portal-results {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.portal-service-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
gap: 12px;
}
.portal-service-entry {
display: flex;
flex-direction: column;
min-height: 190px;
padding: 16px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
background: var(--surface-raised);
}
.portal-service-entry.is-unavailable {
border-left-color: var(--warning);
}
.portal-service-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.portal-service-heading h2 {
margin: 0;
font-size: 1rem;
line-height: 1.35;
letter-spacing: 0;
}
.portal-service-key,
.portal-entry-kind {
color: var(--text-soft);
font-size: 0.78rem;
}
.portal-service-metadata {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
}
.portal-service-metadata span {
padding: 3px 7px;
border-radius: 4px;
background: var(--surface-muted);
color: var(--text-soft);
font-size: 0.78rem;
}
.portal-service-reasons {
margin: 12px 0 0;
padding-left: 18px;
color: var(--warning-text-strong);
font-size: 0.86rem;
}
.portal-service-actions {
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 34px;
margin-top: auto;
padding-top: 14px;
}
.portal-service-actions .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.portal-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
@media (max-width: 720px) {
.portal-toolbar {
align-items: stretch;
flex-direction: column;
}
.portal-search {
width: 100%;
}
}