@aooth/auth-moost 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atscript/index.d.mts +2 -2
- package/dist/atscript/index.mjs +2 -2
- package/dist/forms-DV4UcC29.mjs +442 -0
- package/dist/index.d.mts +2540 -1682
- package/dist/index.mjs +4993 -3572
- package/package.json +32 -25
- package/src/atscript/models/forms.as +576 -268
- package/src/atscript/models/forms.as.d.ts +140 -100
- package/dist/forms-DtQMdkA_.mjs +0 -380
|
@@ -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 '
|
|
8
|
-
*
|
|
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
|
|
11
|
-
* `
|
|
12
|
-
* checkbox per descriptor; the
|
|
13
|
-
* SUBSET of `descriptor.id`s the
|
|
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
|
|
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.
|
|
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 '
|
|
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.
|
|
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
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
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
|
-
@
|
|
54
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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.
|
|
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 `
|
|
92
|
-
* recipient) out of the workflow context so the operator knows
|
|
93
|
-
* the workflow is currently verifying. The hint requires
|
|
94
|
-
* from `@atscript/ui-fns` on the consumer
|
|
95
|
-
* stays inert and the paragraph
|
|
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
|
-
@
|
|
98
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
151
|
+
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfa?.methodCount ?? 0) < 2'
|
|
117
152
|
useDifferentMethod?: ui.action
|
|
118
153
|
|
|
119
|
-
@ui.form.
|
|
120
|
-
@
|
|
121
|
-
|
|
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
|
-
*
|
|
162
|
+
* Email identifier form — used for password recovery initiation.
|
|
126
163
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* `
|
|
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
|
-
|
|
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 '
|
|
135
|
-
@ui.form.autocomplete '
|
|
176
|
+
@meta.label 'Email'
|
|
177
|
+
@ui.form.autocomplete 'email'
|
|
136
178
|
@meta.required
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
@
|
|
140
|
-
|
|
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
|
-
*
|
|
189
|
+
* Self-signup identifier form — the entry pause of `auth/signup/flow`.
|
|
145
190
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
@
|
|
153
|
-
|
|
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
|
-
|
|
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`
|
|
168
|
-
*
|
|
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 '
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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.
|
|
179
|
-
* transferable list seeded by the workflow's `prepare-password-rules`
|
|
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
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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
|
-
@
|
|
193
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
-
@
|
|
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
|
|
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.
|
|
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
|
-
@
|
|
329
|
-
@
|
|
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.
|
|
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.
|
|
343
|
-
@
|
|
344
|
-
|
|
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 `
|
|
353
|
-
* masked recipient set by the pincode-send step) out of the workflow
|
|
354
|
-
* so the operator can see which factor the workflow is currently
|
|
355
|
-
* Requires `installDynamicResolver()` from `@atscript/ui-fns`
|
|
356
|
-
* side; without it `@ui.form.fn.value` stays inert and
|
|
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
|
-
@
|
|
359
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
@
|
|
407
|
-
@
|
|
408
|
-
@wf.context.pass '
|
|
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
|
-
@
|
|
423
|
-
@
|
|
424
|
-
@wf.context.pass '
|
|
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
|
|
495
|
+
@meta.label 'Phone number'
|
|
429
496
|
@ui.form.autocomplete 'tel'
|
|
430
497
|
@meta.required
|
|
431
498
|
phone: string
|
|
@@ -433,66 +500,89 @@ export interface AskPhoneForm extends WithInlineConsentForm {
|
|
|
433
500
|
|
|
434
501
|
/**
|
|
435
502
|
* Forced MFA enrollment — method picker for `mfa-enroll-required`. Options
|
|
436
|
-
* come from `ctx.
|
|
503
|
+
* come from `ctx.public?.mfaEnroll?.availableTransports` so only consumer-enabled
|
|
437
504
|
* transports appear.
|
|
438
505
|
*/
|
|
439
|
-
@
|
|
440
|
-
@
|
|
506
|
+
@meta.label 'Set up two-factor authentication'
|
|
507
|
+
@meta.description 'Pick a method to receive your verification codes.'
|
|
508
|
+
@wf.context.pass 'public'
|
|
509
|
+
@ui.form.submit.text 'Continue'
|
|
441
510
|
export interface EnrollPickMethodForm {
|
|
511
|
+
@ui.form.order 10
|
|
442
512
|
@ui.form.type 'radio'
|
|
443
|
-
@ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.
|
|
513
|
+
@ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.mfaEnroll?.availableTransports) ? ctx.public.mfaEnroll.availableTransports.map(t => ({ key: t, label: t === "totp" ? "Authenticator app (TOTP)" : t === "sms" ? "SMS" : t === "email" ? "Email" : t })) : []'
|
|
444
514
|
@meta.label 'Choose a verification method'
|
|
445
515
|
@meta.required
|
|
446
516
|
method: string
|
|
447
517
|
|
|
518
|
+
// Login/invite opt-in only: a user who chose to defer MFA backs out here.
|
|
448
519
|
@ui.form.action 'skip', 'Skip for now'
|
|
449
|
-
@ui.form.fn.hidden '(_, _d, ctx) => ctx.
|
|
520
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
|
|
450
521
|
skip?: ui.action
|
|
522
|
+
|
|
523
|
+
// Manage-MFA only: the user opened this on purpose, so "Skip" makes no
|
|
524
|
+
// sense — offer a clean cancel instead.
|
|
525
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
526
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
|
|
527
|
+
cancel?: ui.action
|
|
451
528
|
}
|
|
452
529
|
|
|
453
530
|
/**
|
|
454
531
|
* Forced MFA enrollment — address collection for sms/email. TOTP skips this
|
|
455
532
|
* form (secret is provisioned server-side).
|
|
456
533
|
*
|
|
457
|
-
* `skip` is hidden unless `
|
|
534
|
+
* `skip` is hidden unless `mfaEnroll.mode === 'optional'` (`'required'` mode
|
|
458
535
|
* forbids backing out mid-flow). `useDifferentMethod` is hidden when the
|
|
459
536
|
* consumer has only one transport configured (nothing to switch to).
|
|
460
537
|
*/
|
|
461
|
-
@
|
|
462
|
-
@
|
|
463
|
-
@wf.context.pass '
|
|
538
|
+
@ui.form.fn.title '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "sms" ? "Add your phone number" : "Add your email"'
|
|
539
|
+
@meta.description 'We will send you a one-time code to confirm.'
|
|
540
|
+
@wf.context.pass 'public'
|
|
541
|
+
@ui.form.submit.text 'Send code'
|
|
464
542
|
export interface EnrollAddressForm {
|
|
543
|
+
@ui.form.order 10
|
|
465
544
|
@ui.form.type 'text'
|
|
466
545
|
@meta.label 'Address'
|
|
467
546
|
@meta.required
|
|
547
|
+
// Client-side format hint — email branch must look like an email; the SMS
|
|
548
|
+
// branch stays free-form (server-side E.164 normalization). The robust
|
|
549
|
+
// check is server-side in the `enroll-address` step regardless of client.
|
|
550
|
+
@ui.form.validate '(v, _d, ctx) => ctx.public?.mfaEnroll?.method !== "email" || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) || "Enter a valid email address"'
|
|
468
551
|
address: string
|
|
469
552
|
|
|
470
553
|
@ui.form.action 'skip', 'Skip for now'
|
|
471
|
-
@ui.form.fn.hidden '(_, _d, ctx) => ctx.
|
|
554
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
|
|
472
555
|
skip?: ui.action
|
|
473
556
|
|
|
557
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
558
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
|
|
559
|
+
cancel?: ui.action
|
|
560
|
+
|
|
474
561
|
@ui.form.action 'useDifferentMethod', 'Use a different method'
|
|
475
|
-
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.
|
|
562
|
+
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
|
|
476
563
|
useDifferentMethod?: ui.action
|
|
477
564
|
}
|
|
478
565
|
|
|
479
566
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
567
|
+
* MFA enrollment — confirm code, shared by all three transports.
|
|
568
|
+
*
|
|
569
|
+
* **TOTP branch.** The scannable QR + manual base32 secret are shown on the
|
|
570
|
+
* PRECEDING `enroll-totp-qr` step ({@link EnrollTotpQrForm}), so this form only
|
|
571
|
+
* collects the 6-digit code the authenticator generates — `transportHint`
|
|
572
|
+
* reminds the user to enter it.
|
|
573
|
+
*
|
|
574
|
+
* **SMS / email branch.** Single `transportHint` paragraph shows the masked
|
|
575
|
+
* recipient.
|
|
484
576
|
*/
|
|
485
|
-
@
|
|
486
|
-
@wf.context.pass '
|
|
487
|
-
@wf.context.pass 'enrollSecret'
|
|
488
|
-
@wf.context.pass 'enrollUri'
|
|
489
|
-
@wf.context.pass 'enrollAvailableTransports'
|
|
490
|
-
@wf.context.pass 'pinSentTo'
|
|
577
|
+
@meta.label 'Confirm your verification code'
|
|
578
|
+
@wf.context.pass 'public'
|
|
491
579
|
@ui.form.submit.text 'Confirm'
|
|
492
580
|
export interface EnrollConfirmForm {
|
|
493
|
-
@ui.form.
|
|
581
|
+
@ui.form.order 1
|
|
582
|
+
@ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp" ? "Enter the 6-digit code from your authenticator app." : ctx.public?.pincode?.sentTo ? "Code sent to " + ctx.public.pincode.sentTo + ". Enter it below to confirm." : "Enter the code to confirm enrollment."'
|
|
494
583
|
transportHint?: ui.paragraph
|
|
495
584
|
|
|
585
|
+
@ui.form.order 10
|
|
496
586
|
@ui.form.type 'text'
|
|
497
587
|
@meta.label 'Code'
|
|
498
588
|
@ui.form.autocomplete 'one-time-code'
|
|
@@ -503,96 +593,161 @@ export interface EnrollConfirmForm {
|
|
|
503
593
|
code: string
|
|
504
594
|
|
|
505
595
|
@ui.form.action 'resend', 'Resend code'
|
|
506
|
-
@ui.form.fn.
|
|
596
|
+
@ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.pincode?.resendAllowedAt'
|
|
597
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.method === "totp"'
|
|
507
598
|
resend?: ui.action
|
|
508
599
|
|
|
509
600
|
@ui.form.action 'useDifferentMethod', 'Use a different method'
|
|
510
|
-
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.
|
|
601
|
+
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
|
|
511
602
|
useDifferentMethod?: ui.action
|
|
512
603
|
|
|
604
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
605
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
|
|
606
|
+
cancel?: ui.action
|
|
607
|
+
|
|
513
608
|
@ui.form.action 'skip', 'Skip for now'
|
|
514
|
-
@ui.form.fn.hidden '(_, _d, ctx) => ctx.
|
|
609
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
|
|
515
610
|
skip?: ui.action
|
|
516
611
|
}
|
|
517
612
|
|
|
518
613
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
614
|
+
* MFA enrollment — TOTP QR step. Shown on its OWN pause (the `enroll-totp-qr`
|
|
615
|
+
* step) BETWEEN method-pick and code-entry, so the user scans first and types
|
|
616
|
+
* the code on the next screen — instead of QR + input crowded on one form.
|
|
617
|
+
*
|
|
618
|
+
* `ctx.public.mfaEnroll.uri` carries the `otpauth://` URI (provisioned
|
|
619
|
+
* server-side). The `qrCode` field renders it as a scannable image via the
|
|
620
|
+
* `AsQrCode` component (`@atscript/vue-aooth`); `AsQrCode` also extracts the
|
|
621
|
+
* base32 secret from the URI and shows it for manual entry (its `manualSecret`
|
|
622
|
+
* prop defaults on), so users whose app lacks a scanner can still set up.
|
|
521
623
|
*/
|
|
522
|
-
@
|
|
523
|
-
@
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
@
|
|
528
|
-
|
|
624
|
+
@meta.label 'Scan this QR code'
|
|
625
|
+
@meta.description 'Open your authenticator app and scan the code (or enter the key manually), then continue to enter the code it shows.'
|
|
626
|
+
@wf.context.pass 'public'
|
|
627
|
+
@ui.form.submit.text 'Continue'
|
|
628
|
+
export interface EnrollTotpQrForm {
|
|
629
|
+
@ui.form.order 5
|
|
630
|
+
@ui.form.component 'AsQrCode'
|
|
631
|
+
@ui.form.fn.attr 'size', '() => 180'
|
|
632
|
+
@ui.form.fn.value '(_, _d, ctx) => ctx.public?.mfaEnroll?.uri || ""'
|
|
633
|
+
qrCode: ui.paragraph
|
|
529
634
|
|
|
530
|
-
@ui.form.
|
|
531
|
-
@ui.form.
|
|
532
|
-
|
|
533
|
-
lastName?: string
|
|
534
|
-
}
|
|
635
|
+
@ui.form.action 'useDifferentMethod', 'Use a different method'
|
|
636
|
+
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.mfaEnroll?.availableTransports?.length ?? 0) < 2 || ctx.public?.mfaEnroll?.mode === "manage"'
|
|
637
|
+
useDifferentMethod?: ui.action
|
|
535
638
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
* inherited consent block (no additional fields).
|
|
544
|
-
*/
|
|
545
|
-
@wf.context.pass 'pendingConsents'
|
|
546
|
-
@wf.context.pass 'consentsPersisted'
|
|
547
|
-
export interface TermsBumpForm extends WithInlineConsentForm {
|
|
639
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
640
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "manage"'
|
|
641
|
+
cancel?: ui.action
|
|
642
|
+
|
|
643
|
+
@ui.form.action 'skip', 'Skip for now'
|
|
644
|
+
@ui.form.fn.hidden '(_, _d, ctx) => ctx.public?.mfaEnroll?.mode !== "optional"'
|
|
645
|
+
skip?: ui.action
|
|
548
646
|
}
|
|
549
647
|
|
|
550
648
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
649
|
+
* Manage-MFA menu — the authenticated user's hub for the standalone
|
|
650
|
+
* `auth/add-mfa/flow` once they have ≥1 confirmed factor (shown after the
|
|
651
|
+
* step-up challenge). A single radio whose value encodes both action and
|
|
652
|
+
* target (`add:totp` / `replace:email` / `remove:sms`):
|
|
653
|
+
* - **Add** options come from `ctx.public.manage.candidates` (un-enrolled).
|
|
654
|
+
* - **Change / Remove** options come from `ctx.public.mfa.enrolledMethods`,
|
|
655
|
+
* with any transport in `ctx.public.manage.locked` omitted (a handle-bound
|
|
656
|
+
* factor the consumer forbids changing here — `lockedNote` explains why).
|
|
657
|
+
*
|
|
658
|
+
* A zero-MFA user never sees this form — the flow routes straight to the
|
|
659
|
+
* enrol picker (first-time opt-in).
|
|
555
660
|
*/
|
|
556
|
-
@
|
|
557
|
-
|
|
661
|
+
@meta.label 'Manage two-factor authentication'
|
|
662
|
+
@meta.description 'Add, change, or remove a verification method.'
|
|
663
|
+
@wf.context.pass 'public'
|
|
664
|
+
@ui.form.submit.text 'Continue'
|
|
665
|
+
export interface ManageMfaForm {
|
|
666
|
+
@ui.form.order 5
|
|
667
|
+
@ui.form.fn.value '(_, _d, ctx) => (ctx.public?.manage?.locked?.length ?? 0) > 0 ? "Some methods are also used to sign in and can’t be changed here." : ""'
|
|
668
|
+
@ui.form.fn.hidden '(_, _d, ctx) => (ctx.public?.manage?.locked?.length ?? 0) === 0'
|
|
669
|
+
lockedNote: ui.paragraph
|
|
670
|
+
|
|
671
|
+
@ui.form.order 10
|
|
558
672
|
@ui.form.type 'radio'
|
|
559
|
-
@ui.form.fn.options '(_, _d, ctx) =>
|
|
560
|
-
@meta.label '
|
|
673
|
+
@ui.form.fn.options '(_, _d, ctx) => { const lbl = (t) => t === "totp" ? "authenticator app" : t === "sms" ? "SMS" : t === "email" ? "email" : t; const locked = ctx.public?.manage?.locked ?? []; const out = []; for (const t of (ctx.public?.manage?.candidates ?? [])) out.push({ key: "add:" + t, label: "Add " + lbl(t) }); for (const m of (ctx.public?.mfa?.enrolledMethods ?? [])) { if (locked.includes(m.kind)) continue; out.push({ key: "replace:" + m.kind, label: "Change " + lbl(m.kind) + (m.masked ? " (" + m.masked + ")" : "") }); out.push({ key: "remove:" + m.kind, label: "Remove " + lbl(m.kind) }); } return out; }'
|
|
674
|
+
@meta.label 'What would you like to do?'
|
|
561
675
|
@meta.required
|
|
562
|
-
|
|
676
|
+
operation: string
|
|
677
|
+
|
|
678
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
679
|
+
cancel?: ui.action
|
|
563
680
|
}
|
|
564
681
|
|
|
565
682
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* `
|
|
569
|
-
* the
|
|
683
|
+
* Manage-MFA — confirm removing a factor. Fieldless apart from the explanatory
|
|
684
|
+
* paragraph; the primary submit ('Remove') performs the removal, 'Cancel'
|
|
685
|
+
* backs out. `manage-menu` has already bound the target transport on
|
|
686
|
+
* `ctx.addMfa.target`; the description reads it back for the user.
|
|
570
687
|
*/
|
|
571
|
-
@
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
@
|
|
576
|
-
@
|
|
577
|
-
|
|
688
|
+
@meta.label 'Remove this method?'
|
|
689
|
+
@wf.context.pass 'public'
|
|
690
|
+
@ui.form.submit.text 'Remove'
|
|
691
|
+
export interface RemoveMfaConfirmForm {
|
|
692
|
+
@ui.form.order 1
|
|
693
|
+
@ui.form.fn.value '(_, _d, ctx) => { const t = ctx.public?.mfaEnroll?.method; const lbl = t === "totp" ? "your authenticator app" : t === "sms" ? "SMS codes" : t === "email" ? "email codes" : "this method"; return "Remove " + lbl + " as a two-factor method? You can set it up again later."; }'
|
|
694
|
+
notice: ui.paragraph
|
|
695
|
+
|
|
696
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
697
|
+
cancel?: ui.action
|
|
578
698
|
}
|
|
579
699
|
|
|
580
700
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
701
|
+
* Manage-MFA password re-auth — the step-up FALLBACK rendered when the user's
|
|
702
|
+
* only confirmed factor(s) are of kinds the policy no longer allows, so nothing
|
|
703
|
+
* is MFA-challengeable (`ctx.addMfa.stepUpMode === "password"`). A single
|
|
704
|
+
* current-password field; the submit verifies it via `UserService.verifyPassword`
|
|
705
|
+
* and 'Cancel' backs out. See `AuthWorkflow.managePasswordReauth`.
|
|
583
706
|
*/
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
707
|
+
@meta.label 'Confirm your password'
|
|
708
|
+
@meta.description 'Re-enter your account password to manage your two-factor methods.'
|
|
709
|
+
@wf.context.pass 'public'
|
|
710
|
+
@ui.form.submit.text 'Verify'
|
|
711
|
+
export interface PasswordReauthForm {
|
|
712
|
+
@ui.form.order 10
|
|
713
|
+
@ui.form.type 'password'
|
|
714
|
+
@meta.label 'Password'
|
|
715
|
+
@ui.form.autocomplete 'current-password'
|
|
716
|
+
@meta.sensitive
|
|
587
717
|
@meta.required
|
|
588
|
-
@expect.
|
|
589
|
-
|
|
718
|
+
@expect.minLength 1
|
|
719
|
+
password: string
|
|
590
720
|
|
|
591
721
|
@ui.form.action 'cancel', 'Cancel'
|
|
592
722
|
cancel?: ui.action
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Standalone consent-bump prompt. Fires for returning users with pending
|
|
727
|
+
* consents (set by `prepare-consents` from `ConsentStore.getPendingConsents`)
|
|
728
|
+
* who did NOT pass through any onboarding carrier form (`AskEmailForm` /
|
|
729
|
+
* `AskPhoneForm` / `SetPasswordForm`) on this login — those carrier forms
|
|
730
|
+
* collect consents inline via `WithInlineConsentForm`'s inherited
|
|
731
|
+
* `AsConsentArray` field. The bump-prompt only renders the same inherited
|
|
732
|
+
* consent block (no additional fields).
|
|
733
|
+
*/
|
|
734
|
+
@meta.label 'Updated terms and policies'
|
|
735
|
+
@meta.description 'Please review and accept the updated terms to continue.'
|
|
736
|
+
@wf.context.pass 'public'
|
|
737
|
+
export interface TermsBumpForm extends WithInlineConsentForm {
|
|
738
|
+
}
|
|
593
739
|
|
|
594
|
-
|
|
595
|
-
|
|
740
|
+
/**
|
|
741
|
+
* Concurrency-limit kick prompt. Fieldless by design — just the explanatory
|
|
742
|
+
* paragraph plus the primary submit ('Login'): submitting logs out the user's
|
|
743
|
+
* other sessions and continues the login. No alt-action and no in-form cancel;
|
|
744
|
+
* the user backs out by navigating away (the wf state token expires per the
|
|
745
|
+
* engine's TTL).
|
|
746
|
+
*/
|
|
747
|
+
@meta.label 'Session limit reached'
|
|
748
|
+
@meta.description 'You are already signed in elsewhere. Other sessions will be logged out if you proceed to log in.'
|
|
749
|
+
@ui.form.submit.text 'Login'
|
|
750
|
+
export interface ConcurrencyLimitForm {
|
|
596
751
|
}
|
|
597
752
|
|
|
598
753
|
/**
|
|
@@ -600,7 +755,10 @@ export interface ConcurrencyLimitForm {
|
|
|
600
755
|
* {@link EmailIdentifierForm} but kept separate because future iterations may
|
|
601
756
|
* accept either email or username.
|
|
602
757
|
*/
|
|
758
|
+
@meta.label 'Sign in with a magic link'
|
|
759
|
+
@meta.description 'Enter your account email or username and we will send you a one-time sign-in link.'
|
|
603
760
|
export interface MagicLinkRequestForm {
|
|
761
|
+
@ui.form.order 10
|
|
604
762
|
@ui.form.type 'text'
|
|
605
763
|
@meta.label 'Email or username'
|
|
606
764
|
@ui.form.autocomplete 'username'
|
|
@@ -612,7 +770,10 @@ export interface MagicLinkRequestForm {
|
|
|
612
770
|
* Recovery delivery-mode picker — rendered only when
|
|
613
771
|
* `RecoveryWorkflowOptions.deliveryMode === 'choice'`.
|
|
614
772
|
*/
|
|
773
|
+
@meta.label 'Choose how to verify'
|
|
774
|
+
@meta.description 'Pick how you would like to recover access.'
|
|
615
775
|
export interface RecoveryModeSelectForm {
|
|
776
|
+
@ui.form.order 10
|
|
616
777
|
@ui.form.type 'radio'
|
|
617
778
|
@ui.form.options 'Magic link', 'magicLink'
|
|
618
779
|
@ui.form.options 'One-time code', 'otp'
|
|
@@ -620,8 +781,6 @@ export interface RecoveryModeSelectForm {
|
|
|
620
781
|
@meta.required
|
|
621
782
|
mode: string
|
|
622
783
|
|
|
623
|
-
@ui.form.action 'backToLogin', 'Back to sign-in'
|
|
624
|
-
backToLogin?: ui.action
|
|
625
784
|
}
|
|
626
785
|
|
|
627
786
|
/**
|
|
@@ -629,25 +788,174 @@ export interface RecoveryModeSelectForm {
|
|
|
629
788
|
* `RecoveryWorkflowOptions.requireKnownRecoveryFactor` is true. The user
|
|
630
789
|
* picks a factor type and supplies its value; the server validates against
|
|
631
790
|
* the enrolled factor (phone last-4 or current TOTP code). Options are
|
|
632
|
-
* built from `ctx.availableRecoveryFactors` (workflow whitelist ∩
|
|
633
|
-
* enrolled factors), so users only see factors they can actually
|
|
634
|
-
* AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
|
|
791
|
+
* built from `ctx.public.preReset.availableRecoveryFactors` (workflow whitelist ∩
|
|
792
|
+
* user's enrolled factors), so users only see factors they can actually
|
|
793
|
+
* verify AND that the admin hasn't disabled via `opts.preReset.allowedFactors`.
|
|
635
794
|
*/
|
|
636
|
-
@
|
|
795
|
+
@meta.label 'Verify your identity'
|
|
796
|
+
@meta.description 'Confirm a detail we have on file before resetting your password.'
|
|
797
|
+
@wf.context.pass 'public'
|
|
637
798
|
export interface RecoveryFactorForm {
|
|
799
|
+
@ui.form.order 10
|
|
638
800
|
@ui.form.type 'radio'
|
|
639
|
-
@ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.availableRecoveryFactors) ? ctx.availableRecoveryFactors : []'
|
|
801
|
+
@ui.form.fn.options '(_, _d, ctx) => Array.isArray(ctx.public?.preReset?.availableRecoveryFactors) ? ctx.public.preReset.availableRecoveryFactors : []'
|
|
640
802
|
@meta.label 'Factor'
|
|
641
803
|
@meta.required
|
|
642
804
|
factor: string
|
|
643
805
|
|
|
806
|
+
@ui.form.order 20
|
|
644
807
|
@ui.form.type 'text'
|
|
645
808
|
@meta.label 'Value'
|
|
646
809
|
@meta.required
|
|
647
810
|
@expect.minLength 4
|
|
648
811
|
@expect.maxLength 12
|
|
649
812
|
value: string
|
|
813
|
+
}
|
|
650
814
|
|
|
651
|
-
|
|
652
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Authenticated "change my password" form — surfaced by the
|
|
817
|
+
* change-password.flow `change-password-form` step to a SIGNED-IN user.
|
|
818
|
+
*
|
|
819
|
+
* Standalone (no `extends WithInlineConsentForm`) — a self-service password
|
|
820
|
+
* change carries no consent capture, unlike `SetPasswordForm`. The leading
|
|
821
|
+
* `currentPassword` field is the PRIMARY protection for this flow
|
|
822
|
+
* (re-authentication per OWASP ASVS 6.2.3) — `UserService.changePassword`
|
|
823
|
+
* verifies it before applying policy + history checks server-side.
|
|
824
|
+
*
|
|
825
|
+
* Reuses the same `ctx.public.password.{heading,intro,policies}` surface as
|
|
826
|
+
* `SetPasswordForm`, so the live `AsPasswordRules` renderer and dynamic copy
|
|
827
|
+
* work identically. Heading/intro are staged by `change-password-form`.
|
|
828
|
+
*/
|
|
829
|
+
@ui.form.fn.title '(_, _d, ctx) => ctx.public?.password?.heading || "Change your password"'
|
|
830
|
+
@ui.form.submit.text 'Change password'
|
|
831
|
+
@wf.context.pass 'public'
|
|
832
|
+
export interface ChangePasswordForm {
|
|
833
|
+
@ui.form.order 5
|
|
834
|
+
@ui.form.fn.value '(_, _d, ctx) => ctx.public?.password?.intro || ""'
|
|
835
|
+
@ui.form.fn.hidden '(_, _d, ctx) => !ctx.public?.password?.intro'
|
|
836
|
+
@ui.form.grid.colSpan '12'
|
|
837
|
+
intro: ui.paragraph
|
|
838
|
+
|
|
839
|
+
@ui.form.order 8
|
|
840
|
+
@ui.form.type 'password'
|
|
841
|
+
@meta.label 'Current password'
|
|
842
|
+
@ui.form.autocomplete 'current-password'
|
|
843
|
+
@meta.sensitive
|
|
844
|
+
@meta.required
|
|
845
|
+
currentPassword: string
|
|
846
|
+
|
|
847
|
+
@ui.form.order 10
|
|
848
|
+
@ui.form.type 'password'
|
|
849
|
+
@meta.label 'New password'
|
|
850
|
+
@ui.form.autocomplete 'new-password'
|
|
851
|
+
@meta.sensitive
|
|
852
|
+
@meta.required
|
|
853
|
+
@expect.minLength 8
|
|
854
|
+
newPassword: string
|
|
855
|
+
|
|
856
|
+
@ui.form.order 20
|
|
857
|
+
@ui.form.type 'password'
|
|
858
|
+
@meta.label 'Confirm new password'
|
|
859
|
+
@ui.form.autocomplete 'new-password'
|
|
860
|
+
@meta.sensitive
|
|
861
|
+
@meta.required
|
|
862
|
+
@expect.minLength 8
|
|
863
|
+
@ui.form.validate '(v, data) => v === data.newPassword || "Passwords must match"'
|
|
864
|
+
confirmPassword: string
|
|
865
|
+
|
|
866
|
+
@ui.form.order 25
|
|
867
|
+
@meta.label 'Password requirements'
|
|
868
|
+
@ui.form.component 'AsPasswordRules'
|
|
869
|
+
@ui.form.fn.attr 'policies', '(_, _d, ctx) => ctx.public?.password?.policies'
|
|
870
|
+
@ui.form.fn.attr 'password', '(_, data) => data.newPassword'
|
|
871
|
+
@ui.form.grid.colSpan '12'
|
|
872
|
+
passwordRules: ui.paragraph
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Prove control of an EXISTING local account before a federated identity is
|
|
877
|
+
* attached to it — the interactive completion of `FederatedLoginService`'s
|
|
878
|
+
* `needs-link` outcome (a verified provider profile whose email matches an
|
|
879
|
+
* existing account under the default `require-interactive-link` policy). The
|
|
880
|
+
* PASSWORD variant: the matched account has a real password, so the user
|
|
881
|
+
* re-enters it to prove ownership.
|
|
882
|
+
*
|
|
883
|
+
* The `prove-control` @Step binds the username to the matched account
|
|
884
|
+
* server-side (the user never types it) and verifies via `UserService.login`,
|
|
885
|
+
* so this form collects only the password. `intro` renders the masked account
|
|
886
|
+
* hint off `ctx.public.proveControl.hint` ("…account for a***@x.com…") — a
|
|
887
|
+
* deliberate, BOUNDED account-existence disclosure (surfacing the candidate is
|
|
888
|
+
* the whole point of `needs-link`). A wrong password re-pauses with a generic
|
|
889
|
+
* inline error; the `cancel` action abandons the link (no account created, no
|
|
890
|
+
* session issued, generic terminal).
|
|
891
|
+
*
|
|
892
|
+
* Override via `setupAuthWorkflows({ forms: { proveControl: MyForm } })`.
|
|
893
|
+
*/
|
|
894
|
+
@meta.label 'Confirm your identity'
|
|
895
|
+
@wf.context.pass 'public'
|
|
896
|
+
@ui.form.submit.text 'Verify and link'
|
|
897
|
+
export interface ProveControlForm {
|
|
898
|
+
@ui.form.order 5
|
|
899
|
+
@ui.form.fn.value '(_, _d, ctx) => ctx.public?.proveControl?.hint ? "An account for " + ctx.public.proveControl.hint + " already exists. Enter its password to link this sign-in method." : "Enter your existing account password to link this sign-in method."'
|
|
900
|
+
intro: ui.paragraph
|
|
901
|
+
|
|
902
|
+
@ui.form.order 10
|
|
903
|
+
@ui.form.type 'password'
|
|
904
|
+
@meta.label 'Password'
|
|
905
|
+
@ui.form.autocomplete 'current-password'
|
|
906
|
+
@meta.sensitive
|
|
907
|
+
@meta.required
|
|
908
|
+
@expect.minLength 1
|
|
909
|
+
password: string
|
|
910
|
+
|
|
911
|
+
@ui.form.order 20
|
|
912
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
913
|
+
@ui.form.attr 'align', 'center'
|
|
914
|
+
@ui.form.pushDown
|
|
915
|
+
cancel?: ui.action
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* OTP FALLBACK of the `needs-link` completion — used when the matched account
|
|
920
|
+
* is passwordless (`password.isInitial`), so there is no password to re-enter.
|
|
921
|
+
* The `prove-control` @Step mints a one-time code and delivers it to the
|
|
922
|
+
* account's OWN confirmed email/SMS channel (NEVER the provider-supplied
|
|
923
|
+
* address — that would be circular, since the attacker controls the provider
|
|
924
|
+
* account), then this form collects the code. `intro` shows the masked
|
|
925
|
+
* delivery target off `ctx.public.proveControl.sentTo`.
|
|
926
|
+
*
|
|
927
|
+
* Override via `setupAuthWorkflows({ forms: { proveControlOtp: MyForm } })`.
|
|
928
|
+
*/
|
|
929
|
+
@meta.label 'Confirm your identity'
|
|
930
|
+
@wf.context.pass 'public'
|
|
931
|
+
@ui.form.submit.text 'Verify and link'
|
|
932
|
+
export interface ProveControlOtpForm {
|
|
933
|
+
@ui.form.order 5
|
|
934
|
+
@ui.form.fn.value '(_, _d, ctx) => ctx.public?.proveControl?.sentTo ? "We sent a verification code to " + ctx.public.proveControl.sentTo + ". Enter it to link this sign-in method to your existing account." : "Enter the verification code to link this sign-in method to your existing account."'
|
|
935
|
+
intro: ui.paragraph
|
|
936
|
+
|
|
937
|
+
@ui.form.order 10
|
|
938
|
+
@ui.form.type 'text'
|
|
939
|
+
@meta.label 'Verification code'
|
|
940
|
+
@ui.form.autocomplete 'one-time-code'
|
|
941
|
+
@meta.required
|
|
942
|
+
@expect.minLength 4
|
|
943
|
+
@expect.maxLength 12
|
|
944
|
+
@expect.pattern '^[0-9]+$'
|
|
945
|
+
code: string
|
|
946
|
+
|
|
947
|
+
// Resend the OTP proof code to the SAME own channel — mirrors PincodeForm's
|
|
948
|
+
// resend. `available-at` binds the server-armed cooldown so the renderer can
|
|
949
|
+
// disable / count down the button; the `prove-control` @Step also gates it
|
|
950
|
+
// server-side (a too-soon resend re-pauses with a "Please wait Ns" message).
|
|
951
|
+
@ui.form.order 15
|
|
952
|
+
@ui.form.action 'resend', 'Resend code'
|
|
953
|
+
@ui.form.fn.attr 'available-at', '(_, _d, ctx) => ctx.public?.proveControl?.resendAllowedAt'
|
|
954
|
+
resend?: ui.action
|
|
955
|
+
|
|
956
|
+
@ui.form.order 20
|
|
957
|
+
@ui.form.action 'cancel', 'Cancel'
|
|
958
|
+
@ui.form.attr 'align', 'center'
|
|
959
|
+
@ui.form.pushDown
|
|
960
|
+
cancel?: ui.action
|
|
653
961
|
}
|