@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.
- package/dist/atscript/index.d.mts +2 -2
- package/dist/atscript/index.mjs +2 -2
- package/dist/forms-Bkr7ECKu.mjs +375 -0
- package/dist/index.d.mts +2173 -1656
- package/dist/index.mjs +4350 -3659
- package/package.json +32 -25
- package/src/atscript/models/forms.as +474 -273
- package/src/atscript/models/forms.as.d.ts +87 -120
- package/dist/forms-sF41Fzzn.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,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.
|
|
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).
|
|
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
|
-
@
|
|
186
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
-
@
|
|
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
|
|
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.
|
|
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
|
-
@
|
|
321
|
-
@
|
|
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.
|
|
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.
|
|
335
|
-
@
|
|
336
|
-
|
|
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 `
|
|
345
|
-
* masked recipient set by the pincode-send step) out of the workflow
|
|
346
|
-
* so the operator can see which factor the workflow is currently
|
|
347
|
-
* Requires `installDynamicResolver()` from `@atscript/ui-fns`
|
|
348
|
-
* 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.
|
|
349
413
|
*/
|
|
350
|
-
@
|
|
351
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
@
|
|
399
|
-
@
|
|
400
|
-
@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'
|
|
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
|
-
@
|
|
415
|
-
@
|
|
416
|
-
@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'
|
|
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
|
|
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.
|
|
503
|
+
* come from `ctx.public?.mfaEnroll?.availableTransports` so only consumer-enabled
|
|
429
504
|
* transports appear.
|
|
430
505
|
*/
|
|
431
|
-
@
|
|
432
|
-
@
|
|
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.
|
|
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.
|
|
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 `
|
|
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
|
-
@
|
|
454
|
-
@
|
|
455
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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.
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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
|
-
@
|
|
478
|
-
@wf.context.pass '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
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
|
-
@
|
|
538
|
-
@
|
|
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
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
* the
|
|
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 ∩
|
|
625
|
-
* enrolled factors), so users only see factors they can actually
|
|
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
|
-
@
|
|
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
|
-
|
|
644
|
-
|
|
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
|
}
|