@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Persistence for hub-login TOTP 2FA (hub#473). Reads/writes the three
3
+ * `users` columns added in migration v11: `totp_secret`, `totp_backup_codes`
4
+ * (JSON array of argon2id hashes), `totp_enrolled_at`.
5
+ *
6
+ * The pure crypto lives in `totp.ts`; this is the storage seam. The login +
7
+ * enroll handlers compose both. Backup-code consumption is done here inside a
8
+ * DB transaction so verify-then-remove is atomic against concurrent login
9
+ * POSTs (two requests can't both consume the same code).
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import { findBackupCodeIndex, generateBackupCodes, verifyTotpCode } from "./totp.ts";
13
+
14
+ export interface TotpState {
15
+ /** Base32 secret, or null if 2FA isn't enrolled for this user. */
16
+ secret: string | null;
17
+ /** argon2id hashes of the remaining single-use backup codes. */
18
+ backupCodes: string[];
19
+ /** ISO-8601 enrollment timestamp, or null. */
20
+ enrolledAt: string | null;
21
+ }
22
+
23
+ interface TotpRow {
24
+ totp_secret: string | null;
25
+ totp_backup_codes: string | null;
26
+ totp_enrolled_at: string | null;
27
+ }
28
+
29
+ function parseBackupCodes(raw: string | null): string[] {
30
+ if (!raw) return [];
31
+ try {
32
+ const parsed = JSON.parse(raw) as unknown;
33
+ if (!Array.isArray(parsed)) return [];
34
+ return parsed.filter((c): c is string => typeof c === "string");
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read a user's TOTP state. Returns `secret: null` (not enrolled) when the
42
+ * row is missing — callers treat "no row" identically to "not enrolled."
43
+ */
44
+ export function getTotpState(db: Database, userId: string): TotpState {
45
+ const row = db
46
+ .query<TotpRow, [string]>(
47
+ "SELECT totp_secret, totp_backup_codes, totp_enrolled_at FROM users WHERE id = ?",
48
+ )
49
+ .get(userId);
50
+ if (!row) return { secret: null, backupCodes: [], enrolledAt: null };
51
+ return {
52
+ secret: row.totp_secret && row.totp_secret.length > 0 ? row.totp_secret : null,
53
+ backupCodes: parseBackupCodes(row.totp_backup_codes),
54
+ enrolledAt: row.totp_enrolled_at,
55
+ };
56
+ }
57
+
58
+ /** Cheap "is 2FA on for this user?" check — true iff a non-empty secret is stored. */
59
+ export function isTotpEnrolled(db: Database, userId: string): boolean {
60
+ return getTotpState(db, userId).secret !== null;
61
+ }
62
+
63
+ /** Number of unused backup codes remaining for a user. */
64
+ export function backupCodesRemaining(db: Database, userId: string): number {
65
+ return getTotpState(db, userId).backupCodes.length;
66
+ }
67
+
68
+ export interface EnrollResult {
69
+ /** Plaintext backup codes — show ONCE; never retrievable after. */
70
+ backupCodes: string[];
71
+ /** Enrollment timestamp persisted on the row. */
72
+ enrolledAt: string;
73
+ }
74
+
75
+ /**
76
+ * Persist a confirmed enrollment: store the (already-verified) secret, mint +
77
+ * store a fresh set of backup-code hashes, stamp `totp_enrolled_at`. Returns
78
+ * the plaintext backup codes for one-time display. Overwrites any prior
79
+ * enrollment (re-enroll rotates the secret + codes).
80
+ *
81
+ * The caller MUST have verified a live code against `secret` before calling
82
+ * this (proves the authenticator was provisioned correctly). The async hash
83
+ * happens before the write (single statement, no transaction needed).
84
+ */
85
+ export async function persistEnrollment(
86
+ db: Database,
87
+ userId: string,
88
+ secret: string,
89
+ now: () => Date = () => new Date(),
90
+ ): Promise<EnrollResult> {
91
+ const { codes, hashes } = await generateBackupCodes();
92
+ const enrolledAt = now().toISOString();
93
+ const result = db
94
+ .prepare(
95
+ `UPDATE users
96
+ SET totp_secret = ?, totp_backup_codes = ?, totp_enrolled_at = ?, updated_at = ?
97
+ WHERE id = ?`,
98
+ )
99
+ .run(secret, JSON.stringify(hashes), enrolledAt, enrolledAt, userId);
100
+ if (result.changes === 0) {
101
+ throw new Error(`persistEnrollment: no user row for id ${userId}`);
102
+ }
103
+ return { backupCodes: codes, enrolledAt };
104
+ }
105
+
106
+ /** Clear all TOTP state for a user (disenroll). Idempotent. */
107
+ export function clearEnrollment(
108
+ db: Database,
109
+ userId: string,
110
+ now: () => Date = () => new Date(),
111
+ ): void {
112
+ const stamp = now().toISOString();
113
+ db.prepare(
114
+ `UPDATE users
115
+ SET totp_secret = NULL, totp_backup_codes = NULL, totp_enrolled_at = NULL, updated_at = ?
116
+ WHERE id = ?`,
117
+ ).run(stamp, userId);
118
+ }
119
+
120
+ export type SecondFactorResult =
121
+ | { ok: true; via: "totp" }
122
+ | { ok: true; via: "backup_code" }
123
+ | { ok: false };
124
+
125
+ /**
126
+ * Verify a submitted second factor for a user during login. Tries the TOTP
127
+ * code first; if that fails, tries each stored backup code. On a backup-code
128
+ * match the code is **consumed** — removed from the stored list inside a
129
+ * transaction so a concurrent login can't reuse it.
130
+ *
131
+ * Returns which factor succeeded (for logging / "X backup codes left"
132
+ * messaging) or `{ ok: false }`. A user who isn't enrolled returns `ok:false`
133
+ * — but the login handler only reaches this when `totp_secret` is set, so that
134
+ * path is defensive.
135
+ *
136
+ * `markUsed` is forwarded to {@link verifyTotpCode}'s replay cache (tests set
137
+ * false to reuse a code; production leaves it true).
138
+ */
139
+ export async function verifySecondFactor(
140
+ db: Database,
141
+ userId: string,
142
+ submitted: string,
143
+ markUsed = true,
144
+ ): Promise<SecondFactorResult> {
145
+ const state = getTotpState(db, userId);
146
+ if (!state.secret) return { ok: false };
147
+
148
+ const code = submitted.trim();
149
+ if (!code) return { ok: false };
150
+
151
+ // TOTP path: a 6-digit numeric is almost certainly a TOTP attempt. Try it
152
+ // first (cheap, no DB write on success beyond the in-memory replay cache).
153
+ if (verifyTotpCode(state.secret, code, markUsed)) {
154
+ return { ok: true, via: "totp" };
155
+ }
156
+
157
+ // Backup-code path. Find a matching hash, then consume it transactionally.
158
+ if (state.backupCodes.length === 0) return { ok: false };
159
+ const idx = await findBackupCodeIndex(code, state.backupCodes);
160
+ if (idx < 0) return { ok: false };
161
+
162
+ // Consume inside a transaction: re-read the stored list, confirm the code
163
+ // we matched is still present (defends against a concurrent login that
164
+ // consumed it between our read and this write), splice it out, write back.
165
+ let consumed = false;
166
+ db.transaction(() => {
167
+ const fresh = getTotpState(db, userId);
168
+ // The matched hash (by value) must still be in the current list.
169
+ const matchedHash = state.backupCodes[idx]!;
170
+ const freshIdx = fresh.backupCodes.indexOf(matchedHash);
171
+ if (freshIdx < 0) return; // already consumed by a racing request
172
+ const remaining = fresh.backupCodes.filter((_, j) => j !== freshIdx);
173
+ db.prepare("UPDATE users SET totp_backup_codes = ? WHERE id = ?").run(
174
+ JSON.stringify(remaining),
175
+ userId,
176
+ );
177
+ consumed = true;
178
+ })();
179
+
180
+ return consumed ? { ok: true, via: "backup_code" } : { ok: false };
181
+ }
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Server-rendered HTML for `/account/2fa` — user self-service TOTP 2FA
3
+ * enroll / disenroll (hub#473). Part of the `/account/*` surface family;
4
+ * chrome cloned from `account-home-ui.ts` so the family stays cohesive.
5
+ *
6
+ * Three render states:
7
+ *
8
+ * - `not-enrolled`: a "Set up two-factor authentication" card with a POST
9
+ * button that starts enrollment.
10
+ * - `enrolling`: the QR code (inline SVG, no external fetch) + the manual
11
+ * base32 key + a confirm-code form. Posting a valid code finalizes.
12
+ * - `enrolled`: status (enabled since …, N backup codes left) + a disenroll
13
+ * form (requires the current password).
14
+ *
15
+ * Plus `backup-codes`: a one-time display of the freshly-minted backup codes
16
+ * after a successful enroll-confirm — shown ONCE, never retrievable.
17
+ *
18
+ * Pure renderer — no DB, no fs. The route handlers in `two-factor-handlers.ts`
19
+ * resolve the user + state, then call in here.
20
+ */
21
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
22
+ import { renderCsrfHiddenInput } from "./csrf.ts";
23
+ import { escapeHtml } from "./oauth-ui.ts";
24
+
25
+ const PALETTE = {
26
+ bg: "#faf8f4",
27
+ bgSoft: "#f3f0ea",
28
+ fg: "#2c2a26",
29
+ fgMuted: "#6b6860",
30
+ fgDim: "#9a9690",
31
+ accent: "#4a7c59",
32
+ accentHover: "#3d6849",
33
+ accentSoft: "rgba(74, 124, 89, 0.08)",
34
+ border: "#e4e0d8",
35
+ borderLight: "#ece9e2",
36
+ cardBg: "#ffffff",
37
+ danger: "#a3392b",
38
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
39
+ success: "#3d6849",
40
+ successSoft: "rgba(61, 104, 73, 0.08)",
41
+ } as const;
42
+
43
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
44
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
45
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
46
+
47
+ function escapeAttr(s: string): string {
48
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
49
+ }
50
+
51
+ function baseDocument(title: string, body: string): string {
52
+ return `<!doctype html>
53
+ <html lang="en">
54
+ <head>
55
+ <meta charset="utf-8" />
56
+ <title>${escapeHtml(title)}</title>
57
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
58
+ <meta name="referrer" content="no-referrer" />
59
+ <style>${STYLES}</style>
60
+ </head>
61
+ <body>
62
+ <main>
63
+ ${body}
64
+ </main>
65
+ </body>
66
+ </html>`;
67
+ }
68
+
69
+ function header(): string {
70
+ return `
71
+ <div class="brand">
72
+ <span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "account-2fa")}</span>
73
+ <span class="brand-name">${WORDMARK_TEXT}</span>
74
+ <span class="brand-tag">two-factor</span>
75
+ </div>`;
76
+ }
77
+
78
+ function errorBanner(msg: string | undefined): string {
79
+ return msg ? `<p class="error-banner">${escapeHtml(msg)}</p>` : "";
80
+ }
81
+
82
+ function noticeBanner(msg: string | undefined): string {
83
+ return msg ? `<p class="notice-banner">${escapeHtml(msg)}</p>` : "";
84
+ }
85
+
86
+ function shell(title: string, inner: string): string {
87
+ const body = `
88
+ <div class="card">
89
+ <div class="card-header">
90
+ ${header()}
91
+ <h1>Two-factor authentication</h1>
92
+ <p class="subtitle">Protect your hub sign-in with a one-time code from an authenticator app.</p>
93
+ </div>
94
+ ${inner}
95
+ <p class="footer-link"><a href="/account/">← Back to your account</a></p>
96
+ </div>`;
97
+ return baseDocument(title, body);
98
+ }
99
+
100
+ // --- not enrolled ---------------------------------------------------------
101
+
102
+ export interface NotEnrolledProps {
103
+ csrfToken: string;
104
+ errorMessage?: string;
105
+ notice?: string;
106
+ }
107
+
108
+ export function renderTwoFactorNotEnrolled(props: NotEnrolledProps): string {
109
+ const inner = `
110
+ ${errorBanner(props.errorMessage)}
111
+ ${noticeBanner(props.notice)}
112
+ <section class="section">
113
+ <p class="status status-off"><span class="dot dot-off"></span>Two-factor authentication is <strong>off</strong>.</p>
114
+ <p>When enabled, signing in will require a 6-digit code from your authenticator
115
+ app (Google Authenticator, 1Password, Authy, …) in addition to your password.</p>
116
+ <form method="POST" action="/account/2fa" class="action-form">
117
+ ${renderCsrfHiddenInput(props.csrfToken)}
118
+ <input type="hidden" name="action" value="start" />
119
+ <button type="submit" class="btn btn-primary">Set up two-factor authentication</button>
120
+ </form>
121
+ </section>`;
122
+ return shell("Two-factor authentication — Parachute", inner);
123
+ }
124
+
125
+ // --- enrolling (show QR + confirm) ----------------------------------------
126
+
127
+ export interface EnrollingProps {
128
+ csrfToken: string;
129
+ /** Inline SVG markup for the otpauth QR code. */
130
+ qrSvg: string;
131
+ /** Base32 secret for manual authenticator entry. */
132
+ secret: string;
133
+ errorMessage?: string;
134
+ }
135
+
136
+ export function renderTwoFactorEnrolling(props: EnrollingProps): string {
137
+ const inner = `
138
+ ${errorBanner(props.errorMessage)}
139
+ <section class="section">
140
+ <p>1. Scan this QR code with your authenticator app:</p>
141
+ <div class="qr" aria-label="TOTP QR code">${props.qrSvg}</div>
142
+ <p>Can't scan? Enter this key manually:</p>
143
+ <div class="copy-row">
144
+ <code data-testid="totp-secret">${escapeHtml(props.secret)}</code>
145
+ </div>
146
+ <p>2. Enter the 6-digit code your app shows to confirm:</p>
147
+ <form method="POST" action="/account/2fa" class="action-form">
148
+ ${renderCsrfHiddenInput(props.csrfToken)}
149
+ <input type="hidden" name="action" value="confirm" />
150
+ <input type="hidden" name="secret" value="${escapeAttr(props.secret)}" />
151
+ <label class="field">
152
+ <span class="field-label">Authentication code</span>
153
+ <input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"
154
+ autofocus required placeholder="123456" />
155
+ </label>
156
+ <button type="submit" class="btn btn-primary">Confirm and enable</button>
157
+ </form>
158
+ </section>`;
159
+ return shell("Set up two-factor authentication — Parachute", inner);
160
+ }
161
+
162
+ // --- backup codes (one-time display) --------------------------------------
163
+
164
+ export interface BackupCodesProps {
165
+ codes: string[];
166
+ }
167
+
168
+ export function renderTwoFactorBackupCodes(props: BackupCodesProps): string {
169
+ const list = props.codes
170
+ .map((c) => `<li><code>${escapeHtml(c)}</code></li>`)
171
+ .join("\n ");
172
+ const inner = `
173
+ <section class="section">
174
+ <p class="status status-on"><span class="dot dot-on"></span>Two-factor authentication is now <strong>on</strong>.</p>
175
+ <p class="warn-text"><strong>Save these backup codes now.</strong> Each can be used once to
176
+ sign in if you lose access to your authenticator app. They are shown only once —
177
+ store them somewhere safe.</p>
178
+ <ul class="backup-codes" data-testid="backup-codes">
179
+ ${list}
180
+ </ul>
181
+ <p class="footer-link"><a class="btn btn-secondary" href="/account/2fa">I've saved my codes</a></p>
182
+ </section>`;
183
+ return shell("Backup codes — Parachute", inner);
184
+ }
185
+
186
+ // --- enrolled (status + disenroll) ----------------------------------------
187
+
188
+ export interface EnrolledProps {
189
+ csrfToken: string;
190
+ /** ISO-8601 enrollment timestamp, or null. */
191
+ enrolledAt: string | null;
192
+ /** Count of unused backup codes. */
193
+ backupCodesRemaining: number;
194
+ errorMessage?: string;
195
+ notice?: string;
196
+ }
197
+
198
+ export function renderTwoFactorEnrolled(props: EnrolledProps): string {
199
+ const since = props.enrolledAt ? ` (since ${escapeHtml(props.enrolledAt.slice(0, 10))})` : "";
200
+ const inner = `
201
+ ${errorBanner(props.errorMessage)}
202
+ ${noticeBanner(props.notice)}
203
+ <section class="section">
204
+ <p class="status status-on"><span class="dot dot-on"></span>Two-factor authentication is <strong>on</strong>${since}.</p>
205
+ <p>You have <strong data-testid="backup-remaining">${props.backupCodesRemaining}</strong>
206
+ backup code${props.backupCodesRemaining === 1 ? "" : "s"} remaining.</p>
207
+ </section>
208
+ <section class="section">
209
+ <h2>Turn off two-factor authentication</h2>
210
+ <p>Disabling 2FA removes the second-factor requirement from your sign-in.
211
+ Enter your current password to confirm.</p>
212
+ <form method="POST" action="/account/2fa" class="action-form">
213
+ ${renderCsrfHiddenInput(props.csrfToken)}
214
+ <input type="hidden" name="action" value="disable" />
215
+ <label class="field">
216
+ <span class="field-label">Current password</span>
217
+ <input type="password" name="password" autocomplete="current-password" required />
218
+ </label>
219
+ <button type="submit" class="btn btn-danger">Turn off two-factor authentication</button>
220
+ </form>
221
+ </section>`;
222
+ return shell("Two-factor authentication — Parachute", inner);
223
+ }
224
+
225
+ // --- styles ---------------------------------------------------------------
226
+
227
+ const STYLES = `
228
+ *, *::before, *::after { box-sizing: border-box; }
229
+ html, body { margin: 0; padding: 0; }
230
+ body {
231
+ font-family: ${FONT_SANS};
232
+ background: ${PALETTE.bg};
233
+ color: ${PALETTE.fg};
234
+ line-height: 1.55;
235
+ min-height: 100vh;
236
+ -webkit-font-smoothing: antialiased;
237
+ -moz-osx-font-smoothing: grayscale;
238
+ }
239
+ main {
240
+ display: flex;
241
+ align-items: flex-start;
242
+ justify-content: center;
243
+ min-height: 100vh;
244
+ padding: 2rem 1.5rem;
245
+ }
246
+ .card {
247
+ width: 100%;
248
+ max-width: 34rem;
249
+ background: ${PALETTE.cardBg};
250
+ border: 1px solid ${PALETTE.border};
251
+ border-radius: 12px;
252
+ padding: 2rem 1.75rem;
253
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
254
+ }
255
+ .card-header { margin-bottom: 1.5rem; }
256
+ .brand {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 0.5rem;
260
+ color: ${PALETTE.accent};
261
+ font-weight: 500;
262
+ font-size: 0.95rem;
263
+ margin-bottom: 1.25rem;
264
+ }
265
+ .brand-mark { display: inline-flex; line-height: 0; }
266
+ .brand-mark svg { width: 20px; height: 20px; }
267
+ .brand-name { letter-spacing: 0.01em; }
268
+ .brand-tag {
269
+ text-transform: uppercase;
270
+ letter-spacing: 0.06em;
271
+ font-size: 0.7rem;
272
+ color: ${PALETTE.fgMuted};
273
+ border: 1px solid ${PALETTE.borderLight};
274
+ padding: 0.05rem 0.4rem;
275
+ border-radius: 999px;
276
+ }
277
+ h1 {
278
+ font-family: ${FONT_SERIF};
279
+ font-weight: 400;
280
+ font-size: 1.6rem;
281
+ line-height: 1.2;
282
+ margin: 0 0 0.4rem;
283
+ color: ${PALETTE.fg};
284
+ }
285
+ h2 {
286
+ font-family: ${FONT_SERIF};
287
+ font-weight: 400;
288
+ font-size: 1.15rem;
289
+ line-height: 1.25;
290
+ margin: 0 0 0.6rem;
291
+ color: ${PALETTE.fg};
292
+ }
293
+ .subtitle { margin: 0; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
294
+
295
+ .section {
296
+ border-top: 1px solid ${PALETTE.borderLight};
297
+ padding-top: 1.25rem;
298
+ margin-top: 1.25rem;
299
+ }
300
+ .section p { margin: 0.5rem 0; }
301
+
302
+ .status { font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
303
+ .dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 999px; }
304
+ .dot-on { background: ${PALETTE.success}; }
305
+ .dot-off { background: ${PALETTE.fgDim}; }
306
+ .status-on strong { color: ${PALETTE.success}; }
307
+
308
+ .qr {
309
+ width: 200px;
310
+ height: 200px;
311
+ margin: 0.75rem 0;
312
+ padding: 0.6rem;
313
+ background: #ffffff;
314
+ border: 1px solid ${PALETTE.borderLight};
315
+ border-radius: 8px;
316
+ }
317
+ .qr svg { width: 100%; height: 100%; display: block; }
318
+
319
+ .copy-row {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 0.5rem;
323
+ background: ${PALETTE.bgSoft};
324
+ border: 1px solid ${PALETTE.borderLight};
325
+ border-radius: 6px;
326
+ padding: 0.5rem 0.6rem;
327
+ margin: 0.4rem 0 0.75rem;
328
+ }
329
+ .copy-row code {
330
+ flex: 1 1 auto;
331
+ overflow-x: auto;
332
+ white-space: nowrap;
333
+ background: transparent;
334
+ padding: 0;
335
+ font-size: 0.95rem;
336
+ letter-spacing: 0.08em;
337
+ }
338
+
339
+ .backup-codes {
340
+ list-style: none;
341
+ margin: 0.75rem 0;
342
+ padding: 0;
343
+ display: grid;
344
+ grid-template-columns: repeat(2, 1fr);
345
+ gap: 0.5rem;
346
+ }
347
+ .backup-codes code {
348
+ font-size: 0.95rem;
349
+ letter-spacing: 0.04em;
350
+ display: block;
351
+ text-align: center;
352
+ padding: 0.4rem;
353
+ }
354
+ .warn-text {
355
+ background: ${PALETTE.dangerSoft};
356
+ border: 1px solid ${PALETTE.danger};
357
+ border-radius: 6px;
358
+ color: ${PALETTE.danger};
359
+ padding: 0.6rem 0.8rem;
360
+ }
361
+
362
+ code {
363
+ font-family: ${FONT_MONO};
364
+ background: ${PALETTE.bgSoft};
365
+ padding: 0.1rem 0.4rem;
366
+ border-radius: 4px;
367
+ font-size: 0.88em;
368
+ }
369
+
370
+ .action-form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 0.75rem; }
371
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
372
+ .field-label {
373
+ font-size: 0.85rem;
374
+ font-weight: 500;
375
+ color: ${PALETTE.fgMuted};
376
+ letter-spacing: 0.01em;
377
+ font-family: ${FONT_MONO};
378
+ }
379
+ input[type=text], input[type=password] {
380
+ font: inherit;
381
+ width: 100%;
382
+ padding: 0.6rem 0.75rem;
383
+ border: 1px solid ${PALETTE.border};
384
+ border-radius: 6px;
385
+ background: ${PALETTE.bg};
386
+ color: ${PALETTE.fg};
387
+ transition: border-color 0.15s ease, background 0.15s ease;
388
+ }
389
+ input[type=text]:focus, input[type=password]:focus {
390
+ outline: none;
391
+ border-color: ${PALETTE.accent};
392
+ background: ${PALETTE.cardBg};
393
+ box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
394
+ }
395
+
396
+ .btn {
397
+ font: inherit;
398
+ font-weight: 500;
399
+ padding: 0.6rem 1.1rem;
400
+ border-radius: 6px;
401
+ border: 1px solid transparent;
402
+ cursor: pointer;
403
+ text-decoration: none;
404
+ display: inline-block;
405
+ align-self: flex-start;
406
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
407
+ }
408
+ .btn-primary { background: ${PALETTE.accent}; color: ${PALETTE.cardBg}; }
409
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
410
+ .btn-secondary { background: transparent; color: ${PALETTE.fg}; border-color: ${PALETTE.border}; }
411
+ .btn-secondary:hover { background: ${PALETTE.bgSoft}; border-color: ${PALETTE.accent}; }
412
+ .btn-danger { background: ${PALETTE.danger}; color: ${PALETTE.cardBg}; }
413
+ .btn-danger:hover { background: #8a3023; }
414
+
415
+ .error-banner {
416
+ background: ${PALETTE.dangerSoft};
417
+ border: 1px solid ${PALETTE.danger};
418
+ border-radius: 6px;
419
+ color: ${PALETTE.danger};
420
+ padding: 0.6rem 0.8rem;
421
+ margin: 0 0 1rem;
422
+ font-size: 0.9rem;
423
+ }
424
+ .notice-banner {
425
+ background: ${PALETTE.successSoft};
426
+ border: 1px solid ${PALETTE.success};
427
+ border-radius: 6px;
428
+ color: ${PALETTE.success};
429
+ padding: 0.6rem 0.8rem;
430
+ margin: 0 0 1rem;
431
+ font-size: 0.9rem;
432
+ }
433
+
434
+ .footer-link { margin-top: 1.25rem; }
435
+ a { color: ${PALETTE.accent}; }
436
+ a:hover { color: ${PALETTE.accentHover}; }
437
+
438
+ @media (max-width: 480px) {
439
+ main { padding: 1rem 0.75rem; }
440
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
441
+ h1 { font-size: 1.4rem; }
442
+ .backup-codes { grid-template-columns: 1fr; }
443
+ }
444
+
445
+ @media (prefers-color-scheme: dark) {
446
+ body { background: #1a1815; color: #e8e4dc; }
447
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
448
+ h1, h2 { color: #f0ece4; }
449
+ .subtitle, .field-label { color: #a8a29a; }
450
+ code { background: #1f1c18; color: #e8e4dc; }
451
+ .copy-row { background: #1f1c18; border-color: #3a362f; }
452
+ .copy-row code { background: transparent; }
453
+ .section { border-top-color: #3a362f; }
454
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
455
+ input[type=text], input[type=password] {
456
+ background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
457
+ }
458
+ input[type=text]:focus, input[type=password]:focus { background: #25221d; }
459
+ .btn-secondary { color: #e8e4dc; border-color: #3a362f; }
460
+ .btn-secondary:hover { background: #1f1c18; border-color: ${PALETTE.accent}; }
461
+ }
462
+ `;