diff --git a/README.md b/README.md index 0fa86f0..7087efd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, `docs/registration-identity-and-factor-model.md`, `docs/prepared-accounts-and-entitlement-claims.md`, `docs/hats-realms-services-assets-access-profiles.md`, -`docs/onboarding-journeys-and-welcome-protocols.md`, `docs/scenarios.md`, +`docs/onboarding-journeys-and-welcome-protocols.md`, +`docs/registration-and-access-management-ui.md`, `docs/scenarios.md`, `docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`, `docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md` for implementation boundaries, contracts, canon mappings, examples, and release diff --git a/SCOPE.md b/SCOPE.md index 9086d92..234a56c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -63,5 +63,6 @@ registration and factor-evidence slice. `USER-WP-0011` implements prepared accounts and entitlement claims. `USER-WP-0012` implements hats, realms, services, assets, access profiles, active context, and exportable access-control facts. `USER-WP-0013` implements onboarding journeys and -welcome protocols. `USER-WP-0014` and `USER-WP-0015` remain proposed future -workplans for optional UI and security conformance. +welcome protocols. `USER-WP-0014` implements the optional registration and +access-management UI contract facade. `USER-WP-0015` remains proposed future +work for security conformance. diff --git a/docs/contracts.md b/docs/contracts.md index d7bba75..5f229d4 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -30,6 +30,19 @@ HTTP or RPC adapters should preserve these operation names: `accept_family_invitation` - `audit_records`, `outbox_events` +## UI Contract Surface + +`RegistrationAccessManagementUi` is the optional UI-facing contract facade for +registration and access management. It returns screen/view models and route +definitions over `UserEngineService`; transport adapters may serve those as +HTTP, RPC, desktop, CLI, or rendered HTML. + +The facade covers self-service registration, factor status, terms/consent, +prepared-rights review and claim, active hat selection, admin diagnostics, and +accessible HTML verification. It does not handle credential entry, MFA +challenges, token issuance, hidden policy decisions, notifications, or +service-specific admin consoles. + ## Registration Contract Registration is a headless user-entry facade. It creates a diff --git a/docs/hats-realms-services-assets-access-profiles.md b/docs/hats-realms-services-assets-access-profiles.md index 3c6f7eb..392934f 100644 --- a/docs/hats-realms-services-assets-access-profiles.md +++ b/docs/hats-realms-services-assets-access-profiles.md @@ -103,4 +103,4 @@ proofing data. - Approval workflows for privileged hats remain a later slice. - Access profile profile-default values are carried into active context and projections, but this slice does not persist them as catalog profile values. -- UI selection flows are left to USER-WP-0014. +- UI selection flow contracts are implemented by USER-WP-0014. diff --git a/docs/netkingdom-registration-onboarding-vision.md b/docs/netkingdom-registration-onboarding-vision.md index b3304c4..6e8761c 100644 --- a/docs/netkingdom-registration-onboarding-vision.md +++ b/docs/netkingdom-registration-onboarding-vision.md @@ -235,9 +235,9 @@ once. ## Recommended Workplans -As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`, and -`USER-WP-0013` are implemented as headless user-engine slices. The later -workplans remain recommended follow-on work. +As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`, +`USER-WP-0013`, and `USER-WP-0014` are implemented as user-engine slices. The +later security-conformance workplan remains recommended follow-on work. | Workplan | Title | Purpose | | --- | --- | --- | diff --git a/docs/onboarding-journeys-and-welcome-protocols.md b/docs/onboarding-journeys-and-welcome-protocols.md index d4261dc..95cc903 100644 --- a/docs/onboarding-journeys-and-welcome-protocols.md +++ b/docs/onboarding-journeys-and-welcome-protocols.md @@ -88,4 +88,4 @@ gap identifiers. - No notification platform or support-content renderer is implemented. - No protected subsystem tour is hard-coded into user-engine. - External task and callback execution is left to adapters. -- UI surfaces are left to USER-WP-0014. +- UI surface contracts are implemented by USER-WP-0014. diff --git a/docs/registration-and-access-management-ui.md b/docs/registration-and-access-management-ui.md new file mode 100644 index 0000000..141a2af --- /dev/null +++ b/docs/registration-and-access-management-ui.md @@ -0,0 +1,128 @@ +# Registration And Access Management UI + +Status: implemented headless UI contract slice +Date: 2026-06-15 +Related workplan: USER-WP-0014 + +## Purpose + +This slice adds an optional NetKingdom registration and access-management UI +contract over the headless `UserEngineService`. It is not a credential UI and +does not own browser state. It provides transport-neutral screen models, +route contracts, and an accessible HTML renderer that web, desktop, or CLI +adapters can serve. + +## Information Architecture + +The implemented UI facade exposes these primary areas: + +- registration +- prepared rights +- active hat +- profile +- onboarding +- admin + +The supported views cover registration start/resume, factor status, +registration completion, prepared-account review and claim, active hat +selection, onboarding status, admin prepared accounts, admin access profiles, +and admin onboarding diagnostics. + +## Public Facade + +`RegistrationAccessManagementUi` lives in `user_engine.ui` and is exported from +`user_engine`. + +It exposes: + +- `information_architecture()` +- `api_contract()` +- `start_registration(...)` +- `attach_factor(...)` +- `complete_registration(...)` +- `registration_screen(...)` +- `prepared_rights_review(...)` +- `accept_prepared_claim(...)` +- `deny_prepared_claim(...)` +- `hat_selection_view(...)` +- `select_hat(...)` +- `admin_dashboard(...)` +- `render_html(...)` + +## Route Contract + +The UI route contract maps thin transport routes to existing headless service +facades: + +- `registration.start` -> `start_registration` +- `registration.factor` -> `attach_registration_factor` +- `registration.complete` -> `complete_registration` +- `prepared_account.review` -> `list_prepared_accounts` +- `prepared_account.accept` -> `claim_prepared_account` +- `prepared_account.deny` -> UI dismiss decision +- `access_profile.select_hat` -> `select_active_hat` +- `admin.dashboard` -> diagnostics/list views + +Proofing, IAM, authorization, notification, and protected service consoles +remain adapter boundaries. + +## Registration Flow + +The self-service UI facade can start registration, attach adapter-supplied +factor evidence, show safe factor status by type, enforce UI terms/consent +before completion, and show the resulting NetKingdom ID. + +Factor values are never rendered. The flow displays factor types and status, +not email addresses, phone numbers, postal addresses, eID payloads, provider +tokens, or challenge material. + +## Prepared Rights + +Prepared-account review displays pending packages by id, display name, +required factor types, entitlement kinds, and status. Required factor values +are redacted. Accepting a package calls `claim_prepared_account`. Denying a +package is an explicit UI dismiss state and does not mutate prepared-account +domain state. + +## Hats And Active Context + +The active-hat view lists access profiles and the current active context. +Selecting a hat calls `select_active_hat`; the domain service still enforces +tenant, membership, factor, approval, and authorization rules. The UI does not +display hidden policy logic or final authorization decisions. + +## Admin Surface + +The admin dashboard composes existing diagnostics and list operations: + +- registration diagnostics +- tenant diagnostics +- prepared-account counts +- access-profile counts +- onboarding diagnostics + +The dashboard intentionally reports counts and lifecycle gaps without exposing +factor values, prepared-account factor matches, profile default values, claim +values, or raw proofing payloads. + +## Accessibility And Layout + +The renderer emits semantic landmarks: + +- `banner` +- `navigation` +- `main` + +Sections are linked from navigation, action controls expose `aria-label`, and +mobile/desktop layout metadata is available through the screen model. Mobile +screens use one column with a 44px minimum touch target; desktop screens use a +two-column workbench layout. + +## Current Limits + +- This slice does not ship a web server, JavaScript client, or CSS bundle. +- Browser persistence is not authoritative over domain state. +- The HTML renderer is a verification artifact and adapter starting point, not + a final branded application. +- Credential entry, password reset, passkeys, MFA challenges, token issuance, + notifications, and service-specific consoles remain outside user-engine. diff --git a/docs/registration-identity-and-factor-model.md b/docs/registration-identity-and-factor-model.md index 6a141cc..eafb00d 100644 --- a/docs/registration-identity-and-factor-model.md +++ b/docs/registration-identity-and-factor-model.md @@ -115,5 +115,6 @@ return factor values. `docs/hats-realms-services-assets-access-profiles.md`. - Welcome protocols and onboarding journeys are implemented by USER-WP-0013 and documented in `docs/onboarding-journeys-and-welcome-protocols.md`. -- Registration UI is left to USER-WP-0014. +- Registration UI contracts are implemented by USER-WP-0014 and documented in + `docs/registration-and-access-management-ui.md`. - Provider-backed proofing and credential flows remain external adapters. diff --git a/docs/ui-contracts.md b/docs/ui-contracts.md index d3559b6..545f03b 100644 --- a/docs/ui-contracts.md +++ b/docs/ui-contracts.md @@ -3,11 +3,26 @@ Future self-service and scope-admin UIs should consume user-engine through a transport adapter that preserves the service shapes below. +USER-WP-0014 adds `RegistrationAccessManagementUi` as the first implemented +headless UI contract facade. It returns transport-neutral screen models, +route definitions, responsive layout metadata, and an accessible HTML +verification renderer. + ## Self-Service Account UI Required backend operations: - `me` to resolve the current actor, user, account, and identity links. +- `RegistrationAccessManagementUi.start_registration` to create a UI-backed + registration session. +- `RegistrationAccessManagementUi.attach_factor` to attach adapter-supplied + factor evidence without rendering factor values. +- `RegistrationAccessManagementUi.complete_registration` to enforce UI + terms/consent and complete the headless registration flow. +- `RegistrationAccessManagementUi.prepared_rights_review` and + `accept_prepared_claim` to review and claim prepared rights. +- `RegistrationAccessManagementUi.hat_selection_view` and `select_hat` to show + available hats and select active access context. - `effective_profile` with the actor tenant and optional application id. - `projection` with `SELF_SERVICE` for editable user-visible fields. - `set_profile_value` for fields whose catalog mutability includes `USER`. @@ -19,11 +34,37 @@ Required backend operations: Required backend operations: - `resolve_tenant_context` before all tenant-scoped screens. +- `RegistrationAccessManagementUi.admin_dashboard` for registration, + prepared-account, access-profile, and onboarding diagnostics. - `set_tenant_account_status` for in-scope account state. - `add_membership` for tenant/team membership changes. - `projection` with `ADMIN` or a future admin transport projection. - `tenant_diagnostics` for onboarding and support readiness checks. +## UI Route Contract + +`RegistrationAccessManagementUi.api_contract()` defines these route ids: + +- `registration.start` +- `registration.factor` +- `registration.complete` +- `prepared_account.review` +- `prepared_account.accept` +- `prepared_account.deny` +- `access_profile.select_hat` +- `admin.dashboard` + +Transport adapters may map these ids to HTTP, RPC, desktop, or CLI routes. +The route contract marks factor values, prepared-account factor matches, +profile defaults, claim values, and hidden policy details as redacted. + +## Accessibility And Responsive Contract + +`render_html` emits `banner`, `navigation`, and `main` landmarks. Section +navigation uses labels, controls expose `aria-label`, and screen models include +mobile and desktop layout metadata. Mobile screens use a one-column layout and +44px minimum touch target. Desktop screens use a two-column workbench layout. + ## Fixtures Use `user_engine.testing.scenarios` for human, tenant admin, platform diff --git a/src/user_engine/__init__.py b/src/user_engine/__init__.py index 1046f6a..e3874f2 100644 --- a/src/user_engine/__init__.py +++ b/src/user_engine/__init__.py @@ -2,11 +2,13 @@ from user_engine.projections import CacheStatus, ClaimsEnrichmentProjectionCache from user_engine.service import PLATFORM_TENANT, UserEngineService +from user_engine.ui import RegistrationAccessManagementUi __all__ = [ "CacheStatus", "ClaimsEnrichmentProjectionCache", "PLATFORM_TENANT", + "RegistrationAccessManagementUi", "UserEngineService", "__version__", ] diff --git a/src/user_engine/ui.py b/src/user_engine/ui.py new file mode 100644 index 0000000..b52f313 --- /dev/null +++ b/src/user_engine/ui.py @@ -0,0 +1,803 @@ +"""UI-facing contracts for registration and access management surfaces. + +The module is deliberately transport-neutral. Web, desktop, or CLI adapters can +serve these view models without making browser state authoritative over +user-engine records. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from html import escape +from typing import Any, Mapping + +from user_engine.domain import ( + AccessProfile, + Actor, + FactorVerification, + IdentityFactorType, + OnboardingJourneyStatus, + PreparedAccount, + PreparedAccountStatus, + ProjectionType, + RegistrationSession, +) +from user_engine.errors import NotFoundError, ValidationError +from user_engine.service import ( + PreparedAccountClaim, + RegistrationCompletion, + UserEngineService, +) + + +class UiViewport(StrEnum): + MOBILE = "mobile" + DESKTOP = "desktop" + + +class UiTone(StrEnum): + DEFAULT = "default" + SUCCESS = "success" + WARNING = "warning" + DANGER = "danger" + INFO = "info" + + +@dataclass(frozen=True) +class UiAction: + action_id: str + label: str + route_id: str + method: str = "POST" + icon: str | None = None + disabled: bool = False + description: str | None = None + + +@dataclass(frozen=True) +class UiField: + key: str + label: str + value: Any + kind: str = "text" + tone: UiTone = UiTone.DEFAULT + redacted: bool = False + required: bool = False + + +@dataclass(frozen=True) +class UiSection: + section_id: str + title: str + fields: tuple[UiField, ...] = () + actions: tuple[UiAction, ...] = () + summary: str | None = None + tone: UiTone = UiTone.DEFAULT + + +@dataclass(frozen=True) +class UiScreen: + screen_id: str + route_id: str + title: str + viewport: UiViewport + sections: tuple[UiSection, ...] + actions: tuple[UiAction, ...] = () + alerts: tuple[str, ...] = () + landmarks: tuple[str, ...] = ("banner", "navigation", "main") + layout: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class UiRoute: + route_id: str + path: str + method: str + operation: str + persona: str + redaction: tuple[str, ...] = () + + +@dataclass(frozen=True) +class UiInformationArchitecture: + primary_navigation: tuple[str, ...] + views: tuple[str, ...] + personas: tuple[str, ...] + breakpoints: Mapping[str, Mapping[str, Any]] + + +@dataclass(frozen=True) +class UiApiContract: + routes: tuple[UiRoute, ...] + adapter_boundaries: tuple[str, ...] + + +@dataclass(frozen=True) +class UiRegistrationFlow: + screen: UiScreen + session: RegistrationSession | None = None + completion: RegistrationCompletion | None = None + claim: PreparedAccountClaim | None = None + + +class RegistrationAccessManagementUi: + """Thin UI facade over user-engine service operations.""" + + def __init__(self, service: UserEngineService) -> None: + self.service = service + + def information_architecture(self) -> UiInformationArchitecture: + return UiInformationArchitecture( + primary_navigation=( + "registration", + "prepared-rights", + "active-hat", + "profile", + "onboarding", + "admin", + ), + views=( + "registration.start", + "registration.factor_status", + "registration.complete", + "prepared_account.review", + "access_profile.select_hat", + "profile.completion", + "onboarding.status", + "admin.prepared_accounts", + "admin.access_profiles", + "admin.onboarding_diagnostics", + ), + personas=("self-service", "tenant-admin", "family-owner", "operator"), + breakpoints={ + UiViewport.MOBILE.value: { + "min_width": 320, + "columns": 1, + "navigation": "bottom-tabs", + }, + UiViewport.DESKTOP.value: { + "min_width": 960, + "columns": 2, + "navigation": "sidebar", + }, + }, + ) + + def api_contract(self) -> UiApiContract: + return UiApiContract( + routes=( + UiRoute( + "registration.start", + "/ui/registration", + "POST", + "start_registration", + "self-service", + ), + UiRoute( + "registration.factor", + "/ui/registration/{registration_id}/factors", + "POST", + "attach_registration_factor", + "self-service", + redaction=("normalized_value", "display_value"), + ), + UiRoute( + "registration.complete", + "/ui/registration/{registration_id}/complete", + "POST", + "complete_registration", + "self-service", + ), + UiRoute( + "prepared_account.review", + "/ui/registration/{registration_id}/prepared-rights", + "GET", + "list_prepared_accounts", + "self-service", + redaction=("normalized_factor_values",), + ), + UiRoute( + "prepared_account.accept", + "/ui/registration/{registration_id}/prepared-rights/{id}", + "POST", + "claim_prepared_account", + "self-service", + ), + UiRoute( + "prepared_account.deny", + "/ui/registration/{registration_id}/prepared-rights/{id}/deny", + "POST", + "prepared_claim_dismiss", + "self-service", + ), + UiRoute( + "access_profile.select_hat", + "/ui/users/{user_id}/active-hat", + "POST", + "select_active_hat", + "self-service", + ), + UiRoute( + "admin.dashboard", + "/ui/admin/{tenant}", + "GET", + "admin_dashboard", + "tenant-admin", + redaction=("factor_values", "profile_defaults", "claim_values"), + ), + ), + adapter_boundaries=( + "identity proofing", + "credential lifecycle", + "authorization decisions", + "notifications", + "service runtime consoles", + ), + ) + + def start_registration( + self, + actor: Actor, + *, + required_factor_types: tuple[IdentityFactorType, ...] = (), + viewport: UiViewport = UiViewport.DESKTOP, + correlation_id: str | None = None, + ) -> UiRegistrationFlow: + session = self.service.start_registration( + actor, + required_factor_types=required_factor_types, + correlation_id=correlation_id, + ) + return UiRegistrationFlow( + screen=self.registration_screen(actor, session.registration_id, viewport), + session=session, + ) + + def attach_factor( + self, + actor: Actor, + registration_id: str, + verification: FactorVerification | Mapping[str, Any], + *, + viewport: UiViewport = UiViewport.DESKTOP, + correlation_id: str | None = None, + ) -> UiRegistrationFlow: + session = self.service.attach_registration_factor( + actor, + registration_id, + verification, + correlation_id=correlation_id, + ) + return UiRegistrationFlow( + screen=self.registration_screen(actor, session.registration_id, viewport), + session=session, + ) + + def complete_registration( + self, + actor: Actor, + registration_id: str, + *, + terms_accepted: bool, + viewport: UiViewport = UiViewport.DESKTOP, + correlation_id: str | None = None, + ) -> UiRegistrationFlow: + if not terms_accepted: + return UiRegistrationFlow( + screen=self.registration_screen( + actor, + registration_id, + viewport, + alerts=("Terms and consent must be accepted to continue.",), + ) + ) + completion = self.service.complete_registration( + actor, + registration_id, + correlation_id=correlation_id, + ) + return UiRegistrationFlow( + screen=self._registration_complete_screen(completion, viewport), + completion=completion, + ) + + def registration_screen( + self, + actor: Actor, + registration_id: str, + viewport: UiViewport = UiViewport.DESKTOP, + *, + alerts: tuple[str, ...] = (), + ) -> UiScreen: + session = self._registration_session(registration_id) + factors = self.service.store.factors_for_registration(registration_id) + factor_types = tuple(sorted({factor.factor_type.value for factor in factors})) + pending_required = tuple( + factor_type.value + for factor_type in session.required_factor_types + if factor_type.value not in factor_types + ) + return UiScreen( + screen_id="registration", + route_id="registration.start", + title="Registration", + viewport=viewport, + alerts=alerts, + layout=_layout(viewport), + sections=( + UiSection( + "session", + "Session", + fields=( + UiField("registration_id", "Registration ID", registration_id), + UiField("status", "Status", session.status.value), + UiField( + "netkingdom_id", + "NetKingdom ID", + session.netkingdom_id or "pending", + ), + ), + ), + UiSection( + "factor_status", + "Factor Status", + fields=( + UiField("verified_factors", "Verified Factors", factor_types), + UiField("pending_factors", "Pending Factors", pending_required), + ), + actions=( + UiAction( + "attach_factor", + "Attach Factor Evidence", + "registration.factor", + icon="shield-check", + ), + ), + ), + UiSection( + "terms", + "Terms And Consent", + fields=( + UiField( + "terms_accepted", + "Terms Accepted", + False, + kind="checkbox", + required=True, + ), + ), + actions=( + UiAction( + "complete_registration", + "Complete Registration", + "registration.complete", + icon="check", + ), + ), + ), + ), + ) + + def prepared_rights_review( + self, + actor: Actor, + registration_id: str, + *, + viewport: UiViewport = UiViewport.DESKTOP, + ) -> UiScreen: + session = self._registration_session(registration_id) + prepared_accounts = self.service.list_prepared_accounts( + actor, + tenant=session.tenant, + correlation_id="ui-prepared-review", + ) + sections = tuple( + self._prepared_account_section(prepared) + for prepared in prepared_accounts + if prepared.status == PreparedAccountStatus.PENDING + ) + if not sections: + sections = ( + UiSection( + "prepared-empty", + "Prepared Rights", + fields=(UiField("status", "Status", "none available"),), + ), + ) + return UiScreen( + screen_id="prepared-rights", + route_id="prepared_account.review", + title="Prepared Rights", + viewport=viewport, + layout=_layout(viewport), + sections=sections, + ) + + def accept_prepared_claim( + self, + actor: Actor, + registration_id: str, + prepared_account_id: str, + *, + viewport: UiViewport = UiViewport.DESKTOP, + correlation_id: str | None = None, + ) -> UiRegistrationFlow: + claim = self.service.claim_prepared_account( + actor, + registration_id, + prepared_account_id=prepared_account_id, + correlation_id=correlation_id, + ) + return UiRegistrationFlow( + screen=UiScreen( + screen_id="prepared-claim-accepted", + route_id="prepared_account.accept", + title="Prepared Rights Accepted", + viewport=viewport, + layout=_layout(viewport), + sections=( + UiSection( + "claim", + "Claim", + fields=( + UiField( + "prepared_account_id", + "Prepared Account", + claim.prepared_account.prepared_account_id, + ), + UiField("status", "Status", claim.prepared_account.status.value), + UiField( + "entitlement_count", + "Activated Entitlements", + len(claim.prepared_account.entitlements), + ), + ), + tone=UiTone.SUCCESS, + ), + ), + ), + claim=claim, + ) + + def deny_prepared_claim( + self, + prepared_account_id: str, + *, + viewport: UiViewport = UiViewport.DESKTOP, + ) -> UiScreen: + return UiScreen( + screen_id="prepared-claim-denied", + route_id="prepared_account.deny", + title="Prepared Rights Dismissed", + viewport=viewport, + layout=_layout(viewport), + alerts=("The prepared-rights package was dismissed in the UI.",), + sections=( + UiSection( + "decision", + "Decision", + fields=( + UiField("prepared_account_id", "Prepared Account", prepared_account_id), + UiField("decision", "Decision", "denied_by_user"), + ), + tone=UiTone.WARNING, + ), + ), + ) + + def hat_selection_view( + self, + actor: Actor, + user_id: str, + *, + tenant: str, + viewport: UiViewport = UiViewport.DESKTOP, + ) -> UiScreen: + profiles = self.service.list_access_profiles( + actor, + tenant=tenant, + correlation_id="ui-hat-list", + ) + context = self.service.identity_context( + actor, + user_id=user_id, + tenant=tenant, + correlation_id="ui-hat-context", + ) + active = context.active_access_context + profile_sections = tuple(self._access_profile_section(profile) for profile in profiles) + return UiScreen( + screen_id="active-hat", + route_id="access_profile.select_hat", + title="Active Hat", + viewport=viewport, + layout=_layout(viewport), + sections=( + UiSection( + "current", + "Current Context", + fields=( + UiField("active_hat", "Active Hat", active.hat if active else "none"), + UiField( + "scope", + "Scope", + f"{active.scope_type.value}:{active.scope_id}" if active else "none", + ), + ), + ), + *profile_sections, + ), + ) + + def select_hat( + self, + actor: Actor, + user_id: str, + access_profile_id: str, + *, + viewport: UiViewport = UiViewport.DESKTOP, + correlation_id: str | None = None, + ) -> UiScreen: + selection = self.service.select_active_hat( + actor, + user_id, + access_profile_id, + correlation_id=correlation_id, + ) + return self.hat_selection_view( + actor, + user_id, + tenant=selection.active_context.tenant, + viewport=viewport, + ) + + def admin_dashboard( + self, + actor: Actor, + *, + tenant: str, + viewport: UiViewport = UiViewport.DESKTOP, + ) -> UiScreen: + registration = self.service.registration_diagnostics( + actor, + tenant=tenant, + correlation_id="ui-admin-registration", + ) + tenant_diag = self.service.tenant_diagnostics( + actor, + tenant=tenant, + correlation_id="ui-admin-tenant", + ) + prepared_accounts = self.service.list_prepared_accounts( + actor, + tenant=tenant, + correlation_id="ui-admin-prepared", + ) + access_profiles = self.service.list_access_profiles( + actor, + tenant=tenant, + correlation_id="ui-admin-access", + ) + onboarding = self.service.onboarding_diagnostics( + actor, + tenant=tenant, + correlation_id="ui-admin-onboarding", + ) + return UiScreen( + screen_id="admin-dashboard", + route_id="admin.dashboard", + title="Admin", + viewport=viewport, + layout=_layout(viewport), + sections=( + UiSection( + "registration", + "Registration", + fields=( + UiField("total_sessions", "Sessions", registration.total_sessions), + UiField( + "pending_sessions", + "Pending", + registration.pending_session_count, + ), + ), + ), + UiSection( + "prepared_accounts", + "Prepared Accounts", + fields=( + UiField("prepared_count", "Prepared Accounts", len(prepared_accounts)), + UiField( + "pending_count", + "Pending", + len( + [ + item + for item in prepared_accounts + if item.status == PreparedAccountStatus.PENDING + ] + ), + ), + ), + ), + UiSection( + "access", + "Access", + fields=( + UiField("membership_count", "Memberships", len(tenant_diag.memberships)), + UiField("profile_count", "Access Profiles", len(access_profiles)), + ), + ), + UiSection( + "onboarding", + "Onboarding", + fields=( + UiField("journey_count", "Journeys", onboarding.journey_count), + UiField("blocked_steps", "Blocked Steps", onboarding.blocked_steps), + UiField("lifecycle_gaps", "Lifecycle Gaps", onboarding.lifecycle_gaps), + ), + ), + ), + ) + + def render_html(self, screen: UiScreen) -> str: + nav_items = "".join( + f"
{escape(alert)}
" for alert in screen.alerts + ) + return ( + "" + "" + "" + f"{escape(section.summary)}
" if section.summary else "" + return ( + f"