messagefoundry-webconsole 0.2.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
@@ -0,0 +1,771 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Auth/session/account page builders for the /ui ops dashboard (ADR 0065): sign-in, step-up
4
+ re-auth, and the L4b self-service account pages (change password, TOTP MFA lifecycle).
5
+
6
+ The account pages carry the normal chrome. The UNAUTHENTICATED entry pages (sign-in, the Windows-SSO
7
+ challenge) are bare — ``nav=Markup("")``. Every page that is reached WITH a live session but drops the
8
+ full nav for focus/confinement (step-up re-auth, reauth-continue, the forced must-change-password
9
+ page, passkey enrolment, the federated landing hop) renders ``nav=minimal_nav()`` instead: the
10
+ wordmark plus the same one-click POST Sign-out form, so an authenticated page never leaves the
11
+ operator without a way out (ASVS 7.4.4). Every dynamic value is placed through the escaping element
12
+ builders in :mod:`.._html`. Secrets are handled once: a password is only ever an
13
+ ``<input type=password>`` (never echoed back), and the TOTP secret / recovery codes render exactly
14
+ once on their dedicated pages.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Mapping, Sequence
20
+ from datetime import UTC, datetime
21
+
22
+ from messagefoundry.api.auth_models import CurrentUser, MfaStatusResponse
23
+
24
+ from .._html import Markup, el, minimal_nav, page, register_nav, wordmark
25
+
26
+ __all__ = [
27
+ "account_page",
28
+ "login",
29
+ "oidc_landing",
30
+ "mfa_confirm_page",
31
+ "mfa_enroll_page",
32
+ "mfa_gate",
33
+ "mfa_recovery_page",
34
+ "password_page",
35
+ "reauth",
36
+ "reauth_continue",
37
+ "sessions_page",
38
+ "sso_challenge",
39
+ "webauthn_enroll_page",
40
+ ]
41
+
42
+
43
+ def _when(ts: object) -> str:
44
+ if not isinstance(ts, (int, float)):
45
+ return "—"
46
+ return datetime.fromtimestamp(float(ts), UTC).strftime("%Y-%m-%d %H:%MZ")
47
+
48
+
49
+ def login(
50
+ error: str | None = None,
51
+ *,
52
+ ad_enabled: bool = False,
53
+ sso_enabled: bool = False,
54
+ oidc_enabled: bool = False,
55
+ ) -> Markup:
56
+ """The sign-in form. Same-origin POST to /ui/login (form-action 'self').
57
+
58
+ ``ad_enabled`` (L5b, ADR 0068 §8) renders the provider selector — zero visual change for
59
+ local-only installs. AD passwords verify via the existing live directory bind; AD sessions
60
+ arrive MFA-delegated exactly as on the JSON surface."""
61
+ notes = {
62
+ "must_change": "Password change required — sign in to rotate it.",
63
+ "bad": "Invalid credentials.",
64
+ "loggedout": "Signed out.",
65
+ # ASVS 14.3.1 — the client session watchdog's landing code: it blanks the page and navigates
66
+ # here when the server has ended the session (or can no longer confirm it), so the operator
67
+ # gets a reason instead of an unexplained login form.
68
+ "expired": "Your session ended — sign in again.",
69
+ "pwchanged": "Password changed — sign in with the new password.",
70
+ # L5c (ADR 0068 §9) — allow-listed SSO outcome codes, never reflected text.
71
+ "sso_failed": "Windows SSO sign-in failed — sign in with a password instead.",
72
+ "sso_unavailable": "Windows SSO is not available on this server.",
73
+ "rate_limited": "Too many attempts — wait a moment and try again.",
74
+ # ADR 0142 — allow-listed FEDERATED outcome codes. The IdP's own `error` /
75
+ # `error_description` are never reflected: they are attacker/IdP-controlled text, and echoing
76
+ # them would put it in the URL, the referrer chain, and every proxy log.
77
+ "oidc_failed": "Federated sign-in failed — sign in with a password instead.",
78
+ "oidc_unavailable": "Federated sign-in is not available on this server.",
79
+ "flow_binding_missing": (
80
+ "Your sign-in session expired or did not return to this browser — start again."
81
+ ),
82
+ "sso_mfa_required": (
83
+ "Your identity provider did not confirm multi-factor authentication for this sign-in."
84
+ ),
85
+ }
86
+ banner = el("p", notes.get(error or "", ""), class_="banner") if error else Markup("")
87
+ provider: Markup = Markup("")
88
+ if ad_enabled:
89
+ provider = el(
90
+ "label",
91
+ "Sign in with",
92
+ el(
93
+ "select",
94
+ el("option", "Local account", value="local"),
95
+ el("option", "Active Directory", value="ad"),
96
+ name="provider",
97
+ ),
98
+ )
99
+ form = el(
100
+ "form",
101
+ provider,
102
+ el(
103
+ "label",
104
+ "Username",
105
+ el("input", name="username", autofocus=True, autocomplete="username"),
106
+ ),
107
+ el(
108
+ "label",
109
+ "Password",
110
+ el("input", name="password", type="password", autocomplete="current-password"),
111
+ ),
112
+ el("button", "Sign in", type="submit"),
113
+ method="post",
114
+ action="/ui/login",
115
+ class_="login",
116
+ )
117
+ sso = (
118
+ el("p", el("a", "Sign in with Windows (SSO)", href="/ui/sso"), class_="muted")
119
+ if sso_enabled
120
+ else Markup("")
121
+ )
122
+ federated = (
123
+ el("p", el("a", "Sign in with your organization", href="/ui/oidc/start"), class_="muted")
124
+ if oidc_enabled
125
+ else Markup("")
126
+ )
127
+ body = el("div", el("h1", wordmark(tm=True)), banner, form, sso, federated, class_="card")
128
+ # A bare page (no nav) for the unauthenticated login screen.
129
+ return page("Sign in", body, nav=Markup(""))
130
+
131
+
132
+ def oidc_landing() -> Markup:
133
+ """The same-site landing hop after a successful federated sign-in (ADR 0142).
134
+
135
+ Returned as **200 HTML with a meta refresh**, deliberately NOT a 303. The session cookie is
136
+ ``SameSite=Strict``; a 303 issued in response to the IdP's cross-site redirect would leave the
137
+ browser's follow-up request to ``/ui`` still cross-site-initiated, so the Strict cookie would be
138
+ withheld and the operator would land back on the login page despite a successful login. Bouncing
139
+ through a document served from OUR origin makes the next navigation same-site, so the cookie is
140
+ unambiguously sent.
141
+
142
+ No JavaScript, so this works under the strict ``/ui`` CSP with no nonce and no ``script-src``
143
+ relaxation. The visible link is the fallback for a browser with meta refresh disabled.
144
+ """
145
+ body = el(
146
+ "div",
147
+ el("h1", wordmark(tm=True)),
148
+ el("p", "Signed in — continuing to the console…"),
149
+ el("p", el("a", "Continue", href="/ui"), class_="muted"),
150
+ class_="card",
151
+ )
152
+ return page(
153
+ "Signing in",
154
+ body,
155
+ nav=minimal_nav(),
156
+ head_extra=el("meta", **{"http-equiv": "refresh", "content": "0;url=/ui"}),
157
+ )
158
+
159
+
160
+ def sso_challenge() -> Markup:
161
+ """The HTML body of the RFC 4559 401 challenge (L5c, ADR 0068 §9): a browser configured for
162
+ Windows SSO retries the request with its Negotiate token and never renders this; one that
163
+ isn't gets a legible path back to the password form instead of a bare 401."""
164
+ body = el(
165
+ "div",
166
+ el("h1", "Windows SSO"),
167
+ el(
168
+ "p",
169
+ "Your browser did not present a Windows SSO token. SSO needs a domain-joined "
170
+ "machine and this site allow-listed for integrated authentication.",
171
+ class_="muted",
172
+ ),
173
+ el("p", el("a", "Sign in with a password instead", href="/ui/login"), class_="muted"),
174
+ class_="card",
175
+ )
176
+ return page("Windows SSO", body, nav=Markup(""))
177
+
178
+
179
+ def reauth(
180
+ next_path: str,
181
+ *,
182
+ mfa_needed: bool,
183
+ error: str | None = None,
184
+ webauthn_options: str | None = None,
185
+ webauthn_notice: str | None = None,
186
+ ) -> Markup:
187
+ """The step-up re-authentication form for a sensitive action (replay). POSTs to /ui/reauth.
188
+
189
+ Shows a password field always, plus a TOTP field when the session's second factor isn't
190
+ satisfied — and, additively (ADR 0068 decision 1(b)), a passkey button when the user has
191
+ WebAuthn credentials: ``webauthn_options`` carries the staged assertion-options JSON in a
192
+ ``data-*`` hook for app.js (``navigator.credentials.get`` → POST /ui/reauth/webauthn — the
193
+ assertion satisfies the MFA leg; the password below still completes the step-up).
194
+ ``webauthn_notice`` is the legible fail-closed copy (extra absent / public_origin unset) — a
195
+ dead-end message, never a redirect loop. ``next_path`` (a validated /ui action) rides in a
196
+ hidden field so a successful re-auth can auto-retry it.
197
+ """
198
+ banner = el("p", error, class_="banner") if error else Markup("")
199
+ passkey: Markup
200
+ if webauthn_options is not None:
201
+ passkey = el(
202
+ "div",
203
+ el(
204
+ "button",
205
+ "Use passkey",
206
+ type="button",
207
+ data_mf_webauthn_get=webauthn_options,
208
+ ),
209
+ el("p", "", class_="muted", data_mf_webauthn_status=True),
210
+ class_="ctl",
211
+ )
212
+ elif webauthn_notice:
213
+ passkey = el("p", webauthn_notice, class_="muted")
214
+ else:
215
+ passkey = Markup("")
216
+ fields: list[object] = [
217
+ el(
218
+ "label",
219
+ "Password",
220
+ el("input", name="password", type="password", autocomplete="current-password"),
221
+ )
222
+ ]
223
+ if mfa_needed:
224
+ fields.append(
225
+ el(
226
+ "label",
227
+ "Authenticator code",
228
+ el("input", name="code", inputmode="numeric", autocomplete="one-time-code"),
229
+ )
230
+ )
231
+ form = el(
232
+ "form",
233
+ el("input", type="hidden", name="next", value=next_path),
234
+ *fields,
235
+ el("button", "Verify", type="submit"),
236
+ method="post",
237
+ action="/ui/reauth",
238
+ class_="login",
239
+ )
240
+ body = el(
241
+ "div",
242
+ el("h1", "Confirm it's you"),
243
+ el("p", "This action needs a fresh sign-in confirmation.", class_="muted"),
244
+ banner,
245
+ passkey,
246
+ form,
247
+ class_="card",
248
+ )
249
+ return page("Confirm", body, nav=minimal_nav())
250
+
251
+
252
+ def mfa_gate(
253
+ *,
254
+ totp_enrolled: bool,
255
+ error: str | None = None,
256
+ webauthn_options: str | None = None,
257
+ webauthn_notice: str | None = None,
258
+ ) -> Markup:
259
+ """The ASVS 6.3.3 second-factor page a MFA-pending browser session is confined to. POSTs /ui/mfa.
260
+
261
+ Distinct from :func:`reauth`, which confirms a *sensitive action* and therefore always asks for
262
+ the password. This page completes SIGN-IN: the password was proven seconds ago, so asking for it
263
+ again would be noise. Only the second factor is collected.
264
+
265
+ Rendered on ``minimal_nav`` for the same reason the reauth page is — the app nav's status poll is
266
+ gated, so a full nav here would fire a request the pending session is not allowed to make.
267
+
268
+ Splits by factor exactly like :func:`reauth` (ADR 0068 decision 1(b)): the code field renders iff
269
+ TOTP is enrolled, the passkey button iff WebAuthn is. A user with only a passkey never sees an
270
+ unanswerable code box, and a user with only TOTP never sees a button they cannot use.
271
+ """
272
+ banner = el("p", error, class_="banner") if error else Markup("")
273
+ passkey: Markup
274
+ if webauthn_options is not None:
275
+ passkey = el(
276
+ "div",
277
+ el(
278
+ "button",
279
+ "Use passkey",
280
+ type="button",
281
+ data_mf_webauthn_get=webauthn_options,
282
+ # Tells app.js this assertion COMPLETES sign-in and should navigate, rather than
283
+ # unlocking a password field as it does on the reauth page (where the password is
284
+ # still owed). Without it a passkey-only user verifies and then sits on a dead page.
285
+ data_mf_webauthn_done="/ui",
286
+ ),
287
+ el("p", "", class_="muted", data_mf_webauthn_status=True),
288
+ class_="ctl",
289
+ )
290
+ elif webauthn_notice:
291
+ passkey = el("p", webauthn_notice, class_="muted")
292
+ else:
293
+ passkey = Markup("")
294
+ fields: list[object] = []
295
+ if totp_enrolled:
296
+ fields.append(
297
+ el(
298
+ "label",
299
+ "Authenticator code",
300
+ # No `value=`: never echo a submitted code back into the form. A TOTP or recovery
301
+ # code is a bearer credential, and a re-render is exactly where one leaks.
302
+ el("input", name="code", inputmode="numeric", autocomplete="one-time-code"),
303
+ )
304
+ )
305
+ fields.append(el("button", "Verify", type="submit"))
306
+ form = (
307
+ el("form", *fields, method="post", action="/ui/mfa", class_="login")
308
+ if fields
309
+ else Markup("")
310
+ )
311
+ body = el(
312
+ "div",
313
+ el("h1", "Second factor required"),
314
+ el(
315
+ "p",
316
+ "Your account requires a second factor. Confirm it to continue.",
317
+ class_="muted",
318
+ ),
319
+ banner,
320
+ passkey,
321
+ form,
322
+ class_="card",
323
+ )
324
+ return page("Second factor", body, nav=minimal_nav())
325
+
326
+
327
+ def reauth_continue(next_path: str) -> Markup:
328
+ """After a successful step-up, auto-POST the pending action (``next_path``) via app.js.
329
+
330
+ ``next_path`` has already been validated as a same-origin /ui replay action. If JavaScript is off,
331
+ the user clicks Continue (graceful degradation); the POST is same-origin so the CSRF check passes.
332
+ """
333
+ form = el(
334
+ "form",
335
+ el("button", "Continue", type="submit"),
336
+ method="post",
337
+ action=next_path,
338
+ data_autosubmit=True,
339
+ class_="login",
340
+ )
341
+ body = el(
342
+ "div",
343
+ el("h1", "Verified"),
344
+ el("p", "Continuing…", class_="muted"),
345
+ form,
346
+ class_="card",
347
+ )
348
+ return page("Verified", body, nav=minimal_nav())
349
+
350
+
351
+ # --- L4b: self-service account pages (change password + TOTP MFA lifecycle) ------------------------
352
+
353
+
354
+ def account_page(
355
+ me: CurrentUser,
356
+ mfa: MfaStatusResponse,
357
+ *,
358
+ notice: str | None = None,
359
+ error: str | None = None,
360
+ passkeys: Sequence[Mapping[str, object]] | None = None,
361
+ webauthn_notice: str | None = None,
362
+ ) -> Markup:
363
+ """The signed-in user's account overview: identity, password rotation, and MFA posture/actions.
364
+
365
+ ``passkeys`` (ADR 0068): plain row mappings built by the route (label / created_at /
366
+ last_used_at / backed_up / usable / credential_id_hash) — this module never touches the store.
367
+ ``webauthn_notice`` renders the fail-closed copy in place of the Add-a-passkey form (extra
368
+ absent / public_origin unset)."""
369
+ banner = el("p", error, class_="banner") if error else Markup("")
370
+ note = el("p", notice, class_="muted") if notice else Markup("")
371
+ is_ad = me.auth_provider == "ad"
372
+ ident = el(
373
+ "div",
374
+ el("h2", "Identity"),
375
+ el("p", f"Signed in as {me.username} ({me.auth_provider})", class_="muted"),
376
+ el("p", "Roles: " + (", ".join(me.roles) or "(none)"), class_="muted"),
377
+ class_="card",
378
+ )
379
+ if is_ad:
380
+ pw_section = el("p", "AD passwords are managed in Active Directory.", class_="muted")
381
+ mfa_section: Markup = el(
382
+ "p", "AD accounts use directory MFA, not an engine TOTP.", class_="muted"
383
+ )
384
+ else:
385
+ pw_section = el("p", el("a", "Change password", href="/ui/account/password"))
386
+ if mfa.enabled:
387
+ status_line = el(
388
+ "p",
389
+ f"Enabled — {mfa.recovery_codes_remaining} recovery code(s) remaining.",
390
+ class_="muted",
391
+ )
392
+ action = el(
393
+ "form",
394
+ el("button", "Disable MFA", type="submit"),
395
+ method="post",
396
+ action="/ui/account/mfa/disable",
397
+ class_="ctl",
398
+ )
399
+ else:
400
+ status_line = el(
401
+ "p",
402
+ "Not enrolled."
403
+ + (" This account REQUIRES MFA — enroll now." if mfa.required else ""),
404
+ class_="muted",
405
+ )
406
+ action = el(
407
+ "form",
408
+ el("button", "Enroll an authenticator", type="submit"),
409
+ method="post",
410
+ action="/ui/account/mfa/enroll",
411
+ class_="ctl",
412
+ )
413
+ mfa_section = Markup(status_line + action)
414
+ passkey_card = Markup("") if is_ad else _passkey_card(mfa, passkeys or (), webauthn_notice)
415
+ # Active-session management is self-service for EVERY account (local + AD), unlike password/MFA.
416
+ sessions_card = el(
417
+ "div",
418
+ el("h2", "Active sessions"),
419
+ el("p", el("a", "Manage active sessions", href="/ui/account/sessions")),
420
+ class_="card",
421
+ )
422
+ return page(
423
+ "My account",
424
+ el("h1", "My account"),
425
+ note,
426
+ banner,
427
+ ident,
428
+ el("div", el("h2", "Password"), pw_section, class_="card"),
429
+ el("div", el("h2", "Multi-factor authentication"), mfa_section, class_="card"),
430
+ passkey_card,
431
+ sessions_card,
432
+ active="account",
433
+ )
434
+
435
+
436
+ def sessions_page(sessions: Sequence[Mapping[str, object]], *, notice: str | None = None) -> Markup:
437
+ """The self-service active-session inventory (L6b — the desktop `console/sessions.py` twin):
438
+ every live session for the caller with its own **Revoke**, plus **Sign out everywhere else**.
439
+
440
+ ``sessions`` are plain row mappings built by the route (id / created_at / last_used_at /
441
+ expires_at / client / current) — this module never touches the store. Revoking one's OWN
442
+ sessions is cookie-authenticated self-service (no step-up); the current session shows no Revoke
443
+ button (use the header Sign out to end it) so the list can't leave the user mid-request."""
444
+ note = el("p", notice, class_="muted") if notice else Markup("")
445
+ rows: list[Markup] = []
446
+ others = 0
447
+ for s in sessions:
448
+ is_current = bool(s.get("current"))
449
+ if not is_current:
450
+ others += 1
451
+ action: Markup = (
452
+ el("span", "(this session)", class_="muted")
453
+ if is_current
454
+ else el(
455
+ "form",
456
+ el("button", "Revoke", type="submit"),
457
+ method="post",
458
+ action=f"/ui/account/sessions/{s.get('id', '')}/revoke",
459
+ class_="ctl",
460
+ )
461
+ )
462
+ rows.append(
463
+ el(
464
+ "tr",
465
+ el("td", _when(s.get("created_at"))),
466
+ el("td", _when(s.get("last_used_at"))),
467
+ el("td", str(s.get("client") or "—")),
468
+ el("td", action),
469
+ )
470
+ )
471
+ table = el(
472
+ "table",
473
+ el("tr", el("th", "Signed in"), el("th", "Last used"), el("th", "Client"), el("th", "")),
474
+ *rows,
475
+ )
476
+ sign_out_others = (
477
+ el(
478
+ "form",
479
+ el("button", f"Sign out everywhere else ({others})", type="submit"),
480
+ method="post",
481
+ action="/ui/account/sessions/revoke-others",
482
+ class_="ctl",
483
+ )
484
+ if others
485
+ else Markup("")
486
+ )
487
+ body = el(
488
+ "div",
489
+ el("h1", "Active sessions"),
490
+ note,
491
+ el("p", el("a", "← Back to my account", href="/ui/account"), class_="muted"),
492
+ el("div", table, sign_out_others, class_="card"),
493
+ )
494
+ # active= belongs on page() (highlights the "My account" nav) — not on the wrapper div.
495
+ return page("Active sessions", body, active="account")
496
+
497
+
498
+ def _passkey_card(
499
+ mfa: MfaStatusResponse,
500
+ passkeys: Sequence[Mapping[str, object]],
501
+ webauthn_notice: str | None,
502
+ ) -> Markup:
503
+ """The L5a passkeys card: enrolled-credential table (per-row delete), the Add form, and the
504
+ posture caveats (ADR 0068 §6)."""
505
+ rows: list[Markup] = []
506
+ for cred in passkeys:
507
+ flags: list[str] = []
508
+ if cred.get("backed_up"):
509
+ flags.append("synced")
510
+ if not cred.get("usable", True):
511
+ flags.append("unusable (origin changed)")
512
+ remove = el(
513
+ "form",
514
+ el("button", "Remove", type="submit"),
515
+ method="post",
516
+ action=f"/ui/account/webauthn/{cred.get('credential_id_hash', '')}/delete",
517
+ class_="ctl",
518
+ )
519
+ rows.append(
520
+ el(
521
+ "tr",
522
+ el("td", str(cred.get("label", ""))),
523
+ el("td", _when(cred.get("created_at"))),
524
+ el("td", _when(cred.get("last_used_at"))),
525
+ el("td", ", ".join(flags) or "—"),
526
+ el("td", remove),
527
+ )
528
+ )
529
+ table = (
530
+ el(
531
+ "table",
532
+ el(
533
+ "tr",
534
+ el("th", "Label"),
535
+ el("th", "Created"),
536
+ el("th", "Last used"),
537
+ el("th", "Notes"),
538
+ el("th", ""),
539
+ ),
540
+ *rows,
541
+ )
542
+ if rows
543
+ else el("p", "No passkeys enrolled.", class_="muted")
544
+ )
545
+ caveats: list[Markup] = []
546
+ if webauthn_notice:
547
+ add: Markup = el("p", webauthn_notice, class_="muted")
548
+ else:
549
+ add = el(
550
+ "form",
551
+ el("button", "Add a passkey", type="submit"),
552
+ method="post",
553
+ action="/ui/account/webauthn/enroll",
554
+ class_="ctl",
555
+ )
556
+ caveats.append(
557
+ el(
558
+ "p",
559
+ "Keep TOTP enrolled if you use the desktop console — passkeys work in the "
560
+ "browser only. Changing [api].public_origin invalidates enrolled passkeys.",
561
+ class_="muted",
562
+ )
563
+ )
564
+ if len(passkeys) == 1 and not mfa.enabled:
565
+ # Recovery nudge (no recovery codes for passkeys by design — ADR 0068 decision 5).
566
+ caveats.append(
567
+ el(
568
+ "p",
569
+ "This is your only second factor: enroll a second passkey or TOTP so a "
570
+ "lost authenticator doesn't lock you out (recovery is admin-reset only).",
571
+ class_="muted",
572
+ )
573
+ )
574
+ return el("div", el("h2", "Passkeys"), table, add, *caveats, class_="card")
575
+
576
+
577
+ def password_page(*, forced: bool = False, error: str | None = None) -> Markup:
578
+ """The change-password form (current + new twice; nothing is ever echoed back).
579
+
580
+ ``forced`` renders the bare must-change variant: the account is confined here until it rotates
581
+ (every other /ui route 303s back), so the page explains why and drops the (useless) nav.
582
+ """
583
+ banner = el("p", error, class_="banner") if error else Markup("")
584
+ intro = (
585
+ el(
586
+ "p",
587
+ "Your password must be changed before you can continue.",
588
+ class_="muted",
589
+ )
590
+ if forced
591
+ else el("p", "Re-enter your current password, then choose a new one.", class_="muted")
592
+ )
593
+ form = el(
594
+ "form",
595
+ el(
596
+ "label",
597
+ "Current password",
598
+ el(
599
+ "input",
600
+ name="current_password",
601
+ type="password",
602
+ autocomplete="current-password",
603
+ autofocus=True,
604
+ ),
605
+ ),
606
+ el(
607
+ "label",
608
+ "New password",
609
+ el("input", name="new_password", type="password", autocomplete="new-password"),
610
+ ),
611
+ el(
612
+ "label",
613
+ "New password (again)",
614
+ el("input", name="new_password2", type="password", autocomplete="new-password"),
615
+ ),
616
+ el("button", "Change password", type="submit"),
617
+ method="post",
618
+ action="/ui/account/password",
619
+ class_="login",
620
+ )
621
+ body = el("div", el("h1", "Change password"), intro, banner, form, class_="card")
622
+ if forced:
623
+ return page("Change password", body, nav=minimal_nav())
624
+ return page(
625
+ "Change password",
626
+ body,
627
+ el("p", el("a", "← My account", href="/ui/account")),
628
+ active="account",
629
+ )
630
+
631
+
632
+ def mfa_enroll_page(secret: str, otpauth_uri: str) -> Markup:
633
+ """The staged-enrollment page: the TOTP secret + otpauth URI (shown once for authenticator entry)
634
+ and the confirm-code form. No QR image — the /ui surface is zero-dependency (ADR 0065), so the
635
+ secret is entered manually or the URI pasted; the desktop console renders the QR."""
636
+ confirm = el(
637
+ "form",
638
+ el(
639
+ "label",
640
+ "Code from your authenticator",
641
+ el(
642
+ "input",
643
+ name="code",
644
+ inputmode="numeric",
645
+ autocomplete="one-time-code",
646
+ autofocus=True,
647
+ ),
648
+ ),
649
+ el("button", "Activate MFA", type="submit"),
650
+ method="post",
651
+ action="/ui/account/mfa/verify",
652
+ class_="ctl",
653
+ )
654
+ body = el(
655
+ "div",
656
+ el("h1", "Enroll an authenticator"),
657
+ el(
658
+ "p",
659
+ "Add this secret to your authenticator app (manual entry), then prove a live code. "
660
+ "The secret is shown once and is not active until confirmed.",
661
+ class_="muted",
662
+ ),
663
+ el("p", "Secret: ", el("code", secret)),
664
+ el("p", "URI: ", el("code", otpauth_uri)),
665
+ confirm,
666
+ el("p", el("a", "← My account", href="/ui/account")),
667
+ class_="card",
668
+ )
669
+ return page("Enroll MFA", body, active="account")
670
+
671
+
672
+ def webauthn_enroll_page(options_json: str) -> Markup:
673
+ """The passkey creation ceremony page (ADR 0068 §6): the staged creation-options JSON rides a
674
+ ``data-*`` hook (never an inline script — CSP is 'self'-only); app.js runs
675
+ ``navigator.credentials.create`` and POSTs the attestation + label to
676
+ /ui/account/webauthn/verify. The no-JS fallback is a plain explanation (progressive
677
+ enhancement — the TOTP path remains fully script-free)."""
678
+ body = el(
679
+ "div",
680
+ el("h1", "Add a passkey"),
681
+ el(
682
+ "p",
683
+ "Name this passkey, then follow your browser's prompt.",
684
+ class_="muted",
685
+ ),
686
+ el(
687
+ "div",
688
+ el("label", "Label", el("input", name="label", maxlength="100", value="")),
689
+ el(
690
+ "button",
691
+ "Create passkey",
692
+ type="button",
693
+ data_mf_webauthn_create=options_json,
694
+ ),
695
+ el("p", "", class_="muted", data_mf_webauthn_status=True),
696
+ class_="ctl",
697
+ ),
698
+ el(
699
+ "noscript",
700
+ el(
701
+ "p",
702
+ "Passkey enrollment needs JavaScript (the browser credential prompt). "
703
+ "TOTP enrollment on the account page works without it.",
704
+ class_="banner",
705
+ ),
706
+ ),
707
+ el("p", el("a", "Back to my account", href="/ui/account"), class_="muted"),
708
+ class_="card",
709
+ )
710
+ return page("Add a passkey", body, nav=minimal_nav())
711
+
712
+
713
+ def mfa_confirm_page(*, error: str | None = None) -> Markup:
714
+ """The standalone confirm-code form (the unlock re-entry point after a step-up re-auth): the
715
+ secret is already staged server-side and in the user's authenticator, so it is NOT re-shown."""
716
+ banner = el("p", error, class_="banner") if error else Markup("")
717
+ form = el(
718
+ "form",
719
+ el(
720
+ "label",
721
+ "Code from your authenticator",
722
+ el(
723
+ "input",
724
+ name="code",
725
+ inputmode="numeric",
726
+ autocomplete="one-time-code",
727
+ autofocus=True,
728
+ ),
729
+ ),
730
+ el("button", "Activate MFA", type="submit"),
731
+ method="post",
732
+ action="/ui/account/mfa/verify",
733
+ class_="ctl",
734
+ )
735
+ body = el(
736
+ "div",
737
+ el("h1", "Confirm enrollment"),
738
+ el(
739
+ "p",
740
+ "Enter a live code from the authenticator you just added to activate MFA.",
741
+ class_="muted",
742
+ ),
743
+ banner,
744
+ form,
745
+ el("p", el("a", "← My account", href="/ui/account")),
746
+ class_="card",
747
+ )
748
+ return page("Confirm MFA", body, active="account")
749
+
750
+
751
+ def mfa_recovery_page(codes: Sequence[str]) -> Markup:
752
+ """The single-use recovery codes — shown ONCE, immediately after activation. Never re-fetchable."""
753
+ items = [el("li", el("code", c)) for c in codes]
754
+ body = el(
755
+ "div",
756
+ el("h1", "MFA is active"),
757
+ el(
758
+ "p",
759
+ "Save these single-use recovery codes somewhere safe NOW — they are shown once and "
760
+ "each unlocks your account exactly once if the authenticator is lost.",
761
+ class_="muted",
762
+ ),
763
+ el("ul", *items),
764
+ el("p", el("a", "← My account", href="/ui/account")),
765
+ class_="card",
766
+ )
767
+ return page("Recovery codes", body, active="account")
768
+
769
+
770
+ # Nav registration (append-at-tail). Co-located with the builders (ADR 0065 §multi-session-build).
771
+ register_nav("account", "/ui/account", "My account")