@aooth/auth-moost 0.1.6 → 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,79 +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).
247
+ *
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).
184
260
  */
185
- @wf.context.pass 'passwordPolicies'
186
- @wf.context.pass 'pendingConsents'
187
- @wf.context.pass 'consentsPersisted'
261
+ @ui.form.fn.title '(_, _d, ctx) => ctx.public?.password?.heading || "Set your password"'
262
+ @wf.context.pass 'public'
188
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
+
189
278
  @ui.form.order 10
190
279
  @ui.form.type 'password'
191
280
  @meta.label 'New password'
@@ -202,12 +291,13 @@ export interface SetPasswordForm extends WithInlineConsentForm {
202
291
  @meta.sensitive
203
292
  @meta.required
204
293
  @expect.minLength 8
294
+ @ui.form.validate '(v, data) => v === data.newPassword || "Passwords must match"'
205
295
  confirmPassword: string
206
296
 
207
297
  /**
208
298
  * Phantom display field — `ui.paragraph` carries no submission value;
209
299
  * it exists purely so `AsPasswordRules` (registered on the SPA via
210
- * `<AsWfForm :components>`) renders one row per `ctx.passwordPolicies`
300
+ * `<AsWfForm :components>`) renders one row per `ctx.password.policies`
211
301
  * descriptor between the confirm-password input and the action buttons.
212
302
  *
213
303
  * The `policies` attr reads from workflow context (seeded by the
@@ -222,89 +312,47 @@ export interface SetPasswordForm extends WithInlineConsentForm {
222
312
  @ui.form.order 25
223
313
  @meta.label 'Password requirements'
224
314
  @ui.form.component 'AsPasswordRules'
225
- @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.passwordPolicies'
315
+ @ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.public?.password?.policies'
226
316
  @ui.form.fn.attr 'password', '(_, data) => data.newPassword'
227
317
  @ui.form.grid.colSpan '12'
228
318
  passwordRules: ui.paragraph
229
-
230
- @ui.form.order 30
231
- @ui.form.action 'logout', 'Logout'
232
- logout?: ui.action
233
-
234
- @ui.form.order 40
235
- @ui.form.action 'cancel', 'Cancel'
236
- cancel?: ui.action
237
-
238
- @ui.form.order 50
239
- @ui.form.action 'backToLogin', 'Back to sign-in'
240
- backToLogin?: ui.action
241
319
  }
242
320
 
243
321
  /**
244
322
  * Invite form — used by an admin to send an invite magic link.
245
323
  *
246
- * `@wf.context.pass 'availableRoles'` whitelists the workflow ctx key so the
247
- * `inviteAdminInviteForm` step can pass the role-picker options into the
248
- * 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).
249
337
  */
250
- @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'
251
341
  export interface InviteForm {
342
+ @ui.form.order 10
252
343
  @ui.form.type 'text'
253
344
  @meta.label 'Email'
254
345
  @ui.form.autocomplete 'email'
255
346
  @meta.required
256
347
  email: string.email
257
348
 
258
- @ui.form.type 'text'
259
- @meta.label 'First name'
260
- firstName?: string
261
-
262
- @ui.form.type 'text'
263
- @meta.label 'Last name'
264
- lastName?: string
265
-
266
349
  // UX: select-on-array currently renders single-text-per-item via AsArray;
267
350
  // dedicated multi-select widget tracked as atscript-ui follow-up.
351
+ @ui.form.order 20
268
352
  @ui.form.type 'select'
269
- @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 })) : []'
270
354
  @meta.label 'Roles'
271
- roles?: string[]
272
-
273
- @ui.form.action 'cancel', 'Cancel'
274
- cancel?: ui.action
275
- }
276
-
277
- /**
278
- * Email-only form — used by `auth.reInvite` (loadPendingUser step) and
279
- * `auth.cancelInvite` (cancelInvite step). Separate from `EmailIdentifierForm`
280
- * so future invite-side tweaks don't ripple into the recovery form.
281
- */
282
- export interface InviteEmailForm {
283
- @ui.form.type 'text'
284
- @meta.label 'Email'
285
- @ui.form.autocomplete 'email'
286
- @meta.required
287
- email: string.email
288
-
289
- @ui.form.action 'cancel', 'Cancel'
290
- cancel?: ui.action
291
- }
292
-
293
- /**
294
- * Send-mode picker — rendered only when
295
- * `InviteWorkflowOptions.sendMode === 'choice'`. `mode` matches one of
296
- * `'email'` or `'shareableLink'`.
297
- */
298
- export interface InviteSendModeForm {
299
- @ui.form.type 'radio'
300
- @ui.form.options 'Email', 'email'
301
- @ui.form.options 'Shareable link', 'shareableLink'
302
- @meta.label 'Delivery mode'
303
- @meta.required
304
- mode: string
305
-
306
- @ui.form.action 'cancel', 'Cancel'
307
- cancel?: ui.action
355
+ roles: string[]
308
356
  }
309
357
 
310
358
  /**
@@ -314,14 +362,16 @@ export interface InviteSendModeForm {
314
362
  * `opts.mfaTransports` filtering. `methodName` is the `MfaMethod.name`
315
363
  * (e.g. `"totp"`, `"email"`, `"sms"`); the workflow itself validates that the
316
364
  * supplied value is in the user's enrolled set. The dropdown options are
317
- * built from `ctx.mfaEnrolledMethods` (a `MfaSummary[]` populated by
365
+ * built from `ctx.public.mfa.enrolledMethods` (a `MfaSummary[]` populated by
318
366
  * `prepareMfaOptions`) so the user only sees factors they actually have.
319
367
  */
320
- @wf.context.pass 'mfaBackupCodes'
321
- @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'
322
371
  export interface Select2faForm {
372
+ @ui.form.order 10
323
373
  @ui.form.type 'radio'
324
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.mfaEnrolledMethods) ? ctx.mfaEnrolledMethods.map(m => ({ key: m.methodName, label: m.kind === "totp" ? "TOTP (Authenticator app)" : m.kind === "email" ? "Email" : m.kind === "sms" ? "SMS" : m.kind })) : []'
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 })) : []'
325
375
  @meta.label 'MFA method'
326
376
  @meta.required
327
377
  methodName: string
@@ -331,9 +381,11 @@ export interface Select2faForm {
331
381
  @meta.default 'false'
332
382
  saveAsDefault: boolean
333
383
 
334
- @ui.form.action 'useBackupCode', 'Use backup code'
335
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
336
- 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
337
389
  }
338
390
 
339
391
  /**
@@ -341,26 +393,35 @@ export interface Select2faForm {
341
393
  *
342
394
  * `rememberDevice` is rendered only when `opts.deviceTrust && opts.deviceTrustOptIn`.
343
395
  *
344
- * The leading `transportHint` paragraph reads `mfaMethod` + `pinSentTo` (the
345
- * masked recipient set by the pincode-send step) out of the workflow context
346
- * so the operator can see which factor the workflow is currently verifying.
347
- * Requires `installDynamicResolver()` from `@atscript/ui-fns` on the consumer
348
- * side; without it `@ui.form.fn.value` stays inert and the paragraph renders empty.
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.
349
413
  */
350
- @wf.context.pass 'mfaMethod'
351
- @wf.context.pass 'pinSentTo'
352
- @wf.context.pass 'mfaMethodCount'
353
- @wf.context.pass 'mfaBackupCodes'
354
- @wf.context.pass 'deviceTrustOptIn'
355
- @wf.context.pass 'recoveryTransportCount'
414
+ @meta.label 'Enter the verification code'
415
+ @wf.context.pass 'public'
356
416
  @ui.form.submit.text 'Verify'
357
417
  export interface PincodeForm {
358
- @ui.form.fn.value '(_, _d, ctx) => ctx.mfaMethod === "totp" ? "Enter the current 6-digit code from your authenticator app." : ctx.mfaMethod ? "Code sent to " + (ctx.pinSentTo || "your " + ctx.mfaMethod) + " — check the dev server console for the code." : "Enter your verification code."'
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."'
359
419
  transportHint?: ui.paragraph
360
420
 
361
421
  @ui.form.type 'text'
362
422
  @meta.label 'Verification code'
363
423
  @ui.form.autocomplete 'one-time-code'
424
+ @ui.form.fn.attr 'maxlength', '(_, _d, ctx) => ctx.public?.pincode?.codeLength'
364
425
  @meta.required
365
426
  @expect.minLength 4
366
427
  @expect.maxLength 12
@@ -370,35 +431,43 @@ export interface PincodeForm {
370
431
  @ui.form.type 'checkbox'
371
432
  @meta.label 'Remember this device'
372
433
  @meta.default 'false'
373
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.deviceTrustOptIn'
434
+ @ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.trust?.optIn || !!ctx.public?.newPasswordRequired'
374
435
  rememberDevice: boolean
375
436
 
376
437
  @ui.form.action 'resend', 'Resend code'
438
+ @ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.pincode?.resendAllowedAt'
377
439
  resend?: ui.action
378
440
 
379
441
  @ui.form.action 'useDifferentMethod', 'Use a different method'
380
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.mfaMethodCount ?? 0) < 2'
442
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfa?.methodCount ?? 0) < 2'
381
443
  useDifferentMethod?: ui.action
382
444
 
383
- @ui.form.action 'useBackupCode', 'Use backup code'
384
- @ui.form.fn.hidden '(_, _d, ctx) => !ctx.mfaBackupCodes'
385
- useBackupCode?: ui.action
386
-
387
- @ui.form.action 'backToLogin', 'Back to sign-in'
388
- backToLogin?: ui.action
389
-
390
445
  @ui.form.action 'useDifferentTransport', 'Use a different transport'
391
- @ui.form.fn.hidden '(_, _d, ctx) => (ctx.recoveryTransportCount ?? 0) < 2'
446
+ @ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.otp?.transportCount ?? 0) < 2'
392
447
  useDifferentTransport?: ui.action
393
448
  }
394
449
 
395
450
  /**
396
451
  * Email-only form for the `ask/email` enrollment step.
397
452
  */
398
- @wf.context.pass 'pendingConsents'
399
- @wf.context.pass 'consentsPersisted'
400
- @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'
401
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
+
402
471
  @ui.form.order 10
403
472
  @ui.form.type 'text'
404
473
  @meta.label 'Email'
@@ -411,13 +480,19 @@ export interface AskEmailForm extends WithInlineConsentForm {
411
480
  * Phone-only form for the `ask/phone` enrollment step. Free-form text —
412
481
  * E.164 normalization happens server-side.
413
482
  */
414
- @wf.context.pass 'pendingConsents'
415
- @wf.context.pass 'consentsPersisted'
416
- @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'
417
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
+
418
493
  @ui.form.order 10
419
494
  @ui.form.type 'text'
420
- @meta.label 'Phone (E.164)'
495
+ @meta.label 'Phone number'
421
496
  @ui.form.autocomplete 'tel'
422
497
  @meta.required
423
498
  phone: string
@@ -425,20 +500,22 @@ export interface AskPhoneForm extends WithInlineConsentForm {
425
500
 
426
501
  /**
427
502
  * Forced MFA enrollment — method picker for `mfa-enroll-required`. Options
428
- * come from `ctx.enrollAvailableTransports` so only consumer-enabled
503
+ * come from `ctx.public?.mfaEnroll?.availableTransports` so only consumer-enabled
429
504
  * transports appear.
430
505
  */
431
- @wf.context.pass 'enrollAvailableTransports'
432
- @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'
433
509
  export interface EnrollPickMethodForm {
510
+ @ui.form.order 10
434
511
  @ui.form.type 'radio'
435
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.enrollAvailableTransports) ? ctx.enrollAvailableTransports.map(t => ({ key: t, label: t === "totp" ? "Authenticator app (TOTP)" : t === "sms" ? "SMS" : t === "email" ? "Email" : t })) : []'
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 })) : []'
436
513
  @meta.label 'Choose a verification method'
437
514
  @meta.required
438
515
  method: string
439
516
 
440
517
  @ui.form.action 'skip', 'Skip for now'
441
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
518
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
442
519
  skip?: ui.action
443
520
  }
444
521
 
@@ -446,45 +523,67 @@ export interface EnrollPickMethodForm {
446
523
  * Forced MFA enrollment — address collection for sms/email. TOTP skips this
447
524
  * form (secret is provisioned server-side).
448
525
  *
449
- * `skip` is hidden unless `enrollMode === 'optional'` (`'required'` mode
526
+ * `skip` is hidden unless `mfaEnroll.mode === 'optional'` (`'required'` mode
450
527
  * forbids backing out mid-flow). `useDifferentMethod` is hidden when the
451
528
  * consumer has only one transport configured (nothing to switch to).
452
529
  */
453
- @wf.context.pass 'enrollMethod'
454
- @wf.context.pass 'enrollMode'
455
- @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'
456
533
  export interface EnrollAddressForm {
534
+ @ui.form.order 10
457
535
  @ui.form.type 'text'
458
536
  @meta.label 'Address'
459
537
  @meta.required
460
538
  address: string
461
539
 
462
540
  @ui.form.action 'skip', 'Skip for now'
463
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
541
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
464
542
  skip?: ui.action
465
543
 
466
544
  @ui.form.action 'useDifferentMethod', 'Use a different method'
467
- @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'
468
546
  useDifferentMethod?: ui.action
469
547
  }
470
548
 
471
549
  /**
472
- * Forced MFA enrollment — confirm code, shared by all three transports. The
473
- * leading paragraph swaps between "scan this QR" (totp) and "code sent to …"
474
- * (sms/email) based on `enrollMethod`; `enrollSecret` / `enrollUri` are passed
475
- * for the totp QR + manual-entry fallback.
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.
476
564
  */
477
- @wf.context.pass 'enrollMethod'
478
- @wf.context.pass 'enrollMode'
479
- @wf.context.pass 'enrollSecret'
480
- @wf.context.pass 'enrollUri'
481
- @wf.context.pass 'enrollAvailableTransports'
482
- @wf.context.pass 'pinSentTo'
565
+ @meta.label 'Confirm your verification code'
566
+ @wf.context.pass 'public'
483
567
  @ui.form.submit.text 'Confirm'
484
568
  export interface EnrollConfirmForm {
485
- @ui.form.fn.value '(_, _d, ctx) => ctx.enrollMethod === "totp" ? "Scan the QR with your authenticator app, or enter the secret manually. Then type the 6-digit code it generates." : ctx.enrollMethod ? "Code sent to " + (ctx.pinSentTo || "your " + ctx.enrollMethod) + ". Enter it below to confirm." : "Enter the code to confirm enrollment."'
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."'
486
571
  transportHint?: ui.paragraph
487
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
488
587
  @ui.form.type 'text'
489
588
  @meta.label 'Code'
490
589
  @ui.form.autocomplete 'one-time-code'
@@ -495,96 +594,45 @@ export interface EnrollConfirmForm {
495
594
  code: string
496
595
 
497
596
  @ui.form.action 'resend', 'Resend code'
498
- @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"'
499
599
  resend?: ui.action
500
600
 
501
601
  @ui.form.action 'useDifferentMethod', 'Use a different method'
502
- @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'
503
603
  useDifferentMethod?: ui.action
504
604
 
505
605
  @ui.form.action 'skip', 'Skip for now'
506
- @ui.form.fn.hidden '(_, _d, ctx) => ctx.enrollMode !== "optional"'
606
+ @ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
507
607
  skip?: ui.action
508
608
  }
509
609
 
510
- /**
511
- * Default minimal profile completion form. Consumers replace via
512
- * `LoginWorkflowOptions.profileCompleteForm` for richer shapes.
513
- */
514
- @wf.context.pass 'pendingConsents'
515
- @wf.context.pass 'consentsPersisted'
516
- export interface ProfileCompleteForm extends WithInlineConsentForm {
517
- @ui.form.order 10
518
- @ui.form.type 'text'
519
- @meta.label 'First name'
520
- firstName?: string
521
-
522
- @ui.form.order 20
523
- @ui.form.type 'text'
524
- @meta.label 'Last name'
525
- lastName?: string
526
- }
527
-
528
610
  /**
529
611
  * Standalone consent-bump prompt. Fires for returning users with pending
530
612
  * consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
531
613
  * who did NOT pass through any onboarding carrier form (`AskEmailForm` /
532
- * `AskPhoneForm` / `SetPasswordForm` / `ProfileCompleteForm`) on this login —
533
- * those carrier forms collect consents inline via `WithInlineConsentForm`'s
534
- * inherited `AsConsentArray` field. The bump-prompt only renders the same
535
- * inherited consent block (no additional fields).
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).
536
618
  */
537
- @wf.context.pass 'pendingConsents'
538
- @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'
539
622
  export interface TermsBumpForm extends WithInlineConsentForm {
540
623
  }
541
624
 
542
625
  /**
543
- * Tenant picker `tenantId` matches one of `ctx.availableTenants[].id`.
544
- * Options are built from `ctx.availableTenants` (set by the workflow's
545
- * `tenant-select` step / `loadTenants` hook); `@wf.context.pass` whitelists
546
- * the key so it survives `extractPassContext`.
547
- */
548
- @wf.context.pass 'availableTenants'
549
- export interface TenantSelectForm {
550
- @ui.form.type 'radio'
551
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableTenants) ? ctx.availableTenants.map(t => ({ key: t.id, label: t.name })) : []'
552
- @meta.label 'Tenant'
553
- @meta.required
554
- tenantId: string
555
- }
556
-
557
- /**
558
- * Persona picker — `personaId` matches one of `ctx.availablePersonas[].id`.
559
- * Options are built from `ctx.availablePersonas` (set by the workflow's
560
- * `persona-select` step / `loadPersonas` hook); `@wf.context.pass` whitelists
561
- * the key so it survives `extractPassContext`.
562
- */
563
- @wf.context.pass 'availablePersonas'
564
- export interface PersonaSelectForm {
565
- @ui.form.type 'radio'
566
- @ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availablePersonas) ? ctx.availablePersonas.map(p => ({ key: p.id, label: p.label })) : []'
567
- @meta.label 'Persona'
568
- @meta.required
569
- personaId: string
570
- }
571
-
572
- /**
573
- * Concurrency-limit kick prompt — user picks between logging out other
574
- * 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).
575
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'
576
635
  export interface ConcurrencyLimitForm {
577
- @ui.form.type 'text'
578
- @meta.label 'Action'
579
- @meta.required
580
- @expect.pattern '^(logoutOthers|cancel)$'
581
- action: string
582
-
583
- @ui.form.action 'cancel', 'Cancel'
584
- cancel?: ui.action
585
-
586
- @ui.form.action 'logoutOthers', 'Log out other sessions'
587
- logoutOthers?: ui.action
588
636
  }
589
637
 
590
638
  /**
@@ -592,7 +640,10 @@ export interface ConcurrencyLimitForm {
592
640
  * {@link EmailIdentifierForm} but kept separate because future iterations may
593
641
  * accept either email or username.
594
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.'
595
645
  export interface MagicLinkRequestForm {
646
+ @ui.form.order 10
596
647
  @ui.form.type 'text'
597
648
  @meta.label 'Email or username'
598
649
  @ui.form.autocomplete 'username'
@@ -604,7 +655,10 @@ export interface MagicLinkRequestForm {
604
655
  * Recovery delivery-mode picker — rendered only when
605
656
  * `RecoveryWorkflowOptions.deliveryMode === 'choice'`.
606
657
  */
658
+ @meta.label 'Choose how to verify'
659
+ @meta.description 'Pick how you would like to recover access.'
607
660
  export interface RecoveryModeSelectForm {
661
+ @ui.form.order 10
608
662
  @ui.form.type 'radio'
609
663
  @ui.form.options 'Magic link', 'magicLink'
610
664
  @ui.form.options 'One-time code', 'otp'
@@ -612,8 +666,6 @@ export interface RecoveryModeSelectForm {
612
666
  @meta.required
613
667
  mode: string
614
668
 
615
- @ui.form.action 'backToLogin', 'Back to sign-in'
616
- backToLogin?: ui.action
617
669
  }
618
670
 
619
671
  /**
@@ -621,25 +673,174 @@ export interface RecoveryModeSelectForm {
621
673
  * `RecoveryWorkflowOptions.requireKnownRecoveryFactor` is true. The user
622
674
  * picks a factor type and supplies its value; the server validates against
623
675
  * the enrolled factor (phone last-4 or current TOTP code). Options are
624
- * built from `ctx.availableRecoveryFactors` (workflow whitelist ∩ user's
625
- * enrolled factors), so users only see factors they can actually verify
626
- * AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
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`.
627
679
  */
628
- @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'
629
683
  export interface RecoveryFactorForm {
684
+ @ui.form.order 10
630
685
  @ui.form.type 'radio'
631
- @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 : []'
632
687
  @meta.label 'Factor'
633
688
  @meta.required
634
689
  factor: string
635
690
 
691
+ @ui.form.order 20
636
692
  @ui.form.type 'text'
637
693
  @meta.label 'Value'
638
694
  @meta.required
639
695
  @expect.minLength 4
640
696
  @expect.maxLength 12
641
697
  value: string
698
+ }
642
699
 
643
- @ui.form.action 'backToLogin', 'Back to sign-in'
644
- 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
645
846
  }