generated from coulomb/repo-seed
feat: add registration access ui contracts
This commit is contained in:
@@ -14,7 +14,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
|
||||
`docs/registration-identity-and-factor-model.md`,
|
||||
`docs/prepared-accounts-and-entitlement-claims.md`,
|
||||
`docs/hats-realms-services-assets-access-profiles.md`,
|
||||
`docs/onboarding-journeys-and-welcome-protocols.md`, `docs/scenarios.md`,
|
||||
`docs/onboarding-journeys-and-welcome-protocols.md`,
|
||||
`docs/registration-and-access-management-ui.md`, `docs/scenarios.md`,
|
||||
`docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
|
||||
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
||||
for implementation boundaries, contracts, canon mappings, examples, and release
|
||||
|
||||
5
SCOPE.md
5
SCOPE.md
@@ -63,5 +63,6 @@ registration and factor-evidence slice. `USER-WP-0011` implements prepared
|
||||
accounts and entitlement claims. `USER-WP-0012` implements hats, realms,
|
||||
services, assets, access profiles, active context, and exportable
|
||||
access-control facts. `USER-WP-0013` implements onboarding journeys and
|
||||
welcome protocols. `USER-WP-0014` and `USER-WP-0015` remain proposed future
|
||||
workplans for optional UI and security conformance.
|
||||
welcome protocols. `USER-WP-0014` implements the optional registration and
|
||||
access-management UI contract facade. `USER-WP-0015` remains proposed future
|
||||
work for security conformance.
|
||||
|
||||
@@ -30,6 +30,19 @@ HTTP or RPC adapters should preserve these operation names:
|
||||
`accept_family_invitation`
|
||||
- `audit_records`, `outbox_events`
|
||||
|
||||
## UI Contract Surface
|
||||
|
||||
`RegistrationAccessManagementUi` is the optional UI-facing contract facade for
|
||||
registration and access management. It returns screen/view models and route
|
||||
definitions over `UserEngineService`; transport adapters may serve those as
|
||||
HTTP, RPC, desktop, CLI, or rendered HTML.
|
||||
|
||||
The facade covers self-service registration, factor status, terms/consent,
|
||||
prepared-rights review and claim, active hat selection, admin diagnostics, and
|
||||
accessible HTML verification. It does not handle credential entry, MFA
|
||||
challenges, token issuance, hidden policy decisions, notifications, or
|
||||
service-specific admin consoles.
|
||||
|
||||
## Registration Contract
|
||||
|
||||
Registration is a headless user-entry facade. It creates a
|
||||
|
||||
@@ -103,4 +103,4 @@ proofing data.
|
||||
- Approval workflows for privileged hats remain a later slice.
|
||||
- Access profile profile-default values are carried into active context and
|
||||
projections, but this slice does not persist them as catalog profile values.
|
||||
- UI selection flows are left to USER-WP-0014.
|
||||
- UI selection flow contracts are implemented by USER-WP-0014.
|
||||
|
||||
@@ -235,9 +235,9 @@ once.
|
||||
|
||||
## Recommended Workplans
|
||||
|
||||
As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`, and
|
||||
`USER-WP-0013` are implemented as headless user-engine slices. The later
|
||||
workplans remain recommended follow-on work.
|
||||
As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, `USER-WP-0012`,
|
||||
`USER-WP-0013`, and `USER-WP-0014` are implemented as user-engine slices. The
|
||||
later security-conformance workplan remains recommended follow-on work.
|
||||
|
||||
| Workplan | Title | Purpose |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -88,4 +88,4 @@ gap identifiers.
|
||||
- No notification platform or support-content renderer is implemented.
|
||||
- No protected subsystem tour is hard-coded into user-engine.
|
||||
- External task and callback execution is left to adapters.
|
||||
- UI surfaces are left to USER-WP-0014.
|
||||
- UI surface contracts are implemented by USER-WP-0014.
|
||||
|
||||
128
docs/registration-and-access-management-ui.md
Normal file
128
docs/registration-and-access-management-ui.md
Normal 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.
|
||||
@@ -115,5 +115,6 @@ return factor values.
|
||||
`docs/hats-realms-services-assets-access-profiles.md`.
|
||||
- Welcome protocols and onboarding journeys are implemented by USER-WP-0013
|
||||
and documented in `docs/onboarding-journeys-and-welcome-protocols.md`.
|
||||
- Registration UI is left to USER-WP-0014.
|
||||
- Registration UI contracts are implemented by USER-WP-0014 and documented in
|
||||
`docs/registration-and-access-management-ui.md`.
|
||||
- Provider-backed proofing and credential flows remain external adapters.
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
Future self-service and scope-admin UIs should consume user-engine through a
|
||||
transport adapter that preserves the service shapes below.
|
||||
|
||||
USER-WP-0014 adds `RegistrationAccessManagementUi` as the first implemented
|
||||
headless UI contract facade. It returns transport-neutral screen models,
|
||||
route definitions, responsive layout metadata, and an accessible HTML
|
||||
verification renderer.
|
||||
|
||||
## Self-Service Account UI
|
||||
|
||||
Required backend operations:
|
||||
|
||||
- `me` to resolve the current actor, user, account, and identity links.
|
||||
- `RegistrationAccessManagementUi.start_registration` to create a UI-backed
|
||||
registration session.
|
||||
- `RegistrationAccessManagementUi.attach_factor` to attach adapter-supplied
|
||||
factor evidence without rendering factor values.
|
||||
- `RegistrationAccessManagementUi.complete_registration` to enforce UI
|
||||
terms/consent and complete the headless registration flow.
|
||||
- `RegistrationAccessManagementUi.prepared_rights_review` and
|
||||
`accept_prepared_claim` to review and claim prepared rights.
|
||||
- `RegistrationAccessManagementUi.hat_selection_view` and `select_hat` to show
|
||||
available hats and select active access context.
|
||||
- `effective_profile` with the actor tenant and optional application id.
|
||||
- `projection` with `SELF_SERVICE` for editable user-visible fields.
|
||||
- `set_profile_value` for fields whose catalog mutability includes `USER`.
|
||||
@@ -19,11 +34,37 @@ Required backend operations:
|
||||
Required backend operations:
|
||||
|
||||
- `resolve_tenant_context` before all tenant-scoped screens.
|
||||
- `RegistrationAccessManagementUi.admin_dashboard` for registration,
|
||||
prepared-account, access-profile, and onboarding diagnostics.
|
||||
- `set_tenant_account_status` for in-scope account state.
|
||||
- `add_membership` for tenant/team membership changes.
|
||||
- `projection` with `ADMIN` or a future admin transport projection.
|
||||
- `tenant_diagnostics` for onboarding and support readiness checks.
|
||||
|
||||
## UI Route Contract
|
||||
|
||||
`RegistrationAccessManagementUi.api_contract()` defines these route ids:
|
||||
|
||||
- `registration.start`
|
||||
- `registration.factor`
|
||||
- `registration.complete`
|
||||
- `prepared_account.review`
|
||||
- `prepared_account.accept`
|
||||
- `prepared_account.deny`
|
||||
- `access_profile.select_hat`
|
||||
- `admin.dashboard`
|
||||
|
||||
Transport adapters may map these ids to HTTP, RPC, desktop, or CLI routes.
|
||||
The route contract marks factor values, prepared-account factor matches,
|
||||
profile defaults, claim values, and hidden policy details as redacted.
|
||||
|
||||
## Accessibility And Responsive Contract
|
||||
|
||||
`render_html` emits `banner`, `navigation`, and `main` landmarks. Section
|
||||
navigation uses labels, controls expose `aria-label`, and screen models include
|
||||
mobile and desktop layout metadata. Mobile screens use a one-column layout and
|
||||
44px minimum touch target. Desktop screens use a two-column workbench layout.
|
||||
|
||||
## Fixtures
|
||||
|
||||
Use `user_engine.testing.scenarios` for human, tenant admin, platform
|
||||
|
||||
@@ -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>"
|
||||
286
tests/test_registration_access_ui.py
Normal file
286
tests/test_registration_access_ui.py
Normal 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("<redacted>", 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()
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "Registration And Access Management UI"
|
||||
domain: netkingdom
|
||||
repo: user-engine
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: medium
|
||||
@@ -46,7 +46,7 @@ owners, and operators.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "983087e1-c512-419f-86a6-b954d0a1ab54"
|
||||
```
|
||||
@@ -57,7 +57,7 @@ and admin setup views.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T2
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0af5d8ef-0d1e-44bd-b807-bc40e87afef2"
|
||||
```
|
||||
@@ -67,7 +67,7 @@ Keep proofing, IAM, authorization, and notification calls behind adapters.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a2e00aa3-5849-469c-a3a3-f4f5bd2df6c8"
|
||||
```
|
||||
@@ -77,7 +77,7 @@ review, factor status, terms/consent, and completion states.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T4
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "36d49049-cfe7-4f87-9a7f-78e37de9188a"
|
||||
```
|
||||
@@ -87,7 +87,7 @@ groups, and assets.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T5
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "e58038fc-6138-40cc-bb6b-4cbf7a8b0b87"
|
||||
```
|
||||
@@ -97,7 +97,7 @@ group membership, realms/services/assets, and onboarding diagnostics.
|
||||
|
||||
```task
|
||||
id: USER-WP-0014-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "4de949d6-e330-41b2-87cf-9b9425f0f8be"
|
||||
```
|
||||
@@ -123,3 +123,42 @@ for the registration and admin flows.
|
||||
- Hat/access management UI views.
|
||||
- Admin prepared-account and onboarding views.
|
||||
- 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user