@globalpayments-private/gpds-pattern-web 0.0.0-stage → 0.1.0-pr.7.gdcb74cd
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/README.md +567 -2
- package/dist/cjs/control-strategy-DhhYTbse.js +113 -0
- package/dist/cjs/gpds-pattern-web.cjs.js +24 -0
- package/dist/cjs/index-Dzm6P89U.js +1565 -0
- package/dist/cjs/index.cjs.js +180 -0
- package/dist/cjs/loader.cjs.js +12 -0
- package/dist/cjs/uds-pattern-login-card_2.cjs.entry.js +1662 -0
- package/dist/collection/collection-manifest.json +14 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/identity-memory.slimmer.js +63 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/login-workflow.slimmer.js +357 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/renderers/login-card-renderer.js +100 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/renderers/login-field-renderer.js +129 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/renderers/login-step-renderer.js +192 -0
- package/dist/collection/components/uds-pattern-login-card/slimmers/resend-throttle.slimmer.js +65 -0
- package/dist/collection/components/uds-pattern-login-card/uds-pattern-login-card.css +154 -0
- package/dist/collection/components/uds-pattern-login-card/uds-pattern-login-card.js +508 -0
- package/dist/collection/components/uds-pattern-login-card/validation/login-validation-rules.js +86 -0
- package/dist/collection/components/uds-pattern-login-card/view-model/login-card-handlers.js +1 -0
- package/dist/collection/components/uds-pattern-login-card/view-model/login-step.view-model.js +138 -0
- package/dist/collection/components/uds-pattern-login-page/uds-pattern-login-page.css +37 -0
- package/dist/collection/components/uds-pattern-login-page/uds-pattern-login-page.js +97 -0
- package/dist/collection/index.js +5 -0
- package/dist/collection/providers/auth-provider.js +28 -0
- package/dist/collection/providers/control-strategy.js +21 -0
- package/dist/collection/providers/login-config.js +4 -0
- package/dist/collection/providers/memory-auth-provider.js +133 -0
- package/dist/collection/providers/parse-auth-result.js +135 -0
- package/dist/collection/slimmer/common/log.js +14 -0
- package/dist/collection/slimmer/common/symbols.js +10 -0
- package/dist/collection/slimmer/common/types.js +2 -0
- package/dist/collection/slimmer/decorators/inject-slimmer.js +73 -0
- package/dist/collection/slimmer/decorators/map-to-component-field.js +41 -0
- package/dist/collection/slimmer/decorators/map-to-component-method.js +33 -0
- package/dist/collection/slimmer/decorators/slimmer.js +10 -0
- package/dist/collection/slimmer/index.js +5 -0
- package/dist/collection/utils/labels.js +81 -0
- package/dist/collection/utils/types.js +1 -0
- package/dist/components/index.d.ts +35 -0
- package/dist/components/index.js +1 -0
- package/dist/components/p-DOrg55po.js +1 -0
- package/dist/components/p-_G8A9gg0.js +1 -0
- package/dist/components/uds-pattern-login-card.d.ts +11 -0
- package/dist/components/uds-pattern-login-card.js +1 -0
- package/dist/components/uds-pattern-login-page.d.ts +11 -0
- package/dist/components/uds-pattern-login-page.js +1 -0
- package/dist/docs.d.ts +480 -0
- package/dist/docs.json +549 -0
- package/dist/esm/control-strategy-DOrg55po.js +105 -0
- package/dist/esm/gpds-pattern-web.js +20 -0
- package/dist/esm/index-DsXlLyGj.js +1537 -0
- package/dist/esm/index.js +170 -0
- package/dist/esm/loader.js +10 -0
- package/dist/esm/uds-pattern-login-card_2.entry.js +1659 -0
- package/dist/gpds-pattern-web/gpds-pattern-web.css +1 -0
- package/dist/gpds-pattern-web/gpds-pattern-web.esm.js +1 -0
- package/dist/gpds-pattern-web/index.esm.js +1 -0
- package/dist/gpds-pattern-web/p-2c97d2da.entry.js +1 -0
- package/dist/gpds-pattern-web/p-DOrg55po.js +1 -0
- package/dist/gpds-pattern-web/p-DsXlLyGj.js +3 -0
- package/dist/index.cjs.js +1 -0
- package/dist/index.js +1 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/identity-memory.slimmer.d.ts +21 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/login-workflow.slimmer.d.ts +100 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/renderers/login-card-renderer.d.ts +44 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/renderers/login-field-renderer.d.ts +91 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/renderers/login-step-renderer.d.ts +40 -0
- package/dist/types/components/uds-pattern-login-card/slimmers/resend-throttle.slimmer.d.ts +21 -0
- package/dist/types/components/uds-pattern-login-card/uds-pattern-login-card.d.ts +134 -0
- package/dist/types/components/uds-pattern-login-card/validation/login-validation-rules.d.ts +72 -0
- package/dist/types/components/uds-pattern-login-card/view-model/login-card-handlers.d.ts +21 -0
- package/dist/types/components/uds-pattern-login-card/view-model/login-step.view-model.d.ts +96 -0
- package/dist/types/components/uds-pattern-login-page/uds-pattern-login-page.d.ts +21 -0
- package/dist/types/components.d.ts +327 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/providers/auth-provider.d.ts +168 -0
- package/dist/types/providers/control-strategy.d.ts +144 -0
- package/dist/types/providers/login-config.d.ts +52 -0
- package/dist/types/providers/memory-auth-provider.d.ts +37 -0
- package/dist/types/providers/parse-auth-result.d.ts +8 -0
- package/dist/types/slimmer/common/log.d.ts +8 -0
- package/dist/types/slimmer/common/symbols.d.ts +10 -0
- package/dist/types/slimmer/common/types.d.ts +30 -0
- package/dist/types/slimmer/decorators/inject-slimmer.d.ts +24 -0
- package/dist/types/slimmer/decorators/map-to-component-field.d.ts +16 -0
- package/dist/types/slimmer/decorators/map-to-component-method.d.ts +11 -0
- package/dist/types/slimmer/decorators/slimmer.d.ts +10 -0
- package/dist/types/slimmer/index.d.ts +6 -0
- package/dist/types/stencil-public-runtime.d.ts +1879 -0
- package/dist/types/utils/labels.d.ts +13 -0
- package/dist/types/utils/types.d.ts +106 -0
- package/dist/types/vega.d.ts +11 -0
- package/loader/cdn.js +1 -0
- package/loader/index.cjs.js +1 -0
- package/loader/index.d.ts +24 -0
- package/loader/index.es2017.js +1 -0
- package/loader/index.js +2 -0
- package/package.json +82 -4
|
@@ -0,0 +1,1662 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var index = require('./index-Dzm6P89U.js');
|
|
4
|
+
var controlStrategy = require('./control-strategy-DhhYTbse.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base class for every slimmer.
|
|
8
|
+
*
|
|
9
|
+
* A slimmer owns exactly one responsibility lifted out of a component class —
|
|
10
|
+
* the workflow machine, OTP input behaviour, a throttle timer. It reaches back
|
|
11
|
+
* into its host component only through the two mapping decorators, which keeps
|
|
12
|
+
* the coupling declarative and greppable.
|
|
13
|
+
*/
|
|
14
|
+
class UdsSlimmer {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Narrows the `Object` a decorator is handed to something indexable. */
|
|
18
|
+
const asBag = (target) => target;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Internal markers used to attach slimmer wiring to component and slimmer
|
|
22
|
+
* instances without colliding with any real field name.
|
|
23
|
+
*/
|
|
24
|
+
/** On a slimmer prototype: the list of component methods it hooks into. */
|
|
25
|
+
const SlimmerMappedMethod = Symbol('SlimmerMappedMethod');
|
|
26
|
+
/** On a slimmer instance: the component that injected it. */
|
|
27
|
+
const SlimmerConsumedBy = Symbol('SlimmerConsumedBy');
|
|
28
|
+
/** On a component instance: the slimmers it has injected, keyed by field name. */
|
|
29
|
+
const ConsumerSlimmers = Symbol('ConsumerSlimmers');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Marks a component field as a slimmer injection point.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* class UdsPatternLoginCard {
|
|
36
|
+
* @InjectSlimmer() private workflow = new WorkflowSlimmer();
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* The decorator replaces the field with an accessor pair, so assigning the
|
|
41
|
+
* instance in the field initialiser is what performs the injection: the slimmer
|
|
42
|
+
* is handed a back-reference to the component and any `@MapToComponentMethod`
|
|
43
|
+
* hooks it declares are attached.
|
|
44
|
+
*
|
|
45
|
+
* Note this relies on field initialisers compiling to assignment rather than
|
|
46
|
+
* `Object.defineProperty` — i.e. `useDefineForClassFields` must be off, which is
|
|
47
|
+
* the default below an ES2022 target. With define semantics the initialiser
|
|
48
|
+
* would shadow this accessor on the instance and nothing would be wired up.
|
|
49
|
+
*/
|
|
50
|
+
function InjectSlimmer() {
|
|
51
|
+
return function (componentPrototype, propertyKey) {
|
|
52
|
+
Object.defineProperty(componentPrototype, propertyKey, {
|
|
53
|
+
set: createSlimmerFieldSetter(propertyKey),
|
|
54
|
+
get: createSlimmerFieldGetter(propertyKey),
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createSlimmerFieldSetter(fieldName) {
|
|
60
|
+
return function (slimmer) {
|
|
61
|
+
if (!this[ConsumerSlimmers]) {
|
|
62
|
+
this[ConsumerSlimmers] = new Map();
|
|
63
|
+
}
|
|
64
|
+
this[ConsumerSlimmers].set(fieldName, slimmer);
|
|
65
|
+
asBag(slimmer)[SlimmerConsumedBy] = this;
|
|
66
|
+
registerSlimmerMethods(slimmer, this);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function createSlimmerFieldGetter(fieldName) {
|
|
70
|
+
return function () {
|
|
71
|
+
return this[ConsumerSlimmers]?.get(fieldName);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Attaches every `@MapToComponentMethod` hook the slimmer declares. */
|
|
75
|
+
function registerSlimmerMethods(slimmer, component) {
|
|
76
|
+
const slimmerPrototype = asBag(Object.getPrototypeOf(slimmer));
|
|
77
|
+
const entries = slimmerPrototype[SlimmerMappedMethod];
|
|
78
|
+
if (!entries) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
component[entry.componentMethodName] = chainSlimmerMethod(component, slimmer, component[entry.componentMethodName], entry);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Appends the slimmer method to the component method, preserving any hook that
|
|
87
|
+
* an earlier slimmer already attached so several slimmers can share a target.
|
|
88
|
+
*
|
|
89
|
+
* When the component method returns a promise the slimmer method is chained
|
|
90
|
+
* onto it, so an async `componentWillLoad` still completes in order.
|
|
91
|
+
*/
|
|
92
|
+
function chainSlimmerMethod(component, slimmer, originalMethod, mapEntry) {
|
|
93
|
+
return function (...args) {
|
|
94
|
+
const result = originalMethod?.apply(component, args);
|
|
95
|
+
const slimmerMethod = asBag(slimmer)[mapEntry.slimmerMethodName];
|
|
96
|
+
if (result && typeof result.then === 'function') {
|
|
97
|
+
return result.then(() => slimmerMethod.apply(slimmer, args));
|
|
98
|
+
}
|
|
99
|
+
return slimmerMethod.apply(slimmer, args);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Console wrapper carrying a GP prefix, so framework diagnostics are traceable. */
|
|
104
|
+
class LogUtility {
|
|
105
|
+
static log(...message) {
|
|
106
|
+
console.log(`%c${this.PREFIX}`, this.PREFIX_STYLE, ...message);
|
|
107
|
+
}
|
|
108
|
+
static warn(...message) {
|
|
109
|
+
console.warn(`%c${this.PREFIX}`, this.PREFIX_STYLE, ...message);
|
|
110
|
+
}
|
|
111
|
+
static error(...message) {
|
|
112
|
+
console.error(`%c${this.PREFIX}`, this.PREFIX_STYLE, ...message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
LogUtility.PREFIX = '[GP]';
|
|
116
|
+
LogUtility.PREFIX_STYLE = 'font-weight: bold';
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Binds a slimmer field to a field on the component that injected it:
|
|
120
|
+
*
|
|
121
|
+
* - reading the slimmer field returns the component's current value;
|
|
122
|
+
* - writing it updates the component (only when `writable` is set).
|
|
123
|
+
*
|
|
124
|
+
* The slimmer therefore holds no copy of the state. That matters inside a
|
|
125
|
+
* Stencil component, where the component's `@State()` fields are the values the
|
|
126
|
+
* renderer actually observes — a slimmer-local duplicate would drift and would
|
|
127
|
+
* not trigger re-renders.
|
|
128
|
+
*
|
|
129
|
+
* The slimmer field name is used as the component field name unless
|
|
130
|
+
* `componentFieldName` says otherwise.
|
|
131
|
+
*/
|
|
132
|
+
function MapToComponentField(options) {
|
|
133
|
+
return function (slimmerPrototype, propertyKey) {
|
|
134
|
+
Object.defineProperty(slimmerPrototype, propertyKey, {
|
|
135
|
+
get: createMappedFieldGetter(propertyKey, options),
|
|
136
|
+
set: createMappedFieldSetter(propertyKey, options),
|
|
137
|
+
configurable: true,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function createMappedFieldSetter(slimmerFieldName, options = {}) {
|
|
142
|
+
return function (value) {
|
|
143
|
+
if (!options.writable) {
|
|
144
|
+
LogUtility.error(`${String(slimmerFieldName)} is not writable`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const component = this[SlimmerConsumedBy];
|
|
148
|
+
component[options.componentFieldName ?? slimmerFieldName] = value;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function createMappedFieldGetter(slimmerFieldName, options = {}) {
|
|
152
|
+
return function () {
|
|
153
|
+
const component = this[SlimmerConsumedBy];
|
|
154
|
+
return component[options.componentFieldName ?? slimmerFieldName];
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Hooks a slimmer method onto a component method of the given name.
|
|
160
|
+
*
|
|
161
|
+
* When the component method runs, the slimmer method runs after it and its
|
|
162
|
+
* return value becomes the result. The hook is one-way: calling the slimmer
|
|
163
|
+
* method directly does not invoke the component's.
|
|
164
|
+
*
|
|
165
|
+
* Typical targets are Stencil lifecycle methods (`componentWillLoad`,
|
|
166
|
+
* `disconnectedCallback`), `@Watch` handlers, and `@Method()` members.
|
|
167
|
+
*/
|
|
168
|
+
function MapToComponentMethod(componentMethodName) {
|
|
169
|
+
return function (slimmerPrototype, propertyKey) {
|
|
170
|
+
const bag = asBag(slimmerPrototype);
|
|
171
|
+
initMappedMethodList(bag);
|
|
172
|
+
bag[SlimmerMappedMethod].push({
|
|
173
|
+
componentMethodName,
|
|
174
|
+
slimmerMethodName: String(propertyKey),
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Gives each prototype in an inheritance chain its own copy of the list, seeded
|
|
180
|
+
* from the parent's. Without the `hasOwnProperty` check a subclass would push
|
|
181
|
+
* onto the parent's array and every sibling would inherit its hooks.
|
|
182
|
+
*/
|
|
183
|
+
function initMappedMethodList(slimmerPrototype) {
|
|
184
|
+
if (!Object.prototype.hasOwnProperty.call(slimmerPrototype, SlimmerMappedMethod)) {
|
|
185
|
+
const inherited = slimmerPrototype[SlimmerMappedMethod];
|
|
186
|
+
slimmerPrototype[SlimmerMappedMethod] = inherited ? [...inherited] : [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Runtime validation for the one boundary where data genuinely enters the
|
|
192
|
+
* pattern from outside: a provider's `UdsAuthResult`.
|
|
193
|
+
*
|
|
194
|
+
* ## Why validate here and nowhere else
|
|
195
|
+
*
|
|
196
|
+
* `UdsAuthResult` is a *DTO* in the strict sense — it crosses a trust boundary.
|
|
197
|
+
* An adapter is written by a consuming team, usually by mapping a vendor SDK's
|
|
198
|
+
* response, and TypeScript's guarantees stop at the seam: a compiled adapter
|
|
199
|
+
* can still hand back `{ status: 'otp_requred' }` from a mistyped mapping, or
|
|
200
|
+
* `{ status: 'mfa_required' }` with no `methods` because the vendor returned
|
|
201
|
+
* `null`.
|
|
202
|
+
*
|
|
203
|
+
* Unvalidated, those land as *silence*. The workflow's `switch` falls through,
|
|
204
|
+
* the card stays on its current step, no error is raised, and the user sees a
|
|
205
|
+
* button that does nothing. Debugging that means reading our source. Validated,
|
|
206
|
+
* the same mistake names itself at the point it was made.
|
|
207
|
+
*
|
|
208
|
+
* The view model deliberately has no equivalent: it never crosses a boundary,
|
|
209
|
+
* only moves between layers that are compiled together, so checking it at
|
|
210
|
+
* runtime would buy nothing the compiler has not already proven.
|
|
211
|
+
*
|
|
212
|
+
* ## Failure policy
|
|
213
|
+
*
|
|
214
|
+
* A malformed result is converted into an `unknown` error — the user always
|
|
215
|
+
* sees approved copy — and reported on the console with the offending value.
|
|
216
|
+
* The pattern never throws into a consumer's promise chain for this.
|
|
217
|
+
*/
|
|
218
|
+
const RESULT_STATUSES = [
|
|
219
|
+
'success',
|
|
220
|
+
'password_required',
|
|
221
|
+
'mfa_required',
|
|
222
|
+
'otp_required',
|
|
223
|
+
'password_reset_required',
|
|
224
|
+
'reset_link_sent',
|
|
225
|
+
'username_sent',
|
|
226
|
+
'locked',
|
|
227
|
+
'error',
|
|
228
|
+
];
|
|
229
|
+
const ERROR_CODES = [
|
|
230
|
+
'invalid_credentials',
|
|
231
|
+
'invalid_identifier',
|
|
232
|
+
'invalid_code',
|
|
233
|
+
'code_expired',
|
|
234
|
+
'account_locked',
|
|
235
|
+
'account_not_found',
|
|
236
|
+
'account_exists',
|
|
237
|
+
'password_policy',
|
|
238
|
+
'passwords_do_not_match',
|
|
239
|
+
'rate_limited',
|
|
240
|
+
'network',
|
|
241
|
+
'unknown',
|
|
242
|
+
];
|
|
243
|
+
/** Thrown only internally; never surfaces to a consumer. */
|
|
244
|
+
class InvalidAuthResultError extends Error {
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Validates a provider's response, returning it narrowed to `UdsAuthResult`.
|
|
248
|
+
*
|
|
249
|
+
* @param value what the adapter returned
|
|
250
|
+
* @param source the provider method called, for the diagnostic message
|
|
251
|
+
*/
|
|
252
|
+
function parseAuthResult(value, source) {
|
|
253
|
+
try {
|
|
254
|
+
return assertAuthResult(value, source);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (!(error instanceof InvalidAuthResultError)) {
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
// eslint-disable-next-line no-console
|
|
261
|
+
console.error(`[uds-pattern-login-card] ${error.message}`, value);
|
|
262
|
+
return { status: 'error', code: 'unknown' };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function assertAuthResult(value, source) {
|
|
266
|
+
const result = value;
|
|
267
|
+
if (!result || typeof result !== 'object') {
|
|
268
|
+
throw new InvalidAuthResultError(`${source} resolved with ${describe(value)}, expected a UdsAuthResult object.`);
|
|
269
|
+
}
|
|
270
|
+
const status = result.status;
|
|
271
|
+
if (!RESULT_STATUSES.includes(status)) {
|
|
272
|
+
throw new InvalidAuthResultError(`${source} resolved with status ${describe(status)}. Expected one of: ${RESULT_STATUSES.join(', ')}.`);
|
|
273
|
+
}
|
|
274
|
+
switch (status) {
|
|
275
|
+
case 'mfa_required':
|
|
276
|
+
assertMethods(result.methods, source);
|
|
277
|
+
break;
|
|
278
|
+
case 'otp_required':
|
|
279
|
+
if (result.method !== undefined) {
|
|
280
|
+
assertMethod(result.method, source, 'method');
|
|
281
|
+
}
|
|
282
|
+
assertOptionalNumber(result.codeLength, source, 'codeLength');
|
|
283
|
+
assertOptionalNumber(result.resendAfterMs, source, 'resendAfterMs');
|
|
284
|
+
break;
|
|
285
|
+
case 'locked':
|
|
286
|
+
assertOptionalNumber(result.unlockAfterMs, source, 'unlockAfterMs');
|
|
287
|
+
break;
|
|
288
|
+
case 'error':
|
|
289
|
+
if (!ERROR_CODES.includes(result.code)) {
|
|
290
|
+
throw new InvalidAuthResultError(`${source} resolved with error code ${describe(result.code)}. Expected one of: ${ERROR_CODES.join(', ')}.`);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
function assertMethods(value, source) {
|
|
297
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
298
|
+
throw new InvalidAuthResultError(`${source} resolved with status 'mfa_required' but methods was ${describe(value)}. ` +
|
|
299
|
+
'Expected a non-empty array of UdsMfaMethod — the card has nothing to offer the user otherwise.');
|
|
300
|
+
}
|
|
301
|
+
value.forEach((method, index) => assertMethod(method, source, `methods[${index}]`));
|
|
302
|
+
}
|
|
303
|
+
function assertMethod(value, source, path) {
|
|
304
|
+
const method = value;
|
|
305
|
+
if (!method || typeof method !== 'object' || typeof method.id !== 'string' || typeof method.label !== 'string') {
|
|
306
|
+
throw new InvalidAuthResultError(`${source} resolved with ${path} = ${describe(value)}. Expected { id: string, label: string }.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function assertOptionalNumber(value, source, path) {
|
|
310
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
311
|
+
throw new InvalidAuthResultError(`${source} resolved with ${path} = ${describe(value)}. Expected a finite number or undefined.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function describe(value) {
|
|
315
|
+
if (value === null)
|
|
316
|
+
return 'null';
|
|
317
|
+
if (value === undefined)
|
|
318
|
+
return 'undefined';
|
|
319
|
+
if (typeof value === 'string')
|
|
320
|
+
return `'${value}'`;
|
|
321
|
+
if (Array.isArray(value))
|
|
322
|
+
return `an array of ${value.length}`;
|
|
323
|
+
return typeof value === 'object' ? 'an object' : String(value);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
var __decorate$6 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
327
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
328
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
329
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
330
|
+
else
|
|
331
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
332
|
+
if (d = decorators[i])
|
|
333
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
334
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* The login state machine.
|
|
338
|
+
*
|
|
339
|
+
* This is the pattern's core asset: the translation from "what the identity
|
|
340
|
+
* platform needs next" into "what the user sees next". Keeping it in one class,
|
|
341
|
+
* away from markup and input plumbing, is what makes the sequencing reviewable.
|
|
342
|
+
*
|
|
343
|
+
* Every decision it makes passes through the host's `controlStrategy` first, so
|
|
344
|
+
* a consumer can redirect the flow without reimplementing it.
|
|
345
|
+
*/
|
|
346
|
+
class LoginWorkflowSlimmer extends UdsSlimmer {
|
|
347
|
+
constructor() {
|
|
348
|
+
super();
|
|
349
|
+
/** The identifier being authenticated, carried across steps. */
|
|
350
|
+
this.pendingIdentifier = '';
|
|
351
|
+
}
|
|
352
|
+
// --- Lifecycle ----------------------------------------------------------
|
|
353
|
+
start() {
|
|
354
|
+
this.step = this.initialStep;
|
|
355
|
+
this.pendingIdentifier = this.memory.initialIdentifier();
|
|
356
|
+
this.resetStepState();
|
|
357
|
+
}
|
|
358
|
+
/** Returns the workflow to its first step and clears all collected input. */
|
|
359
|
+
async restart() {
|
|
360
|
+
this.methods = [];
|
|
361
|
+
this.errorText = undefined;
|
|
362
|
+
this.destination = undefined;
|
|
363
|
+
this.unlockMinutes = undefined;
|
|
364
|
+
this.otpCount = undefined;
|
|
365
|
+
this.resend.stop();
|
|
366
|
+
this.applyTransition(controlStrategy.goTo(this.initialStep), 'reset');
|
|
367
|
+
}
|
|
368
|
+
// --- Capabilities -------------------------------------------------------
|
|
369
|
+
/**
|
|
370
|
+
* Which affordances to render. The presence of an optional provider method
|
|
371
|
+
* *is* the configuration, so a route can never be offered for something the
|
|
372
|
+
* backend cannot service.
|
|
373
|
+
*/
|
|
374
|
+
get capabilities() {
|
|
375
|
+
const a = this.auth;
|
|
376
|
+
return {
|
|
377
|
+
signUp: typeof a?.signUp === 'function',
|
|
378
|
+
reset: typeof a?.requestPasswordReset === 'function',
|
|
379
|
+
username: typeof a?.requestUsernameReminder === 'function',
|
|
380
|
+
resend: typeof a?.resendOtp === 'function',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// --- Navigation ---------------------------------------------------------
|
|
384
|
+
/** In-workflow navigation, triggered by a link in the card. */
|
|
385
|
+
navigateTo(step) {
|
|
386
|
+
this.errorText = undefined;
|
|
387
|
+
this.applyTransition(controlStrategy.goTo(step), 'link');
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Performs a transition, giving the strategy its say on every step change —
|
|
391
|
+
* including the ones the workflow initiates for itself.
|
|
392
|
+
*/
|
|
393
|
+
applyTransition(transition, cause) {
|
|
394
|
+
if (transition.type === 'stay') {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (transition.type === 'fail') {
|
|
398
|
+
this.showError(transition.code, transition.message);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const decided = controlStrategy.intercept(this.controlStrategy?.navigate, { from: this.step, to: transition.step, cause }, () => transition);
|
|
402
|
+
// A strategy may answer a navigation with a different kind of outcome
|
|
403
|
+
// entirely — stay put, or surface a failure instead of moving.
|
|
404
|
+
if (decided.type === 'stay') {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (decided.type === 'fail') {
|
|
408
|
+
this.showError(decided.code, decided.message);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.setStep(decided.step);
|
|
412
|
+
}
|
|
413
|
+
setStep(next) {
|
|
414
|
+
const previousStep = this.step;
|
|
415
|
+
this.step = next;
|
|
416
|
+
this.resetStepState();
|
|
417
|
+
if (previousStep !== next) {
|
|
418
|
+
this.udsLoginStepChange.emit({ step: next, previousStep });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/** Clears per-step input, seeding the identifier field where appropriate. */
|
|
422
|
+
resetStepState() {
|
|
423
|
+
const seedsIdentifier = this.step === 'identifier' || this.step === 'forgot-password';
|
|
424
|
+
this.values = seedsIdentifier && this.pendingIdentifier ? { identifier: this.pendingIdentifier } : {};
|
|
425
|
+
}
|
|
426
|
+
/** How many characters the passcode entry should render. */
|
|
427
|
+
get codeLength() {
|
|
428
|
+
return Math.max(1, this.otpCount ?? this.otpLength);
|
|
429
|
+
}
|
|
430
|
+
// --- Result translation -------------------------------------------------
|
|
431
|
+
/**
|
|
432
|
+
* Translates a provider result into the next step. This inversion is what
|
|
433
|
+
* makes the pattern a pattern: the identity platform reports what it needs,
|
|
434
|
+
* and the card — not the consumer — decides what that looks like.
|
|
435
|
+
*/
|
|
436
|
+
applyResult(result) {
|
|
437
|
+
this.errorText = undefined;
|
|
438
|
+
const transition = controlStrategy.intercept(this.controlStrategy?.transition, { result, from: this.step }, () => this.defaultTransitionFor(result));
|
|
439
|
+
// Side effects that belong to the result rather than the destination are
|
|
440
|
+
// staged before the move, so the step renders with its data already in place.
|
|
441
|
+
this.stageResultState(result);
|
|
442
|
+
if (result.status === 'mfa_required' && this.methods.length === 1 && transition.type === 'stay') {
|
|
443
|
+
// A single option is not a choice — skip the picker.
|
|
444
|
+
void this.selectMfaMethod(this.methods[0].id);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
this.applyTransition(transition, 'auto');
|
|
448
|
+
if (result.status === 'success' && transition.type === 'goto' && transition.step === 'success') {
|
|
449
|
+
controlStrategy.intercept(this.controlStrategy?.complete, { session: result.session }, () => this.udsLoginSuccess.emit({ session: result.session }));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
defaultTransitionFor(result) {
|
|
453
|
+
switch (result.status) {
|
|
454
|
+
case 'success':
|
|
455
|
+
return controlStrategy.goTo('success');
|
|
456
|
+
case 'password_required':
|
|
457
|
+
return controlStrategy.goTo('password');
|
|
458
|
+
case 'mfa_required':
|
|
459
|
+
// Staying signals "the picker is not needed"; `applyResult` turns that
|
|
460
|
+
// into an auto-selection when the provider offered exactly one method.
|
|
461
|
+
return result.methods.length === 1 ? controlStrategy.stay() : controlStrategy.goTo('mfa-method');
|
|
462
|
+
case 'otp_required':
|
|
463
|
+
return controlStrategy.goTo('otp');
|
|
464
|
+
case 'password_reset_required':
|
|
465
|
+
return controlStrategy.goTo('reset-password');
|
|
466
|
+
case 'reset_link_sent':
|
|
467
|
+
case 'username_sent':
|
|
468
|
+
return controlStrategy.goTo('check-email');
|
|
469
|
+
case 'locked':
|
|
470
|
+
return controlStrategy.goTo('locked');
|
|
471
|
+
case 'error':
|
|
472
|
+
return controlStrategy.fail(result.code, result.message);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Captures the data a result carries, independent of where the flow goes. */
|
|
476
|
+
stageResultState(result) {
|
|
477
|
+
switch (result.status) {
|
|
478
|
+
case 'mfa_required':
|
|
479
|
+
this.methods = result.methods;
|
|
480
|
+
return;
|
|
481
|
+
case 'otp_required':
|
|
482
|
+
this.otpMethodLabel = result.method?.label;
|
|
483
|
+
this.otpCount = result.codeLength ?? this.otpLength;
|
|
484
|
+
this.resend.start(result.resendAfterMs);
|
|
485
|
+
return;
|
|
486
|
+
case 'reset_link_sent':
|
|
487
|
+
case 'username_sent':
|
|
488
|
+
this.destination = result.destination;
|
|
489
|
+
return;
|
|
490
|
+
case 'locked':
|
|
491
|
+
this.unlockMinutes = result.unlockAfterMs ? Math.ceil(result.unlockAfterMs / 60000) : undefined;
|
|
492
|
+
return;
|
|
493
|
+
default:
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// --- Failures -----------------------------------------------------------
|
|
498
|
+
showError(code, message) {
|
|
499
|
+
const outcome = controlStrategy.intercept(this.controlStrategy?.failure, { code, step: this.step, message }, () => ({
|
|
500
|
+
text: this.copy.errors[code] ?? this.copy.errors.unknown,
|
|
501
|
+
}));
|
|
502
|
+
this.errorText = outcome.text;
|
|
503
|
+
this.udsLoginError.emit({ code, step: this.step, message });
|
|
504
|
+
if (outcome.transition) {
|
|
505
|
+
this.applyTransition(outcome.transition, 'auto');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// --- Provider calls -----------------------------------------------------
|
|
509
|
+
/**
|
|
510
|
+
* Wraps a provider call with the in-flight guard and the network catch.
|
|
511
|
+
*
|
|
512
|
+
* `busy` is released *before* the result is applied, because applying a
|
|
513
|
+
* result may legitimately chain another provider call (auto-selecting a lone
|
|
514
|
+
* MFA method, for example). Holding the guard across `applyResult` would make
|
|
515
|
+
* that continuation a no-op and strand the user on the previous step. The
|
|
516
|
+
* chained call re-acquires the guard synchronously, so no un-busy frame is
|
|
517
|
+
* ever painted.
|
|
518
|
+
*
|
|
519
|
+
* Every provider response is validated here, at the one point where data
|
|
520
|
+
* crosses into the pattern from a consumer's adapter. See `parseAuthResult`.
|
|
521
|
+
*/
|
|
522
|
+
async run(call, source) {
|
|
523
|
+
if (this.busy) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.busy = true;
|
|
527
|
+
let result;
|
|
528
|
+
try {
|
|
529
|
+
result = parseAuthResult(await call(), `auth.${source}`);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
this.busy = false;
|
|
533
|
+
this.showError('network');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.busy = false;
|
|
537
|
+
this.applyResult(result);
|
|
538
|
+
}
|
|
539
|
+
async selectMfaMethod(methodId) {
|
|
540
|
+
if (!this.auth?.selectMfaMethod) {
|
|
541
|
+
this.showError('unknown');
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
await this.run(() => this.auth.selectMfaMethod({ methodId }), 'selectMfaMethod');
|
|
545
|
+
}
|
|
546
|
+
async resendCode() {
|
|
547
|
+
if (!this.resend.canResend || !this.auth?.resendOtp) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await this.run(() => this.auth.resendOtp(), 'resendOtp');
|
|
551
|
+
}
|
|
552
|
+
// --- Submission ---------------------------------------------------------
|
|
553
|
+
/**
|
|
554
|
+
* Submits the current step.
|
|
555
|
+
*
|
|
556
|
+
* The strategy wraps the whole provider call, so a consumer can gate it
|
|
557
|
+
* (CAPTCHA, device check, consent) or answer it outright without the pattern
|
|
558
|
+
* needing a dedicated prop for each case.
|
|
559
|
+
*/
|
|
560
|
+
async submit() {
|
|
561
|
+
if (this.busy) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const step = this.step;
|
|
565
|
+
const values = { ...this.values };
|
|
566
|
+
this.rememberIdentifierIfRequested(step);
|
|
567
|
+
await this.run(() => controlStrategy.intercept(this.controlStrategy?.submit, { step, values, identifier: this.pendingIdentifier }, () => this.callProvider(step)), `submit(${step})`);
|
|
568
|
+
}
|
|
569
|
+
rememberIdentifierIfRequested(step) {
|
|
570
|
+
if (step !== 'identifier') {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const identifier = this.str('identifier').trim();
|
|
574
|
+
this.pendingIdentifier = identifier;
|
|
575
|
+
this.memory.remember(identifier, this.values.rememberIdentifier === true);
|
|
576
|
+
}
|
|
577
|
+
callProvider(step) {
|
|
578
|
+
const auth = this.auth;
|
|
579
|
+
if (!auth) {
|
|
580
|
+
return Promise.resolve({ status: 'error', code: 'unknown' });
|
|
581
|
+
}
|
|
582
|
+
const unsupported = Promise.resolve({ status: 'error', code: 'unknown' });
|
|
583
|
+
switch (step) {
|
|
584
|
+
case 'identifier':
|
|
585
|
+
return auth.signIn({
|
|
586
|
+
identifier: this.pendingIdentifier,
|
|
587
|
+
rememberIdentifier: this.values.rememberIdentifier === true,
|
|
588
|
+
});
|
|
589
|
+
case 'password': {
|
|
590
|
+
const password = this.str('password');
|
|
591
|
+
const identifier = this.pendingIdentifier;
|
|
592
|
+
return auth.verifyPassword
|
|
593
|
+
? auth.verifyPassword({ identifier, password })
|
|
594
|
+
: auth.signIn({ identifier, password });
|
|
595
|
+
}
|
|
596
|
+
case 'mfa-method':
|
|
597
|
+
return auth.selectMfaMethod?.({ methodId: this.str('mfaMethod') }) ?? unsupported;
|
|
598
|
+
case 'otp':
|
|
599
|
+
return auth.verifyOtp?.({ code: this.str('code') }) ?? unsupported;
|
|
600
|
+
case 'forgot-password':
|
|
601
|
+
return auth.requestPasswordReset?.({ identifier: this.str('identifier').trim() }) ?? unsupported;
|
|
602
|
+
case 'forgot-username':
|
|
603
|
+
return auth.requestUsernameReminder?.({ email: this.str('email').trim() }) ?? unsupported;
|
|
604
|
+
case 'reset-password':
|
|
605
|
+
return auth.resetPassword?.({ password: this.str('password') }) ?? unsupported;
|
|
606
|
+
case 'sign-up':
|
|
607
|
+
return (auth.signUp?.({
|
|
608
|
+
firstName: this.str('firstName'),
|
|
609
|
+
lastName: this.str('lastName'),
|
|
610
|
+
identifier: this.str('identifier').trim(),
|
|
611
|
+
}) ?? unsupported);
|
|
612
|
+
default:
|
|
613
|
+
return unsupported;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
str(name) {
|
|
617
|
+
return String(this.values[name] ?? '');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
__decorate$6([
|
|
621
|
+
MapToComponentField()
|
|
622
|
+
], LoginWorkflowSlimmer.prototype, "resend", void 0);
|
|
623
|
+
__decorate$6([
|
|
624
|
+
MapToComponentField()
|
|
625
|
+
], LoginWorkflowSlimmer.prototype, "memory", void 0);
|
|
626
|
+
__decorate$6([
|
|
627
|
+
MapToComponentField()
|
|
628
|
+
], LoginWorkflowSlimmer.prototype, "udsLoginStepChange", void 0);
|
|
629
|
+
__decorate$6([
|
|
630
|
+
MapToComponentField()
|
|
631
|
+
], LoginWorkflowSlimmer.prototype, "udsLoginError", void 0);
|
|
632
|
+
__decorate$6([
|
|
633
|
+
MapToComponentField()
|
|
634
|
+
], LoginWorkflowSlimmer.prototype, "udsLoginSuccess", void 0);
|
|
635
|
+
__decorate$6([
|
|
636
|
+
MapToComponentField()
|
|
637
|
+
], LoginWorkflowSlimmer.prototype, "auth", void 0);
|
|
638
|
+
__decorate$6([
|
|
639
|
+
MapToComponentField()
|
|
640
|
+
], LoginWorkflowSlimmer.prototype, "controlStrategy", void 0);
|
|
641
|
+
__decorate$6([
|
|
642
|
+
MapToComponentField()
|
|
643
|
+
], LoginWorkflowSlimmer.prototype, "initialStep", void 0);
|
|
644
|
+
__decorate$6([
|
|
645
|
+
MapToComponentField()
|
|
646
|
+
], LoginWorkflowSlimmer.prototype, "otpLength", void 0);
|
|
647
|
+
__decorate$6([
|
|
648
|
+
MapToComponentField()
|
|
649
|
+
], LoginWorkflowSlimmer.prototype, "copy", void 0);
|
|
650
|
+
__decorate$6([
|
|
651
|
+
MapToComponentField({ writable: true })
|
|
652
|
+
], LoginWorkflowSlimmer.prototype, "step", void 0);
|
|
653
|
+
__decorate$6([
|
|
654
|
+
MapToComponentField({ writable: true })
|
|
655
|
+
], LoginWorkflowSlimmer.prototype, "busy", void 0);
|
|
656
|
+
__decorate$6([
|
|
657
|
+
MapToComponentField({ writable: true })
|
|
658
|
+
], LoginWorkflowSlimmer.prototype, "errorText", void 0);
|
|
659
|
+
__decorate$6([
|
|
660
|
+
MapToComponentField({ writable: true })
|
|
661
|
+
], LoginWorkflowSlimmer.prototype, "methods", void 0);
|
|
662
|
+
__decorate$6([
|
|
663
|
+
MapToComponentField({ writable: true })
|
|
664
|
+
], LoginWorkflowSlimmer.prototype, "otpMethodLabel", void 0);
|
|
665
|
+
__decorate$6([
|
|
666
|
+
MapToComponentField({ writable: true })
|
|
667
|
+
], LoginWorkflowSlimmer.prototype, "destination", void 0);
|
|
668
|
+
__decorate$6([
|
|
669
|
+
MapToComponentField({ writable: true })
|
|
670
|
+
], LoginWorkflowSlimmer.prototype, "unlockMinutes", void 0);
|
|
671
|
+
__decorate$6([
|
|
672
|
+
MapToComponentField({ writable: true })
|
|
673
|
+
], LoginWorkflowSlimmer.prototype, "values", void 0);
|
|
674
|
+
__decorate$6([
|
|
675
|
+
MapToComponentMethod('componentWillLoad')
|
|
676
|
+
], LoginWorkflowSlimmer.prototype, "start", null);
|
|
677
|
+
__decorate$6([
|
|
678
|
+
MapToComponentMethod('reset')
|
|
679
|
+
], LoginWorkflowSlimmer.prototype, "restart", null);
|
|
680
|
+
|
|
681
|
+
var __decorate$5 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
682
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
683
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
684
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
685
|
+
else
|
|
686
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
687
|
+
if (d = decorators[i])
|
|
688
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
689
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
690
|
+
};
|
|
691
|
+
/**
|
|
692
|
+
* The resend throttle and its countdown.
|
|
693
|
+
*
|
|
694
|
+
* Small, but exactly the kind of thing that disappears into a large component
|
|
695
|
+
* and then gets subtly broken: an interval that must be cleared on disconnect,
|
|
696
|
+
* and a tick that must drive a re-render.
|
|
697
|
+
*/
|
|
698
|
+
class ResendThrottleSlimmer extends UdsSlimmer {
|
|
699
|
+
constructor() {
|
|
700
|
+
super();
|
|
701
|
+
}
|
|
702
|
+
/** Begins a throttle window. A missing or non-positive duration clears it. */
|
|
703
|
+
start(resendAfterMs) {
|
|
704
|
+
this.stop();
|
|
705
|
+
if (!resendAfterMs || resendAfterMs <= 0) {
|
|
706
|
+
this.resendAt = undefined;
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
this.resendAt = Date.now() + resendAfterMs;
|
|
710
|
+
this.nowTick = Date.now();
|
|
711
|
+
this.timer = setInterval(() => {
|
|
712
|
+
this.nowTick = Date.now();
|
|
713
|
+
if (this.resendAt !== undefined && this.nowTick >= this.resendAt) {
|
|
714
|
+
this.stop();
|
|
715
|
+
this.resendAt = undefined;
|
|
716
|
+
}
|
|
717
|
+
}, 1000);
|
|
718
|
+
}
|
|
719
|
+
stop() {
|
|
720
|
+
if (this.timer) {
|
|
721
|
+
clearInterval(this.timer);
|
|
722
|
+
this.timer = undefined;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** Seconds left on the throttle; zero when resending is allowed. */
|
|
726
|
+
get secondsRemaining() {
|
|
727
|
+
if (this.resendAt === undefined) {
|
|
728
|
+
return 0;
|
|
729
|
+
}
|
|
730
|
+
return Math.max(0, Math.ceil((this.resendAt - this.nowTick) / 1000));
|
|
731
|
+
}
|
|
732
|
+
get canResend() {
|
|
733
|
+
return this.secondsRemaining === 0;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
__decorate$5([
|
|
737
|
+
MapToComponentField({ writable: true })
|
|
738
|
+
], ResendThrottleSlimmer.prototype, "resendAt", void 0);
|
|
739
|
+
__decorate$5([
|
|
740
|
+
MapToComponentField({ writable: true })
|
|
741
|
+
], ResendThrottleSlimmer.prototype, "nowTick", void 0);
|
|
742
|
+
__decorate$5([
|
|
743
|
+
MapToComponentMethod('disconnectedCallback')
|
|
744
|
+
], ResendThrottleSlimmer.prototype, "stop", null);
|
|
745
|
+
|
|
746
|
+
var __decorate$4 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
747
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
748
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
749
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
750
|
+
else
|
|
751
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
752
|
+
if (d = decorators[i])
|
|
753
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
754
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
755
|
+
};
|
|
756
|
+
/**
|
|
757
|
+
* Persistence of the remembered identifier.
|
|
758
|
+
*
|
|
759
|
+
* Isolated because it is the one place the pattern touches storage, and storage
|
|
760
|
+
* can fail for reasons that have nothing to do with signing in — private
|
|
761
|
+
* browsing, blocked cookies, a strict enterprise policy. Those failures are
|
|
762
|
+
* swallowed deliberately: remembering an email address is a convenience, never
|
|
763
|
+
* a precondition for authenticating.
|
|
764
|
+
*/
|
|
765
|
+
class IdentityMemorySlimmer extends UdsSlimmer {
|
|
766
|
+
constructor() {
|
|
767
|
+
super();
|
|
768
|
+
}
|
|
769
|
+
/** The identifier the card should start with. */
|
|
770
|
+
initialIdentifier() {
|
|
771
|
+
return this.identifier || this.read() || '';
|
|
772
|
+
}
|
|
773
|
+
read() {
|
|
774
|
+
if (!this.rememberStorageKey) {
|
|
775
|
+
return '';
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
return globalThis.localStorage?.getItem(this.rememberStorageKey) ?? '';
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return '';
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** Stores or clears the identifier according to the user's choice. */
|
|
785
|
+
remember(identifier, remember) {
|
|
786
|
+
if (!this.rememberStorageKey) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
if (remember) {
|
|
791
|
+
globalThis.localStorage?.setItem(this.rememberStorageKey, identifier);
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
globalThis.localStorage?.removeItem(this.rememberStorageKey);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
// See the class comment: persistence is best-effort by design.
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
__decorate$4([
|
|
803
|
+
MapToComponentField()
|
|
804
|
+
], IdentityMemorySlimmer.prototype, "rememberStorageKey", void 0);
|
|
805
|
+
__decorate$4([
|
|
806
|
+
MapToComponentField()
|
|
807
|
+
], IdentityMemorySlimmer.prototype, "identifier", void 0);
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The pattern's own `vega-form` validation rules.
|
|
811
|
+
*
|
|
812
|
+
* ## Why rules rather than a pre-submit check
|
|
813
|
+
*
|
|
814
|
+
* `vega-form` already gates submission: its submit handler awaits `valid()` and
|
|
815
|
+
* only emits `vegaFormSubmit` when every field passes. Anything the pattern
|
|
816
|
+
* checks *outside* that gate is a second, competing validation pass — it fires
|
|
817
|
+
* at a different moment, reports through a different surface (a card-level
|
|
818
|
+
* banner rather than the field at fault), and has to be kept in step by hand.
|
|
819
|
+
*
|
|
820
|
+
* Expressing the same checks as rules puts them inside the one flow. The field
|
|
821
|
+
* that is wrong is the field that says so, the error clears as the user fixes
|
|
822
|
+
* it, and the submit gate needs no pattern-specific code at all.
|
|
823
|
+
*
|
|
824
|
+
* ## Why these two, and nothing else
|
|
825
|
+
*
|
|
826
|
+
* Everything Vega already derives from a prop — `required`, `type="email"`,
|
|
827
|
+
* `minLength` — is left to Vega, including its wording. Restating "this field
|
|
828
|
+
* is required" here would fork copy that the design system maintains for every
|
|
829
|
+
* Global Payments product. Only checks Vega cannot express from a prop alone
|
|
830
|
+
* live here: one that needs a sibling field's value, and one that needs the
|
|
831
|
+
* configured code length.
|
|
832
|
+
*
|
|
833
|
+
* ## Why they read through callbacks
|
|
834
|
+
*
|
|
835
|
+
* A rule is handed to `vega-input` as a prop and re-read when its identity
|
|
836
|
+
* changes, so rebuilding the array on each render would churn Vega's watchers
|
|
837
|
+
* every time any unrelated state moved. These are built once and resolve their
|
|
838
|
+
* inputs — the value to compare against, the message to show — when they are
|
|
839
|
+
* evaluated, so a single stable instance always sees current state.
|
|
840
|
+
*/
|
|
841
|
+
/** Evaluated only once the field has a value; emptiness is `required`'s job. */
|
|
842
|
+
class NonEmptyStringRule {
|
|
843
|
+
canEvaluate(input, status) {
|
|
844
|
+
if (typeof input !== 'string' || input.length === 0) {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
return status ? !status.disabled && status.visible : true;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Mirrors Vega's built-in rules: say nothing until the user has engaged with
|
|
851
|
+
* the field, so a freshly rendered step is not pre-littered with errors.
|
|
852
|
+
*/
|
|
853
|
+
shouldShowError(status) {
|
|
854
|
+
return status ? !status.disabled && status.visible && (status.touched || status.modified) : true;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Requires a field to equal another field's current value.
|
|
859
|
+
*
|
|
860
|
+
* Used for the "confirm new password" pairing, which no single-field rule can
|
|
861
|
+
* express: the answer depends on a sibling that may change after this field was
|
|
862
|
+
* last touched.
|
|
863
|
+
*/
|
|
864
|
+
class MatchesFieldRule extends NonEmptyStringRule {
|
|
865
|
+
constructor(expected, message) {
|
|
866
|
+
super();
|
|
867
|
+
this.expected = expected;
|
|
868
|
+
this.message = message;
|
|
869
|
+
}
|
|
870
|
+
evaluate(input) {
|
|
871
|
+
const isValid = input === this.expected();
|
|
872
|
+
return { isValid, message: isValid ? '' : this.message() };
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Requires every character of a passcode to be present.
|
|
877
|
+
*
|
|
878
|
+
* `required` alone is not enough here. It only asks whether the value is
|
|
879
|
+
* non-empty, so a half-typed code — three boxes of six — satisfies it and
|
|
880
|
+
* reaches the provider, which then has to answer a question the pattern could
|
|
881
|
+
* have answered without a round trip.
|
|
882
|
+
*/
|
|
883
|
+
class CompleteCodeRule extends NonEmptyStringRule {
|
|
884
|
+
constructor(length, message) {
|
|
885
|
+
super();
|
|
886
|
+
this.length = length;
|
|
887
|
+
this.message = message;
|
|
888
|
+
}
|
|
889
|
+
evaluate(input) {
|
|
890
|
+
const expected = this.length();
|
|
891
|
+
const isValid = input.length >= expected;
|
|
892
|
+
return { isValid, message: isValid ? '' : this.message(expected) };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
var __decorate$3 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
897
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
898
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
899
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
900
|
+
else
|
|
901
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
902
|
+
if (d = decorators[i])
|
|
903
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
904
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
905
|
+
};
|
|
906
|
+
/**
|
|
907
|
+
* Renders the individual form controls, composed from Vega design-system
|
|
908
|
+
* elements rather than raw HTML.
|
|
909
|
+
*
|
|
910
|
+
* ## Why these are Vega elements and not our own markup
|
|
911
|
+
*
|
|
912
|
+
* A pattern's job is workflow choreography; the look, the focus ring, the label
|
|
913
|
+
* association, the error presentation and the contrast ratios are the design
|
|
914
|
+
* system's job. Hand-rolling an `<input>` here would fork all of that: the DS
|
|
915
|
+
* could ship an accessibility fix to `vega-input` and this card would never
|
|
916
|
+
* receive it. Composing the DS element instead means the pattern inherits those
|
|
917
|
+
* guarantees — and keeps inheriting them.
|
|
918
|
+
*
|
|
919
|
+
* It is the same principle that led us to withdraw `::part` (ADR 0003), applied
|
|
920
|
+
* from the inside: we do not override design-system guardrails, and we do not
|
|
921
|
+
* quietly re-implement them either.
|
|
922
|
+
*
|
|
923
|
+
* Note how much behaviour this removes. `vega-input` carries its own password
|
|
924
|
+
* reveal affordance, so the card no longer tracks a `revealSecret` flag or
|
|
925
|
+
* renders an eye icon.
|
|
926
|
+
*/
|
|
927
|
+
class LoginFieldRenderer extends UdsSlimmer {
|
|
928
|
+
constructor() {
|
|
929
|
+
super();
|
|
930
|
+
/**
|
|
931
|
+
* Rule instances are built once and kept.
|
|
932
|
+
*
|
|
933
|
+
* `validationRules` is a watched Vega prop, so handing it a freshly built
|
|
934
|
+
* array on every render would re-register the rules each time any unrelated
|
|
935
|
+
* state moved. These close over `this.viewModel`, which the slimmer re-reads
|
|
936
|
+
* from the component, so a single stable instance always evaluates against
|
|
937
|
+
* current state.
|
|
938
|
+
*/
|
|
939
|
+
this.matchRules = new Map();
|
|
940
|
+
this.completeCode = [
|
|
941
|
+
new CompleteCodeRule(() => this.viewModel.otpLength, count => controlStrategy.formatLabel(this.viewModel.copy.otpIncomplete, { count })),
|
|
942
|
+
];
|
|
943
|
+
}
|
|
944
|
+
/** One rule per pairing, memoised so the array identity is stable per field. */
|
|
945
|
+
matchRuleFor(sibling) {
|
|
946
|
+
let rule = this.matchRules.get(sibling);
|
|
947
|
+
if (!rule) {
|
|
948
|
+
rule = [
|
|
949
|
+
new MatchesFieldRule(() => this.viewModel.valueOf(sibling), () => this.viewModel.copy.errors.passwords_do_not_match),
|
|
950
|
+
];
|
|
951
|
+
this.matchRules.set(sibling, rule);
|
|
952
|
+
}
|
|
953
|
+
return rule;
|
|
954
|
+
}
|
|
955
|
+
/** A labelled text input bound to a collected value. */
|
|
956
|
+
textField(options) {
|
|
957
|
+
const vm = this.viewModel;
|
|
958
|
+
const { name, label, type = 'text', autocomplete, mustMatch } = options;
|
|
959
|
+
return (index.h("vega-input", { key: name, class: "field", name: name, label: label, type: type, autocomplete: autocomplete, value: vm.valueOf(name), disabled: vm.busy, showClearIcon: false,
|
|
960
|
+
// `required` and `type="email"` are the whole of this field's
|
|
961
|
+
// validation: Vega derives the rules, the messages and the error
|
|
962
|
+
// presentation from them, and `vega-form` will not submit until they
|
|
963
|
+
// pass. `autoValidation` is what keeps the form's own `isValid` current
|
|
964
|
+
// between submits, and lets an error clear as the user corrects it.
|
|
965
|
+
required: true, autoValidation: true, validationRules: mustMatch ? this.matchRuleFor(mustMatch) : undefined, onVegaChange: (event) => this.handlers.onValueChange(name, event.detail) }));
|
|
966
|
+
}
|
|
967
|
+
/** The passcode entry for the OTP step. */
|
|
968
|
+
passcodeField() {
|
|
969
|
+
const vm = this.viewModel;
|
|
970
|
+
return (index.h("vega-input-passcode", { class: "field", name: "code", label: vm.copy.otpFieldLabel, length: vm.otpLength, inputType: "numeric", disabled: vm.busy,
|
|
971
|
+
// `required` only asks whether the value is non-empty, so a half-typed
|
|
972
|
+
// code would pass it; `CompleteCodeRule` is what holds the submit until
|
|
973
|
+
// every box is filled.
|
|
974
|
+
required: true, autoValidation: true, validationRules: this.completeCode, value: vm.valueOf('code'), onVegaChange: (event) => this.handlers.onValueChange('code', event.detail) }));
|
|
975
|
+
}
|
|
976
|
+
/** A checkbox bound to a collected boolean. */
|
|
977
|
+
checkbox(name, label) {
|
|
978
|
+
const vm = this.viewModel;
|
|
979
|
+
return (index.h("vega-checkbox", { class: "field", name: name, identifier: name, checked: vm.isChecked(name), disabled: vm.busy, onVegaUserChange: (event) => this.handlers.onValueChange(name, event.detail !== false) }, label));
|
|
980
|
+
}
|
|
981
|
+
/** The step's primary action. */
|
|
982
|
+
submit(label) {
|
|
983
|
+
const vm = this.viewModel;
|
|
984
|
+
return index.h("vega-button", { class: "submit", type: "submit", variant: "primary", block: true, label: label, loading: vm.busy });
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* An inline textual action.
|
|
988
|
+
*
|
|
989
|
+
* `eventful` stops `vega-button-link` from navigating on its own so the
|
|
990
|
+
* workflow — not the browser — decides what the click means.
|
|
991
|
+
*/
|
|
992
|
+
link(label, onClick) {
|
|
993
|
+
return (index.h("vega-button-link", { class: "link", label: label, eventful: true, disabled: this.viewModel.busy, onVegaClick: (event) => onClick(event) }));
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* The MFA method chooser, as a design-system selection-tile group.
|
|
997
|
+
*
|
|
998
|
+
* The group — not the individual tiles — is the component Vega expects to be
|
|
999
|
+
* composed: it owns the radio `name`, the selected `value`, the change event
|
|
1000
|
+
* and the group's accessible labelling. Rendering loose `vega-selection-tile`
|
|
1001
|
+
* elements inside a plain `role="radiogroup"` div looked correct and even
|
|
1002
|
+
* behaved correctly, but threw inside Vega's runtime when the tiles
|
|
1003
|
+
* unmounted, because each tile reaches for a group ancestor that was not
|
|
1004
|
+
* there.
|
|
1005
|
+
*/
|
|
1006
|
+
methodGroup(methods, label) {
|
|
1007
|
+
const vm = this.viewModel;
|
|
1008
|
+
return (index.h("vega-selection-tile-group", { class: "options", name: "mfaMethod", label: label, selectType: "radio", layout: "vertical", disabled: vm.busy,
|
|
1009
|
+
// No `autoValidation` counterpart on the group — a tile selection has
|
|
1010
|
+
// no intermediate state to validate against, so the submit gate is
|
|
1011
|
+
// the only moment the rule can fire.
|
|
1012
|
+
required: true, value: vm.valueOf('mfaMethod'), onVegaChange: (event) => this.handlers.onValueChange('mfaMethod', event.detail) }, methods.map(method => (index.h("vega-selection-tile", { key: method.id, class: "option", value: method.id, titleText: method.label, note: method.description, selectType: "radio", checked: vm.valueOf('mfaMethod') === method.id, disabled: method.disabled === true || vm.busy })))));
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
__decorate$3([
|
|
1016
|
+
MapToComponentField()
|
|
1017
|
+
], LoginFieldRenderer.prototype, "viewModel", void 0);
|
|
1018
|
+
__decorate$3([
|
|
1019
|
+
MapToComponentField()
|
|
1020
|
+
], LoginFieldRenderer.prototype, "handlers", void 0);
|
|
1021
|
+
|
|
1022
|
+
var __decorate$2 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
1023
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1024
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
1025
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
1026
|
+
else
|
|
1027
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
1028
|
+
if (d = decorators[i])
|
|
1029
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
1030
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1031
|
+
};
|
|
1032
|
+
/**
|
|
1033
|
+
* Renders the body of whichever step the workflow is on.
|
|
1034
|
+
*
|
|
1035
|
+
* ## Why a renderer slimmer
|
|
1036
|
+
*
|
|
1037
|
+
* This is the same Slimmer mechanism the stateful layer uses, pointed at
|
|
1038
|
+
* rendering: one class, one responsibility, bound to the component by field
|
|
1039
|
+
* mapping rather than by parameter passing. Vega applies the convention the
|
|
1040
|
+
* same way — `slimmers/renderers/<name>-renderer.tsx`, a class ending in
|
|
1041
|
+
* `Renderer`, exposing `render()` — so a reader moving between the design
|
|
1042
|
+
* system and the pattern library meets one structure, not two.
|
|
1043
|
+
*
|
|
1044
|
+
* The gain over the plain `view/` modules this replaces is the binding. A free
|
|
1045
|
+
* function has to be handed everything it needs on each call, so its signature
|
|
1046
|
+
* grows with its appetite and callers thread arguments through layers that do
|
|
1047
|
+
* not use them. `@MapToComponentField()` reaches the component's `viewModel`
|
|
1048
|
+
* and `handlers` directly, and types them by indexing the component class, so
|
|
1049
|
+
* the renderer cannot drift out of step with what the component actually
|
|
1050
|
+
* exposes — rename the getter and this stops compiling.
|
|
1051
|
+
*
|
|
1052
|
+
* Renderers only read. Every mutation goes back through `handlers`, into the
|
|
1053
|
+
* workflow slimmer, and through the control strategy's checkpoints.
|
|
1054
|
+
*/
|
|
1055
|
+
class LoginStepRenderer extends UdsSlimmer {
|
|
1056
|
+
constructor() {
|
|
1057
|
+
super();
|
|
1058
|
+
this.fields = new LoginFieldRenderer();
|
|
1059
|
+
}
|
|
1060
|
+
render() {
|
|
1061
|
+
const vm = this.viewModel;
|
|
1062
|
+
switch (vm.step) {
|
|
1063
|
+
case 'identifier':
|
|
1064
|
+
return this.identifierStep(vm);
|
|
1065
|
+
case 'password':
|
|
1066
|
+
return this.passwordStep(vm);
|
|
1067
|
+
case 'mfa-method':
|
|
1068
|
+
return this.mfaMethodStep(vm);
|
|
1069
|
+
case 'otp':
|
|
1070
|
+
return this.otpStep(vm);
|
|
1071
|
+
case 'forgot-password':
|
|
1072
|
+
case 'forgot-username':
|
|
1073
|
+
return this.forgotStep(vm);
|
|
1074
|
+
case 'reset-password':
|
|
1075
|
+
return this.resetPasswordStep(vm);
|
|
1076
|
+
case 'sign-up':
|
|
1077
|
+
return this.signUpStep(vm);
|
|
1078
|
+
default:
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
identifierStep(vm) {
|
|
1083
|
+
const { copy } = vm;
|
|
1084
|
+
return [
|
|
1085
|
+
this.fields.textField({
|
|
1086
|
+
name: 'identifier',
|
|
1087
|
+
label: copy.identifierFieldLabel,
|
|
1088
|
+
type: 'email',
|
|
1089
|
+
autocomplete: 'username',
|
|
1090
|
+
}),
|
|
1091
|
+
this.fields.checkbox('rememberIdentifier', copy.rememberIdentifier),
|
|
1092
|
+
this.fields.submit(copy.continue),
|
|
1093
|
+
index.h("div", { class: "prose" }, index.h("p", { class: "prose__lead" }, copy.troubleSigningInPrompt), index.h("p", null, "Follow the steps in \u201C", this.fields.link(copy.troubleSigningIn, this.handlers.onAction('trouble-signing-in')), "\u201D."), vm.can.signUp && (index.h("p", null, copy.noAccountPrompt, " ", this.fields.link(copy.createAccount, this.handlers.onNavigate('sign-up'))))),
|
|
1094
|
+
];
|
|
1095
|
+
}
|
|
1096
|
+
passwordStep(vm) {
|
|
1097
|
+
const { copy, can } = vm;
|
|
1098
|
+
const recovery = [];
|
|
1099
|
+
if (can.reset) {
|
|
1100
|
+
recovery.push(this.fields.link(copy.forgotPassword, this.handlers.onNavigate('forgot-password')));
|
|
1101
|
+
}
|
|
1102
|
+
if (can.username) {
|
|
1103
|
+
recovery.push(this.fields.link(copy.forgotUsername, this.handlers.onNavigate('forgot-username')));
|
|
1104
|
+
}
|
|
1105
|
+
return [
|
|
1106
|
+
// `vega-input` carries its own password reveal control, so the card no
|
|
1107
|
+
// longer renders — or tracks the state for — an eye toggle of its own.
|
|
1108
|
+
this.fields.textField({
|
|
1109
|
+
name: 'password',
|
|
1110
|
+
label: copy.passwordFieldLabel,
|
|
1111
|
+
type: 'password',
|
|
1112
|
+
autocomplete: 'current-password',
|
|
1113
|
+
}),
|
|
1114
|
+
...(recovery.length ? [index.h("div", { class: "inline-links" }, recovery)] : []),
|
|
1115
|
+
this.fields.submit(copy.continue),
|
|
1116
|
+
];
|
|
1117
|
+
}
|
|
1118
|
+
mfaMethodStep(vm) {
|
|
1119
|
+
const { copy } = vm;
|
|
1120
|
+
return [
|
|
1121
|
+
this.fields.methodGroup(vm.methods, copy.mfaMethodHeading),
|
|
1122
|
+
this.fields.submit(copy.continue),
|
|
1123
|
+
];
|
|
1124
|
+
}
|
|
1125
|
+
otpStep(vm) {
|
|
1126
|
+
const { copy } = vm;
|
|
1127
|
+
const tail = [];
|
|
1128
|
+
if (vm.methods.length > 1) {
|
|
1129
|
+
tail.push(this.fields.link(copy.changeMfaMethod, this.handlers.onNavigate('mfa-method')));
|
|
1130
|
+
}
|
|
1131
|
+
if (vm.can.resend) {
|
|
1132
|
+
tail.push(vm.isResendThrottled ? (
|
|
1133
|
+
// Rendered as text rather than a disabled link: a countdown is a
|
|
1134
|
+
// status, and announcing it politely is more useful than offering a
|
|
1135
|
+
// dead target.
|
|
1136
|
+
index.h("span", { class: "countdown", "aria-live": "polite" }, vm.resendCountdownText)) : (this.fields.link(copy.resendCode, this.handlers.onResend)));
|
|
1137
|
+
}
|
|
1138
|
+
return [
|
|
1139
|
+
// `vega-input-passcode` owns the whole multi-box entry: per-character
|
|
1140
|
+
// focus advance, backspace retreat, paste distribution and masking. That
|
|
1141
|
+
// replaces the card's own OTP slimmer outright.
|
|
1142
|
+
this.fields.passcodeField(),
|
|
1143
|
+
this.fields.submit(copy.otpSubmit),
|
|
1144
|
+
...(tail.length
|
|
1145
|
+
? [
|
|
1146
|
+
index.h("div", { class: "prose" }, index.h("p", null, copy.changeMfaMethodPrompt), index.h("p", { class: "inline-links" }, tail)),
|
|
1147
|
+
]
|
|
1148
|
+
: []),
|
|
1149
|
+
];
|
|
1150
|
+
}
|
|
1151
|
+
forgotStep(vm) {
|
|
1152
|
+
const { copy } = vm;
|
|
1153
|
+
return [
|
|
1154
|
+
this.fields.textField({
|
|
1155
|
+
name: vm.step === 'forgot-password' ? 'identifier' : 'email',
|
|
1156
|
+
label: copy.identifierFieldLabel,
|
|
1157
|
+
type: 'email',
|
|
1158
|
+
autocomplete: 'username',
|
|
1159
|
+
}),
|
|
1160
|
+
this.fields.submit(copy.continue),
|
|
1161
|
+
index.h("p", { class: "prose" }, this.fields.link(copy.back, this.handlers.onNavigate('identifier'))),
|
|
1162
|
+
];
|
|
1163
|
+
}
|
|
1164
|
+
resetPasswordStep(vm) {
|
|
1165
|
+
const { copy } = vm;
|
|
1166
|
+
return [
|
|
1167
|
+
this.fields.textField({
|
|
1168
|
+
name: 'password',
|
|
1169
|
+
label: copy.newPasswordFieldLabel,
|
|
1170
|
+
type: 'password',
|
|
1171
|
+
autocomplete: 'new-password',
|
|
1172
|
+
}),
|
|
1173
|
+
// The pairing is checked on the field that is wrong, inside the same
|
|
1174
|
+
// `vega-form` gate as every other rule — not as a pre-submit test
|
|
1175
|
+
// reported through the card-level banner.
|
|
1176
|
+
this.fields.textField({
|
|
1177
|
+
name: 'confirmPassword',
|
|
1178
|
+
label: copy.confirmPasswordFieldLabel,
|
|
1179
|
+
type: 'password',
|
|
1180
|
+
autocomplete: 'new-password',
|
|
1181
|
+
mustMatch: 'password',
|
|
1182
|
+
}),
|
|
1183
|
+
this.fields.submit(copy.continue),
|
|
1184
|
+
];
|
|
1185
|
+
}
|
|
1186
|
+
signUpStep(vm) {
|
|
1187
|
+
const { copy } = vm;
|
|
1188
|
+
return [
|
|
1189
|
+
this.fields.textField({ name: 'firstName', label: copy.firstNameFieldLabel, autocomplete: 'given-name' }),
|
|
1190
|
+
this.fields.textField({ name: 'lastName', label: copy.lastNameFieldLabel, autocomplete: 'family-name' }),
|
|
1191
|
+
this.fields.textField({
|
|
1192
|
+
name: 'identifier',
|
|
1193
|
+
label: copy.identifierFieldLabel,
|
|
1194
|
+
type: 'email',
|
|
1195
|
+
autocomplete: 'email',
|
|
1196
|
+
}),
|
|
1197
|
+
this.fields.submit(copy.signUpSubmit),
|
|
1198
|
+
index.h("p", { class: "prose" }, copy.haveAccountPrompt, " ", this.fields.link(copy.signIn, this.handlers.onNavigate('identifier'))),
|
|
1199
|
+
];
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
__decorate$2([
|
|
1203
|
+
InjectSlimmer()
|
|
1204
|
+
], LoginStepRenderer.prototype, "fields", void 0);
|
|
1205
|
+
__decorate$2([
|
|
1206
|
+
MapToComponentField()
|
|
1207
|
+
], LoginStepRenderer.prototype, "viewModel", void 0);
|
|
1208
|
+
__decorate$2([
|
|
1209
|
+
MapToComponentField()
|
|
1210
|
+
], LoginStepRenderer.prototype, "handlers", void 0);
|
|
1211
|
+
|
|
1212
|
+
var __decorate$1 = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
1213
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1214
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
1215
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
1216
|
+
else
|
|
1217
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
1218
|
+
if (d = decorators[i])
|
|
1219
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
1220
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1221
|
+
};
|
|
1222
|
+
/**
|
|
1223
|
+
* Renders the card's body — heading, description, error, form and the terminal
|
|
1224
|
+
* outcome — and delegates the step's own controls to {@link LoginStepRenderer}.
|
|
1225
|
+
*
|
|
1226
|
+
* ## Why a renderer slimmer, and not the `view/` modules this replaces
|
|
1227
|
+
*
|
|
1228
|
+
* This is the same Slimmer mechanism the stateful layer already uses, pointed
|
|
1229
|
+
* at rendering: one class, one responsibility, bound to the component by field
|
|
1230
|
+
* mapping rather than by parameter passing. Vega applies the convention the
|
|
1231
|
+
* same way — `slimmers/renderers/<name>-renderer.tsx`, a class ending in
|
|
1232
|
+
* `Renderer`, exposing `render()` — so a reader moving between the design
|
|
1233
|
+
* system and the pattern library meets one structure, not two.
|
|
1234
|
+
*
|
|
1235
|
+
* The gain over free functions is the binding. A free function has to be handed
|
|
1236
|
+
* everything it needs on each call, so its signature grows with its appetite
|
|
1237
|
+
* and callers thread arguments through layers that never use them — which is
|
|
1238
|
+
* exactly what the old `StepViewModel` parameter had become.
|
|
1239
|
+
* `@MapToComponentField()` reaches the component's `viewModel` and `handlers`
|
|
1240
|
+
* directly, and types them by indexing the component class, so a renderer
|
|
1241
|
+
* cannot drift out of step with what the component actually exposes: rename the
|
|
1242
|
+
* getter and this stops compiling.
|
|
1243
|
+
*
|
|
1244
|
+
* Renderers only read. Every mutation goes back through `handlers`, into the
|
|
1245
|
+
* workflow slimmer, and through the control strategy's checkpoints.
|
|
1246
|
+
*
|
|
1247
|
+
* ## What stays behind in the component
|
|
1248
|
+
*
|
|
1249
|
+
* `render()` itself and both `<slot>` elements remain in `UdsPatternLoginCard`. Stencil
|
|
1250
|
+
* reads slots out of the component's source statically when it compiles, so a
|
|
1251
|
+
* slot that only existed inside a slimmer would never be registered.
|
|
1252
|
+
*/
|
|
1253
|
+
class LoginCardRenderer extends UdsSlimmer {
|
|
1254
|
+
constructor() {
|
|
1255
|
+
super();
|
|
1256
|
+
this.steps = new LoginStepRenderer();
|
|
1257
|
+
}
|
|
1258
|
+
render() {
|
|
1259
|
+
const vm = this.viewModel;
|
|
1260
|
+
return [
|
|
1261
|
+
index.h("h1", { class: "card__heading", id: "uds-pattern-login-heading", tabindex: "-1" }, vm.heading),
|
|
1262
|
+
...(vm.description ? [index.h("p", { class: "card__description" }, vm.description)] : []),
|
|
1263
|
+
// `vega-banner` carries the design system's error presentation — icon,
|
|
1264
|
+
// colour, contrast and live-region semantics — so the pattern does not
|
|
1265
|
+
// maintain a parallel version that can drift away from it.
|
|
1266
|
+
...(vm.errorText
|
|
1267
|
+
? [index.h("vega-banner", { class: "alert", type: "error", content: vm.errorText, isOpen: true, showCloseButton: false })]
|
|
1268
|
+
: []),
|
|
1269
|
+
...(vm.isTerminal ? this.terminalBody(vm) : [this.form()]),
|
|
1270
|
+
];
|
|
1271
|
+
}
|
|
1272
|
+
form() {
|
|
1273
|
+
return (
|
|
1274
|
+
// `vega-form` rather than a native `<form>`: a `vega-button[type=submit]`
|
|
1275
|
+
// lives in its own shadow root, so it cannot submit a native ancestor
|
|
1276
|
+
// form — the click would silently do nothing. Vega's fields and buttons
|
|
1277
|
+
// coordinate submission and validation through this element instead.
|
|
1278
|
+
//
|
|
1279
|
+
// It is also the validation gate. Its submit handler awaits `valid()`
|
|
1280
|
+
// across every field it controls and only emits `vegaFormSubmit` once
|
|
1281
|
+
// they all pass, so the step below never has to pre-check anything.
|
|
1282
|
+
//
|
|
1283
|
+
// `disableBlurValidation` suppresses validation on blur specifically.
|
|
1284
|
+
// Without it, clicking Submit blurs the field underneath it, an error
|
|
1285
|
+
// message appears, the button moves down and the click lands on whatever
|
|
1286
|
+
// took its place — so the first click is swallowed. Errors still appear
|
|
1287
|
+
// on submit, and still clear as the user types.
|
|
1288
|
+
index.h("vega-form", { class: "form", useNativeForm: true, disableBlurValidation: true, onVegaFormSubmit: (event) => this.handlers.onSubmit(event) }, index.h("div", { class: "form__body" }, this.steps.render())));
|
|
1289
|
+
}
|
|
1290
|
+
/** Terminal steps render no form; `check-email` still offers a way back. */
|
|
1291
|
+
terminalBody(vm) {
|
|
1292
|
+
if (vm.step !== 'check-email') {
|
|
1293
|
+
return [];
|
|
1294
|
+
}
|
|
1295
|
+
return [
|
|
1296
|
+
index.h("p", { class: "prose" }, index.h("vega-button-link", { class: "link", label: vm.copy.back, eventful: true, onVegaClick: (event) => this.handlers.onNavigate('identifier')(event) })),
|
|
1297
|
+
];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
__decorate$1([
|
|
1301
|
+
InjectSlimmer()
|
|
1302
|
+
], LoginCardRenderer.prototype, "steps", void 0);
|
|
1303
|
+
__decorate$1([
|
|
1304
|
+
MapToComponentField()
|
|
1305
|
+
], LoginCardRenderer.prototype, "viewModel", void 0);
|
|
1306
|
+
__decorate$1([
|
|
1307
|
+
MapToComponentField()
|
|
1308
|
+
], LoginCardRenderer.prototype, "handlers", void 0);
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* The data a renderer needs to draw one step of the login workflow.
|
|
1312
|
+
*
|
|
1313
|
+
* ## Why this is a class with a factory, and not an inline object
|
|
1314
|
+
*
|
|
1315
|
+
* Deriving "what the user should see" from "what the workflow currently holds"
|
|
1316
|
+
* is real logic: which affordances are available, what the heading says, which
|
|
1317
|
+
* description variant applies, whether a countdown is running. Assembled inline
|
|
1318
|
+
* inside `render()` that derivation is untestable and re-read on every change;
|
|
1319
|
+
* named here it has one home, one test surface and one place to review.
|
|
1320
|
+
*
|
|
1321
|
+
* ## Why it carries no callbacks
|
|
1322
|
+
*
|
|
1323
|
+
* A view model is *data*. Handlers are supplied alongside it
|
|
1324
|
+
* (`LoginCardHandlers`) rather than folded in, because a model that carries
|
|
1325
|
+
* behaviour cannot be compared, snapshotted or reasoned about independently of
|
|
1326
|
+
* the component that produced it. Keeping the two apart is what lets the
|
|
1327
|
+
* renderers be checked as pure functions of their inputs.
|
|
1328
|
+
*
|
|
1329
|
+
* ## Pattern-library standard
|
|
1330
|
+
*
|
|
1331
|
+
* Every pattern component should follow this shape:
|
|
1332
|
+
*
|
|
1333
|
+
* - `view-model/<pattern>.view-model.ts` exporting a plain data class;
|
|
1334
|
+
* - construction through a single `static from(state)` factory;
|
|
1335
|
+
* - no inheritance, and no logic beyond derivation from the state passed in;
|
|
1336
|
+
* - consumed by renderer slimmers, never mutated by them.
|
|
1337
|
+
*
|
|
1338
|
+
* Note this is a *view model*, not a DTO: it never crosses a process or
|
|
1339
|
+
* serialisation boundary, only the boundary between the stateful layer
|
|
1340
|
+
* (slimmers) and the rendering layer. DTO semantics — validation, wire format —
|
|
1341
|
+
* belong at the provider seam, where data genuinely enters from outside.
|
|
1342
|
+
*/
|
|
1343
|
+
class LoginStepViewModel {
|
|
1344
|
+
constructor(
|
|
1345
|
+
/** The step being rendered. */
|
|
1346
|
+
step,
|
|
1347
|
+
/** Resolved copy, after merging any consumer overrides. */
|
|
1348
|
+
copy,
|
|
1349
|
+
/** Heading for this step. */
|
|
1350
|
+
heading,
|
|
1351
|
+
/** Supporting description, when this step has one. */
|
|
1352
|
+
description,
|
|
1353
|
+
/** Error copy to surface, already mapped to approved wording. */
|
|
1354
|
+
errorText,
|
|
1355
|
+
/** Whether a provider call is in flight. */
|
|
1356
|
+
busy,
|
|
1357
|
+
/** Which affordances the provider can service. */
|
|
1358
|
+
can,
|
|
1359
|
+
/** MFA methods offered by the provider. */
|
|
1360
|
+
methods,
|
|
1361
|
+
/** Values collected on the current step. */
|
|
1362
|
+
values,
|
|
1363
|
+
/** How many characters the passcode entry expects. */
|
|
1364
|
+
otpLength,
|
|
1365
|
+
/** Seconds left on the resend throttle; zero when resending is allowed. */
|
|
1366
|
+
resendSeconds) {
|
|
1367
|
+
this.step = step;
|
|
1368
|
+
this.copy = copy;
|
|
1369
|
+
this.heading = heading;
|
|
1370
|
+
this.description = description;
|
|
1371
|
+
this.errorText = errorText;
|
|
1372
|
+
this.busy = busy;
|
|
1373
|
+
this.can = can;
|
|
1374
|
+
this.methods = methods;
|
|
1375
|
+
this.values = values;
|
|
1376
|
+
this.otpLength = otpLength;
|
|
1377
|
+
this.resendSeconds = resendSeconds;
|
|
1378
|
+
}
|
|
1379
|
+
static from(state) {
|
|
1380
|
+
const { step, copy } = state;
|
|
1381
|
+
return new LoginStepViewModel(step, copy, headingFor(step, copy), descriptionFor(step, copy, state), state.errorText, state.busy, state.can, state.methods, state.values, state.otpLength, state.resendSeconds);
|
|
1382
|
+
}
|
|
1383
|
+
/** Steps that render no form: terminal outcomes and confirmations. */
|
|
1384
|
+
get isTerminal() {
|
|
1385
|
+
return this.step === 'success' || this.step === 'locked' || this.step === 'check-email';
|
|
1386
|
+
}
|
|
1387
|
+
/** Whether a resend is currently throttled. */
|
|
1388
|
+
get isResendThrottled() {
|
|
1389
|
+
return this.resendSeconds > 0;
|
|
1390
|
+
}
|
|
1391
|
+
/** Copy for the throttle countdown, with the remaining seconds applied. */
|
|
1392
|
+
get resendCountdownText() {
|
|
1393
|
+
return controlStrategy.formatLabel(this.copy.resendCodeIn, { seconds: this.resendSeconds });
|
|
1394
|
+
}
|
|
1395
|
+
/** The current value of a collected field, as a string. */
|
|
1396
|
+
valueOf(name) {
|
|
1397
|
+
const value = this.values[name];
|
|
1398
|
+
return typeof value === 'string' ? value : '';
|
|
1399
|
+
}
|
|
1400
|
+
/** Whether a boolean field is set. */
|
|
1401
|
+
isChecked(name) {
|
|
1402
|
+
return this.values[name] === true;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
function headingFor(step, copy) {
|
|
1406
|
+
const headings = {
|
|
1407
|
+
identifier: copy.identifierHeading,
|
|
1408
|
+
password: copy.passwordHeading,
|
|
1409
|
+
'mfa-method': copy.mfaMethodHeading,
|
|
1410
|
+
otp: copy.otpHeading,
|
|
1411
|
+
'forgot-password': copy.forgotPasswordHeading,
|
|
1412
|
+
'forgot-username': copy.forgotUsernameHeading,
|
|
1413
|
+
'reset-password': copy.resetPasswordHeading,
|
|
1414
|
+
'sign-up': copy.signUpHeading,
|
|
1415
|
+
'check-email': copy.checkEmailHeading,
|
|
1416
|
+
locked: copy.lockedHeading,
|
|
1417
|
+
success: copy.successHeading,
|
|
1418
|
+
};
|
|
1419
|
+
return headings[step];
|
|
1420
|
+
}
|
|
1421
|
+
function descriptionFor(step, copy, ctx) {
|
|
1422
|
+
switch (step) {
|
|
1423
|
+
case 'mfa-method':
|
|
1424
|
+
return copy.mfaMethodDescription;
|
|
1425
|
+
case 'otp':
|
|
1426
|
+
return ctx.otpMethodLabel
|
|
1427
|
+
? controlStrategy.formatLabel(copy.otpDescriptionForMethod, { method: ctx.otpMethodLabel })
|
|
1428
|
+
: copy.otpDescription;
|
|
1429
|
+
case 'forgot-password':
|
|
1430
|
+
return copy.forgotPasswordDescription;
|
|
1431
|
+
case 'forgot-username':
|
|
1432
|
+
return copy.forgotUsernameDescription;
|
|
1433
|
+
case 'sign-up':
|
|
1434
|
+
return copy.signUpDescription;
|
|
1435
|
+
case 'check-email':
|
|
1436
|
+
return ctx.destination
|
|
1437
|
+
? controlStrategy.formatLabel(copy.checkEmailDescriptionWithDestination, { destination: ctx.destination })
|
|
1438
|
+
: copy.checkEmailDescription;
|
|
1439
|
+
case 'locked':
|
|
1440
|
+
return ctx.unlockMinutes
|
|
1441
|
+
? controlStrategy.formatLabel(copy.lockedDescriptionWithTime, { minutes: ctx.unlockMinutes })
|
|
1442
|
+
: copy.lockedDescription;
|
|
1443
|
+
default:
|
|
1444
|
+
return undefined;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const udsPatternLoginCardCss = () => `:host{display:block;width:100%;max-width:var(--uds-pattern-login-card-max-width);box-sizing:border-box;--uds-pattern-login-card-max-width:35rem}:host *,:host *::before,:host *::after{box-sizing:inherit}.card{padding:var(--uds-pattern-space-2xl);background:var(--uds-pattern-color-surface);border-radius:var(--uds-pattern-radius-lg);box-shadow:var(--uds-pattern-shadow-card)}.card__heading{margin:0 0 var(--uds-pattern-space-sm);font-size:var(--uds-pattern-font-size-lg);font-weight:var(--uds-pattern-font-weight-bold);line-height:var(--uds-pattern-line-height-md)}.card__heading:focus,.card__heading:focus-visible{outline:none}.card__description{margin:0 0 var(--uds-pattern-space-lg);font-size:var(--uds-pattern-font-size-sm);color:var(--uds-pattern-color-text-subtle)}.card__footer{margin-top:var(--uds-pattern-space-xl);padding-top:var(--uds-pattern-space-xl);border-top:1px solid var(--uds-pattern-color-border);font-size:var(--uds-pattern-font-size-sm);color:var(--uds-pattern-color-text-subtle)}.card__footer p{margin:0}.form{margin-top:var(--uds-pattern-space-lg)}.form__body{display:flex;flex-direction:column;gap:var(--uds-pattern-space-xl);align-items:stretch}.alert{display:block;margin-bottom:var(--uds-pattern-space-md)}.options{display:block}.prose{margin:0;font-size:var(--uds-pattern-font-size-sm);color:var(--uds-pattern-color-text-subtle)}.prose p{margin:0 0 var(--uds-pattern-space-xs)}.prose p:last-child{margin-bottom:0}.prose__lead{font-weight:var(--uds-pattern-font-weight-bold);color:var(--uds-pattern-color-text)}.link{display:inline-block;vertical-align:baseline}.inline-links{display:flex;flex-wrap:wrap;gap:var(--uds-pattern-space-md)}.countdown{color:var(--uds-pattern-color-text-subtle)}`;
|
|
1449
|
+
|
|
1450
|
+
var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
1451
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
1452
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
|
|
1453
|
+
r = Reflect.decorate(decorators, target, key, desc);
|
|
1454
|
+
else
|
|
1455
|
+
for (var i = decorators.length - 1; i >= 0; i--)
|
|
1456
|
+
if (d = decorators[i])
|
|
1457
|
+
r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
1458
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
1459
|
+
};
|
|
1460
|
+
const UdsPatternLoginCard = class {
|
|
1461
|
+
constructor(hostRef) {
|
|
1462
|
+
index.registerInstance(this, hostRef);
|
|
1463
|
+
this.udsLoginSuccess = index.createEvent(this, "udsLoginSuccess");
|
|
1464
|
+
this.udsLoginStepChange = index.createEvent(this, "udsLoginStepChange");
|
|
1465
|
+
this.udsLoginError = index.createEvent(this, "udsLoginError");
|
|
1466
|
+
this.udsLoginAction = index.createEvent(this, "udsLoginAction");
|
|
1467
|
+
// --- Slimmers -----------------------------------------------------------
|
|
1468
|
+
// Each owns one responsibility and binds back to the state below.
|
|
1469
|
+
this.workflow = new LoginWorkflowSlimmer();
|
|
1470
|
+
this.resend = new ResendThrottleSlimmer();
|
|
1471
|
+
this.memory = new IdentityMemorySlimmer();
|
|
1472
|
+
this.renderer = new LoginCardRenderer();
|
|
1473
|
+
// --- Props --------------------------------------------------------------
|
|
1474
|
+
/**
|
|
1475
|
+
* Everything that cannot be expressed as an attribute: the `auth` adapter,
|
|
1476
|
+
* the `control` strategy and any `labels` overrides.
|
|
1477
|
+
*
|
|
1478
|
+
* The split is mechanical rather than a matter of taste — see
|
|
1479
|
+
* `UdsLoginConfig`. Scalars below are attributes because they can be;
|
|
1480
|
+
* objects and functions are gathered here because they cannot be.
|
|
1481
|
+
*
|
|
1482
|
+
* Also accepts a JSON string, so the `labels` member alone can be set from
|
|
1483
|
+
* plain HTML markup.
|
|
1484
|
+
*/
|
|
1485
|
+
this.config = {};
|
|
1486
|
+
/** The step the workflow starts on. */
|
|
1487
|
+
this.initialStep = 'identifier';
|
|
1488
|
+
/** Pre-populates the identifier field, overriding any remembered value. */
|
|
1489
|
+
this.identifier = '';
|
|
1490
|
+
/** Default number of digit inputs, when the provider does not specify one. */
|
|
1491
|
+
this.otpLength = 6;
|
|
1492
|
+
/**
|
|
1493
|
+
* Where the "Remember my email" value is persisted. Set to an empty string to
|
|
1494
|
+
* disable persistence entirely.
|
|
1495
|
+
*/
|
|
1496
|
+
this.rememberStorageKey = 'uds-pattern-login.identifier';
|
|
1497
|
+
/** Renders the "Need help signing in?" footer. */
|
|
1498
|
+
this.showHelpFooter = true;
|
|
1499
|
+
// --- State --------------------------------------------------------------
|
|
1500
|
+
// Written by the slimmers through `@MapToComponentField({ writable: true })`.
|
|
1501
|
+
this.step = 'identifier';
|
|
1502
|
+
this.busy = false;
|
|
1503
|
+
this.methods = [];
|
|
1504
|
+
this.nowTick = Date.now();
|
|
1505
|
+
this.values = {};
|
|
1506
|
+
/**
|
|
1507
|
+
* The intents a renderer may raise. Held as one stable object — not rebuilt
|
|
1508
|
+
* per render — so that binding to it from a slimmer is a fixed reference and
|
|
1509
|
+
* the data half of the contract stays free of behaviour.
|
|
1510
|
+
*/
|
|
1511
|
+
this.handlers = {
|
|
1512
|
+
onSubmit: (event) => {
|
|
1513
|
+
event.preventDefault();
|
|
1514
|
+
void this.workflow.submit();
|
|
1515
|
+
},
|
|
1516
|
+
onNavigate: (step) => (event) => {
|
|
1517
|
+
event.preventDefault();
|
|
1518
|
+
this.workflow.navigateTo(step);
|
|
1519
|
+
},
|
|
1520
|
+
onAction: (action) => (event) => {
|
|
1521
|
+
event.preventDefault();
|
|
1522
|
+
this.udsLoginAction.emit({ action, step: this.step });
|
|
1523
|
+
},
|
|
1524
|
+
onResend: (event) => {
|
|
1525
|
+
event.preventDefault();
|
|
1526
|
+
void this.workflow.resendCode();
|
|
1527
|
+
},
|
|
1528
|
+
onValueChange: (name, value) => {
|
|
1529
|
+
this.values = { ...this.values, [name]: value };
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
// --- Lifecycle ----------------------------------------------------------
|
|
1534
|
+
// Bodies are intentionally empty: slimmers hook onto these by name via
|
|
1535
|
+
// `@MapToComponentMethod`, and the hooks run after the method itself.
|
|
1536
|
+
componentWillLoad() {
|
|
1537
|
+
/* LoginWorkflowSlimmer.start */
|
|
1538
|
+
}
|
|
1539
|
+
disconnectedCallback() {
|
|
1540
|
+
/* ResendThrottleSlimmer.stop */
|
|
1541
|
+
}
|
|
1542
|
+
componentDidRender() {
|
|
1543
|
+
if (this.lastRenderedStep !== undefined && this.lastRenderedStep !== this.step) {
|
|
1544
|
+
this.focusFirstControl();
|
|
1545
|
+
}
|
|
1546
|
+
this.lastRenderedStep = this.step;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Returns the workflow to its first step and clears all collected input.
|
|
1550
|
+
* Useful after a sign-out, or to recover from a terminal step.
|
|
1551
|
+
*/
|
|
1552
|
+
async reset() {
|
|
1553
|
+
/* LoginWorkflowSlimmer.restart */
|
|
1554
|
+
}
|
|
1555
|
+
// --- Derived ------------------------------------------------------------
|
|
1556
|
+
/** The resolved configuration, accepting a JSON string for HTML consumers. */
|
|
1557
|
+
get resolvedConfig() {
|
|
1558
|
+
return parseJsonProp(this.config, {});
|
|
1559
|
+
}
|
|
1560
|
+
/** The authentication seam. Read by the workflow slimmer. */
|
|
1561
|
+
get auth() {
|
|
1562
|
+
return this.resolvedConfig.auth;
|
|
1563
|
+
}
|
|
1564
|
+
/** The control seam. Read by the workflow slimmer at each checkpoint. */
|
|
1565
|
+
get controlStrategy() {
|
|
1566
|
+
return this.resolvedConfig.control;
|
|
1567
|
+
}
|
|
1568
|
+
/** Merged copy. Read by the slimmers as well, for error text. */
|
|
1569
|
+
get copy() {
|
|
1570
|
+
const overrides = this.resolvedConfig.labels ?? {};
|
|
1571
|
+
return {
|
|
1572
|
+
...controlStrategy.DEFAULT_LOGIN_LABELS,
|
|
1573
|
+
...overrides,
|
|
1574
|
+
errors: { ...controlStrategy.DEFAULT_LOGIN_LABELS.errors, ...(overrides.errors ?? {}) },
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Everything the renderers need to draw the current step, and nothing else.
|
|
1579
|
+
*
|
|
1580
|
+
* Derivation happens in one place — `LoginStepViewModel.from` — rather than
|
|
1581
|
+
* being assembled inline in `render()`, so it can be tested without a DOM and
|
|
1582
|
+
* reviewed without reading JSX.
|
|
1583
|
+
*/
|
|
1584
|
+
get viewModel() {
|
|
1585
|
+
return LoginStepViewModel.from({
|
|
1586
|
+
step: this.step,
|
|
1587
|
+
copy: this.copy,
|
|
1588
|
+
errorText: this.errorText,
|
|
1589
|
+
busy: this.busy,
|
|
1590
|
+
can: this.workflow.capabilities,
|
|
1591
|
+
methods: this.methods,
|
|
1592
|
+
values: this.values,
|
|
1593
|
+
otpLength: this.workflow.codeLength,
|
|
1594
|
+
resendSeconds: this.resend.secondsRemaining,
|
|
1595
|
+
otpMethodLabel: this.otpMethodLabel,
|
|
1596
|
+
destination: this.destination,
|
|
1597
|
+
unlockMinutes: this.unlockMinutes,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
focusFirstControl() {
|
|
1601
|
+
// `shadow: true`, so a shadow root is always attached by the time a render
|
|
1602
|
+
// has completed. The ambient type is nullable; the value is not.
|
|
1603
|
+
const root = this.hostEl.shadowRoot;
|
|
1604
|
+
// Vega fields are custom elements, so the focusable control is inside their
|
|
1605
|
+
// own shadow root. Delegating to the element's own `focus()` lets Vega
|
|
1606
|
+
// decide which of its internals should receive focus.
|
|
1607
|
+
//
|
|
1608
|
+
// Every step renders a heading, so the fallback always resolves — the
|
|
1609
|
+
// first arm is the one that matters, moving focus into the form.
|
|
1610
|
+
const target = root.querySelector('.form .field:not([disabled])') ??
|
|
1611
|
+
root.querySelector('.card__heading');
|
|
1612
|
+
target.focus();
|
|
1613
|
+
}
|
|
1614
|
+
// --- Rendering ----------------------------------------------------------
|
|
1615
|
+
render() {
|
|
1616
|
+
return (index.h(index.Host, { key: 'e8d37d2f818b9b2260fb2ccd3d784393a28385c4' }, index.h("section", { key: '1c0ce6ee5c73d24b06212d8a4b2f293c89e6cb70', class: "card", "aria-busy": String(this.busy) }, index.h("slot", { key: 'c87b3ceba0d974e15958aec53c19e76dc3996096', name: "brand" }), this.renderer.render(), this.showHelpFooter && (index.h("div", { key: '003268c5b8abf8755d55ba54e433fc8114171113', class: "card__footer" }, index.h("slot", { key: '376accf53b60a5cb489abde889ff74bf7fab3fe6', name: "footer" }, index.h("p", { key: 'a6834071896f9a3d988c089807fc6a77ee928ffc' }, this.copy.helpPrompt, ' ', index.h("vega-button-link", { key: '4645b67e1ccf70b0b386a516025659540a9308a9', class: "link", label: this.copy.help, eventful: true, onVegaClick: (event) => this.handlers.onAction('login-help')(event) }))))))));
|
|
1617
|
+
}
|
|
1618
|
+
get hostEl() { return index.getElement(this); }
|
|
1619
|
+
};
|
|
1620
|
+
__decorate([
|
|
1621
|
+
InjectSlimmer()
|
|
1622
|
+
], UdsPatternLoginCard.prototype, "workflow", void 0);
|
|
1623
|
+
__decorate([
|
|
1624
|
+
InjectSlimmer()
|
|
1625
|
+
], UdsPatternLoginCard.prototype, "resend", void 0);
|
|
1626
|
+
__decorate([
|
|
1627
|
+
InjectSlimmer()
|
|
1628
|
+
], UdsPatternLoginCard.prototype, "memory", void 0);
|
|
1629
|
+
__decorate([
|
|
1630
|
+
InjectSlimmer()
|
|
1631
|
+
], UdsPatternLoginCard.prototype, "renderer", void 0);
|
|
1632
|
+
/** Accepts either a parsed value or a JSON string (for plain-HTML consumers). */
|
|
1633
|
+
function parseJsonProp(value, fallback) {
|
|
1634
|
+
if (typeof value !== 'string') {
|
|
1635
|
+
return value ?? fallback;
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
return JSON.parse(value);
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
return fallback;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
UdsPatternLoginCard.style = udsPatternLoginCardCss();
|
|
1645
|
+
|
|
1646
|
+
const udsPatternLoginPageCss = () => `:host{display:flex;flex-direction:column;min-height:100vh;box-sizing:border-box;font-family:var(--uds-pattern-font-family);color:var(--uds-pattern-color-text);background:var(--uds-pattern-color-background)}.masthead{display:flex;justify-content:center;align-items:center;padding:var(--uds-pattern-space-md) var(--uds-pattern-space-lg);background:var(--uds-pattern-color-surface)}.masthead__logo{display:block;max-height:2rem}.content{display:flex;flex:1;justify-content:center;align-items:center;padding:var(--uds-pattern-space-xl) var(--uds-pattern-space-md)}.page-footer:not(:empty){padding:var(--uds-pattern-space-md) var(--uds-pattern-space-lg);text-align:center;font-size:var(--uds-pattern-font-size-sm);color:var(--uds-pattern-color-text-subtle)}`;
|
|
1647
|
+
|
|
1648
|
+
const UdsPatternLoginPage = class {
|
|
1649
|
+
constructor(hostRef) {
|
|
1650
|
+
index.registerInstance(this, hostRef);
|
|
1651
|
+
/** Accessible name for the masthead logo. */
|
|
1652
|
+
this.logoAlt = '';
|
|
1653
|
+
}
|
|
1654
|
+
render() {
|
|
1655
|
+
const logo = this.logoSrc ? index.h("img", { class: "masthead__logo", src: this.logoSrc, alt: this.logoAlt }) : null;
|
|
1656
|
+
return (index.h(index.Host, { key: '7c2d0d6ae80dc7e52c0780c457de982738ef8faa' }, index.h("header", { key: '7eac231827e53a5e4018591506f7632aff8ddda5', class: "masthead" }, index.h("slot", { key: 'e80b31f6301079a15f45066189c646ed4c444e24', name: "brand" }, this.logoHref && logo ? index.h("a", { href: this.logoHref }, logo) : logo)), index.h("main", { key: '3081bf6ec017cff44900f6cd0d9c98b38baf5126', class: "content" }, index.h("slot", { key: '3c84048fae834578b00254e6bdbad4eecc74b5e2' })), index.h("footer", { key: 'dfb064a71e3f39c8e99212b523d043f9f9f20538', class: "page-footer" }, index.h("slot", { key: '5ea9ef897c20a96f3c42b2b4f54496155516a423', name: "footer" }))));
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
UdsPatternLoginPage.style = udsPatternLoginPageCss();
|
|
1660
|
+
|
|
1661
|
+
exports.uds_pattern_login_card = UdsPatternLoginCard;
|
|
1662
|
+
exports.uds_pattern_login_page = UdsPatternLoginPage;
|