Show login state, active sign-in, and allowed memberships separately.
USER-WP-0036 keeps the token tenant off the membership list and leaves workload decisions unchecked until the catalogue reports them. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
This commit is contained in:
parent
1c7634c7ce
commit
85423e8e09
7 changed files with 320 additions and 53 deletions
|
|
@ -276,7 +276,12 @@ class PortalApplication:
|
|||
if path == "/" and method == "GET":
|
||||
actor = self._optional_actor(environ)
|
||||
self._set_account_navigation(environ, actor)
|
||||
return self._html(start_response, self._home(actor), correlation_id)
|
||||
memberships: tuple[Any, ...] = ()
|
||||
if actor is not None:
|
||||
identity = self.service.store.find_identity(*actor.identity_key)
|
||||
if identity is not None:
|
||||
memberships = self.service.store.memberships_for_user(identity.user_id)
|
||||
return self._html(start_response, self._home(actor, memberships), correlation_id)
|
||||
|
||||
if path == "/register" and method == "GET":
|
||||
if self._optional_actor(environ) is not None:
|
||||
|
|
@ -389,8 +394,7 @@ class PortalApplication:
|
|||
return self._html(
|
||||
start_response,
|
||||
self._onboarding(
|
||||
session, memberships, journeys, str(selected_tenant),
|
||||
self._csrf_token(environ),
|
||||
session, memberships, journeys, self._csrf_token(environ),
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
|
|
@ -1894,25 +1898,26 @@ If the recovery message does not arrive, ask your tenant administrator for a new
|
|||
Use the login name they provide; it may differ from your display name.</p></section>
|
||||
<p><a href="/access-recovery">Back to sign-in help</a></p>""")
|
||||
|
||||
def _home(self, actor: Any | None) -> str:
|
||||
identity = (
|
||||
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
|
||||
'<p><a class="button" href="/onboarding">View my account</a></p>'
|
||||
if actor is not None
|
||||
else (
|
||||
'<p>You are not signed in to this portal.</p>'
|
||||
+ (
|
||||
'<p>New here? <a href="/register">Create an account</a>.</p>'
|
||||
if self.public_registration and self.registration_verification is not None
|
||||
else ""
|
||||
)
|
||||
)
|
||||
def _home(self, actor: Any | None, memberships: tuple[Any, ...] = ()) -> str:
|
||||
register = (
|
||||
'<p>New here? <a href="/register">Create an account</a>.</p>'
|
||||
if actor is None and self.public_registration and self.registration_verification is not None
|
||||
else ""
|
||||
)
|
||||
account = (
|
||||
'<p><a class="button" href="/onboarding">View my account</a></p>'
|
||||
if actor is not None else ""
|
||||
)
|
||||
situation = self._login_state(actor)
|
||||
if actor is not None:
|
||||
situation += self._active_now(actor, memberships)
|
||||
situation += self._allowed_tenants(actor, memberships)
|
||||
situation += self._allowed_workloads(memberships)
|
||||
return self._page_html(
|
||||
"Identity & access",
|
||||
"<h1>Your account, on your terms.</h1>"
|
||||
"<p>Join a tenant, complete onboarding, and manage access without exposing credentials to applications.</p>"
|
||||
+ identity,
|
||||
+ situation + register + account,
|
||||
)
|
||||
|
||||
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str:
|
||||
|
|
@ -2168,44 +2173,147 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
|
||||
def _onboarding(
|
||||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||
selected_tenant: str, csrf_token: str,
|
||||
csrf_token: str,
|
||||
) -> str:
|
||||
platform_operator = "platform-operator" in session.actor.roles
|
||||
empty_memberships = (
|
||||
"<li>You have no personal tenant memberships. Your platform operator role lets you manage tenants through platform administration.</li>"
|
||||
if platform_operator else "<li>No tenant memberships yet.</li>"
|
||||
)
|
||||
membership_items = "".join(
|
||||
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
|
||||
for item in memberships
|
||||
) or empty_memberships
|
||||
journey_items = "".join(
|
||||
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
||||
) or "<li>No additional onboarding steps are required.</li>"
|
||||
verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
|
||||
consent_checked = " checked" if session.user.consented_at else ""
|
||||
actor = session.actor
|
||||
return self._page_html(
|
||||
"Onboarding",
|
||||
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
|
||||
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p><a href="/security">Password and two-step verification help</a></p></section>
|
||||
f"""<h1>Welcome, {escape(session.user.display_name or actor.preferred_username or session.user.user_id)}</h1>
|
||||
{self._login_state(actor)}
|
||||
{self._active_now(actor, memberships)}
|
||||
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
|
||||
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
|
||||
<label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label>
|
||||
<button type="submit">Save profile</button></form></section>
|
||||
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
|
||||
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
|
||||
<section aria-labelledby="workloads"><h2 id="workloads">Workload access</h2>{self._workload_memberships(memberships)}<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>
|
||||
{self._allowed_tenants(actor, memberships)}
|
||||
{self._allowed_workloads(memberships)}
|
||||
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _workload_memberships(memberships: tuple[Any, ...]) -> str:
|
||||
items = "".join(
|
||||
f"<li>{escape(item.scope_id)} — {escape(item.kind)} ({escape(item.tenant)})</li>"
|
||||
for item in memberships if item.scope_type in {"application", "service", "workload", "asset"}
|
||||
def _login_state(actor: Any | None) -> str:
|
||||
if actor is None:
|
||||
return (
|
||||
'<section aria-labelledby="login-state"><h2 id="login-state">Login state</h2>'
|
||||
"<p>You are not signed in to this portal.</p></section>"
|
||||
)
|
||||
name = escape(actor.preferred_username or actor.subject)
|
||||
verification = "Verified by your identity provider" if actor.assurance else "Verification pending"
|
||||
return (
|
||||
'<section aria-labelledby="login-state"><h2 id="login-state">Login state</h2>'
|
||||
f"<p>Signed in to this portal as <strong>{name}</strong>.</p>"
|
||||
"<p>This is the portal session. An application can keep its own session.</p>"
|
||||
f"<p>{escape(verification)}</p>"
|
||||
'<p><a href="/security">Password and two-step verification help</a></p></section>'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_exception_account(actor: Any, memberships: tuple[Any, ...]) -> bool:
|
||||
if {"tenant-admin", "platform-operator", "platform-root"}.intersection(actor.roles):
|
||||
return True
|
||||
return any(item.kind in {"vendor", "multi-hire"} for item in memberships)
|
||||
|
||||
@staticmethod
|
||||
def _active_tenants(actor: Any, memberships: tuple[Any, ...]) -> tuple[str, ...]:
|
||||
active = [str(actor.tenant)]
|
||||
if not PortalApplication._is_exception_account(actor, memberships):
|
||||
return tuple(active)
|
||||
claimed = actor.claims.get("active_tenants", ())
|
||||
if isinstance(claimed, str):
|
||||
claimed = (claimed,)
|
||||
for tenant in claimed:
|
||||
if (
|
||||
isinstance(tenant, str)
|
||||
and tenant.startswith("tenant:")
|
||||
and len(tenant) <= 200
|
||||
and tenant not in active
|
||||
):
|
||||
active.append(tenant)
|
||||
if len(active) >= 8:
|
||||
break
|
||||
return tuple(active)
|
||||
|
||||
@staticmethod
|
||||
def _active_now(actor: Any, memberships: tuple[Any, ...]) -> str:
|
||||
active = PortalApplication._active_tenants(actor, memberships)
|
||||
items = "".join(f"<li><strong>{escape(tenant)}</strong> - Active</li>" for tenant in active)
|
||||
if len(active) > 1:
|
||||
note = "<p>More than one tenant is active because this sign-in carries an administrator, vendor, or multi-hire role.</p>"
|
||||
elif PortalApplication._is_exception_account(actor, memberships):
|
||||
note = "<p>This account may use more than one tenant when the sign-in says so. This sign-in has one active tenant.</p>"
|
||||
else:
|
||||
note = "<p>An ordinary sign-in uses one tenant. Other allowed tenants stay inactive until you sign in to them.</p>"
|
||||
roles = escape(", ".join(actor.roles) or "None")
|
||||
groups = escape(", ".join(actor.groups) or "None")
|
||||
return (
|
||||
'<section aria-labelledby="active-now"><h2 id="active-now">Active now</h2>'
|
||||
"<p>This is the tenant and the privileges on this sign-in.</p>"
|
||||
f'<ul id="active-tenant-list">{items}</ul>'
|
||||
f"<p>Roles: {roles}.</p>"
|
||||
f"<p>Directory groups: {groups}.</p>"
|
||||
f"{note}</section>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _allowed_tenants(actor: Any, memberships: tuple[Any, ...]) -> str:
|
||||
active = set(PortalApplication._active_tenants(actor, memberships))
|
||||
rows = [item for item in memberships if item.scope_type == "tenant"]
|
||||
if not rows:
|
||||
if "platform-operator" in actor.roles:
|
||||
body = (
|
||||
"<li>No tenant memberships are recorded. You have no personal tenant memberships. "
|
||||
"Your platform operator role lets you manage tenants through platform administration.</li>"
|
||||
)
|
||||
else:
|
||||
body = "<li>No tenant memberships are recorded.</li>"
|
||||
else:
|
||||
parts = []
|
||||
for item in rows:
|
||||
if item.tenant in active:
|
||||
action = "Active"
|
||||
else:
|
||||
hint = escape(urlencode({"tenant_hint": item.tenant}))
|
||||
action = (
|
||||
'Inactive. <a href="/login?'
|
||||
+ hint
|
||||
+ '">Sign in to use this tenant</a>'
|
||||
)
|
||||
parts.append(
|
||||
f"<li>{escape(item.tenant)} - {escape(item.kind)} - {action}</li>"
|
||||
)
|
||||
body = "".join(parts)
|
||||
return (
|
||||
'<section aria-labelledby="allowed-tenants"><h2 id="allowed-tenants">Allowed tenants</h2>'
|
||||
"<p>These are memberships recorded for this account. A tenant account created at first sign-in is not a membership.</p>"
|
||||
f'<ul id="allowed-tenant-list">{body}</ul>'
|
||||
"<p>Signing in to an inactive tenant replaces the active tenant for an ordinary account. "
|
||||
"It does not end a session an application already has.</p></section>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _allowed_workloads(memberships: tuple[Any, ...]) -> str:
|
||||
rows = [
|
||||
item for item in memberships
|
||||
if item.scope_type in {"application", "service", "workload", "asset"}
|
||||
]
|
||||
if rows:
|
||||
items = "".join(
|
||||
f"<li>{escape(item.scope_id)} - {escape(item.kind)} ({escape(item.tenant)}) - Allowed, recorded here</li>"
|
||||
for item in rows
|
||||
)
|
||||
else:
|
||||
items = "<li>No workload access is recorded.</li>"
|
||||
return (
|
||||
'<section aria-labelledby="allowed-workloads"><h2 id="allowed-workloads">Allowed workloads</h2>'
|
||||
f'<ul id="allowed-workload-list">{items}</ul>'
|
||||
"<p>Workload decisions are not checked.</p>"
|
||||
"<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>"
|
||||
)
|
||||
return "<ul>" + items + "</ul>" if items else "<p>No workload-specific access is recorded for this account.</p>"
|
||||
|
||||
@staticmethod
|
||||
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue