diff --git a/README.md b/README.md index 5bbc568..1cb155b 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,30 @@ **Repository type:** module (domain). + +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 `/` 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). diff --git a/docs/SERVICE_DIRECTORY_CONCEPT.md b/docs/SERVICE_DIRECTORY_CONCEPT.md index ea53bd6..706d87c 100644 --- a/docs/SERVICE_DIRECTORY_CONCEPT.md +++ b/docs/SERVICE_DIRECTORY_CONCEPT.md @@ -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:` 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 +`/`, validates launch values, and retains Service/binding +provenance. Reduced installations still fail closed rather than simulating a +submission in Portal. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb46b88 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/govoplan_portal/__init__.py b/src/govoplan_portal/__init__.py new file mode 100644 index 0000000..c330d66 --- /dev/null +++ b/src/govoplan_portal/__init__.py @@ -0,0 +1,5 @@ +"""GovOPlaN Portal module.""" + +from govoplan_portal.backend.manifest import get_manifest + +__all__ = ["get_manifest"] diff --git a/src/govoplan_portal/backend/__init__.py b/src/govoplan_portal/backend/__init__.py new file mode 100644 index 0000000..9301e9c --- /dev/null +++ b/src/govoplan_portal/backend/__init__.py @@ -0,0 +1 @@ +"""Portal backend contracts.""" diff --git a/src/govoplan_portal/backend/manifest.py b/src/govoplan_portal/backend/manifest.py new file mode 100644 index 0000000..72777b3 --- /dev/null +++ b/src/govoplan_portal/backend/manifest.py @@ -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 diff --git a/src/govoplan_portal/backend/router.py b/src/govoplan_portal/backend/router.py new file mode 100644 index 0000000..99e06b7 --- /dev/null +++ b/src/govoplan_portal/backend/router.py @@ -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"] diff --git a/src/govoplan_portal/backend/schemas.py b/src/govoplan_portal/backend/schemas.py new file mode 100644 index 0000000..f707e7f --- /dev/null +++ b/src/govoplan_portal/backend/schemas.py @@ -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", +] diff --git a/src/govoplan_portal/backend/service_directory.py b/src/govoplan_portal/backend/service_directory.py new file mode 100644 index 0000000..fe236c4 --- /dev/null +++ b/src/govoplan_portal/backend/service_directory.py @@ -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", +] diff --git a/src/govoplan_portal/py.typed b/src/govoplan_portal/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_portal/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_service_directory.py b/tests/test_service_directory.py new file mode 100644 index 0000000..43c556a --- /dev/null +++ b/tests/test_service_directory.py @@ -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() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..96861e1 --- /dev/null +++ b/webui/package.json @@ -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 + } + } +} diff --git a/webui/src/api/portal.ts b/webui/src/api/portal.ts new file mode 100644 index 0000000..46a9576 --- /dev/null +++ b/webui/src/api/portal.ts @@ -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>; +}; + +export type PortalServiceListResponse = { + services: PortalServiceEntry[]; +}; + +export type PortalServiceLaunchResult = { + service_ref: PortalServiceDefinition["reference"]; + binding: PortalServiceBinding; + state: "started" | "redirect"; + target_ref?: Record | null; + href?: string | null; + replayed: boolean; + evidence: Array>; + metadata: Record; +}; + +export function listPortalServices( + settings: ApiSettings, + options: { + query?: string; + includeUnavailable?: boolean; + limit?: number; + }, + signal?: AbortSignal +): Promise { + return apiFetch( + 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; + } +): Promise { + return apiFetch( + settings, + `/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`, + { + method: "POST", + body: JSON.stringify(payload) + } + ); +} diff --git a/webui/src/features/portal/PortalPage.tsx b/webui/src/features/portal/PortalPage.tsx new file mode 100644 index 0000000..3b2ea50 --- /dev/null +++ b/webui/src/features/portal/PortalPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [launchingId, setLaunchingId] = useState(""); + const launchAttempts = useRef(new Map()); + + 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 ( +
+
+
+
+
+ +
+ {counts.available} available + {includeUnavailable && <>{counts.unavailable} unavailable} +
+ + + {error && + + {error} + + } + {loading && } + {!loading && !error && services.length === 0 && +
No matching services.
+ } + {!loading && services.length > 0 && +
+ {services.map((entry) => + void launch(entry)} + /> + )} +
+ } +
+
+
+ ); +} + +function ServiceEntry({ + entry, + launching, + onLaunch +}: { + entry: PortalServiceEntry; + launching: boolean; + onLaunch: () => void; +}) { + const reasons = userFacingReasons(entry.reason_codes); + return ( +
+
+
+

{entry.definition.title}

+ {entry.definition.key} +
+ +
+
+ {entry.definition.channels.map((channel) => + {humanize(channel)} + )} + {entry.definition.required_evidence_types.map((evidence) => + {humanize(evidence)} + )} +
+ {reasons.length > 0 && +
    + {reasons.map((reason) =>
  • {reason}
  • )} +
+ } +
+ {entry.entry_binding && entry.state === "available" ? + : + entry.entry_binding && + {humanize(entry.entry_binding.kind)} + } +
+
+ ); +} + +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()); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..9a89e7c --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { default, portalModule } from "./module"; +export * from "./api/portal"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..c1f3549 --- /dev/null +++ b/webui/src/module.ts @@ -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; diff --git a/webui/src/styles/portal.css b/webui/src/styles/portal.css new file mode 100644 index 0000000..dd1a96a --- /dev/null +++ b/webui/src/styles/portal.css @@ -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%; + } +}