generated from coulomb/repo-seed
feat: add registration access ui contracts
This commit is contained in:
@@ -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
803
src/user_engine/ui.py
Normal 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>"
|
||||
Reference in New Issue
Block a user