fix: expose operator navigation and protected portal logout
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
9657b72067
commit
655dce7165
4 changed files with 266 additions and 7 deletions
|
|
@ -9,6 +9,7 @@ of user-engine.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, is_dataclass, replace
|
||||
from enum import Enum
|
||||
from html import escape
|
||||
|
|
@ -44,6 +45,9 @@ from user_engine.service import PLATFORM_TENANT, UserEngineService
|
|||
|
||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||
|
||||
# Rendering state is scoped to one request, including concurrent WSGI requests.
|
||||
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if is_dataclass(value):
|
||||
|
|
@ -107,6 +111,7 @@ class PortalApplication:
|
|||
|
||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
||||
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||
navigation_token = _ACCOUNT_NAVIGATION.set("")
|
||||
try:
|
||||
return self._dispatch(environ, start_response, str(correlation_id))
|
||||
except ConflictError as exc:
|
||||
|
|
@ -127,6 +132,8 @@ class PortalApplication:
|
|||
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
|
||||
finally:
|
||||
_ACCOUNT_NAVIGATION.reset(navigation_token)
|
||||
|
||||
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
|
||||
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
|
||||
|
|
@ -181,17 +188,43 @@ class PortalApplication:
|
|||
]
|
||||
start_response("303 See Other", headers)
|
||||
return [b""]
|
||||
if path == "/logged-out" and method == "GET":
|
||||
if self._optional_actor(environ) is not None:
|
||||
return self._redirect(start_response, "/", correlation_id)
|
||||
return self._html(start_response, self._page_html(
|
||||
"Logged out",
|
||||
'<h1>You have logged out.</h1>'
|
||||
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
|
||||
'<p>To sign in as another account while keeping that session, open this portal in a private browser window.</p>'
|
||||
'<p><a class="button" href="/login">Sign in</a></p>',
|
||||
), correlation_id)
|
||||
if path == "/logout" and method == "GET":
|
||||
actor = self._optional_actor(environ)
|
||||
if actor is None:
|
||||
return self._redirect(start_response, "/logged-out", correlation_id)
|
||||
self._set_account_navigation(environ, actor)
|
||||
token = self._csrf_token(environ)
|
||||
return self._html(start_response, self._page_html(
|
||||
"Log out", '<h1>Log out of this portal?</h1>'
|
||||
'<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>'
|
||||
'<form method="post" action="/logout">'
|
||||
f'<input type="hidden" name="csrf_token" value="{escape(token)}">'
|
||||
'<button type="submit">Log out</button></form>',
|
||||
), correlation_id)
|
||||
if path == "/logout" and method == "POST":
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
if session_id and self.oidc_client:
|
||||
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
self.oidc_client.logout(session_id)
|
||||
start_response(
|
||||
"303 See Other",
|
||||
[("Location", "/"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
|
||||
[("Location", "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
|
||||
)
|
||||
return [b""]
|
||||
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)
|
||||
|
||||
if path == "/register" and method == "GET":
|
||||
|
|
@ -269,6 +302,7 @@ class PortalApplication:
|
|||
)
|
||||
|
||||
actor = self._actor(environ)
|
||||
self._set_account_navigation(environ, actor)
|
||||
if path == "/api/v1/me" and method == "GET":
|
||||
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
|
||||
if path == "/api/v1/me/profile" and method == "PATCH":
|
||||
|
|
@ -703,12 +737,17 @@ class PortalApplication:
|
|||
)
|
||||
if path == "/platform/tenant" and method == "GET":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
lookup = parse_qs(str(environ.get("QUERY_STRING", ""))).get("tenant", [""])[0]
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
lookup = query.get("tenant", [""])[0]
|
||||
view = query.get("view", ["lifecycle"])[0]
|
||||
if view not in {"lifecycle", "users"}:
|
||||
raise ValidationError("unknown tenant view")
|
||||
if not lookup.startswith("tenant:") or lookup == PLATFORM_TENANT:
|
||||
raise ValidationError("a non-platform tenant identifier is required")
|
||||
return self._redirect(
|
||||
start_response,
|
||||
"/platform/tenants/" + quote(lookup, safe=""), correlation_id,
|
||||
("/admin/" if view == "users" else "/platform/tenants/")
|
||||
+ quote(lookup, safe=""), correlation_id,
|
||||
)
|
||||
if path.startswith("/platform/tenants/") and method in {"GET", "POST"}:
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
|
|
@ -1667,7 +1706,8 @@ class PortalApplication:
|
|||
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
|
||||
<form method="get" action="/platform/tenant">
|
||||
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
|
||||
<button type="submit">Open tenant lifecycle</button></form>
|
||||
<button type="submit" name="view" value="users">Manage users</button>
|
||||
<button type="submit" name="view" value="lifecycle">Open tenant lifecycle</button></form>
|
||||
<p>Tenant records, metadata, and retirement are owned by the tenant authority.</p></section>""",
|
||||
)
|
||||
|
||||
|
|
@ -1694,6 +1734,7 @@ class PortalApplication:
|
|||
f"Tenant {record.tenant}",
|
||||
f"""<h1>{escape(record.tenant)}</h1>
|
||||
<p>Lifecycle <strong>{escape(record.lifecycle)}</strong> at version {record.version}.</p>
|
||||
<p><a class="button" href="/admin/{escape(quote(record.tenant, safe=''))}">Manage users</a></p>
|
||||
<p>Grouping <strong>{escape(record.grouping or 'not reported')}</strong>, as reported by the tenant authority. The identifier's own segment is historical after a reclassification and is not the grouping.</p>
|
||||
{replayed}
|
||||
{metadata_form}
|
||||
|
|
@ -1720,10 +1761,15 @@ class PortalApplication:
|
|||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||
selected_tenant: 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 "<li>No tenant memberships yet.</li>"
|
||||
) 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>"
|
||||
|
|
@ -1790,6 +1836,25 @@ class PortalApplication:
|
|||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
return [b""]
|
||||
|
||||
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
|
||||
if actor is None:
|
||||
_ACCOUNT_NAVIGATION.set("")
|
||||
return
|
||||
links = '<a href="/">Home</a><a href="/onboarding">My account</a>'
|
||||
if "platform-operator" in actor.roles:
|
||||
links += '<a href="/platform">Platform administration</a>'
|
||||
elif "tenant-admin" in actor.roles:
|
||||
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
csrf = self.oidc_client.csrf_token(session_id or "") if self.oidc_client else None
|
||||
if csrf:
|
||||
links += (
|
||||
'<form method="post" action="/logout">'
|
||||
f'<input type="hidden" name="csrf_token" value="{escape(csrf)}">'
|
||||
'<button type="submit">Log out</button></form>'
|
||||
)
|
||||
_ACCOUNT_NAVIGATION.set('<nav aria-label="Account navigation">' + links + '</nav>')
|
||||
|
||||
@staticmethod
|
||||
def _page_html(title: str, body: str) -> str:
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
|
|
@ -1798,13 +1863,14 @@ class PortalApplication:
|
|||
:root{{--ink:#17201c;--paper:#f5f1e8;--accent:#195b47;--line:#c8c1b3}}
|
||||
*{{box-sizing:border-box}}body{{margin:0;background:var(--paper);color:var(--ink);font:18px/1.55 system-ui,sans-serif}}
|
||||
header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom:1px solid var(--line)}}
|
||||
header nav{{display:flex;align-items:center;flex-wrap:wrap;gap:.75rem 1.25rem;margin-top:.75rem}}header nav form{{margin:0}}header nav button{{padding:.45rem .8rem}}
|
||||
h1{{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}}a{{color:var(--accent)}}
|
||||
.button{{display:inline-block;background:var(--accent);color:white;padding:.8rem 1.15rem;border-radius:.3rem;text-decoration:none}}
|
||||
table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75rem;text-align:left;border-bottom:1px solid var(--line)}}
|
||||
section{{margin:2rem 0}}form{{display:grid;gap:.8rem;max-width:42rem}}label{{display:grid;gap:.25rem}}
|
||||
input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}}
|
||||
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
|
||||
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
|
||||
</style></head><body><header><strong>Railiance identity</strong>{_ACCOUNT_NAVIGATION.get()}</header><main>{body}</main></body></html>"""
|
||||
|
||||
def _html(
|
||||
self, start_response: StartResponse, body: str, correlation_id: str,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue