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

@@ -14,7 +14,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/registration-identity-and-factor-model.md`, `docs/registration-identity-and-factor-model.md`,
`docs/prepared-accounts-and-entitlement-claims.md`, `docs/prepared-accounts-and-entitlement-claims.md`,
`docs/hats-realms-services-assets-access-profiles.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/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md` `docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, canon mappings, examples, and release for implementation boundaries, contracts, canon mappings, examples, and release

View File

@@ -63,5 +63,6 @@ registration and factor-evidence slice. `USER-WP-0011` implements prepared
accounts and entitlement claims. `USER-WP-0012` implements hats, realms, accounts and entitlement claims. `USER-WP-0012` implements hats, realms,
services, assets, access profiles, active context, and exportable services, assets, access profiles, active context, and exportable
access-control facts. `USER-WP-0013` implements onboarding journeys and access-control facts. `USER-WP-0013` implements onboarding journeys and
welcome protocols. `USER-WP-0014` and `USER-WP-0015` remain proposed future welcome protocols. `USER-WP-0014` implements the optional registration and
workplans for optional UI and security conformance. access-management UI contract facade. `USER-WP-0015` remains proposed future
work for security conformance.

View File

@@ -30,6 +30,19 @@ HTTP or RPC adapters should preserve these operation names:
`accept_family_invitation` `accept_family_invitation`
- `audit_records`, `outbox_events` - `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 Contract
Registration is a headless user-entry facade. It creates a Registration is a headless user-entry facade. It creates a

View File

@@ -103,4 +103,4 @@ proofing data.
- Approval workflows for privileged hats remain a later slice. - Approval workflows for privileged hats remain a later slice.
- Access profile profile-default values are carried into active context and - Access profile profile-default values are carried into active context and
projections, but this slice does not persist them as catalog profile values. 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.

View File

@@ -235,9 +235,9 @@ once.
## Recommended Workplans ## Recommended Workplans
As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`, and As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`,
`USER-WP-0013` are implemented as headless user-engine slices. The later `USER-WP-0013`, and `USER-WP-0014` are implemented as user-engine slices. The
workplans remain recommended follow-on work. later security-conformance workplan remains recommended follow-on work.
| Workplan | Title | Purpose | | Workplan | Title | Purpose |
| --- | --- | --- | | --- | --- | --- |

View File

@@ -88,4 +88,4 @@ gap identifiers.
- No notification platform or support-content renderer is implemented. - No notification platform or support-content renderer is implemented.
- No protected subsystem tour is hard-coded into user-engine. - No protected subsystem tour is hard-coded into user-engine.
- External task and callback execution is left to adapters. - 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.

View File

@@ -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.

View File

@@ -115,5 +115,6 @@ return factor values.
`docs/hats-realms-services-assets-access-profiles.md`. `docs/hats-realms-services-assets-access-profiles.md`.
- Welcome protocols and onboarding journeys are implemented by USER-WP-0013 - Welcome protocols and onboarding journeys are implemented by USER-WP-0013
and documented in `docs/onboarding-journeys-and-welcome-protocols.md`. 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. - Provider-backed proofing and credential flows remain external adapters.

View File

@@ -3,11 +3,26 @@
Future self-service and scope-admin UIs should consume user-engine through a Future self-service and scope-admin UIs should consume user-engine through a
transport adapter that preserves the service shapes below. 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 ## Self-Service Account UI
Required backend operations: Required backend operations:
- `me` to resolve the current actor, user, account, and identity links. - `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. - `effective_profile` with the actor tenant and optional application id.
- `projection` with `SELF_SERVICE` for editable user-visible fields. - `projection` with `SELF_SERVICE` for editable user-visible fields.
- `set_profile_value` for fields whose catalog mutability includes `USER`. - `set_profile_value` for fields whose catalog mutability includes `USER`.
@@ -19,11 +34,37 @@ Required backend operations:
Required backend operations: Required backend operations:
- `resolve_tenant_context` before all tenant-scoped screens. - `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. - `set_tenant_account_status` for in-scope account state.
- `add_membership` for tenant/team membership changes. - `add_membership` for tenant/team membership changes.
- `projection` with `ADMIN` or a future admin transport projection. - `projection` with `ADMIN` or a future admin transport projection.
- `tenant_diagnostics` for onboarding and support readiness checks. - `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 ## Fixtures
Use `user_engine.testing.scenarios` for human, tenant admin, platform Use `user_engine.testing.scenarios` for human, tenant admin, platform

View File

@@ -2,11 +2,13 @@
from user_engine.projections import CacheStatus, ClaimsEnrichmentProjectionCache from user_engine.projections import CacheStatus, ClaimsEnrichmentProjectionCache
from user_engine.service import PLATFORM_TENANT, UserEngineService from user_engine.service import PLATFORM_TENANT, UserEngineService
from user_engine.ui import RegistrationAccessManagementUi
__all__ = [ __all__ = [
"CacheStatus", "CacheStatus",
"ClaimsEnrichmentProjectionCache", "ClaimsEnrichmentProjectionCache",
"PLATFORM_TENANT", "PLATFORM_TENANT",
"RegistrationAccessManagementUi",
"UserEngineService", "UserEngineService",
"__version__", "__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>"

View File

@@ -0,0 +1,286 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
FactorVerification,
IdentityFactorType,
OnboardingTriggerType,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
WelcomeProtocol,
WelcomeProtocolStep,
)
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.ui import RegistrationAccessManagementUi, UiViewport
class RegistrationAccessUiTests(unittest.TestCase):
def test_information_architecture_and_api_contract_expose_expected_routes(self):
ui, _, _ = _ui()
ia = ui.information_architecture()
contract = ui.api_contract()
route_ids = {route.route_id for route in contract.routes}
self.assertIn("registration", ia.primary_navigation)
self.assertIn("prepared_account.review", route_ids)
self.assertIn("access_profile.select_hat", route_ids)
self.assertIn("admin.dashboard", route_ids)
self.assertIn("authorization decisions", contract.adapter_boundaries)
self.assertEqual(ia.breakpoints["mobile"]["columns"], 1)
self.assertEqual(ia.breakpoints["desktop"]["columns"], 2)
def test_self_service_registration_flow_requires_terms_and_redacts_factor_values(self):
ui, service, _ = _ui()
actor = _actor()
started = ui.start_registration(
actor,
required_factor_types=(IdentityFactorType.EMAIL,),
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-start",
)
ui.attach_factor(
actor,
started.session.registration_id,
_verified_email(),
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-factor",
)
blocked = ui.complete_registration(
actor,
started.session.registration_id,
terms_accepted=False,
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-blocked",
)
completed = ui.complete_registration(
actor,
started.session.registration_id,
terms_accepted=True,
viewport=UiViewport.MOBILE,
correlation_id="corr-ui-complete",
)
html = ui.render_html(completed.screen)
self.assertIn("Terms and consent", blocked.screen.alerts[0])
self.assertEqual(completed.completion.netkingdom_id, completed.completion.user.user_id)
self.assertEqual(completed.screen.layout["min_touch_target"], 44)
self.assertIn("role='main'", html)
self.assertIn("data-viewport='mobile'", html)
self.assertNotIn("sample.user@example.test", html)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_prepared_rights_can_be_reviewed_accepted_or_dismissed(self):
ui, service, _ = _ui()
actor = _actor()
prepared = service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
role="member",
),
),
display_name="Prepared Member",
primary_email="sample.user@example.test",
correlation_id="corr-ui-prepare",
)
registration = _complete_registration(service, actor)
review = ui.prepared_rights_review(
actor,
registration.session.registration_id,
viewport=UiViewport.DESKTOP,
)
dismissed = ui.deny_prepared_claim(
prepared.prepared_account_id,
viewport=UiViewport.DESKTOP,
)
accepted = ui.accept_prepared_claim(
actor,
registration.session.registration_id,
prepared.prepared_account_id,
viewport=UiViewport.DESKTOP,
correlation_id="corr-ui-claim",
)
html = ui.render_html(review)
self.assertIn("denied_by_user", repr(dismissed))
self.assertEqual(accepted.claim.prepared_account.claimed_by_user_id, accepted.claim.user.user_id)
self.assertIn("Prepared Member", html)
self.assertIn("&lt;redacted&gt;", html)
self.assertNotIn("sample.user@example.test", html)
def test_hat_selection_view_selects_active_context_without_policy_details(self):
ui, service, store = _ui()
actor = _actor()
registration = _complete_registration(service, actor)
service.add_membership(
actor,
registration.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-ui-membership",
)
profile = service.register_access_profile(
actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Realm Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
service_id="app.demo",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
claims={"internal_policy_hint": "do-not-render"},
),
correlation_id="corr-ui-profile",
)
before = ui.hat_selection_view(
actor,
registration.user.user_id,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
selected = ui.select_hat(
actor,
registration.user.user_id,
profile.access_profile_id,
viewport=UiViewport.DESKTOP,
correlation_id="corr-ui-select-hat",
)
active = store.active_access_context(registration.user.user_id, "tenant:coulomb")
html = ui.render_html(selected)
self.assertIn("none", ui.render_html(before))
self.assertEqual(active.hat, "operator")
self.assertIn("Realm Operator", html)
self.assertNotIn("do-not-render", html)
def test_admin_dashboard_redacts_sensitive_setup_details(self):
ui, service, _ = _ui()
actor = _actor()
session = service.me(human_actor_claims(), correlation_id="corr-ui-me")
service.register_welcome_protocol(
session.actor,
WelcomeProtocol(
tenant="tenant:coulomb",
name="Blocked Welcome",
trigger_type=OnboardingTriggerType.MANUAL,
steps=(
WelcomeProtocolStep(
step_key="external",
title="External",
subsystem="crm",
requires_subsystem_callback=True,
),
),
),
correlation_id="corr-ui-protocol",
)
service.prepare_account(
actor,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
),
),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
primary_email="sample.user@example.test",
correlation_id="corr-ui-prepare",
)
dashboard = ui.admin_dashboard(
actor,
tenant="tenant:coulomb",
viewport=UiViewport.DESKTOP,
)
html = ui.render_html(dashboard)
self.assertEqual(dashboard.layout["columns"], 2)
self.assertIn("Prepared Accounts", html)
self.assertIn("Onboarding", html)
self.assertNotIn("sample.user@example.test", html)
self.assertIn("role='navigation'", html)
self.assertIn("aria-label='Sections'", html)
def _ui():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return RegistrationAccessManagementUi(service), service, store
def _actor():
return FixtureIdentityClaimsAdapter().normalize(
human_actor_claims(subject="sample-user", tenant="tenant:coulomb")
)
def _verified_email() -> FactorVerification:
return FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
)
def _complete_registration(service: UserEngineService, actor):
session = service.start_registration(actor, correlation_id="corr-ui-reg-start")
service.attach_registration_factor(
actor,
session.registration_id,
_verified_email(),
correlation_id="corr-ui-reg-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-ui-reg-complete",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Registration And Access Management UI" title: "Registration And Access Management UI"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: proposed status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: medium planning_priority: medium
@@ -46,7 +46,7 @@ owners, and operators.
```task ```task
id: USER-WP-0014-T1 id: USER-WP-0014-T1
status: todo status: done
priority: high priority: high
state_hub_task_id: "983087e1-c512-419f-86a6-b954d0a1ab54" state_hub_task_id: "983087e1-c512-419f-86a6-b954d0a1ab54"
``` ```
@@ -57,7 +57,7 @@ and admin setup views.
```task ```task
id: USER-WP-0014-T2 id: USER-WP-0014-T2
status: todo status: done
priority: high priority: high
state_hub_task_id: "0af5d8ef-0d1e-44bd-b807-bc40e87afef2" state_hub_task_id: "0af5d8ef-0d1e-44bd-b807-bc40e87afef2"
``` ```
@@ -67,7 +67,7 @@ Keep proofing, IAM, authorization, and notification calls behind adapters.
```task ```task
id: USER-WP-0014-T3 id: USER-WP-0014-T3
status: todo status: done
priority: high priority: high
state_hub_task_id: "a2e00aa3-5849-469c-a3a3-f4f5bd2df6c8" state_hub_task_id: "a2e00aa3-5849-469c-a3a3-f4f5bd2df6c8"
``` ```
@@ -77,7 +77,7 @@ review, factor status, terms/consent, and completion states.
```task ```task
id: USER-WP-0014-T4 id: USER-WP-0014-T4
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "36d49049-cfe7-4f87-9a7f-78e37de9188a" state_hub_task_id: "36d49049-cfe7-4f87-9a7f-78e37de9188a"
``` ```
@@ -87,7 +87,7 @@ groups, and assets.
```task ```task
id: USER-WP-0014-T5 id: USER-WP-0014-T5
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "e58038fc-6138-40cc-bb6b-4cbf7a8b0b87" state_hub_task_id: "e58038fc-6138-40cc-bb6b-4cbf7a8b0b87"
``` ```
@@ -97,7 +97,7 @@ group membership, realms/services/assets, and onboarding diagnostics.
```task ```task
id: USER-WP-0014-T6 id: USER-WP-0014-T6
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "4de949d6-e330-41b2-87cf-9b9425f0f8be" state_hub_task_id: "4de949d6-e330-41b2-87cf-9b9425f0f8be"
``` ```
@@ -123,3 +123,42 @@ for the registration and admin flows.
- Hat/access management UI views. - Hat/access management UI views.
- Admin prepared-account and onboarding views. - Admin prepared-account and onboarding views.
- Frontend verification artifacts. - Frontend verification artifacts.
## Implementation Notes
Implemented on 2026-06-15:
- Added `user_engine.ui` with transport-neutral UI contracts:
`UiRoute`, `UiApiContract`, `UiInformationArchitecture`, `UiScreen`,
`UiSection`, `UiField`, `UiAction`, `UiRegistrationFlow`, and
`RegistrationAccessManagementUi`.
- Defined information architecture for registration, prepared rights, active
hat, profile, onboarding, and admin views, with mobile and desktop layout
metadata.
- Added UI route contracts for registration start/factor/complete,
prepared-rights review/accept/deny, active hat selection, and admin
dashboard.
- Implemented self-service registration helpers with resume/status rendering,
adapter-supplied factor evidence, terms/consent gating, and completion
state.
- Implemented prepared-rights review and accept/dismiss screens while
redacting factor values.
- Implemented active hat selection views over access profiles and active
access context without exposing hidden policy logic.
- Implemented admin dashboard composition for registration diagnostics,
prepared accounts, tenant membership state, access profiles, and onboarding
diagnostics.
- Added accessible HTML verification rendering with semantic landmarks,
labeled section navigation, action labels, and mobile/desktop layout
metadata.
- Added `docs/registration-and-access-management-ui.md`, UI contract updates,
and tests for route contracts, self-service registration, prepared claims,
hat selection, admin redaction, accessibility, and responsive metadata.
Verification:
```text
make test
Ran 71 tests in 1.332s
OK
```