@aooth/auth-moost 0.1.2 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth-moost",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Moost auth integration for aoothjs — AuthGuard interceptor, useAuth composable, REST endpoints, workflows",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -51,20 +51,22 @@
51
51
  "access": "public"
52
52
  },
53
53
  "dependencies": {
54
- "@atscript/moost-wf": "^0.1.65",
55
- "@aooth/arbac-moost": "^0.1.2",
56
- "@aooth/auth": "0.1.2",
57
- "@aooth/user": "0.1.2"
54
+ "@atscript/moost-wf": "^0.1.76",
55
+ "@wooksjs/http-body": "^0.7.15",
56
+ "@aooth/arbac-moost": "^0.1.4",
57
+ "@aooth/auth": "0.1.4",
58
+ "@aooth/user": "0.1.4"
58
59
  },
59
60
  "devDependencies": {
60
- "@atscript/core": "^0.1.56",
61
- "@atscript/typescript": "^0.1.56",
62
- "@atscript/ui": "^0.1.65",
63
- "@moostjs/event-http": "^0.6.10",
64
- "@moostjs/event-wf": "^0.6.10",
65
- "moost": "^0.6.10",
66
- "unplugin-atscript": "^0.1.56",
67
- "wooks": "^0.7.12"
61
+ "@atscript/core": "^0.1.61",
62
+ "@atscript/typescript": "^0.1.61",
63
+ "@atscript/ui": "^0.1.76",
64
+ "@atscript/ui-fns": "^0.1.76",
65
+ "@moostjs/event-http": "^0.6.17",
66
+ "@moostjs/event-wf": "^0.6.17",
67
+ "moost": "^0.6.17",
68
+ "unplugin-atscript": "^0.1.61",
69
+ "wooks": "^0.7.15"
68
70
  },
69
71
  "peerDependencies": {
70
72
  "@atscript/moost-wf": ">=0.1.58",
@@ -73,6 +75,7 @@
73
75
  "@moostjs/event-wf": ">=0.6.10",
74
76
  "@wooksjs/event-core": ">=0.7.12",
75
77
  "@wooksjs/event-http": ">=0.7.12",
78
+ "@wooksjs/http-body": ">=0.7.12",
76
79
  "moost": ">=0.6.10"
77
80
  },
78
81
  "peerDependenciesMeta": {
@@ -81,10 +84,11 @@
81
84
  }
82
85
  },
83
86
  "scripts": {
84
- "gen:atscript": "node --experimental-strip-types scripts/gen-as.mjs",
87
+ "gen:atscript": "asc",
85
88
  "build": "vp pack",
86
89
  "dev": "vp pack --watch",
87
90
  "test": "vp test",
88
- "check": "vp check"
91
+ "check": "vp check",
92
+ "postinstall": "asc"
89
93
  }
90
94
  }
@@ -1,9 +1,61 @@
1
+ /**
2
+ * Inline consent collection — a single dynamic `consents: string[]` field
3
+ * attached to whichever carrier form the user is already filling out.
4
+ * Carrier forms `extends WithInlineConsentForm` to inherit it without
5
+ * duplication.
6
+ *
7
+ * Backend transport: `@wf.context.pass 'pendingConsents'` ships the
8
+ * descriptor array (set by the `prepare-consents` @Step from
9
+ * `ConsentStore.getPendingConsents()`) to the client. The
10
+ * `@ui.form.fn.attr 'pendingConsents'` expression below binds it onto the
11
+ * `AsConsentArray` component (`@atscript/vue-aooth`) which renders one
12
+ * checkbox per descriptor; the user-submitted `string[]` carries back the
13
+ * SUBSET of `descriptor.id`s the user ticked.
14
+ *
15
+ * SPA-side hide-when-empty: `AsConsentArray` self-hides when
16
+ * `pendingConsents` is empty / unset — no `@ui.form.fn.hidden` is needed
17
+ * on the field. A carrier form whose customer hasn't configured any
18
+ * pending consents renders WITHOUT this block.
19
+ *
20
+ * SECURITY (silent-drop): the server-side `processInlineConsent` helper
21
+ * uses its OWN `ctx.pendingConsents` as the authoritative whitelist; any
22
+ * id submitted by the client outside that set is silently dropped (audit
23
+ * invariant — see helper rationale). The client cannot forge audit rows
24
+ * by submitting ids it was never shown.
25
+ *
26
+ * SECURITY (mandatory-by-message): each `ConsentDescriptor.required`
27
+ * non-empty string IS the per-row error copy AND the form-level error the
28
+ * server throws when a required consent is missing from the submitted set.
29
+ * Absent / empty ⇒ optional (the `ConsentEvent.accepted` boolean lets
30
+ * customers persist the un-ticked-optional decision for audit).
31
+ */
32
+ @wf.context.pass 'pendingConsents'
33
+ @wf.context.pass 'consentsPersisted'
34
+ export interface WithInlineConsentForm {
35
+ @meta.label 'Pending consents'
36
+ @ui.form.component 'AsConsentArray'
37
+ @ui.form.fn.attr 'pendingConsents', '(_, _d, ctx) => ctx.pendingConsents'
38
+ @ui.form.grid.colSpan '12'
39
+ consents: string[]
40
+ }
41
+
1
42
  /**
2
43
  * Default login credentials form.
3
44
  *
4
45
  * Override via `setupAuthWorkflows({ forms: { loginCredentials: MyForm } })`.
46
+ *
47
+ * SSO provider ids (configured via
48
+ * `opts.alternateCredentials.ssoProviders[].id`) are NOT declared here —
49
+ * consumers who enable SSO supply their own `loginCredentials` form and add a
50
+ * matching phantom `ui.action` field per provider so
51
+ * `useAtscriptWf(form).resolveAction()` accepts the dynamic ids.
5
52
  */
53
+ @wf.context.pass 'altForgotPassword'
54
+ @wf.context.pass 'altSignup'
55
+ @wf.context.pass 'altMagicLink'
56
+ @ui.form.submit.text 'Sign in'
6
57
  export interface LoginCredentialsForm {
58
+ @ui.form.order 10
7
59
  @ui.form.type 'text'
8
60
  @meta.label 'Username'
9
61
  @ui.form.autocomplete 'username'
@@ -11,19 +63,46 @@ export interface LoginCredentialsForm {
11
63
  @expect.minLength 1
12
64
  username: string
13
65
 
66
+ @ui.form.order 20
14
67
  @ui.form.type 'password'
15
68
  @meta.label 'Password'
16
69
  @ui.form.autocomplete 'current-password'
17
70
  @meta.sensitive
18
71
  @meta.required
19
72
  @expect.minLength 1
73
+ @ui.form.action 'forgotPassword', 'Forgot password?'
74
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altForgotPassword'
75
+ @wf.action.withData 'forgotPassword'
20
76
  password: string
77
+
78
+ @ui.form.order 30
79
+ @ui.form.action 'signup', 'Sign up'
80
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altSignup'
81
+ signup?: ui.action
82
+
83
+ @ui.form.order 40
84
+ @ui.form.action 'magicLink', 'Sign in with a magic link'
85
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altMagicLink'
86
+ magicLink?: ui.action
21
87
  }
22
88
 
23
89
  /**
24
- * MFA code form (TOTP today; email/SMS OTP later).
90
+ * MFA code form. Shared by TOTP, email-OTP, and SMS-OTP branches — the
91
+ * leading `transportHint` paragraph reads `mfaMethod` + `pinSentTo` (masked
92
+ * recipient) out of the workflow context so the operator knows which factor
93
+ * the workflow is currently verifying. The hint requires `installDynamicResolver()`
94
+ * from `@atscript/ui-fns` on the consumer side; without it `@ui.form.fn.value`
95
+ * stays inert and the paragraph renders empty.
25
96
  */
97
+ @wf.context.pass 'mfaMethod'
98
+ @wf.context.pass 'pinSentTo'
99
+ @wf.context.pass 'mfaMethodCount'
100
+ @wf.context.pass 'mfaBackupCodes'
101
+ @ui.form.submit.text 'Verify'
26
102
  export interface MfaCodeForm {
103
+ @ui.form.fn.value '(_, _d, ctx) => ctx.mfaMethod === "totp" ? "Enter the current 6-digit code from your authenticator app." : ctx.mfaMethod ? "Code sent to " + (ctx.pinSentTo || "your " + ctx.mfaMethod) + " — check the dev server console for the code." : "Enter your verification code."'
104
+ transportHint?: ui.paragraph
105
+
27
106
  @ui.form.type 'text'
28
107
  @meta.label 'Verification code'
29
108
  @ui.form.autocomplete 'one-time-code'
@@ -32,6 +111,14 @@ export interface MfaCodeForm {
32
111
  @expect.maxLength 12
33
112
  @expect.pattern '^[0-9]+$'
34
113
  code: string
114
+
115
+ @ui.form.action 'useDifferentMethod', 'Use a different method'
116
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.mfaMethodCount ?? 0) < 2'
117
+ useDifferentMethod?: ui.action
118
+
119
+ @ui.form.action 'useBackupCode', 'Use backup code'
120
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
121
+ useBackupCode?: ui.action
35
122
  }
36
123
 
37
124
  /**
@@ -69,6 +156,9 @@ export interface EmailIdentifierForm {
69
156
  @ui.form.autocomplete 'email'
70
157
  @meta.required
71
158
  email: string.email
159
+
160
+ @ui.form.action 'backToLogin', 'Back to sign-in'
161
+ backToLogin?: ui.action
72
162
  }
73
163
 
74
164
  /**
@@ -76,8 +166,27 @@ export interface EmailIdentifierForm {
76
166
  *
77
167
  * `confirmPassword` equality is enforced in the workflow step (cross-field
78
168
  * checks are not expressible via atscript annotations).
169
+ *
170
+ * `@wf.context.pass 'passwordPolicies'` whitelists the workflow ctx key so the
171
+ * prior preparePasswordRules / setPassword steps can ship the transferable
172
+ * password-policy rules (`UserService.getTransferablePolicies()`) to the
173
+ * client for rendering rule hints next to the inputs. Without this annotation
174
+ * the key is stripped by `extractPassContext` before reaching the client.
175
+ *
176
+ * Phase 7 — `passwordRules: ui.paragraph` is a phantom display field bound to
177
+ * the `AsPasswordRules` component (`@atscript/vue-aooth`); the
178
+ * `@ui.form.fn.attr 'policies'` expression reads `ctx.passwordPolicies` (the
179
+ * transferable list seeded by the workflow's `prepare-password-rules` @Step)
180
+ * and the `@ui.form.fn.attr 'password'` expression reads
181
+ * `data.newPassword` so the rule-fulfillment readout updates live on every
182
+ * keystroke. `WithInlineConsentForm` continues to supply the inline-consent
183
+ * `consents: string[]` block via `AsConsentArray` (Phase 5).
79
184
  */
80
- export interface SetPasswordForm {
185
+ @wf.context.pass 'passwordPolicies'
186
+ @wf.context.pass 'pendingConsents'
187
+ @wf.context.pass 'consentsPersisted'
188
+ export interface SetPasswordForm extends WithInlineConsentForm {
189
+ @ui.form.order 10
81
190
  @ui.form.type 'password'
82
191
  @meta.label 'New password'
83
192
  @ui.form.autocomplete 'new-password'
@@ -86,6 +195,7 @@ export interface SetPasswordForm {
86
195
  @expect.minLength 8
87
196
  newPassword: string
88
197
 
198
+ @ui.form.order 20
89
199
  @ui.form.type 'password'
90
200
  @meta.label 'Confirm password'
91
201
  @ui.form.autocomplete 'new-password'
@@ -93,6 +203,41 @@ export interface SetPasswordForm {
93
203
  @meta.required
94
204
  @expect.minLength 8
95
205
  confirmPassword: string
206
+
207
+ /**
208
+ * Phantom display field — `ui.paragraph` carries no submission value;
209
+ * it exists purely so `AsPasswordRules` (registered on the SPA via
210
+ * `<AsWfForm :components>`) renders one row per `ctx.passwordPolicies`
211
+ * descriptor between the confirm-password input and the action buttons.
212
+ *
213
+ * The `policies` attr reads from workflow context (seeded by the
214
+ * `prepare-password-rules` @Step via
215
+ * `UserService.getTransferablePolicies()`); the `password` attr reads
216
+ * from the live form-data so each row's `data-passed` flag re-evaluates
217
+ * on every keystroke. The `(_, data) => data.newPassword` shape is
218
+ * load-bearing — a regression that froze the `password` attr at first
219
+ * render (e.g. `() => data.newPassword`) would silently lie about
220
+ * policy fulfillment after the user starts typing.
221
+ */
222
+ @ui.form.order 25
223
+ @meta.label 'Password requirements'
224
+ @ui.form.component 'AsPasswordRules'
225
+ @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.passwordPolicies'
226
+ @ui.form.fn.attr 'password', '(_, data) => data.newPassword'
227
+ @ui.form.grid.colSpan '12'
228
+ passwordRules: ui.paragraph
229
+
230
+ @ui.form.order 30
231
+ @ui.form.action 'logout', 'Logout'
232
+ logout?: ui.action
233
+
234
+ @ui.form.order 40
235
+ @ui.form.action 'cancel', 'Cancel'
236
+ cancel?: ui.action
237
+
238
+ @ui.form.order 50
239
+ @ui.form.action 'backToLogin', 'Back to sign-in'
240
+ backToLogin?: ui.action
96
241
  }
97
242
 
98
243
  /**
@@ -121,9 +266,12 @@ export interface InviteForm {
121
266
  // UX: select-on-array currently renders single-text-per-item via AsArray;
122
267
  // dedicated multi-select widget tracked as atscript-ui follow-up.
123
268
  @ui.form.type 'select'
124
- @ui.form.fn.options '(_, _data, context) => Array.isArray(context.availableRoles) ? context.availableRoles.map(r => ({ value: r, label: r })) : []'
269
+ @ui.form.fn.options '(_, _data, context) => Array.isArray(context.availableRoles) ? context.availableRoles.map(r => ({ key: r, label: r })) : []'
125
270
  @meta.label 'Roles'
126
271
  roles?: string[]
272
+
273
+ @ui.form.action 'cancel', 'Cancel'
274
+ cancel?: ui.action
127
275
  }
128
276
 
129
277
  /**
@@ -137,6 +285,9 @@ export interface InviteEmailForm {
137
285
  @ui.form.autocomplete 'email'
138
286
  @meta.required
139
287
  email: string.email
288
+
289
+ @ui.form.action 'cancel', 'Cancel'
290
+ cancel?: ui.action
140
291
  }
141
292
 
142
293
  /**
@@ -145,11 +296,15 @@ export interface InviteEmailForm {
145
296
  * `'email'` or `'shareableLink'`.
146
297
  */
147
298
  export interface InviteSendModeForm {
148
- @ui.form.type 'text'
299
+ @ui.form.type 'radio'
300
+ @ui.form.options 'Email', 'email'
301
+ @ui.form.options 'Shareable link', 'shareableLink'
149
302
  @meta.label 'Delivery mode'
150
303
  @meta.required
151
- @expect.pattern '^(email|shareableLink)$'
152
304
  mode: string
305
+
306
+ @ui.form.action 'cancel', 'Cancel'
307
+ cancel?: ui.action
153
308
  }
154
309
 
155
310
  /**
@@ -158,25 +313,51 @@ export interface InviteSendModeForm {
158
313
  * The workflow renders this only when the user has >1 enrolled methods after
159
314
  * `opts.mfaTransports` filtering. `methodName` is the `MfaMethod.name`
160
315
  * (e.g. `"totp"`, `"email"`, `"sms"`); the workflow itself validates that the
161
- * supplied value is in the user's enrolled set.
316
+ * supplied value is in the user's enrolled set. The dropdown options are
317
+ * built from `ctx.mfaEnrolledMethods` (a `MfaSummary[]` populated by
318
+ * `prepareMfaOptions`) so the user only sees factors they actually have.
162
319
  */
320
+ @wf.context.pass 'mfaBackupCodes'
321
+ @wf.context.pass 'mfaEnrolledMethods'
163
322
  export interface Select2faForm {
164
- @ui.form.type 'text'
323
+ @ui.form.type 'radio'
324
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.mfaEnrolledMethods) ? ctx.mfaEnrolledMethods.map(m => ({ key: m.methodName, label: m.kind === "totp" ? "TOTP (Authenticator app)" : m.kind === "email" ? "Email" : m.kind === "sms" ? "SMS" : m.kind })) : []'
165
325
  @meta.label 'MFA method'
166
326
  @meta.required
167
327
  methodName: string
168
328
 
169
329
  @ui.form.type 'checkbox'
170
330
  @meta.label 'Save as default'
171
- saveAsDefault?: boolean
331
+ @meta.default 'false'
332
+ saveAsDefault: boolean
333
+
334
+ @ui.form.action 'useBackupCode', 'Use backup code'
335
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
336
+ useBackupCode?: ui.action
172
337
  }
173
338
 
174
339
  /**
175
340
  * Generic 6-digit pincode form — used by both login and recovery OTP flows.
176
341
  *
177
342
  * `rememberDevice` is rendered only when `opts.deviceTrust && opts.deviceTrustOptIn`.
343
+ *
344
+ * The leading `transportHint` paragraph reads `mfaMethod` + `pinSentTo` (the
345
+ * masked recipient set by the pincode-send step) out of the workflow context
346
+ * so the operator can see which factor the workflow is currently verifying.
347
+ * Requires `installDynamicResolver()` from `@atscript/ui-fns` on the consumer
348
+ * side; without it `@ui.form.fn.value` stays inert and the paragraph renders empty.
178
349
  */
350
+ @wf.context.pass 'mfaMethod'
351
+ @wf.context.pass 'pinSentTo'
352
+ @wf.context.pass 'mfaMethodCount'
353
+ @wf.context.pass 'mfaBackupCodes'
354
+ @wf.context.pass 'deviceTrustOptIn'
355
+ @wf.context.pass 'recoveryTransportCount'
356
+ @ui.form.submit.text 'Verify'
179
357
  export interface PincodeForm {
358
+ @ui.form.fn.value '(_, _d, ctx) => ctx.mfaMethod === "totp" ? "Enter the current 6-digit code from your authenticator app." : ctx.mfaMethod ? "Code sent to " + (ctx.pinSentTo || "your " + ctx.mfaMethod) + " — check the dev server console for the code." : "Enter your verification code."'
359
+ transportHint?: ui.paragraph
360
+
180
361
  @ui.form.type 'text'
181
362
  @meta.label 'Verification code'
182
363
  @ui.form.autocomplete 'one-time-code'
@@ -188,13 +369,37 @@ export interface PincodeForm {
188
369
 
189
370
  @ui.form.type 'checkbox'
190
371
  @meta.label 'Remember this device'
191
- rememberDevice?: boolean
372
+ @meta.default 'false'
373
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.deviceTrustOptIn'
374
+ rememberDevice: boolean
375
+
376
+ @ui.form.action 'resend', 'Resend code'
377
+ resend?: ui.action
378
+
379
+ @ui.form.action 'useDifferentMethod', 'Use a different method'
380
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.mfaMethodCount ?? 0) < 2'
381
+ useDifferentMethod?: ui.action
382
+
383
+ @ui.form.action 'useBackupCode', 'Use backup code'
384
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
385
+ useBackupCode?: ui.action
386
+
387
+ @ui.form.action 'backToLogin', 'Back to sign-in'
388
+ backToLogin?: ui.action
389
+
390
+ @ui.form.action 'useDifferentTransport', 'Use a different transport'
391
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.recoveryTransportCount ?? 0) < 2'
392
+ useDifferentTransport?: ui.action
192
393
  }
193
394
 
194
395
  /**
195
- * Email-only form for the `ensureEmail` enrollment loop.
396
+ * Email-only form for the `ask/email` enrollment step.
196
397
  */
197
- export interface AskEmailForm {
398
+ @wf.context.pass 'pendingConsents'
399
+ @wf.context.pass 'consentsPersisted'
400
+ @wf.context.pass 'otpDisclosure'
401
+ export interface AskEmailForm extends WithInlineConsentForm {
402
+ @ui.form.order 10
198
403
  @ui.form.type 'text'
199
404
  @meta.label 'Email'
200
405
  @ui.form.autocomplete 'email'
@@ -203,10 +408,14 @@ export interface AskEmailForm {
203
408
  }
204
409
 
205
410
  /**
206
- * Phone-only form for the `ensurePhone` enrollment loop. Free-form text —
411
+ * Phone-only form for the `ask/phone` enrollment step. Free-form text —
207
412
  * E.164 normalization happens server-side.
208
413
  */
209
- export interface AskPhoneForm {
414
+ @wf.context.pass 'pendingConsents'
415
+ @wf.context.pass 'consentsPersisted'
416
+ @wf.context.pass 'otpDisclosure'
417
+ export interface AskPhoneForm extends WithInlineConsentForm {
418
+ @ui.form.order 10
210
419
  @ui.form.type 'text'
211
420
  @meta.label 'Phone (E.164)'
212
421
  @ui.form.autocomplete 'tel'
@@ -215,48 +424,131 @@ export interface AskPhoneForm {
215
424
  }
216
425
 
217
426
  /**
218
- * Terms & conditions acceptance form.
427
+ * Forced MFA enrollment method picker for `mfa-enroll-required`. Options
428
+ * come from `ctx.enrollAvailableTransports` so only consumer-enabled
429
+ * transports appear.
219
430
  */
220
- export interface TermsAcceptForm {
431
+ @wf.context.pass 'enrollAvailableTransports'
432
+ @wf.context.pass 'enrollMode'
433
+ export interface EnrollPickMethodForm {
434
+ @ui.form.type 'radio'
435
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.enrollAvailableTransports) ? ctx.enrollAvailableTransports.map(t => ({ key: t, label: t === "totp" ? "Authenticator app (TOTP)" : t === "sms" ? "SMS" : t === "email" ? "Email" : t })) : []'
436
+ @meta.label 'Choose a verification method'
437
+ @meta.required
438
+ method: string
439
+
440
+ @ui.form.action 'skip', 'Skip for now'
441
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
442
+ skip?: ui.action
443
+ }
444
+
445
+ /**
446
+ * Forced MFA enrollment — address collection for sms/email. TOTP skips this
447
+ * form (secret is provisioned server-side).
448
+ *
449
+ * `skip` is hidden unless `enrollMode === 'optional'` (`'required'` mode
450
+ * forbids backing out mid-flow). `useDifferentMethod` is hidden when the
451
+ * consumer has only one transport configured (nothing to switch to).
452
+ */
453
+ @wf.context.pass 'enrollMethod'
454
+ @wf.context.pass 'enrollMode'
455
+ @wf.context.pass 'enrollAvailableTransports'
456
+ export interface EnrollAddressForm {
221
457
  @ui.form.type 'text'
222
- @meta.label 'Accepted version'
458
+ @meta.label 'Address'
223
459
  @meta.required
224
- acceptedVersion: string
460
+ address: string
225
461
 
226
- @ui.form.type 'checkbox'
227
- @meta.label 'I accept the Terms & Conditions'
462
+ @ui.form.action 'skip', 'Skip for now'
463
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
464
+ skip?: ui.action
465
+
466
+ @ui.form.action 'useDifferentMethod', 'Use a different method'
467
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
468
+ useDifferentMethod?: ui.action
469
+ }
470
+
471
+ /**
472
+ * Forced MFA enrollment — confirm code, shared by all three transports. The
473
+ * leading paragraph swaps between "scan this QR" (totp) and "code sent to …"
474
+ * (sms/email) based on `enrollMethod`; `enrollSecret` / `enrollUri` are passed
475
+ * for the totp QR + manual-entry fallback.
476
+ */
477
+ @wf.context.pass 'enrollMethod'
478
+ @wf.context.pass 'enrollMode'
479
+ @wf.context.pass 'enrollSecret'
480
+ @wf.context.pass 'enrollUri'
481
+ @wf.context.pass 'enrollAvailableTransports'
482
+ @wf.context.pass 'pinSentTo'
483
+ @ui.form.submit.text 'Confirm'
484
+ export interface EnrollConfirmForm {
485
+ @ui.form.fn.value '(_, _d, ctx) => ctx.enrollMethod === "totp" ? "Scan the QR with your authenticator app, or enter the secret manually. Then type the 6-digit code it generates." : ctx.enrollMethod ? "Code sent to " + (ctx.pinSentTo || "your " + ctx.enrollMethod) + ". Enter it below to confirm." : "Enter the code to confirm enrollment."'
486
+ transportHint?: ui.paragraph
487
+
488
+ @ui.form.type 'text'
489
+ @meta.label 'Code'
490
+ @ui.form.autocomplete 'one-time-code'
228
491
  @meta.required
229
- accepted: boolean
492
+ @expect.minLength 4
493
+ @expect.maxLength 12
494
+ @expect.pattern '^[0-9]+$'
495
+ code: string
496
+
497
+ @ui.form.action 'resend', 'Resend code'
498
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMethod === "totp"'
499
+ resend?: ui.action
500
+
501
+ @ui.form.action 'useDifferentMethod', 'Use a different method'
502
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
503
+ useDifferentMethod?: ui.action
504
+
505
+ @ui.form.action 'skip', 'Skip for now'
506
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
507
+ skip?: ui.action
230
508
  }
231
509
 
232
510
  /**
233
511
  * Default minimal profile completion form. Consumers replace via
234
512
  * `LoginWorkflowOptions.profileCompleteForm` for richer shapes.
235
513
  */
236
- export interface ProfileCompleteForm {
514
+ @wf.context.pass 'pendingConsents'
515
+ @wf.context.pass 'consentsPersisted'
516
+ export interface ProfileCompleteForm extends WithInlineConsentForm {
517
+ @ui.form.order 10
237
518
  @ui.form.type 'text'
238
519
  @meta.label 'First name'
239
520
  firstName?: string
240
521
 
522
+ @ui.form.order 20
241
523
  @ui.form.type 'text'
242
524
  @meta.label 'Last name'
243
525
  lastName?: string
244
526
  }
245
527
 
246
528
  /**
247
- * Marketing consent opt-in.
529
+ * Standalone consent-bump prompt. Fires for returning users with pending
530
+ * consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
531
+ * who did NOT pass through any onboarding carrier form (`AskEmailForm` /
532
+ * `AskPhoneForm` / `SetPasswordForm` / `ProfileCompleteForm`) on this login —
533
+ * those carrier forms collect consents inline via `WithInlineConsentForm`'s
534
+ * inherited `AsConsentArray` field. The bump-prompt only renders the same
535
+ * inherited consent block (no additional fields).
248
536
  */
249
- export interface ConsentMarketingForm {
250
- @ui.form.type 'checkbox'
251
- @meta.label 'I would like to receive marketing emails'
252
- optIn?: boolean
537
+ @wf.context.pass 'pendingConsents'
538
+ @wf.context.pass 'consentsPersisted'
539
+ export interface TermsBumpForm extends WithInlineConsentForm {
253
540
  }
254
541
 
255
542
  /**
256
543
  * Tenant picker — `tenantId` matches one of `ctx.availableTenants[].id`.
544
+ * Options are built from `ctx.availableTenants` (set by the workflow's
545
+ * `tenant-select` step / `loadTenants` hook); `@wf.context.pass` whitelists
546
+ * the key so it survives `extractPassContext`.
257
547
  */
548
+ @wf.context.pass 'availableTenants'
258
549
  export interface TenantSelectForm {
259
- @ui.form.type 'text'
550
+ @ui.form.type 'radio'
551
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableTenants) ? ctx.availableTenants.map(t => ({ key: t.id, label: t.name })) : []'
260
552
  @meta.label 'Tenant'
261
553
  @meta.required
262
554
  tenantId: string
@@ -264,9 +556,14 @@ export interface TenantSelectForm {
264
556
 
265
557
  /**
266
558
  * Persona picker — `personaId` matches one of `ctx.availablePersonas[].id`.
559
+ * Options are built from `ctx.availablePersonas` (set by the workflow's
560
+ * `persona-select` step / `loadPersonas` hook); `@wf.context.pass` whitelists
561
+ * the key so it survives `extractPassContext`.
267
562
  */
563
+ @wf.context.pass 'availablePersonas'
268
564
  export interface PersonaSelectForm {
269
- @ui.form.type 'text'
565
+ @ui.form.type 'radio'
566
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availablePersonas) ? ctx.availablePersonas.map(p => ({ key: p.id, label: p.label })) : []'
270
567
  @meta.label 'Persona'
271
568
  @meta.required
272
569
  personaId: string
@@ -282,6 +579,12 @@ export interface ConcurrencyLimitForm {
282
579
  @meta.required
283
580
  @expect.pattern '^(logoutOthers|cancel)$'
284
581
  action: string
582
+
583
+ @ui.form.action 'cancel', 'Cancel'
584
+ cancel?: ui.action
585
+
586
+ @ui.form.action 'logoutOthers', 'Log out other sessions'
587
+ logoutOthers?: ui.action
285
588
  }
286
589
 
287
590
  /**
@@ -302,24 +605,32 @@ export interface MagicLinkRequestForm {
302
605
  * `RecoveryWorkflowOptions.deliveryMode === 'choice'`.
303
606
  */
304
607
  export interface RecoveryModeSelectForm {
305
- @ui.form.type 'text'
608
+ @ui.form.type 'radio'
609
+ @ui.form.options 'Magic link', 'magicLink'
610
+ @ui.form.options 'One-time code', 'otp'
306
611
  @meta.label 'Recovery method'
307
612
  @meta.required
308
- @expect.pattern '^(magicLink|otp)$'
309
613
  mode: string
614
+
615
+ @ui.form.action 'backToLogin', 'Back to sign-in'
616
+ backToLogin?: ui.action
310
617
  }
311
618
 
312
619
  /**
313
620
  * Recovery factor-verification form — used when
314
621
  * `RecoveryWorkflowOptions.requireKnownRecoveryFactor` is true. The user
315
622
  * picks a factor type and supplies its value; the server validates against
316
- * the enrolled factor (phone last-4 or current TOTP code).
623
+ * the enrolled factor (phone last-4 or current TOTP code). Options are
624
+ * built from `ctx.availableRecoveryFactors` (workflow whitelist ∩ user's
625
+ * enrolled factors), so users only see factors they can actually verify
626
+ * AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
317
627
  */
628
+ @wf.context.pass 'availableRecoveryFactors'
318
629
  export interface RecoveryFactorForm {
319
- @ui.form.type 'text'
630
+ @ui.form.type 'radio'
631
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableRecoveryFactors) ? ctx.availableRecoveryFactors : []'
320
632
  @meta.label 'Factor'
321
633
  @meta.required
322
- @expect.pattern '^(phone|totp)$'
323
634
  factor: string
324
635
 
325
636
  @ui.form.type 'text'
@@ -328,4 +639,7 @@ export interface RecoveryFactorForm {
328
639
  @expect.minLength 4
329
640
  @expect.maxLength 12
330
641
  value: string
642
+
643
+ @ui.form.action 'backToLogin', 'Back to sign-in'
644
+ backToLogin?: ui.action
331
645
  }