@aooth/auth-moost 0.1.7 → 0.1.9

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.
@@ -4,21 +4,22 @@
4
4
  * Carrier forms `extends WithInlineConsentForm` to inherit it without
5
5
  * duplication.
6
6
  *
7
- * Backend transport: `@wf.context.pass 'pendingConsents'` ships the
8
- * descriptor array (set by the `prepare-consents` @Step from
7
+ * Backend transport: `@wf.context.pass 'public'` ships the
8
+ * `AuthWfConsentsState` group (set by the `prepare-consents` @Step from
9
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.
10
+ * `@ui.form.fn.attr 'pendingConsents'` expression below binds
11
+ * `ctx.public?.consents?.pending` onto the `AsConsentArray` component
12
+ * (`@atscript/vue-aooth`) which renders one checkbox per descriptor; the
13
+ * user-submitted `string[]` carries back the SUBSET of `descriptor.id`s the
14
+ * user ticked.
14
15
  *
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
16
+ * SPA-side hide-when-empty: `AsConsentArray` self-hides when its
17
+ * `pendingConsents` prop is empty / unset — no `@ui.form.fn.hidden` is
18
+ * needed on the field. A carrier form whose customer hasn't configured any
18
19
  * pending consents renders WITHOUT this block.
19
20
  *
20
21
  * SECURITY (silent-drop): the server-side `processInlineConsent` helper
21
- * uses its OWN `ctx.pendingConsents` as the authoritative whitelist; any
22
+ * uses its OWN `ctx.consents.pending` as the authoritative whitelist; any
22
23
  * id submitted by the client outside that set is silently dropped (audit
23
24
  * invariant — see helper rationale). The client cannot forge audit rows
24
25
  * by submitting ids it was never shown.
@@ -29,12 +30,12 @@
29
30
  * Absent / empty ⇒ optional (the `ConsentEvent.accepted` boolean lets
30
31
  * customers persist the un-ticked-optional decision for audit).
31
32
  */
32
- @wf.context.pass 'pendingConsents'
33
- @wf.context.pass 'consentsPersisted'
33
+ @wf.context.pass 'public'
34
34
  export interface WithInlineConsentForm {
35
35
  @meta.label 'Pending consents'
36
36
  @ui.form.component 'AsConsentArray'
37
- @ui.form.fn.attr 'pendingConsents', '(_, _d, ctx) => ctx.pendingConsents'
37
+ @ui.form.fn.attr 'pendingConsents', '(_, _d, ctx) => ctx.public?.consents?.pending'
38
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.consents?.decidedAt !== undefined || (ctx.public?.consents?.pending?.length ?? 0) === 0'
38
39
  @ui.form.grid.colSpan '12'
39
40
  consents: string[]
40
41
  }
@@ -44,15 +45,18 @@ export interface WithInlineConsentForm {
44
45
  *
45
46
  * Override via `setupAuthWorkflows({ forms: { loginCredentials: MyForm } })`.
46
47
  *
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.
48
+ * SSO providers render out of the box: the `AsSsoProviders` component reads the
49
+ * resolved provider list off `ctx.public.altActions.ssoProviders` (a dynamic
50
+ * `SsoProvider[]`) and renders one button per provider. Because the server's
51
+ * `resolveAction()` only accepts DECLARED actions, providers don't get a
52
+ * per-id action — instead a single data-carrying `sso` action is declared
53
+ * here, and `AsSsoProviders` sets the chosen id into `ssoProvider` before
54
+ * invoking it (`useAtscriptWf(form).resolveAction()` sees `sso`; the workflow
55
+ * reads `ssoProvider` from the submitted data). The whole block hides when no
56
+ * providers are configured, so a password-only deployment renders unchanged.
52
57
  */
53
- @wf.context.pass 'altForgotPassword'
54
- @wf.context.pass 'altSignup'
55
- @wf.context.pass 'altMagicLink'
58
+ @meta.label 'Sign in'
59
+ @wf.context.pass 'public'
56
60
  @ui.form.submit.text 'Sign in'
57
61
  export interface LoginCredentialsForm {
58
62
  @ui.form.order 10
@@ -71,41 +75,72 @@ export interface LoginCredentialsForm {
71
75
  @meta.required
72
76
  @expect.minLength 1
73
77
  @ui.form.action 'forgotPassword', 'Forgot password?'
74
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altForgotPassword'
78
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.altActions?.forgotPassword'
75
79
  @wf.action.withData 'forgotPassword'
76
80
  password: string
77
81
 
78
82
  @ui.form.order 30
79
83
  @ui.form.action 'signup', 'Sign up'
80
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altSignup'
84
+ @ui.form.attr 'text', 'Don’t have an account?'
85
+ @ui.form.attr 'align', 'center'
86
+ @ui.form.pushDown
87
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.altActions?.signup'
81
88
  signup?: ui.action
82
89
 
83
90
  @ui.form.order 40
84
91
  @ui.form.action 'magicLink', 'Sign in with a magic link'
85
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.altMagicLink'
92
+ @ui.form.attr 'align', 'center'
93
+ @ui.form.pushDown
94
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.altActions?.magicLink'
86
95
  magicLink?: ui.action
96
+
97
+ // SSO providers — rendered from `ctx.public.altActions.ssoProviders` by the
98
+ // `AsSsoProviders` one-click picker (registered on `<AsWfForm :components>`).
99
+ // Each provider becomes a full-width button whose VERBATIM `text` the server
100
+ // owns ("Continue with {label}"); a click both selects the provider id and
101
+ // fires the `sso` action — the component is chromeless and suppresses the
102
+ // shell's footer action link, so there is NO separate submit button (the
103
+ // `'Continue'` action label is inert here, kept only because the action
104
+ // DECLARATION is what `resolveAction()` whitelists for the emit). The `sso`
105
+ // action is DATA-CARRYING (`@wf.action.withData`), so the selected
106
+ // `ssoProvider` rides in the submitted data and the workflow redirects to
107
+ // that provider (same mechanism `forgotPassword` uses to carry the typed
108
+ // username). OPTIONAL on purpose — a password login (or a hand-rolled
109
+ // client) submits without it and must NOT be blocked; only the `sso` action
110
+ // carries it. `AsSsoProviders` self-hides on an empty `providers` list; the
111
+ // explicit `@ui.form.fn.hidden` keeps the field out of the grid flow too.
112
+ // Swap the component via `setupAuthWorkflows({ forms })`.
113
+ @ui.form.order 50
114
+ @ui.form.component 'AsSsoProviders'
115
+ @ui.form.fn.attr 'providers', '(_, _d, ctx) => Array.isArray(ctx.public?.altActions?.ssoProviders) ? ctx.public.altActions.ssoProviders.map((p) => ({ id: p.id, text: "Continue with " + p.label, icon: p.icon })) : []'
116
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.altActions?.ssoProviders?.length ?? 0) === 0'
117
+ @meta.label 'Or sign in with'
118
+ @ui.form.action 'sso', 'Continue'
119
+ @wf.action.withData 'sso'
120
+ @ui.form.grid.colSpan '12'
121
+ ssoProvider?: string
87
122
  }
88
123
 
89
124
  /**
90
125
  * 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.
126
+ * leading `transportHint` paragraph reads `mfa.method` + `pincode.sentTo`
127
+ * (masked recipient) out of the workflow context so the operator knows
128
+ * which factor the workflow is currently verifying. The hint requires
129
+ * `installDynamicResolver()` from `@atscript/ui-fns` on the consumer
130
+ * side; without it `@ui.form.fn.value` stays inert and the paragraph
131
+ * renders empty.
96
132
  */
97
- @wf.context.pass 'mfaMethod'
98
- @wf.context.pass 'pinSentTo'
99
- @wf.context.pass 'mfaMethodCount'
100
- @wf.context.pass 'mfaBackupCodes'
133
+ @meta.label 'Verify your identity'
134
+ @wf.context.pass 'public'
101
135
  @ui.form.submit.text 'Verify'
102
136
  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."'
137
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfa?.method === "totp" ? "Enter the current 6-digit code from your authenticator app." : ctx.public?.pincode?.sentTo ? "Code sent to " + ctx.public.pincode.sentTo + "." : "Enter your verification code."'
104
138
  transportHint?: ui.paragraph
105
139
 
106
140
  @ui.form.type 'text'
107
141
  @meta.label 'Verification code'
108
142
  @ui.form.autocomplete 'one-time-code'
143
+ @ui.form.fn.attr 'maxlength', '(_, _d, ctx) => ctx.public?.pincode?.codeLength'
109
144
  @meta.required
110
145
  @expect.minLength 4
111
146
  @expect.maxLength 12
@@ -113,87 +148,133 @@ export interface MfaCodeForm {
113
148
  code: string
114
149
 
115
150
  @ui.form.action 'useDifferentMethod', 'Use a different method'
116
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.mfaMethodCount ?? 0) < 2'
151
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfa?.methodCount ?? 0) < 2'
117
152
  useDifferentMethod?: ui.action
118
153
 
119
- @ui.form.action 'useBackupCode', 'Use backup code'
120
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
121
- useBackupCode?: ui.action
154
+ @ui.form.type 'checkbox'
155
+ @meta.label 'Remember this device'
156
+ @meta.default 'false'
157
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.trust?.optIn || !!ctx.public?.newPasswordRequired'
158
+ rememberDevice: boolean
122
159
  }
123
160
 
124
161
  /**
125
- * Backup-code form (alphanumeric, hyphen-grouped e.g. `XXXX-XXXX-XX`).
162
+ * Email identifier form used for password recovery initiation.
126
163
  *
127
- * `UserService.generateBackupCodes` uses a 31-character alphabet (uppercase
128
- * letters minus I/O/L, digits minus 0/1) formatted with hyphens between groups
129
- * of 4 the regex below mirrors that shape. Kept separate from
130
- * `MfaCodeForm` so TOTP entry stays strict-digits.
164
+ * `@wf.context.pass 'public'` whitelists the `defaults` ctx key so the
165
+ * recovery `request` step can pre-fill the email field from the
166
+ * `?username=` query param (carried in by the login workflow's
167
+ * `forgotPassword` alt-action). Without this annotation the field is
168
+ * stripped by `extractPassContext` before reaching the client.
131
169
  */
132
- export interface BackupCodeForm {
170
+ @meta.label 'Forgot your password?'
171
+ @meta.description 'Enter your account email and we will send you a recovery link.'
172
+ @wf.context.pass 'public'
173
+ export interface EmailIdentifierForm {
174
+ @ui.form.order 10
133
175
  @ui.form.type 'text'
134
- @meta.label 'Backup code'
135
- @ui.form.autocomplete 'one-time-code'
176
+ @meta.label 'Email'
177
+ @ui.form.autocomplete 'email'
136
178
  @meta.required
137
- @expect.minLength 4
138
- @expect.maxLength 32
139
- @expect.pattern '^[A-Z2-9-]+$'
140
- code: string
179
+ email: string.email
180
+
181
+ @ui.form.order 20
182
+ @ui.form.action 'backToLogin', 'Back to sign in'
183
+ @ui.form.attr 'align', 'center'
184
+ @ui.form.pushDown
185
+ backToLogin?: ui.action
141
186
  }
142
187
 
143
188
  /**
144
- * Email identifier form — used for password recovery initiation.
189
+ * Self-signup identifier form — the entry pause of `auth/signup/flow`.
145
190
  *
146
- * `@wf.context.pass 'defaults'` whitelists the `defaults` ctx key so the
147
- * recovery `request` step can pre-fill the email field from the
148
- * `?username=` query param (carried in by the login workflow's
149
- * `forgotPassword` alt-action). Without this annotation the field is
150
- * stripped by `extractPassContext` before reaching the client.
191
+ * Intentionally email-only: the flow is verify-first, so the user proves
192
+ * email ownership via OTP BEFORE the account is created, and the password is
193
+ * set afterwards on the shared `SetPasswordForm` (so no plaintext password is
194
+ * ever held in workflow state across the OTP wait). `username` defaults to the
195
+ * email; consumers who want a distinct username — or richer profile capture —
196
+ * override this form via `setupAuthWorkflows({ forms: { signup: MyForm } })`
197
+ * and read the extra fields in a `signup-form` / `signup-extra-step` override.
198
+ *
199
+ * `@wf.context.pass 'public'` mirrors `EmailIdentifierForm` so a future
200
+ * prefill (`ctx.defaults.email`) works the same way.
151
201
  */
152
- @wf.context.pass 'defaults'
153
- export interface EmailIdentifierForm {
202
+ @meta.label 'Create your account'
203
+ @meta.description 'Enter your email to get started — we will send you a verification code.'
204
+ @wf.context.pass 'public'
205
+ export interface SignupForm {
206
+ @ui.form.order 10
154
207
  @ui.form.type 'text'
155
208
  @meta.label 'Email'
156
209
  @ui.form.autocomplete 'email'
157
210
  @meta.required
158
211
  email: string.email
159
212
 
160
- @ui.form.action 'backToLogin', 'Back to sign-in'
213
+ // Primary cross-link to sign-in: signup is typically the INITIAL flow, so
214
+ // existing users click this to reach the login flow.
215
+ @ui.form.order 20
216
+ @ui.form.action 'backToLogin', 'Sign in'
217
+ @ui.form.attr 'text', 'Already have an account?'
218
+ @ui.form.attr 'align', 'center'
219
+ @ui.form.pushDown
161
220
  backToLogin?: ui.action
162
221
  }
163
222
 
164
223
  /**
165
224
  * Set new password form.
166
225
  *
167
- * `confirmPassword` equality is enforced in the workflow step (cross-field
168
- * checks are not expressible via atscript annotations).
226
+ * Cross-field equality — `confirmPassword === newPassword` is enforced by
227
+ * the `@ui.form.validate` rule below (client + server validators key on the
228
+ * same expression). The workflow step retains a defensive equality check as
229
+ * a belt-and-braces guard.
169
230
  *
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.
231
+ * `@wf.context.pass 'public'` whitelists the `AuthWfPasswordUiState`
232
+ * group on `ctx.password` so the prior preparePasswordRules /
233
+ * createPasswordForm / setPassword steps can ship the transferable
234
+ * password-policy rules (`UserService.getTransferablePolicies()`), the
235
+ * structured `changeReason` discriminator, and the `heading` / `intro`
236
+ * copy to the client. Without this annotation the key is stripped by
237
+ * `extractPassContext` before reaching the client.
175
238
  *
176
239
  * Phase 7 — `passwordRules: ui.paragraph` is a phantom display field bound to
177
240
  * 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
241
+ * `@ui.form.fn.attr 'policies'` expression reads `ctx.public?.password?.policies`
242
+ * (the transferable list seeded by the workflow's `prepare-password-rules`
243
+ * @Step) and the `@ui.form.fn.attr 'password'` expression reads
181
244
  * `data.newPassword` so the rule-fulfillment readout updates live on every
182
245
  * keystroke. `WithInlineConsentForm` continues to supply the inline-consent
183
246
  * `consents: string[]` block via `AsConsentArray` (Phase 5).
184
247
  *
185
- * `@wf.context.pass 'passwordChangeReason'` whitelists the workflow ctx key
186
- * carrying `'initial' | 'expired'` set by `LoginWorkflow.credentials` when
187
- * the forced-change branch fires. Default form labels stay reason-agnostic;
188
- * downstream SPAs can read the value and override banner copy via a
189
- * sibling `ui.paragraph` with an `@ui.form.fn.value` expression (mirroring
190
- * the Phase-3 `EnrollConfirmForm.transportHint` pattern).
248
+ * `ctx.password.changeReason` is the structured discriminator
249
+ * (`'initial' | 'expired'`) set by `LoginWorkflow.credentials` when the
250
+ * forced-change branch fires. Downstream consumers consume it for
251
+ * analytics or per-tenant copy overrides; the bundled UX defaults come from
252
+ * `ctx.password.heading` + `ctx.password.intro` (set by each workflow's
253
+ * `create-password-form` / `set-password` step before the pause). The form's
254
+ * `@ui.form.fn.title` / `@ui.form.fn.description` annotations below render
255
+ * those ctx values directly, so the SPA gets context-aware copy out of the box.
256
+ *
257
+ * No alt-actions — the SetPasswordForm submit is mandatory; a user who
258
+ * wants to abandon the flow closes / refreshes the page (the wf state token
259
+ * expires per the engine's TTL).
191
260
  */
192
- @wf.context.pass 'passwordPolicies'
193
- @wf.context.pass 'passwordChangeReason'
194
- @wf.context.pass 'pendingConsents'
195
- @wf.context.pass 'consentsPersisted'
261
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.password?.heading || "Set your password"'
262
+ @wf.context.pass 'public'
196
263
  export interface SetPasswordForm extends WithInlineConsentForm {
264
+ /**
265
+ * Phantom intro paragraph — pairs with the form's dynamic
266
+ * `@ui.form.fn.title` to render context-aware copy. There is no
267
+ * top-level `@ui.form.fn.description` annotation in atscript-ui
268
+ * (`fn.description` is a per-field annotation), so the intro stays as
269
+ * a phantom field while the heading uses the proper type-level dynamic
270
+ * title. The field is hidden when `ctx.password.intro` is unset so
271
+ * a default "Set your password" pause renders without an empty paragraph.
272
+ */
273
+ @ui.form.order 5
274
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.password?.intro || ""'
275
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.password?.intro'
276
+ intro: ui.paragraph
277
+
197
278
  @ui.form.order 10
198
279
  @ui.form.type 'password'
199
280
  @meta.label 'New password'
@@ -210,12 +291,13 @@ export interface SetPasswordForm extends WithInlineConsentForm {
210
291
  @meta.sensitive
211
292
  @meta.required
212
293
  @expect.minLength 8
294
+ @ui.form.validate '(v, data) => v === data.newPassword || "Passwords must match"'
213
295
  confirmPassword: string
214
296
 
215
297
  /**
216
298
  * Phantom display field — `ui.paragraph` carries no submission value;
217
299
  * it exists purely so `AsPasswordRules` (registered on the SPA via
218
- * `<AsWfForm :components>`) renders one row per `ctx.passwordPolicies`
300
+ * `<AsWfForm :components>`) renders one row per `ctx.password.policies`
219
301
  * descriptor between the confirm-password input and the action buttons.
220
302
  *
221
303
  * The `policies` attr reads from workflow context (seeded by the
@@ -230,89 +312,47 @@ export interface SetPasswordForm extends WithInlineConsentForm {
230
312
  @ui.form.order 25
231
313
  @meta.label 'Password requirements'
232
314
  @ui.form.component 'AsPasswordRules'
233
- @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.passwordPolicies'
315
+ @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.public?.password?.policies'
234
316
  @ui.form.fn.attr 'password', '(_, data) => data.newPassword'
235
317
  @ui.form.grid.colSpan '12'
236
318
  passwordRules: ui.paragraph
237
-
238
- @ui.form.order 30
239
- @ui.form.action 'logout', 'Logout'
240
- logout?: ui.action
241
-
242
- @ui.form.order 40
243
- @ui.form.action 'cancel', 'Cancel'
244
- cancel?: ui.action
245
-
246
- @ui.form.order 50
247
- @ui.form.action 'backToLogin', 'Back to sign-in'
248
- backToLogin?: ui.action
249
319
  }
250
320
 
251
321
  /**
252
322
  * Invite form — used by an admin to send an invite magic link.
253
323
  *
254
- * `@wf.context.pass 'availableRoles'` whitelists the workflow ctx key so the
255
- * `inviteAdminInviteForm` step can pass the role-picker options into the
256
- * client form when `opts.getAvailableRoles` is wired.
324
+ * Bundled shape is intentionally minimal: email + roles. Admins who want to
325
+ * collect richer profile data per invitee replace this form via
326
+ * `setupAuthWorkflows({ forms: { invite: MyInviteForm } })` and map the
327
+ * extra fields into their user schema via the `prepareUser({...})` hook
328
+ * (see `InviteWorkflow.prepareUser` jsdoc).
329
+ *
330
+ * `@wf.context.pass 'public'` whitelists the workflow `ctx.admin` group so the
331
+ * `inviteAdminInviteForm` step can pass the role-picker options (via
332
+ * `ctx.admin.availableRoles`) into the client form when `opts.getAvailableRoles`
333
+ * is wired.
334
+ *
335
+ * No cancel alt-action: an admin who wants to back out navigates away from
336
+ * the page (the wf state token expires per the engine's TTL).
257
337
  */
258
- @wf.context.pass 'availableRoles'
338
+ @meta.label 'Send an invitation'
339
+ @meta.description 'Enter the recipient email address and pick the roles to grant on acceptance. They will receive a magic link to set their password.'
340
+ @wf.context.pass 'public'
259
341
  export interface InviteForm {
342
+ @ui.form.order 10
260
343
  @ui.form.type 'text'
261
344
  @meta.label 'Email'
262
345
  @ui.form.autocomplete 'email'
263
346
  @meta.required
264
347
  email: string.email
265
348
 
266
- @ui.form.type 'text'
267
- @meta.label 'First name'
268
- firstName?: string
269
-
270
- @ui.form.type 'text'
271
- @meta.label 'Last name'
272
- lastName?: string
273
-
274
349
  // UX: select-on-array currently renders single-text-per-item via AsArray;
275
350
  // dedicated multi-select widget tracked as atscript-ui follow-up.
351
+ @ui.form.order 20
276
352
  @ui.form.type 'select'
277
- @ui.form.fn.options '(_, _data, context) => Array.isArray(context.availableRoles) ? context.availableRoles.map(r => ({ key: r, label: r })) : []'
353
+ @ui.form.fn.options '(_, _data, context) => Array.isArray(context.public?.admin?.availableRoles) ? context.public.admin.availableRoles.map(r => ({ key: r, label: r })) : []'
278
354
  @meta.label 'Roles'
279
- roles?: string[]
280
-
281
- @ui.form.action 'cancel', 'Cancel'
282
- cancel?: ui.action
283
- }
284
-
285
- /**
286
- * Email-only form — used by `auth.reInvite` (loadPendingUser step) and
287
- * `auth.cancelInvite` (cancelInvite step). Separate from `EmailIdentifierForm`
288
- * so future invite-side tweaks don't ripple into the recovery form.
289
- */
290
- export interface InviteEmailForm {
291
- @ui.form.type 'text'
292
- @meta.label 'Email'
293
- @ui.form.autocomplete 'email'
294
- @meta.required
295
- email: string.email
296
-
297
- @ui.form.action 'cancel', 'Cancel'
298
- cancel?: ui.action
299
- }
300
-
301
- /**
302
- * Send-mode picker — rendered only when
303
- * `InviteWorkflowOptions.sendMode === 'choice'`. `mode` matches one of
304
- * `'email'` or `'shareableLink'`.
305
- */
306
- export interface InviteSendModeForm {
307
- @ui.form.type 'radio'
308
- @ui.form.options 'Email', 'email'
309
- @ui.form.options 'Shareable link', 'shareableLink'
310
- @meta.label 'Delivery mode'
311
- @meta.required
312
- mode: string
313
-
314
- @ui.form.action 'cancel', 'Cancel'
315
- cancel?: ui.action
355
+ roles: string[]
316
356
  }
317
357
 
318
358
  /**
@@ -322,14 +362,16 @@ export interface InviteSendModeForm {
322
362
  * `opts.mfaTransports` filtering. `methodName` is the `MfaMethod.name`
323
363
  * (e.g. `"totp"`, `"email"`, `"sms"`); the workflow itself validates that the
324
364
  * supplied value is in the user's enrolled set. The dropdown options are
325
- * built from `ctx.mfaEnrolledMethods` (a `MfaSummary[]` populated by
365
+ * built from `ctx.public.mfa.enrolledMethods` (a `MfaSummary[]` populated by
326
366
  * `prepareMfaOptions`) so the user only sees factors they actually have.
327
367
  */
328
- @wf.context.pass 'mfaBackupCodes'
329
- @wf.context.pass 'mfaEnrolledMethods'
368
+ @meta.label 'Choose a verification method'
369
+ @meta.description 'Pick how you would like to verify your identity.'
370
+ @wf.context.pass 'public'
330
371
  export interface Select2faForm {
372
+ @ui.form.order 10
331
373
  @ui.form.type 'radio'
332
- @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 })) : []'
374
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.mfa?.enrolledMethods) ? ctx.public.mfa.enrolledMethods.map(m => ({ key: m.methodName, label: m.kind === "totp" ? "TOTP (Authenticator app)" : m.kind === "email" ? "Email" : m.kind === "sms" ? "SMS" : m.kind })) : []'
333
375
  @meta.label 'MFA method'
334
376
  @meta.required
335
377
  methodName: string
@@ -339,9 +381,11 @@ export interface Select2faForm {
339
381
  @meta.default 'false'
340
382
  saveAsDefault: boolean
341
383
 
342
- @ui.form.action 'useBackupCode', 'Use backup code'
343
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
344
- useBackupCode?: ui.action
384
+ @ui.form.type 'checkbox'
385
+ @meta.label 'Remember this device'
386
+ @meta.default 'false'
387
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.trust?.optIn || !!ctx.public?.newPasswordRequired'
388
+ rememberDevice: boolean
345
389
  }
346
390
 
347
391
  /**
@@ -349,26 +393,35 @@ export interface Select2faForm {
349
393
  *
350
394
  * `rememberDevice` is rendered only when `opts.deviceTrust && opts.deviceTrustOptIn`.
351
395
  *
352
- * The leading `transportHint` paragraph reads `mfaMethod` + `pinSentTo` (the
353
- * masked recipient set by the pincode-send step) out of the workflow context
354
- * so the operator can see which factor the workflow is currently verifying.
355
- * Requires `installDynamicResolver()` from `@atscript/ui-fns` on the consumer
356
- * side; without it `@ui.form.fn.value` stays inert and the paragraph renders empty.
396
+ * The leading `transportHint` paragraph reads `mfa.method` + `pincode.sentTo`
397
+ * (the masked recipient set by the pincode-send step) out of the workflow
398
+ * context so the operator can see which factor the workflow is currently
399
+ * verifying. Requires `installDynamicResolver()` from `@atscript/ui-fns`
400
+ * on the consumer side; without it `@ui.form.fn.value` stays inert and
401
+ * the paragraph renders empty.
402
+ *
403
+ * **Resend cooldown contract.** `ctx.public.pincode.resendAllowedAt` is a wall-clock
404
+ * timestamp (`Date.now() + pincodeResendTimeoutMs`) after which the server
405
+ * will accept a `resend` action click. It rides the `@wf.context.pass
406
+ * 'pincode'` whitelist AND is mirrored onto the rendered resend element via
407
+ * `@ui.form.fn.attr 'available-at'` — customers can subscribe a custom
408
+ * action component via `<AsWfForm :components>` and drive a progress bar /
409
+ * countdown / disabled state straight off the DOM attribute (no need to
410
+ * re-derive the value from ctx). Server-side cooldown violations surface as
411
+ * a `formMessage` banner — never an inline `code` field error — so the user
412
+ * isn't told their (unsubmitted) code is wrong.
357
413
  */
358
- @wf.context.pass 'mfaMethod'
359
- @wf.context.pass 'pinSentTo'
360
- @wf.context.pass 'mfaMethodCount'
361
- @wf.context.pass 'mfaBackupCodes'
362
- @wf.context.pass 'deviceTrustOptIn'
363
- @wf.context.pass 'recoveryTransportCount'
414
+ @meta.label 'Enter the verification code'
415
+ @wf.context.pass 'public'
364
416
  @ui.form.submit.text 'Verify'
365
417
  export interface PincodeForm {
366
- @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."'
418
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfa?.method === "totp" ? "Enter the current 6-digit code from your authenticator app." : ctx.public?.pincode?.sentTo ? "Code sent to " + ctx.public.pincode.sentTo + "." : "Enter your verification code."'
367
419
  transportHint?: ui.paragraph
368
420
 
369
421
  @ui.form.type 'text'
370
422
  @meta.label 'Verification code'
371
423
  @ui.form.autocomplete 'one-time-code'
424
+ @ui.form.fn.attr 'maxlength', '(_, _d, ctx) => ctx.public?.pincode?.codeLength'
372
425
  @meta.required
373
426
  @expect.minLength 4
374
427
  @expect.maxLength 12
@@ -378,35 +431,43 @@ export interface PincodeForm {
378
431
  @ui.form.type 'checkbox'
379
432
  @meta.label 'Remember this device'
380
433
  @meta.default 'false'
381
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.deviceTrustOptIn'
434
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.trust?.optIn || !!ctx.public?.newPasswordRequired'
382
435
  rememberDevice: boolean
383
436
 
384
437
  @ui.form.action 'resend', 'Resend code'
438
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.pincode?.resendAllowedAt'
385
439
  resend?: ui.action
386
440
 
387
441
  @ui.form.action 'useDifferentMethod', 'Use a different method'
388
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.mfaMethodCount ?? 0) < 2'
442
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfa?.methodCount ?? 0) < 2'
389
443
  useDifferentMethod?: ui.action
390
444
 
391
- @ui.form.action 'useBackupCode', 'Use backup code'
392
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
393
- useBackupCode?: ui.action
394
-
395
- @ui.form.action 'backToLogin', 'Back to sign-in'
396
- backToLogin?: ui.action
397
-
398
445
  @ui.form.action 'useDifferentTransport', 'Use a different transport'
399
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.recoveryTransportCount ?? 0) < 2'
446
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.otp?.transportCount ?? 0) < 2'
400
447
  useDifferentTransport?: ui.action
401
448
  }
402
449
 
403
450
  /**
404
451
  * Email-only form for the `ask/email` enrollment step.
405
452
  */
406
- @wf.context.pass 'pendingConsents'
407
- @wf.context.pass 'consentsPersisted'
408
- @wf.context.pass 'otpDisclosure'
453
+ @meta.label 'Add your email address'
454
+ @meta.description 'We need a verified email to send security notifications and verification codes.'
455
+ @wf.context.pass 'public'
409
456
  export interface AskEmailForm extends WithInlineConsentForm {
457
+ /**
458
+ * Phantom disclosure paragraph staged by `resolveOtpDisclosure(ctx,
459
+ * 'email')` onto `ctx.channel.otpDisclosure`. Renders adjacent to the
460
+ * email input so the user reads the TCPA / PECR / CASL / GDPR-safe
461
+ * implied-consent copy BEFORE submitting the address. Hidden when the
462
+ * resolver returns an empty string so an override that wants to drop
463
+ * the disclosure (e.g. an enterprise tenant collecting explicit consent
464
+ * elsewhere) renders without an empty paragraph slot.
465
+ */
466
+ @ui.form.order 5
467
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.channel?.otpDisclosure || ""'
468
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.channel?.otpDisclosure'
469
+ disclosure: ui.paragraph
470
+
410
471
  @ui.form.order 10
411
472
  @ui.form.type 'text'
412
473
  @meta.label 'Email'
@@ -419,13 +480,19 @@ export interface AskEmailForm extends WithInlineConsentForm {
419
480
  * Phone-only form for the `ask/phone` enrollment step. Free-form text —
420
481
  * E.164 normalization happens server-side.
421
482
  */
422
- @wf.context.pass 'pendingConsents'
423
- @wf.context.pass 'consentsPersisted'
424
- @wf.context.pass 'otpDisclosure'
483
+ @meta.label 'Add your phone number'
484
+ @meta.description 'We need a verified phone to send security notifications and verification codes. Include your country code (for example +1 555 555 0100).'
485
+ @wf.context.pass 'public'
425
486
  export interface AskPhoneForm extends WithInlineConsentForm {
487
+ /** SMS-branch counterpart of `AskEmailForm.disclosure` — see that field. */
488
+ @ui.form.order 5
489
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.channel?.otpDisclosure || ""'
490
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.channel?.otpDisclosure'
491
+ disclosure: ui.paragraph
492
+
426
493
  @ui.form.order 10
427
494
  @ui.form.type 'text'
428
- @meta.label 'Phone (E.164)'
495
+ @meta.label 'Phone number'
429
496
  @ui.form.autocomplete 'tel'
430
497
  @meta.required
431
498
  phone: string
@@ -433,66 +500,89 @@ export interface AskPhoneForm extends WithInlineConsentForm {
433
500
 
434
501
  /**
435
502
  * Forced MFA enrollment — method picker for `mfa-enroll-required`. Options
436
- * come from `ctx.enrollAvailableTransports` so only consumer-enabled
503
+ * come from `ctx.public?.mfaEnroll?.availableTransports` so only consumer-enabled
437
504
  * transports appear.
438
505
  */
439
- @wf.context.pass 'enrollAvailableTransports'
440
- @wf.context.pass 'enrollMode'
506
+ @meta.label 'Set up two-factor authentication'
507
+ @meta.description 'Pick a method to receive your verification codes.'
508
+ @wf.context.pass 'public'
509
+ @ui.form.submit.text 'Continue'
441
510
  export interface EnrollPickMethodForm {
511
+ @ui.form.order 10
442
512
  @ui.form.type 'radio'
443
- @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 })) : []'
513
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.mfaEnroll?.availableTransports) ? ctx.public.mfaEnroll.availableTransports.map(t => ({ key: t, label: t === "totp" ? "Authenticator app (TOTP)" : t === "sms" ? "SMS" : t === "email" ? "Email" : t })) : []'
444
514
  @meta.label 'Choose a verification method'
445
515
  @meta.required
446
516
  method: string
447
517
 
518
+ // Login/invite opt-in only: a user who chose to defer MFA backs out here.
448
519
  @ui.form.action 'skip', 'Skip for now'
449
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
520
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
450
521
  skip?: ui.action
522
+
523
+ // Manage-MFA only: the user opened this on purpose, so "Skip" makes no
524
+ // sense — offer a clean cancel instead.
525
+ @ui.form.action 'cancel', 'Cancel'
526
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
527
+ cancel?: ui.action
451
528
  }
452
529
 
453
530
  /**
454
531
  * Forced MFA enrollment — address collection for sms/email. TOTP skips this
455
532
  * form (secret is provisioned server-side).
456
533
  *
457
- * `skip` is hidden unless `enrollMode === 'optional'` (`'required'` mode
534
+ * `skip` is hidden unless `mfaEnroll.mode === 'optional'` (`'required'` mode
458
535
  * forbids backing out mid-flow). `useDifferentMethod` is hidden when the
459
536
  * consumer has only one transport configured (nothing to switch to).
460
537
  */
461
- @wf.context.pass 'enrollMethod'
462
- @wf.context.pass 'enrollMode'
463
- @wf.context.pass 'enrollAvailableTransports'
538
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "sms" ? "Add your phone number" : "Add your email"'
539
+ @meta.description 'We will send you a one-time code to confirm.'
540
+ @wf.context.pass 'public'
541
+ @ui.form.submit.text 'Send code'
464
542
  export interface EnrollAddressForm {
543
+ @ui.form.order 10
465
544
  @ui.form.type 'text'
466
545
  @meta.label 'Address'
467
546
  @meta.required
547
+ // Client-side format hint — email branch must look like an email; the SMS
548
+ // branch stays free-form (server-side E.164 normalization). The robust
549
+ // check is server-side in the `enroll-address` step regardless of client.
550
+ @ui.form.validate '(v, _d, ctx) => ctx.public?.mfaEnroll?.method !== "email" || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) || "Enter a valid email address"'
468
551
  address: string
469
552
 
470
553
  @ui.form.action 'skip', 'Skip for now'
471
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
554
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
472
555
  skip?: ui.action
473
556
 
557
+ @ui.form.action 'cancel', 'Cancel'
558
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
559
+ cancel?: ui.action
560
+
474
561
  @ui.form.action 'useDifferentMethod', 'Use a different method'
475
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
562
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
476
563
  useDifferentMethod?: ui.action
477
564
  }
478
565
 
479
566
  /**
480
- * Forced MFA enrollment — confirm code, shared by all three transports. The
481
- * leading paragraph swaps between "scan this QR" (totp) and "code sent to …"
482
- * (sms/email) based on `enrollMethod`; `enrollSecret` / `enrollUri` are passed
483
- * for the totp QR + manual-entry fallback.
567
+ * MFA enrollment — confirm code, shared by all three transports.
568
+ *
569
+ * **TOTP branch.** The scannable QR + manual base32 secret are shown on the
570
+ * PRECEDING `enroll-totp-qr` step ({@link EnrollTotpQrForm}), so this form only
571
+ * collects the 6-digit code the authenticator generates — `transportHint`
572
+ * reminds the user to enter it.
573
+ *
574
+ * **SMS / email branch.** Single `transportHint` paragraph shows the masked
575
+ * recipient.
484
576
  */
485
- @wf.context.pass 'enrollMethod'
486
- @wf.context.pass 'enrollMode'
487
- @wf.context.pass 'enrollSecret'
488
- @wf.context.pass 'enrollUri'
489
- @wf.context.pass 'enrollAvailableTransports'
490
- @wf.context.pass 'pinSentTo'
577
+ @meta.label 'Confirm your verification code'
578
+ @wf.context.pass 'public'
491
579
  @ui.form.submit.text 'Confirm'
492
580
  export interface EnrollConfirmForm {
493
- @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."'
581
+ @ui.form.order 1
582
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp" ? "Enter the 6-digit code from your authenticator app." : ctx.public?.pincode?.sentTo ? "Code sent to " + ctx.public.pincode.sentTo + ". Enter it below to confirm." : "Enter the code to confirm enrollment."'
494
583
  transportHint?: ui.paragraph
495
584
 
585
+ @ui.form.order 10
496
586
  @ui.form.type 'text'
497
587
  @meta.label 'Code'
498
588
  @ui.form.autocomplete 'one-time-code'
@@ -503,96 +593,161 @@ export interface EnrollConfirmForm {
503
593
  code: string
504
594
 
505
595
  @ui.form.action 'resend', 'Resend code'
506
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMethod === "totp"'
596
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.pincode?.resendAllowedAt'
597
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp"'
507
598
  resend?: ui.action
508
599
 
509
600
  @ui.form.action 'useDifferentMethod', 'Use a different method'
510
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
601
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
511
602
  useDifferentMethod?: ui.action
512
603
 
604
+ @ui.form.action 'cancel', 'Cancel'
605
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
606
+ cancel?: ui.action
607
+
513
608
  @ui.form.action 'skip', 'Skip for now'
514
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
609
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
515
610
  skip?: ui.action
516
611
  }
517
612
 
518
613
  /**
519
- * Default minimal profile completion form. Consumers replace via
520
- * `LoginWorkflowOptions.profileCompleteForm` for richer shapes.
614
+ * MFA enrollment TOTP QR step. Shown on its OWN pause (the `enroll-totp-qr`
615
+ * step) BETWEEN method-pick and code-entry, so the user scans first and types
616
+ * the code on the next screen — instead of QR + input crowded on one form.
617
+ *
618
+ * `ctx.public.mfaEnroll.uri` carries the `otpauth://` URI (provisioned
619
+ * server-side). The `qrCode` field renders it as a scannable image via the
620
+ * `AsQrCode` component (`@atscript/vue-aooth`); `AsQrCode` also extracts the
621
+ * base32 secret from the URI and shows it for manual entry (its `manualSecret`
622
+ * prop defaults on), so users whose app lacks a scanner can still set up.
521
623
  */
522
- @wf.context.pass 'pendingConsents'
523
- @wf.context.pass 'consentsPersisted'
524
- export interface ProfileCompleteForm extends WithInlineConsentForm {
525
- @ui.form.order 10
526
- @ui.form.type 'text'
527
- @meta.label 'First name'
528
- firstName?: string
624
+ @meta.label 'Scan this QR code'
625
+ @meta.description 'Open your authenticator app and scan the code (or enter the key manually), then continue to enter the code it shows.'
626
+ @wf.context.pass 'public'
627
+ @ui.form.submit.text 'Continue'
628
+ export interface EnrollTotpQrForm {
629
+ @ui.form.order 5
630
+ @ui.form.component 'AsQrCode'
631
+ @ui.form.fn.attr 'size', '() => 180'
632
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.uri || ""'
633
+ qrCode: ui.paragraph
529
634
 
530
- @ui.form.order 20
531
- @ui.form.type 'text'
532
- @meta.label 'Last name'
533
- lastName?: string
534
- }
635
+ @ui.form.action 'useDifferentMethod', 'Use a different method'
636
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
637
+ useDifferentMethod?: ui.action
535
638
 
536
- /**
537
- * Standalone consent-bump prompt. Fires for returning users with pending
538
- * consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
539
- * who did NOT pass through any onboarding carrier form (`AskEmailForm` /
540
- * `AskPhoneForm` / `SetPasswordForm` / `ProfileCompleteForm`) on this login —
541
- * those carrier forms collect consents inline via `WithInlineConsentForm`'s
542
- * inherited `AsConsentArray` field. The bump-prompt only renders the same
543
- * inherited consent block (no additional fields).
544
- */
545
- @wf.context.pass 'pendingConsents'
546
- @wf.context.pass 'consentsPersisted'
547
- export interface TermsBumpForm extends WithInlineConsentForm {
639
+ @ui.form.action 'cancel', 'Cancel'
640
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
641
+ cancel?: ui.action
642
+
643
+ @ui.form.action 'skip', 'Skip for now'
644
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
645
+ skip?: ui.action
548
646
  }
549
647
 
550
648
  /**
551
- * Tenant picker`tenantId` matches one of `ctx.availableTenants[].id`.
552
- * Options are built from `ctx.availableTenants` (set by the workflow's
553
- * `tenant-select` step / `loadTenants` hook); `@wf.context.pass` whitelists
554
- * the key so it survives `extractPassContext`.
649
+ * Manage-MFA menuthe authenticated user's hub for the standalone
650
+ * `auth/add-mfa/flow` once they have ≥1 confirmed factor (shown after the
651
+ * step-up challenge). A single radio whose value encodes both action and
652
+ * target (`add:totp` / `replace:email` / `remove:sms`):
653
+ * - **Add** options come from `ctx.public.manage.candidates` (un-enrolled).
654
+ * - **Change / Remove** options come from `ctx.public.mfa.enrolledMethods`,
655
+ * with any transport in `ctx.public.manage.locked` omitted (a handle-bound
656
+ * factor the consumer forbids changing here — `lockedNote` explains why).
657
+ *
658
+ * A zero-MFA user never sees this form — the flow routes straight to the
659
+ * enrol picker (first-time opt-in).
555
660
  */
556
- @wf.context.pass 'availableTenants'
557
- export interface TenantSelectForm {
661
+ @meta.label 'Manage two-factor authentication'
662
+ @meta.description 'Add, change, or remove a verification method.'
663
+ @wf.context.pass 'public'
664
+ @ui.form.submit.text 'Continue'
665
+ export interface ManageMfaForm {
666
+ @ui.form.order 5
667
+ @ui.form.fn.value '(_, _d, ctx) => (ctx.public?.manage?.locked?.length ?? 0) > 0 ? "Some methods are also used to sign in and can’t be changed here." : ""'
668
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.manage?.locked?.length ?? 0) === 0'
669
+ lockedNote: ui.paragraph
670
+
671
+ @ui.form.order 10
558
672
  @ui.form.type 'radio'
559
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableTenants) ? ctx.availableTenants.map(t => ({ key: t.id, label: t.name })) : []'
560
- @meta.label 'Tenant'
673
+ @ui.form.fn.options '(_, _d, ctx) => { const lbl = (t) => t === "totp" ? "authenticator app" : t === "sms" ? "SMS" : t === "email" ? "email" : t; const locked = ctx.public?.manage?.locked ?? []; const out = []; for (const t of (ctx.public?.manage?.candidates ?? [])) out.push({ key: "add:" + t, label: "Add " + lbl(t) }); for (const m of (ctx.public?.mfa?.enrolledMethods ?? [])) { if (locked.includes(m.kind)) continue; out.push({ key: "replace:" + m.kind, label: "Change " + lbl(m.kind) + (m.masked ? " (" + m.masked + ")" : "") }); out.push({ key: "remove:" + m.kind, label: "Remove " + lbl(m.kind) }); } return out; }'
674
+ @meta.label 'What would you like to do?'
561
675
  @meta.required
562
- tenantId: string
676
+ operation: string
677
+
678
+ @ui.form.action 'cancel', 'Cancel'
679
+ cancel?: ui.action
563
680
  }
564
681
 
565
682
  /**
566
- * Persona picker `personaId` matches one of `ctx.availablePersonas[].id`.
567
- * Options are built from `ctx.availablePersonas` (set by the workflow's
568
- * `persona-select` step / `loadPersonas` hook); `@wf.context.pass` whitelists
569
- * the key so it survives `extractPassContext`.
683
+ * Manage-MFAconfirm removing a factor. Fieldless apart from the explanatory
684
+ * paragraph; the primary submit ('Remove') performs the removal, 'Cancel'
685
+ * backs out. `manage-menu` has already bound the target transport on
686
+ * `ctx.addMfa.target`; the description reads it back for the user.
570
687
  */
571
- @wf.context.pass 'availablePersonas'
572
- export interface PersonaSelectForm {
573
- @ui.form.type 'radio'
574
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availablePersonas) ? ctx.availablePersonas.map(p => ({ key: p.id, label: p.label })) : []'
575
- @meta.label 'Persona'
576
- @meta.required
577
- personaId: string
688
+ @meta.label 'Remove this method?'
689
+ @wf.context.pass 'public'
690
+ @ui.form.submit.text 'Remove'
691
+ export interface RemoveMfaConfirmForm {
692
+ @ui.form.order 1
693
+ @ui.form.fn.value '(_, _d, ctx) => { const t = ctx.public?.mfaEnroll?.method; const lbl = t === "totp" ? "your authenticator app" : t === "sms" ? "SMS codes" : t === "email" ? "email codes" : "this method"; return "Remove " + lbl + " as a two-factor method? You can set it up again later."; }'
694
+ notice: ui.paragraph
695
+
696
+ @ui.form.action 'cancel', 'Cancel'
697
+ cancel?: ui.action
578
698
  }
579
699
 
580
700
  /**
581
- * Concurrency-limit kick promptuser picks between logging out other
582
- * sessions or cancelling the login.
701
+ * Manage-MFA password re-auththe step-up FALLBACK rendered when the user's
702
+ * only confirmed factor(s) are of kinds the policy no longer allows, so nothing
703
+ * is MFA-challengeable (`ctx.addMfa.stepUpMode === "password"`). A single
704
+ * current-password field; the submit verifies it via `UserService.verifyPassword`
705
+ * and 'Cancel' backs out. See `AuthWorkflow.managePasswordReauth`.
583
706
  */
584
- export interface ConcurrencyLimitForm {
585
- @ui.form.type 'text'
586
- @meta.label 'Action'
707
+ @meta.label 'Confirm your password'
708
+ @meta.description 'Re-enter your account password to manage your two-factor methods.'
709
+ @wf.context.pass 'public'
710
+ @ui.form.submit.text 'Verify'
711
+ export interface PasswordReauthForm {
712
+ @ui.form.order 10
713
+ @ui.form.type 'password'
714
+ @meta.label 'Password'
715
+ @ui.form.autocomplete 'current-password'
716
+ @meta.sensitive
587
717
  @meta.required
588
- @expect.pattern '^(logoutOthers|cancel)$'
589
- action: string
718
+ @expect.minLength 1
719
+ password: string
590
720
 
591
721
  @ui.form.action 'cancel', 'Cancel'
592
722
  cancel?: ui.action
723
+ }
724
+
725
+ /**
726
+ * Standalone consent-bump prompt. Fires for returning users with pending
727
+ * consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
728
+ * who did NOT pass through any onboarding carrier form (`AskEmailForm` /
729
+ * `AskPhoneForm` / `SetPasswordForm`) on this login — those carrier forms
730
+ * collect consents inline via `WithInlineConsentForm`'s inherited
731
+ * `AsConsentArray` field. The bump-prompt only renders the same inherited
732
+ * consent block (no additional fields).
733
+ */
734
+ @meta.label 'Updated terms and policies'
735
+ @meta.description 'Please review and accept the updated terms to continue.'
736
+ @wf.context.pass 'public'
737
+ export interface TermsBumpForm extends WithInlineConsentForm {
738
+ }
593
739
 
594
- @ui.form.action 'logoutOthers', 'Log out other sessions'
595
- logoutOthers?: ui.action
740
+ /**
741
+ * Concurrency-limit kick prompt. Fieldless by design — just the explanatory
742
+ * paragraph plus the primary submit ('Login'): submitting logs out the user's
743
+ * other sessions and continues the login. No alt-action and no in-form cancel;
744
+ * the user backs out by navigating away (the wf state token expires per the
745
+ * engine's TTL).
746
+ */
747
+ @meta.label 'Session limit reached'
748
+ @meta.description 'You are already signed in elsewhere. Other sessions will be logged out if you proceed to log in.'
749
+ @ui.form.submit.text 'Login'
750
+ export interface ConcurrencyLimitForm {
596
751
  }
597
752
 
598
753
  /**
@@ -600,7 +755,10 @@ export interface ConcurrencyLimitForm {
600
755
  * {@link EmailIdentifierForm} but kept separate because future iterations may
601
756
  * accept either email or username.
602
757
  */
758
+ @meta.label 'Sign in with a magic link'
759
+ @meta.description 'Enter your account email or username and we will send you a one-time sign-in link.'
603
760
  export interface MagicLinkRequestForm {
761
+ @ui.form.order 10
604
762
  @ui.form.type 'text'
605
763
  @meta.label 'Email or username'
606
764
  @ui.form.autocomplete 'username'
@@ -612,7 +770,10 @@ export interface MagicLinkRequestForm {
612
770
  * Recovery delivery-mode picker — rendered only when
613
771
  * `RecoveryWorkflowOptions.deliveryMode === 'choice'`.
614
772
  */
773
+ @meta.label 'Choose how to verify'
774
+ @meta.description 'Pick how you would like to recover access.'
615
775
  export interface RecoveryModeSelectForm {
776
+ @ui.form.order 10
616
777
  @ui.form.type 'radio'
617
778
  @ui.form.options 'Magic link', 'magicLink'
618
779
  @ui.form.options 'One-time code', 'otp'
@@ -620,8 +781,6 @@ export interface RecoveryModeSelectForm {
620
781
  @meta.required
621
782
  mode: string
622
783
 
623
- @ui.form.action 'backToLogin', 'Back to sign-in'
624
- backToLogin?: ui.action
625
784
  }
626
785
 
627
786
  /**
@@ -629,25 +788,174 @@ export interface RecoveryModeSelectForm {
629
788
  * `RecoveryWorkflowOptions.requireKnownRecoveryFactor` is true. The user
630
789
  * picks a factor type and supplies its value; the server validates against
631
790
  * the enrolled factor (phone last-4 or current TOTP code). Options are
632
- * built from `ctx.availableRecoveryFactors` (workflow whitelist ∩ user's
633
- * enrolled factors), so users only see factors they can actually verify
634
- * AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
791
+ * built from `ctx.public.preReset.availableRecoveryFactors` (workflow whitelist ∩
792
+ * user's enrolled factors), so users only see factors they can actually
793
+ * verify AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
635
794
  */
636
- @wf.context.pass 'availableRecoveryFactors'
795
+ @meta.label 'Verify your identity'
796
+ @meta.description 'Confirm a detail we have on file before resetting your password.'
797
+ @wf.context.pass 'public'
637
798
  export interface RecoveryFactorForm {
799
+ @ui.form.order 10
638
800
  @ui.form.type 'radio'
639
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableRecoveryFactors) ? ctx.availableRecoveryFactors : []'
801
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.preReset?.availableRecoveryFactors) ? ctx.public.preReset.availableRecoveryFactors : []'
640
802
  @meta.label 'Factor'
641
803
  @meta.required
642
804
  factor: string
643
805
 
806
+ @ui.form.order 20
644
807
  @ui.form.type 'text'
645
808
  @meta.label 'Value'
646
809
  @meta.required
647
810
  @expect.minLength 4
648
811
  @expect.maxLength 12
649
812
  value: string
813
+ }
650
814
 
651
- @ui.form.action 'backToLogin', 'Back to sign-in'
652
- backToLogin?: ui.action
815
+ /**
816
+ * Authenticated "change my password" form — surfaced by the
817
+ * change-password.flow `change-password-form` step to a SIGNED-IN user.
818
+ *
819
+ * Standalone (no `extends WithInlineConsentForm`) — a self-service password
820
+ * change carries no consent capture, unlike `SetPasswordForm`. The leading
821
+ * `currentPassword` field is the PRIMARY protection for this flow
822
+ * (re-authentication per OWASP ASVS 6.2.3) — `UserService.changePassword`
823
+ * verifies it before applying policy + history checks server-side.
824
+ *
825
+ * Reuses the same `ctx.public.password.{heading,intro,policies}` surface as
826
+ * `SetPasswordForm`, so the live `AsPasswordRules` renderer and dynamic copy
827
+ * work identically. Heading/intro are staged by `change-password-form`.
828
+ */
829
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.password?.heading || "Change your password"'
830
+ @ui.form.submit.text 'Change password'
831
+ @wf.context.pass 'public'
832
+ export interface ChangePasswordForm {
833
+ @ui.form.order 5
834
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.password?.intro || ""'
835
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.password?.intro'
836
+ @ui.form.grid.colSpan '12'
837
+ intro: ui.paragraph
838
+
839
+ @ui.form.order 8
840
+ @ui.form.type 'password'
841
+ @meta.label 'Current password'
842
+ @ui.form.autocomplete 'current-password'
843
+ @meta.sensitive
844
+ @meta.required
845
+ currentPassword: string
846
+
847
+ @ui.form.order 10
848
+ @ui.form.type 'password'
849
+ @meta.label 'New password'
850
+ @ui.form.autocomplete 'new-password'
851
+ @meta.sensitive
852
+ @meta.required
853
+ @expect.minLength 8
854
+ newPassword: string
855
+
856
+ @ui.form.order 20
857
+ @ui.form.type 'password'
858
+ @meta.label 'Confirm new password'
859
+ @ui.form.autocomplete 'new-password'
860
+ @meta.sensitive
861
+ @meta.required
862
+ @expect.minLength 8
863
+ @ui.form.validate '(v, data) => v === data.newPassword || "Passwords must match"'
864
+ confirmPassword: string
865
+
866
+ @ui.form.order 25
867
+ @meta.label 'Password requirements'
868
+ @ui.form.component 'AsPasswordRules'
869
+ @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.public?.password?.policies'
870
+ @ui.form.fn.attr 'password', '(_, data) => data.newPassword'
871
+ @ui.form.grid.colSpan '12'
872
+ passwordRules: ui.paragraph
873
+ }
874
+
875
+ /**
876
+ * Prove control of an EXISTING local account before a federated identity is
877
+ * attached to it — the interactive completion of `FederatedLoginService`'s
878
+ * `needs-link` outcome (a verified provider profile whose email matches an
879
+ * existing account under the default `require-interactive-link` policy). The
880
+ * PASSWORD variant: the matched account has a real password, so the user
881
+ * re-enters it to prove ownership.
882
+ *
883
+ * The `prove-control` @Step binds the username to the matched account
884
+ * server-side (the user never types it) and verifies via `UserService.login`,
885
+ * so this form collects only the password. `intro` renders the masked account
886
+ * hint off `ctx.public.proveControl.hint` ("…account for a***@x.com…") — a
887
+ * deliberate, BOUNDED account-existence disclosure (surfacing the candidate is
888
+ * the whole point of `needs-link`). A wrong password re-pauses with a generic
889
+ * inline error; the `cancel` action abandons the link (no account created, no
890
+ * session issued, generic terminal).
891
+ *
892
+ * Override via `setupAuthWorkflows({ forms: { proveControl: MyForm } })`.
893
+ */
894
+ @meta.label 'Confirm your identity'
895
+ @wf.context.pass 'public'
896
+ @ui.form.submit.text 'Verify and link'
897
+ export interface ProveControlForm {
898
+ @ui.form.order 5
899
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.proveControl?.hint ? "An account for " + ctx.public.proveControl.hint + " already exists. Enter its password to link this sign-in method." : "Enter your existing account password to link this sign-in method."'
900
+ intro: ui.paragraph
901
+
902
+ @ui.form.order 10
903
+ @ui.form.type 'password'
904
+ @meta.label 'Password'
905
+ @ui.form.autocomplete 'current-password'
906
+ @meta.sensitive
907
+ @meta.required
908
+ @expect.minLength 1
909
+ password: string
910
+
911
+ @ui.form.order 20
912
+ @ui.form.action 'cancel', 'Cancel'
913
+ @ui.form.attr 'align', 'center'
914
+ @ui.form.pushDown
915
+ cancel?: ui.action
916
+ }
917
+
918
+ /**
919
+ * OTP FALLBACK of the `needs-link` completion — used when the matched account
920
+ * is passwordless (`password.isInitial`), so there is no password to re-enter.
921
+ * The `prove-control` @Step mints a one-time code and delivers it to the
922
+ * account's OWN confirmed email/SMS channel (NEVER the provider-supplied
923
+ * address — that would be circular, since the attacker controls the provider
924
+ * account), then this form collects the code. `intro` shows the masked
925
+ * delivery target off `ctx.public.proveControl.sentTo`.
926
+ *
927
+ * Override via `setupAuthWorkflows({ forms: { proveControlOtp: MyForm } })`.
928
+ */
929
+ @meta.label 'Confirm your identity'
930
+ @wf.context.pass 'public'
931
+ @ui.form.submit.text 'Verify and link'
932
+ export interface ProveControlOtpForm {
933
+ @ui.form.order 5
934
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.proveControl?.sentTo ? "We sent a verification code to " + ctx.public.proveControl.sentTo + ". Enter it to link this sign-in method to your existing account." : "Enter the verification code to link this sign-in method to your existing account."'
935
+ intro: ui.paragraph
936
+
937
+ @ui.form.order 10
938
+ @ui.form.type 'text'
939
+ @meta.label 'Verification code'
940
+ @ui.form.autocomplete 'one-time-code'
941
+ @meta.required
942
+ @expect.minLength 4
943
+ @expect.maxLength 12
944
+ @expect.pattern '^[0-9]+$'
945
+ code: string
946
+
947
+ // Resend the OTP proof code to the SAME own channel — mirrors PincodeForm's
948
+ // resend. `available-at` binds the server-armed cooldown so the renderer can
949
+ // disable / count down the button; the `prove-control` @Step also gates it
950
+ // server-side (a too-soon resend re-pauses with a "Please wait Ns" message).
951
+ @ui.form.order 15
952
+ @ui.form.action 'resend', 'Resend code'
953
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.proveControl?.resendAllowedAt'
954
+ resend?: ui.action
955
+
956
+ @ui.form.order 20
957
+ @ui.form.action 'cancel', 'Cancel'
958
+ @ui.form.attr 'align', 'center'
959
+ @ui.form.pushDown
960
+ cancel?: ui.action
653
961
  }