feat: add registration access ui contracts

This commit is contained in:
2026-06-15 23:39:34 +02:00
parent 5d7685dc8d
commit aaefa48212
13 changed files with 1331 additions and 16 deletions

View File

@@ -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__",
]

803
src/user_engine/ui.py Normal file
View File

@@ -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"<li><a href='#{escape(section.section_id)}'>{escape(section.title)}</a></li>"
for section in screen.sections
)
sections = "\n".join(_render_section(section) for section in screen.sections)
alerts = "".join(
f"<p role='alert' class='alert'>{escape(alert)}</p>" for alert in screen.alerts
)
return (
"<!doctype html><html lang='en'><head>"
"<meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
f"<title>{escape(screen.title)}</title>"
"</head>"
f"<body data-viewport='{escape(screen.viewport.value)}'>"
f"<header role='banner'><h1>{escape(screen.title)}</h1></header>"
f"<nav role='navigation' aria-label='Sections'><ul>{nav_items}</ul></nav>"
f"<main role='main' class='layout-{escape(str(screen.layout.get('columns', 1)))}'>"
f"{alerts}{sections}</main></body></html>"
)
def _registration_complete_screen(
self, completion: RegistrationCompletion, viewport: UiViewport
) -> UiScreen:
return UiScreen(
screen_id="registration-complete",
route_id="registration.complete",
title="Registration Complete",
viewport=viewport,
layout=_layout(viewport),
sections=(
UiSection(
"identity",
"Identity",
fields=(
UiField("netkingdom_id", "NetKingdom ID", completion.netkingdom_id),
UiField("account_status", "Account", completion.account.status.value),
),
tone=UiTone.SUCCESS,
),
),
)
def _prepared_account_section(self, prepared: PreparedAccount) -> UiSection:
factor_types = tuple(
sorted({item.factor_type.value for item in prepared.required_factor_matches})
)
entitlement_kinds = tuple(
sorted({item.kind.value for item in prepared.entitlements})
)
return UiSection(
f"prepared-{prepared.prepared_account_id}",
prepared.display_name or "Prepared Account",
fields=(
UiField("prepared_account_id", "Prepared Account", prepared.prepared_account_id),
UiField("factor_types", "Required Factors", factor_types, redacted=True),
UiField("entitlements", "Entitlements", entitlement_kinds),
UiField("status", "Status", prepared.status.value),
),
actions=(
UiAction("accept", "Accept", "prepared_account.accept", icon="check"),
UiAction("deny", "Deny", "prepared_account.deny", icon="x"),
),
)
def _access_profile_section(self, profile: AccessProfile) -> UiSection:
return UiSection(
f"profile-{profile.access_profile_id}",
profile.display_name,
fields=(
UiField("access_profile_id", "Access Profile", profile.access_profile_id),
UiField("hat", "Hat", profile.hat),
UiField("scope_type", "Scope Type", profile.scope_type.value),
UiField("scope_id", "Scope", profile.scope_id or profile.tenant),
UiField(
"factor_types",
"Required Factors",
tuple(item.value for item in profile.required_factor_types),
redacted=True,
),
),
actions=(
UiAction(
"select_hat",
"Select Hat",
"access_profile.select_hat",
icon="hat",
disabled=profile.requires_approval,
),
),
)
def _registration_session(self, registration_id: str) -> RegistrationSession:
session = self.service.store.registration_session(registration_id)
if session is None:
raise NotFoundError("registration session not found")
return session
def _layout(viewport: UiViewport) -> Mapping[str, Any]:
if viewport == UiViewport.MOBILE:
return {
"columns": 1,
"min_touch_target": 44,
"navigation": "bottom-tabs",
"density": "comfortable",
}
return {
"columns": 2,
"min_touch_target": 32,
"navigation": "sidebar",
"density": "workbench",
}
def _render_section(section: UiSection) -> str:
fields = "".join(_render_field(field) for field in section.fields)
actions = "".join(_render_action(action) for action in section.actions)
summary = f"<p>{escape(section.summary)}</p>" if section.summary else ""
return (
f"<section id='{escape(section.section_id)}' aria-labelledby='"
f"{escape(section.section_id)}-title' data-tone='{escape(section.tone.value)}'>"
f"<h2 id='{escape(section.section_id)}-title'>{escape(section.title)}</h2>"
f"{summary}<dl>{fields}</dl><div class='actions'>{actions}</div></section>"
)
def _render_field(field: UiField) -> str:
value = REDACTED if field.redacted and field.value not in ((), "", None) else field.value
return (
f"<dt>{escape(field.label)}</dt>"
f"<dd data-key='{escape(field.key)}' data-kind='{escape(field.kind)}' "
f"data-tone='{escape(field.tone.value)}'>{escape(_field_value(value))}</dd>"
)
def _render_action(action: UiAction) -> str:
disabled = " disabled aria-disabled='true'" if action.disabled else ""
icon = f"<span aria-hidden='true'>{escape(action.icon)}</span> " if action.icon else ""
description = escape(action.description or action.label)
return (
f"<button type='button' data-route='{escape(action.route_id)}' "
f"aria-label='{description}'{disabled}>{icon}{escape(action.label)}</button>"
)
def _field_value(value: Any) -> str:
if isinstance(value, StrEnum):
return value.value
if isinstance(value, (tuple, list, set)):
return ", ".join(_field_value(item) for item in value) or "none"
if isinstance(value, Mapping):
return ", ".join(f"{key}: {_field_value(item)}" for key, item in value.items())
if value is None:
return "none"
return str(value)
REDACTED = "<redacted>"