"""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"