@aooth/auth-moost 0.1.7 → 0.1.8

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,20 +500,22 @@ 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'
441
509
  export interface EnrollPickMethodForm {
510
+ @ui.form.order 10
442
511
  @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 })) : []'
512
+ @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
513
  @meta.label 'Choose a verification method'
445
514
  @meta.required
446
515
  method: string
447
516
 
448
517
  @ui.form.action 'skip', 'Skip for now'
449
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
518
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
450
519
  skip?: ui.action
451
520
  }
452
521
 
@@ -454,45 +523,67 @@ export interface EnrollPickMethodForm {
454
523
  * Forced MFA enrollment — address collection for sms/email. TOTP skips this
455
524
  * form (secret is provisioned server-side).
456
525
  *
457
- * `skip` is hidden unless `enrollMode === 'optional'` (`'required'` mode
526
+ * `skip` is hidden unless `mfaEnroll.mode === 'optional'` (`'required'` mode
458
527
  * forbids backing out mid-flow). `useDifferentMethod` is hidden when the
459
528
  * consumer has only one transport configured (nothing to switch to).
460
529
  */
461
- @wf.context.pass 'enrollMethod'
462
- @wf.context.pass 'enrollMode'
463
- @wf.context.pass 'enrollAvailableTransports'
530
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "sms" ? "Add your phone number" : "Add your email"'
531
+ @meta.description 'We will send you a one-time code to confirm.'
532
+ @wf.context.pass 'public'
464
533
  export interface EnrollAddressForm {
534
+ @ui.form.order 10
465
535
  @ui.form.type 'text'
466
536
  @meta.label 'Address'
467
537
  @meta.required
468
538
  address: string
469
539
 
470
540
  @ui.form.action 'skip', 'Skip for now'
471
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
541
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
472
542
  skip?: ui.action
473
543
 
474
544
  @ui.form.action 'useDifferentMethod', 'Use a different method'
475
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
545
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2'
476
546
  useDifferentMethod?: ui.action
477
547
  }
478
548
 
479
549
  /**
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.
550
+ * Forced MFA enrollment — confirm code, shared by all three transports.
551
+ *
552
+ * **TOTP branch.** `ctx.public.mfaEnroll.secret` holds the base32 secret and
553
+ * `ctx.public.mfaEnroll.uri` the `otpauth://` URI both are produced server-side by
554
+ * `setup-mfa-method` and ride the `@wf.context.pass 'public'` whitelist.
555
+ * The `qrCode` field renders the `otpauth://` URI as a scannable QR image via
556
+ * the `AsQrCode` component (from `@atscript/vue-aooth`, registered on
557
+ * `<AsWfForm :components>`); its `@ui.form.fn.value` exposes the URI string the
558
+ * component consumes. `AsQrCode` ALSO extracts the base32 secret from the URI
559
+ * and renders it for manual entry (its `manualSecret` prop defaults on), so
560
+ * users whose authenticator app lacks a QR scanner can still set up.
561
+ *
562
+ * **SMS / email branch.** Single `transportHint` paragraph shows the masked
563
+ * recipient.
484
564
  */
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'
565
+ @meta.label 'Confirm your verification code'
566
+ @wf.context.pass 'public'
491
567
  @ui.form.submit.text 'Confirm'
492
568
  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."'
569
+ @ui.form.order 1
570
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp" ? "Add the account to your authenticator app, then enter the 6-digit code it generates." : ctx.public?.pincode?.sentTo ? "Code sent to " + ctx.public.pincode.sentTo + ". Enter it below to confirm." : "Enter the code to confirm enrollment."'
494
571
  transportHint?: ui.paragraph
495
572
 
573
+ /**
574
+ * Phantom field — `otpauth://` URI rendered as a scannable QR image by the
575
+ * `AsQrCode` component. `AsQrCode` also extracts the base32 secret from the
576
+ * URI and shows it for manual entry (its `manualSecret` prop defaults on),
577
+ * so there is no separate manual-secret field.
578
+ */
579
+ @ui.form.order 5
580
+ @ui.form.component 'AsQrCode'
581
+ @ui.form.fn.attr 'size', '() => 180'
582
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.uri || ""'
583
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.method !== "totp" || !ctx.public?.mfaEnroll?.uri'
584
+ qrCode: ui.paragraph
585
+
586
+ @ui.form.order 10
496
587
  @ui.form.type 'text'
497
588
  @meta.label 'Code'
498
589
  @ui.form.autocomplete 'one-time-code'
@@ -503,96 +594,45 @@ export interface EnrollConfirmForm {
503
594
  code: string
504
595
 
505
596
  @ui.form.action 'resend', 'Resend code'
506
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMethod === "totp"'
597
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.pincode?.resendAllowedAt'
598
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp"'
507
599
  resend?: ui.action
508
600
 
509
601
  @ui.form.action 'useDifferentMethod', 'Use a different method'
510
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.enrollAvailableTransports?.length ?? 0) < 2'
602
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2'
511
603
  useDifferentMethod?: ui.action
512
604
 
513
605
  @ui.form.action 'skip', 'Skip for now'
514
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
606
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
515
607
  skip?: ui.action
516
608
  }
517
609
 
518
- /**
519
- * Default minimal profile completion form. Consumers replace via
520
- * `LoginWorkflowOptions.profileCompleteForm` for richer shapes.
521
- */
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
529
-
530
- @ui.form.order 20
531
- @ui.form.type 'text'
532
- @meta.label 'Last name'
533
- lastName?: string
534
- }
535
-
536
610
  /**
537
611
  * Standalone consent-bump prompt. Fires for returning users with pending
538
612
  * consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
539
613
  * 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).
614
+ * `AskPhoneForm` / `SetPasswordForm`) on this login — those carrier forms
615
+ * collect consents inline via `WithInlineConsentForm`'s inherited
616
+ * `AsConsentArray` field. The bump-prompt only renders the same inherited
617
+ * consent block (no additional fields).
544
618
  */
545
- @wf.context.pass 'pendingConsents'
546
- @wf.context.pass 'consentsPersisted'
619
+ @meta.label 'Updated terms and policies'
620
+ @meta.description 'Please review and accept the updated terms to continue.'
621
+ @wf.context.pass 'public'
547
622
  export interface TermsBumpForm extends WithInlineConsentForm {
548
623
  }
549
624
 
550
625
  /**
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`.
555
- */
556
- @wf.context.pass 'availableTenants'
557
- export interface TenantSelectForm {
558
- @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'
561
- @meta.required
562
- tenantId: string
563
- }
564
-
565
- /**
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`.
570
- */
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
578
- }
579
-
580
- /**
581
- * Concurrency-limit kick prompt — user picks between logging out other
582
- * sessions or cancelling the login.
626
+ * Concurrency-limit kick prompt. Fieldless by design just the explanatory
627
+ * paragraph plus the primary submit ('Login'): submitting logs out the user's
628
+ * other sessions and continues the login. No alt-action and no in-form cancel;
629
+ * the user backs out by navigating away (the wf state token expires per the
630
+ * engine's TTL).
583
631
  */
632
+ @meta.label 'Session limit reached'
633
+ @meta.description 'You are already signed in elsewhere. Other sessions will be logged out if you proceed to log in.'
634
+ @ui.form.submit.text 'Login'
584
635
  export interface ConcurrencyLimitForm {
585
- @ui.form.type 'text'
586
- @meta.label 'Action'
587
- @meta.required
588
- @expect.pattern '^(logoutOthers|cancel)$'
589
- action: string
590
-
591
- @ui.form.action 'cancel', 'Cancel'
592
- cancel?: ui.action
593
-
594
- @ui.form.action 'logoutOthers', 'Log out other sessions'
595
- logoutOthers?: ui.action
596
636
  }
597
637
 
598
638
  /**
@@ -600,7 +640,10 @@ export interface ConcurrencyLimitForm {
600
640
  * {@link EmailIdentifierForm} but kept separate because future iterations may
601
641
  * accept either email or username.
602
642
  */
643
+ @meta.label 'Sign in with a magic link'
644
+ @meta.description 'Enter your account email or username and we will send you a one-time sign-in link.'
603
645
  export interface MagicLinkRequestForm {
646
+ @ui.form.order 10
604
647
  @ui.form.type 'text'
605
648
  @meta.label 'Email or username'
606
649
  @ui.form.autocomplete 'username'
@@ -612,7 +655,10 @@ export interface MagicLinkRequestForm {
612
655
  * Recovery delivery-mode picker — rendered only when
613
656
  * `RecoveryWorkflowOptions.deliveryMode === 'choice'`.
614
657
  */
658
+ @meta.label 'Choose how to verify'
659
+ @meta.description 'Pick how you would like to recover access.'
615
660
  export interface RecoveryModeSelectForm {
661
+ @ui.form.order 10
616
662
  @ui.form.type 'radio'
617
663
  @ui.form.options 'Magic link', 'magicLink'
618
664
  @ui.form.options 'One-time code', 'otp'
@@ -620,8 +666,6 @@ export interface RecoveryModeSelectForm {
620
666
  @meta.required
621
667
  mode: string
622
668
 
623
- @ui.form.action 'backToLogin', 'Back to sign-in'
624
- backToLogin?: ui.action
625
669
  }
626
670
 
627
671
  /**
@@ -629,25 +673,174 @@ export interface RecoveryModeSelectForm {
629
673
  * `RecoveryWorkflowOptions.requireKnownRecoveryFactor` is true. The user
630
674
  * picks a factor type and supplies its value; the server validates against
631
675
  * 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`.
676
+ * built from `ctx.public.preReset.availableRecoveryFactors` (workflow whitelist ∩
677
+ * user's enrolled factors), so users only see factors they can actually
678
+ * verify AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
635
679
  */
636
- @wf.context.pass 'availableRecoveryFactors'
680
+ @meta.label 'Verify your identity'
681
+ @meta.description 'Confirm a detail we have on file before resetting your password.'
682
+ @wf.context.pass 'public'
637
683
  export interface RecoveryFactorForm {
684
+ @ui.form.order 10
638
685
  @ui.form.type 'radio'
639
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableRecoveryFactors) ? ctx.availableRecoveryFactors : []'
686
+ @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.preReset?.availableRecoveryFactors) ? ctx.public.preReset.availableRecoveryFactors : []'
640
687
  @meta.label 'Factor'
641
688
  @meta.required
642
689
  factor: string
643
690
 
691
+ @ui.form.order 20
644
692
  @ui.form.type 'text'
645
693
  @meta.label 'Value'
646
694
  @meta.required
647
695
  @expect.minLength 4
648
696
  @expect.maxLength 12
649
697
  value: string
698
+ }
650
699
 
651
- @ui.form.action 'backToLogin', 'Back to sign-in'
652
- backToLogin?: ui.action
700
+ /**
701
+ * Authenticated "change my password" form — surfaced by the
702
+ * change-password.flow `change-password-form` step to a SIGNED-IN user.
703
+ *
704
+ * Standalone (no `extends WithInlineConsentForm`) — a self-service password
705
+ * change carries no consent capture, unlike `SetPasswordForm`. The leading
706
+ * `currentPassword` field is the PRIMARY protection for this flow
707
+ * (re-authentication per OWASP ASVS 6.2.3) — `UserService.changePassword`
708
+ * verifies it before applying policy + history checks server-side.
709
+ *
710
+ * Reuses the same `ctx.public.password.{heading,intro,policies}` surface as
711
+ * `SetPasswordForm`, so the live `AsPasswordRules` renderer and dynamic copy
712
+ * work identically. Heading/intro are staged by `change-password-form`.
713
+ */
714
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.password?.heading || "Change your password"'
715
+ @ui.form.submit.text 'Change password'
716
+ @wf.context.pass 'public'
717
+ export interface ChangePasswordForm {
718
+ @ui.form.order 5
719
+ @ui.form.fn.value '(_, _d, ctx) => ctx.public?.password?.intro || ""'
720
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.password?.intro'
721
+ @ui.form.grid.colSpan '12'
722
+ intro: ui.paragraph
723
+
724
+ @ui.form.order 8
725
+ @ui.form.type 'password'
726
+ @meta.label 'Current password'
727
+ @ui.form.autocomplete 'current-password'
728
+ @meta.sensitive
729
+ @meta.required
730
+ currentPassword: string
731
+
732
+ @ui.form.order 10
733
+ @ui.form.type 'password'
734
+ @meta.label 'New password'
735
+ @ui.form.autocomplete 'new-password'
736
+ @meta.sensitive
737
+ @meta.required
738
+ @expect.minLength 8
739
+ newPassword: string
740
+
741
+ @ui.form.order 20
742
+ @ui.form.type 'password'
743
+ @meta.label 'Confirm new password'
744
+ @ui.form.autocomplete 'new-password'
745
+ @meta.sensitive
746
+ @meta.required
747
+ @expect.minLength 8
748
+ @ui.form.validate '(v, data) => v === data.newPassword || "Passwords must match"'
749
+ confirmPassword: string
750
+
751
+ @ui.form.order 25
752
+ @meta.label 'Password requirements'
753
+ @ui.form.component 'AsPasswordRules'
754
+ @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.public?.password?.policies'
755
+ @ui.form.fn.attr 'password', '(_, data) => data.newPassword'
756
+ @ui.form.grid.colSpan '12'
757
+ passwordRules: ui.paragraph
758
+ }
759
+
760
+ /**
761
+ * Prove control of an EXISTING local account before a federated identity is
762
+ * attached to it — the interactive completion of `FederatedLoginService`'s
763
+ * `needs-link` outcome (a verified provider profile whose email matches an
764
+ * existing account under the default `require-interactive-link` policy). The
765
+ * PASSWORD variant: the matched account has a real password, so the user
766
+ * re-enters it to prove ownership.
767
+ *
768
+ * The `prove-control` @Step binds the username to the matched account
769
+ * server-side (the user never types it) and verifies via `UserService.login`,
770
+ * so this form collects only the password. `intro` renders the masked account
771
+ * hint off `ctx.public.proveControl.hint` ("…account for a***@x.com…") — a
772
+ * deliberate, BOUNDED account-existence disclosure (surfacing the candidate is
773
+ * the whole point of `needs-link`). A wrong password re-pauses with a generic
774
+ * inline error; the `cancel` action abandons the link (no account created, no
775
+ * session issued, generic terminal).
776
+ *
777
+ * Override via `setupAuthWorkflows({ forms: { proveControl: MyForm } })`.
778
+ */
779
+ @meta.label 'Confirm your identity'
780
+ @wf.context.pass 'public'
781
+ @ui.form.submit.text 'Verify and link'
782
+ export interface ProveControlForm {
783
+ @ui.form.order 5
784
+ @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."'
785
+ intro: ui.paragraph
786
+
787
+ @ui.form.order 10
788
+ @ui.form.type 'password'
789
+ @meta.label 'Password'
790
+ @ui.form.autocomplete 'current-password'
791
+ @meta.sensitive
792
+ @meta.required
793
+ @expect.minLength 1
794
+ password: string
795
+
796
+ @ui.form.order 20
797
+ @ui.form.action 'cancel', 'Cancel'
798
+ @ui.form.attr 'align', 'center'
799
+ @ui.form.pushDown
800
+ cancel?: ui.action
801
+ }
802
+
803
+ /**
804
+ * OTP FALLBACK of the `needs-link` completion — used when the matched account
805
+ * is passwordless (`password.isInitial`), so there is no password to re-enter.
806
+ * The `prove-control` @Step mints a one-time code and delivers it to the
807
+ * account's OWN confirmed email/SMS channel (NEVER the provider-supplied
808
+ * address — that would be circular, since the attacker controls the provider
809
+ * account), then this form collects the code. `intro` shows the masked
810
+ * delivery target off `ctx.public.proveControl.sentTo`.
811
+ *
812
+ * Override via `setupAuthWorkflows({ forms: { proveControlOtp: MyForm } })`.
813
+ */
814
+ @meta.label 'Confirm your identity'
815
+ @wf.context.pass 'public'
816
+ @ui.form.submit.text 'Verify and link'
817
+ export interface ProveControlOtpForm {
818
+ @ui.form.order 5
819
+ @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."'
820
+ intro: ui.paragraph
821
+
822
+ @ui.form.order 10
823
+ @ui.form.type 'text'
824
+ @meta.label 'Verification code'
825
+ @ui.form.autocomplete 'one-time-code'
826
+ @meta.required
827
+ @expect.minLength 4
828
+ @expect.maxLength 12
829
+ @expect.pattern '^[0-9]+$'
830
+ code: string
831
+
832
+ // Resend the OTP proof code to the SAME own channel — mirrors PincodeForm's
833
+ // resend. `available-at` binds the server-armed cooldown so the renderer can
834
+ // disable / count down the button; the `prove-control` @Step also gates it
835
+ // server-side (a too-soon resend re-pauses with a "Please wait Ns" message).
836
+ @ui.form.order 15
837
+ @ui.form.action 'resend', 'Resend code'
838
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.proveControl?.resendAllowedAt'
839
+ resend?: ui.action
840
+
841
+ @ui.form.order 20
842
+ @ui.form.action 'cancel', 'Cancel'
843
+ @ui.form.attr 'align', 'center'
844
+ @ui.form.pushDown
845
+ cancel?: ui.action
653
846
  }