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/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
|
||||||
|
|||||||
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,
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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`.
|
`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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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"
|
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
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user