@design.estate/dees-catalog 3.97.1 → 3.98.0
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_bundle/bundle.js +4606 -3404
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/elements/00group-button/dees-button/dees-button.d.ts +35 -0
- package/dist_ts_web/elements/00group-button/dees-button/dees-button.js +128 -5
- package/dist_ts_web/elements/00group-feedback/dees-spinner/dees-spinner.js +12 -3
- package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.d.ts +9 -0
- package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.js +25 -2
- package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.d.ts +15 -0
- package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.js +36 -2
- package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.d.ts +246 -0
- package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.js +439 -21
- package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.js +857 -23
- package/package.json +3 -3
- package/readme.hints.md +35 -0
- package/readme.md +80 -11
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements/00group-button/dees-button/dees-button.ts +128 -2
- package/ts_web/elements/00group-feedback/dees-spinner/dees-spinner.ts +11 -2
- package/ts_web/elements/00group-form/dees-form-submit/dees-form-submit.ts +18 -0
- package/ts_web/elements/00group-input/dees-input-text/dees-input-text.ts +29 -0
- package/ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.ts +418 -19
- package/ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.ts +969 -20
|
@@ -5,12 +5,18 @@ import {
|
|
|
5
5
|
html,
|
|
6
6
|
DeesElement,
|
|
7
7
|
property,
|
|
8
|
+
state,
|
|
8
9
|
type TemplateResult,
|
|
9
10
|
cssManager,
|
|
10
11
|
css,
|
|
11
12
|
} from '@design.estate/dees-element';
|
|
12
13
|
import { themeDefaultStyles } from '../../00theme.js';
|
|
13
14
|
import '../../00group-layout/dees-tile/dees-tile.js';
|
|
15
|
+
import '../../00group-button/dees-button/dees-button.js';
|
|
16
|
+
import '../../00group-form/dees-form/dees-form.js';
|
|
17
|
+
import '../../00group-form/dees-form-submit/dees-form-submit.js';
|
|
18
|
+
import '../../00group-input/dees-input-text/dees-input-text.js';
|
|
19
|
+
import '../../00group-utility/dees-icon/dees-icon.js';
|
|
14
20
|
|
|
15
21
|
declare global {
|
|
16
22
|
interface HTMLElementTagNameMap {
|
|
@@ -18,16 +24,234 @@ declare global {
|
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The authentication methods this component can offer.
|
|
29
|
+
* A method is offered when it is configured — see `passkey`, `providers` and `password`.
|
|
30
|
+
*/
|
|
31
|
+
export type TDeesLoginMethod = 'passkey' | 'provider' | 'password';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Passkey ceremonies are two different things and need two different affordances:
|
|
35
|
+
* `authenticate` signs in with an existing credential, `register` enrolls a new one.
|
|
36
|
+
*/
|
|
37
|
+
export type TDeesLoginPasskeyIntent = 'authenticate' | 'register';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `optional` is a user-initiated ceremony (an explicit button press).
|
|
41
|
+
* `conditional` is the browser's passkey autofill, started silently on mount.
|
|
42
|
+
*/
|
|
43
|
+
export type TDeesLoginMediation = 'optional' | 'conditional';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One "sign in with …" button.
|
|
47
|
+
* Icons are consumer-supplied on purpose: the catalog carries no third-party brand assets.
|
|
48
|
+
*/
|
|
49
|
+
export interface IDeesLoginProvider {
|
|
50
|
+
/** stable identifier handed back to the consumer, e.g. an OIDC provider id */
|
|
51
|
+
id: string;
|
|
52
|
+
/** display name, rendered as "<providerPrefix> <label>" */
|
|
53
|
+
label: string;
|
|
54
|
+
/** dees-icon name — Lucide only, e.g. 'lucide:keyRound'. The `fa:` prefix is unsupported. */
|
|
55
|
+
icon?: string;
|
|
56
|
+
/** optional second line under the label */
|
|
57
|
+
description?: string;
|
|
58
|
+
/** disables this single provider without removing it */
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Addresses one interactive affordance for busy/error purposes.
|
|
64
|
+
* A bare `TDeesLoginMethod` is accepted wherever a target is expected.
|
|
65
|
+
*/
|
|
66
|
+
export interface IDeesLoginTarget {
|
|
67
|
+
method: TDeesLoginMethod;
|
|
68
|
+
/** passkey only — defaults to 'authenticate' */
|
|
69
|
+
intent?: TDeesLoginPasskeyIntent;
|
|
70
|
+
/** provider only — omit to address the provider group as a whole */
|
|
71
|
+
providerId?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Copy overrides. Every field falls back to the built-in default. */
|
|
75
|
+
export interface IDeesLoginLabels {
|
|
76
|
+
heading?: string;
|
|
77
|
+
subheading?: string;
|
|
78
|
+
passkeyAuthenticate?: string;
|
|
79
|
+
passkeyRegister?: string;
|
|
80
|
+
/** prefixed to every provider label; set to '' to render bare provider labels */
|
|
81
|
+
providerPrefix?: string;
|
|
82
|
+
credentialsHeading?: string;
|
|
83
|
+
passwordSubmit?: string;
|
|
84
|
+
usernameLabel?: string;
|
|
85
|
+
passwordLabel?: string;
|
|
86
|
+
divider?: string;
|
|
87
|
+
unavailable?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolved browser capability for passkeys. */
|
|
91
|
+
export interface IDeesLoginPasskeySupport {
|
|
92
|
+
/** WebAuthn is usable at all — secure context plus PublicKeyCredential */
|
|
93
|
+
available: boolean;
|
|
94
|
+
/** browser can run `navigator.credentials.get({ mediation: 'conditional' })` */
|
|
95
|
+
conditionalMediation: boolean;
|
|
96
|
+
/** a user-verifying platform authenticator exists (informational — never a gate) */
|
|
97
|
+
platformAuthenticator: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface IDeesLoginPasskeyContext {
|
|
101
|
+
intent: TDeesLoginPasskeyIntent;
|
|
102
|
+
/** current value of the username field, when the password method renders one */
|
|
103
|
+
username: string | undefined;
|
|
104
|
+
mediation: TDeesLoginMediation;
|
|
105
|
+
/** aborted when the element disconnects or another ceremony supersedes this one */
|
|
106
|
+
signal: AbortSignal;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface IDeesLoginProviderContext {
|
|
110
|
+
providerId: string;
|
|
111
|
+
provider: IDeesLoginProvider;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IDeesLoginPasswordContext {
|
|
115
|
+
data: Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type TDeesLoginPasskeyHandler = (
|
|
119
|
+
contextArg: IDeesLoginPasskeyContext,
|
|
120
|
+
) => unknown | Promise<unknown>;
|
|
121
|
+
|
|
122
|
+
export type TDeesLoginProviderHandler = (
|
|
123
|
+
contextArg: IDeesLoginProviderContext,
|
|
124
|
+
) => unknown | Promise<unknown>;
|
|
125
|
+
|
|
126
|
+
export type TDeesLoginPasswordHandler = (
|
|
127
|
+
contextArg: IDeesLoginPasswordContext,
|
|
128
|
+
) => unknown | Promise<unknown>;
|
|
129
|
+
|
|
130
|
+
const canonicalMethodOrder: TDeesLoginMethod[] = ['passkey', 'provider', 'password'];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* dees-simple-login — the login surface for an application shell.
|
|
134
|
+
*
|
|
135
|
+
* Offers up to three authentication methods in one card: passkeys, identity providers
|
|
136
|
+
* ("sign in with …") and a username/password form. A method is offered only when it is
|
|
137
|
+
* configured, so the zero-configuration default is the password form alone.
|
|
138
|
+
*
|
|
139
|
+
* ## Who owns the WebAuthn ceremony
|
|
140
|
+
*
|
|
141
|
+
* Not this component. A passkey ceremony needs server-issued options and server-side
|
|
142
|
+
* verification, so the catalog deliberately carries no WebAuthn dependency and no
|
|
143
|
+
* knowledge of any wire protocol. There are two ways to plug a ceremony in, and exactly
|
|
144
|
+
* one of them is active per interaction:
|
|
145
|
+
*
|
|
146
|
+
* 1. **Event mode** (no handler set) — the component dispatches `passkey-login`,
|
|
147
|
+
* `passkey-register` or `provider-login` and stops. The consumer drives everything and
|
|
148
|
+
* reports progress back through `setBusy()` / `reportError()`.
|
|
149
|
+
* 2. **Handler mode** (handler property set) — the component awaits the handler, owns the
|
|
150
|
+
* per-method busy state and turns a rejection into that method's error message. The
|
|
151
|
+
* request event is *not* dispatched, so a ceremony can never be started twice.
|
|
152
|
+
*
|
|
153
|
+
* The legacy `login` event is a notification rather than a request and always fires, even
|
|
154
|
+
* when `passwordLoginHandler` is set. Do not both listen to `login` and set that handler.
|
|
155
|
+
*
|
|
156
|
+
* ## Shadow DOM contract
|
|
157
|
+
*
|
|
158
|
+
* Consumers reach into this shadow root: `.loginContainer`, `.login` and `.slotContainer`
|
|
159
|
+
* carry the post-login transition, and `shadowRoot.querySelector('dees-form')` is expected
|
|
160
|
+
* to be the password form. Those are load-bearing and must not be renamed, and no second
|
|
161
|
+
* `dees-form` may precede the password form.
|
|
162
|
+
*/
|
|
21
163
|
@customElement('dees-simple-login')
|
|
22
164
|
export class DeesSimpleLogin extends DeesElement {
|
|
23
165
|
// STATIC
|
|
24
166
|
public static demo = demoFunc;
|
|
25
167
|
public static demoGroups = ['Simple'];
|
|
168
|
+
|
|
26
169
|
// INSTANCE
|
|
27
170
|
|
|
171
|
+
/** application name, interpolated into the default subheading */
|
|
28
172
|
@property()
|
|
29
173
|
accessor name: string = 'Application';
|
|
30
174
|
|
|
175
|
+
/**
|
|
176
|
+
* offer a passkey affordance. Silently drops out when the browser cannot do WebAuthn,
|
|
177
|
+
* unless it is the only configured method — then the card explains itself instead of
|
|
178
|
+
* rendering empty.
|
|
179
|
+
*/
|
|
180
|
+
@property({ type: Boolean })
|
|
181
|
+
accessor passkey: boolean = false;
|
|
182
|
+
|
|
183
|
+
/** which passkey affordances to render, in this order */
|
|
184
|
+
@property({ attribute: false })
|
|
185
|
+
accessor passkeyIntents: TDeesLoginPasskeyIntent[] = ['authenticate'];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* overrides browser capability detection. `undefined` detects automatically; set it when
|
|
189
|
+
* the server already knows whether passkeys are configured for this deployment.
|
|
190
|
+
*/
|
|
191
|
+
@property({ attribute: false })
|
|
192
|
+
accessor passkeyAvailable: boolean | undefined = undefined;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* opt into WebAuthn conditional mediation (passkey autofill). Requires
|
|
196
|
+
* `passkeyLoginHandler` and a rendered password method — the username field is what the
|
|
197
|
+
* browser attaches its passkey picker to. Silently inert when either is missing.
|
|
198
|
+
*/
|
|
199
|
+
@property({ type: Boolean })
|
|
200
|
+
accessor passkeyAutofill: boolean = false;
|
|
201
|
+
|
|
202
|
+
/** identity providers to offer. Empty means the provider method is not configured. */
|
|
203
|
+
@property({ attribute: false })
|
|
204
|
+
accessor providers: IDeesLoginProvider[] = [];
|
|
205
|
+
|
|
206
|
+
/** offer the username/password form. On by default — set `.password=${false}` to drop it. */
|
|
207
|
+
@property({ type: Boolean })
|
|
208
|
+
accessor password: boolean = true;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* reorders the configured methods. Configured methods missing from this list are appended
|
|
212
|
+
* in canonical order, so an incomplete list can never silently hide a method.
|
|
213
|
+
*/
|
|
214
|
+
@property({ attribute: false })
|
|
215
|
+
accessor methodOrder: TDeesLoginMethod[] = ['passkey', 'provider', 'password'];
|
|
216
|
+
|
|
217
|
+
/** copy overrides */
|
|
218
|
+
@property({ attribute: false })
|
|
219
|
+
accessor labels: IDeesLoginLabels = {};
|
|
220
|
+
|
|
221
|
+
/** drives the passkey sign-in ceremony — see "Who owns the WebAuthn ceremony" */
|
|
222
|
+
@property({ attribute: false })
|
|
223
|
+
accessor passkeyLoginHandler: TDeesLoginPasskeyHandler | undefined = undefined;
|
|
224
|
+
|
|
225
|
+
/** drives the passkey enrollment ceremony */
|
|
226
|
+
@property({ attribute: false })
|
|
227
|
+
accessor passkeyRegisterHandler: TDeesLoginPasskeyHandler | undefined = undefined;
|
|
228
|
+
|
|
229
|
+
/** drives a provider sign-in (redirect or popup) */
|
|
230
|
+
@property({ attribute: false })
|
|
231
|
+
accessor providerLoginHandler: TDeesLoginProviderHandler | undefined = undefined;
|
|
232
|
+
|
|
233
|
+
/** drives a credential sign-in. The legacy `login` event fires regardless. */
|
|
234
|
+
@property({ attribute: false })
|
|
235
|
+
accessor passwordLoginHandler: TDeesLoginPasswordHandler | undefined = undefined;
|
|
236
|
+
|
|
237
|
+
@state()
|
|
238
|
+
private accessor detectedPasskeySupport: IDeesLoginPasskeySupport = {
|
|
239
|
+
available: false,
|
|
240
|
+
conditionalMediation: false,
|
|
241
|
+
platformAuthenticator: false,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
@state()
|
|
245
|
+
private accessor busyKeys: string[] = [];
|
|
246
|
+
|
|
247
|
+
@state()
|
|
248
|
+
private accessor errorMap: Record<string, string> = {};
|
|
249
|
+
|
|
250
|
+
private ceremonyControllers = new Map<string, AbortController>();
|
|
251
|
+
private autofillController: AbortController | undefined = undefined;
|
|
252
|
+
private autofillStarted = false;
|
|
253
|
+
private interactivityObserver: MutationObserver | undefined = undefined;
|
|
254
|
+
|
|
31
255
|
public static styles = [
|
|
32
256
|
themeDefaultStyles,
|
|
33
257
|
cssManager.defaultStyles,
|
|
@@ -50,6 +274,11 @@ export class DeesSimpleLogin extends DeesElement {
|
|
|
50
274
|
height: 100%;
|
|
51
275
|
top: 0;
|
|
52
276
|
left: 0;
|
|
277
|
+
box-sizing: border-box;
|
|
278
|
+
/* a card with passkey + several providers + credentials can outgrow a short
|
|
279
|
+
viewport, so the container scrolls instead of clipping */
|
|
280
|
+
padding: var(--dees-spacing-xl);
|
|
281
|
+
overflow-y: auto;
|
|
53
282
|
background: var(--dees-color-bg-primary);
|
|
54
283
|
}
|
|
55
284
|
|
|
@@ -87,8 +316,117 @@ export class DeesSimpleLogin extends DeesElement {
|
|
|
87
316
|
}
|
|
88
317
|
|
|
89
318
|
.subheader {
|
|
319
|
+
font-size: var(--dees-font-control-size);
|
|
320
|
+
/* secondary rather than muted: the tertiary label token is a 0.30 alpha and drops
|
|
321
|
+
under 2:1 against both backgrounds */
|
|
322
|
+
color: var(--dees-color-text-secondary);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.methods {
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
gap: var(--dees-spacing-lg);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.method {
|
|
332
|
+
display: flex;
|
|
333
|
+
flex-direction: column;
|
|
334
|
+
gap: var(--dees-spacing-sm);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.methodActions {
|
|
338
|
+
display: flex;
|
|
339
|
+
flex-direction: column;
|
|
340
|
+
gap: var(--dees-spacing-sm);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* Four or more providers would turn the card into a tower, so they pair up. The
|
|
344
|
+
labels lose their prefix at the same time — see denseProviders. */
|
|
345
|
+
.methodActions.dense {
|
|
346
|
+
display: grid;
|
|
347
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* an odd trailing provider spans the row instead of leaving a ragged half cell */
|
|
351
|
+
.methodActions.dense > :last-child:nth-child(odd) {
|
|
352
|
+
grid-column: 1 / -1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* dees-button's default pending face is a 20% accent tint with accent-coloured text,
|
|
356
|
+
which lands under 2:1 on a dark canvas. Each group therefore states a busy face in
|
|
357
|
+
its own emphasis instead: the primary action keeps its solid accent, and a provider
|
|
358
|
+
keeps its secondary surface so only the spinner changes. */
|
|
359
|
+
.passkeyActions dees-button {
|
|
360
|
+
--button-pending-bg: var(--dees-color-accent-primary);
|
|
361
|
+
--button-pending-fg: var(--dees-color-on-accent);
|
|
362
|
+
--dees-spinner-color: var(--dees-color-on-accent);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.providerActions dees-button {
|
|
366
|
+
--button-pending-bg: var(--dees-color-fill-secondary);
|
|
367
|
+
--button-pending-fg: var(--dees-color-text-primary);
|
|
368
|
+
--dees-spinner-color: var(--dees-color-text-primary);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.divider {
|
|
372
|
+
display: flex;
|
|
373
|
+
align-items: center;
|
|
374
|
+
gap: var(--dees-spacing-md);
|
|
375
|
+
font-size: var(--dees-font-control-size-sm);
|
|
376
|
+
color: var(--dees-color-text-secondary);
|
|
377
|
+
text-transform: lowercase;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.divider::before,
|
|
381
|
+
.divider::after {
|
|
382
|
+
content: '';
|
|
383
|
+
flex: 1;
|
|
384
|
+
height: 1px;
|
|
385
|
+
background: var(--dees-color-border-subtle);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.methodError {
|
|
389
|
+
display: flex;
|
|
390
|
+
align-items: flex-start;
|
|
391
|
+
gap: var(--dees-spacing-xs);
|
|
392
|
+
font-size: var(--dees-font-control-size-sm);
|
|
393
|
+
line-height: 1.4;
|
|
394
|
+
color: var(--dees-color-text-error);
|
|
395
|
+
user-select: text;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.methodError dees-icon {
|
|
399
|
+
flex-shrink: 0;
|
|
400
|
+
margin-top: 1px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.unavailable {
|
|
404
|
+
padding: var(--dees-spacing-lg);
|
|
405
|
+
border: 1px solid var(--dees-color-border-subtle);
|
|
406
|
+
border-radius: var(--dees-radius-xl);
|
|
407
|
+
corner-shape: var(--dees-corner-shape);
|
|
408
|
+
background: var(--dees-color-bg-secondary);
|
|
90
409
|
font-size: var(--dees-font-control-size);
|
|
91
410
|
color: var(--dees-color-text-muted);
|
|
411
|
+
text-align: center;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* The bare credentials block, used whenever the card already offers another method.
|
|
415
|
+
A nested tile there would inset the fields 24px against full-width buttons above
|
|
416
|
+
it and add a second frame inside the card. */
|
|
417
|
+
.credentials {
|
|
418
|
+
display: flex;
|
|
419
|
+
flex-direction: column;
|
|
420
|
+
gap: var(--dees-spacing-lg);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.credentials dees-input-text {
|
|
424
|
+
width: 100%;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.credentials dees-form-submit {
|
|
428
|
+
margin-top: var(--dees-spacing-sm);
|
|
429
|
+
width: 100%;
|
|
92
430
|
}
|
|
93
431
|
|
|
94
432
|
dees-tile {
|
|
@@ -116,21 +454,171 @@ export class DeesSimpleLogin extends DeesElement {
|
|
|
116
454
|
`,
|
|
117
455
|
];
|
|
118
456
|
|
|
457
|
+
// ------------------------------------------------------------------------------------
|
|
458
|
+
// resolution
|
|
459
|
+
// ------------------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/** resolved browser capability, with `passkeyAvailable` applied */
|
|
462
|
+
public get passkeySupport(): IDeesLoginPasskeySupport {
|
|
463
|
+
if (this.passkeyAvailable === undefined) {
|
|
464
|
+
return this.detectedPasskeySupport;
|
|
465
|
+
}
|
|
466
|
+
return { ...this.detectedPasskeySupport, available: this.passkeyAvailable };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** the methods that are configured, in render order */
|
|
470
|
+
public get resolvedMethods(): TDeesLoginMethod[] {
|
|
471
|
+
const configured = canonicalMethodOrder.filter((methodArg) => this.isConfigured(methodArg));
|
|
472
|
+
const ordered: TDeesLoginMethod[] = [];
|
|
473
|
+
for (const methodArg of this.methodOrder || []) {
|
|
474
|
+
if (configured.includes(methodArg) && !ordered.includes(methodArg)) {
|
|
475
|
+
ordered.push(methodArg);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const methodArg of configured) {
|
|
479
|
+
if (!ordered.includes(methodArg)) {
|
|
480
|
+
ordered.push(methodArg);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return ordered;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private isConfigured(methodArg: TDeesLoginMethod): boolean {
|
|
487
|
+
switch (methodArg) {
|
|
488
|
+
case 'passkey':
|
|
489
|
+
return this.passkey && this.passkeySupport.available && this.resolvedPasskeyIntents.length > 0;
|
|
490
|
+
case 'provider':
|
|
491
|
+
return this.providers.length > 0;
|
|
492
|
+
case 'password':
|
|
493
|
+
return this.password;
|
|
494
|
+
default:
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private get resolvedPasskeyIntents(): TDeesLoginPasskeyIntent[] {
|
|
500
|
+
const intents = (this.passkeyIntents || []).filter(
|
|
501
|
+
(intentArg, index, all) => all.indexOf(intentArg) === index,
|
|
502
|
+
);
|
|
503
|
+
return intents.length > 0 ? intents : ['authenticate'];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* true when nothing at all is offered — typically a passkey-only card in a browser that
|
|
508
|
+
* cannot do WebAuthn. Silent degradation would leave an empty card, so this case gets a
|
|
509
|
+
* message instead.
|
|
510
|
+
*/
|
|
511
|
+
private get isUnavailable(): boolean {
|
|
512
|
+
return this.resolvedMethods.length === 0;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private get shouldOfferPasskeyAutofill(): boolean {
|
|
516
|
+
const methods = this.resolvedMethods;
|
|
517
|
+
return (
|
|
518
|
+
this.passkeyAutofill &&
|
|
519
|
+
Boolean(this.passkeyLoginHandler) &&
|
|
520
|
+
this.passkeySupport.conditionalMediation &&
|
|
521
|
+
methods.includes('passkey') &&
|
|
522
|
+
methods.includes('password')
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private copy<K extends keyof IDeesLoginLabels>(keyArg: K, fallbackArg: string): string {
|
|
527
|
+
const value = (this.labels || {})[keyArg];
|
|
528
|
+
return value === undefined ? fallbackArg : value;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ------------------------------------------------------------------------------------
|
|
532
|
+
// per-method state
|
|
533
|
+
// ------------------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
private static targetKey(targetArg: IDeesLoginTarget | TDeesLoginMethod): string {
|
|
536
|
+
const target: IDeesLoginTarget =
|
|
537
|
+
typeof targetArg === 'string' ? { method: targetArg } : targetArg;
|
|
538
|
+
if (target.method === 'passkey') {
|
|
539
|
+
return `passkey:${target.intent || 'authenticate'}`;
|
|
540
|
+
}
|
|
541
|
+
if (target.method === 'provider') {
|
|
542
|
+
return `provider:${target.providerId || ''}`;
|
|
543
|
+
}
|
|
544
|
+
return 'password';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** true while a ceremony for this target is running */
|
|
548
|
+
public isBusy(targetArg: IDeesLoginTarget | TDeesLoginMethod): boolean {
|
|
549
|
+
return this.busyKeys.includes(DeesSimpleLogin.targetKey(targetArg));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** marks a target busy — for consumers driving a ceremony from an intent event */
|
|
553
|
+
public setBusy(targetArg: IDeesLoginTarget | TDeesLoginMethod, busyArg: boolean): void {
|
|
554
|
+
const key = DeesSimpleLogin.targetKey(targetArg);
|
|
555
|
+
const isBusy = this.busyKeys.includes(key);
|
|
556
|
+
if (busyArg === isBusy) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
this.busyKeys = busyArg
|
|
560
|
+
? [...this.busyKeys, key]
|
|
561
|
+
: this.busyKeys.filter((busyKey) => busyKey !== key);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** the error currently shown for a target, if any */
|
|
565
|
+
public getError(targetArg: IDeesLoginTarget | TDeesLoginMethod): string | undefined {
|
|
566
|
+
return this.errorMap[DeesSimpleLogin.targetKey(targetArg)];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* shows an error next to one method without touching the others — a failed passkey
|
|
571
|
+
* attempt must never blank the password form.
|
|
572
|
+
*/
|
|
573
|
+
public reportError(targetArg: IDeesLoginTarget | TDeesLoginMethod, messageArg: string): void {
|
|
574
|
+
const key = DeesSimpleLogin.targetKey(targetArg);
|
|
575
|
+
if (this.errorMap[key] === messageArg) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
this.errorMap = { ...this.errorMap, [key]: messageArg };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** clears one target's error, or every error when called without a target */
|
|
582
|
+
public clearError(targetArg?: IDeesLoginTarget | TDeesLoginMethod): void {
|
|
583
|
+
if (targetArg === undefined) {
|
|
584
|
+
if (Object.keys(this.errorMap).length > 0) {
|
|
585
|
+
this.errorMap = {};
|
|
586
|
+
}
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const key = DeesSimpleLogin.targetKey(targetArg);
|
|
590
|
+
if (this.errorMap[key] === undefined) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const nextMap = { ...this.errorMap };
|
|
594
|
+
delete nextMap[key];
|
|
595
|
+
this.errorMap = nextMap;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** clears every busy and error state and resets the password form */
|
|
599
|
+
public reset(): void {
|
|
600
|
+
this.abortCeremonies();
|
|
601
|
+
this.busyKeys = [];
|
|
602
|
+
this.errorMap = {};
|
|
603
|
+
const form = this.shadowRoot?.querySelector('dees-form');
|
|
604
|
+
form?.reset();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ------------------------------------------------------------------------------------
|
|
608
|
+
// render
|
|
609
|
+
// ------------------------------------------------------------------------------------
|
|
610
|
+
|
|
119
611
|
public render(): TemplateResult {
|
|
120
612
|
return html`
|
|
121
613
|
<div class="loginContainer">
|
|
122
614
|
<div class="login">
|
|
123
615
|
<div class="login-header">
|
|
124
|
-
<div class="header"
|
|
125
|
-
<div class="subheader">
|
|
616
|
+
<div class="header">${this.copy('heading', 'Sign in')}</div>
|
|
617
|
+
<div class="subheader">
|
|
618
|
+
${this.copy('subheading', `Enter your credentials to access ${this.name}`)}
|
|
619
|
+
</div>
|
|
126
620
|
</div>
|
|
127
|
-
|
|
128
|
-
<dees-form>
|
|
129
|
-
<dees-input-text key="username" label="Username" required></dees-input-text>
|
|
130
|
-
<dees-input-text key="password" label="Password" isPasswordBool required></dees-input-text>
|
|
131
|
-
<dees-form-submit>Sign in</dees-form-submit>
|
|
132
|
-
</dees-form>
|
|
133
|
-
</dees-tile>
|
|
621
|
+
${this.renderMethods()}
|
|
134
622
|
</div>
|
|
135
623
|
</div>
|
|
136
624
|
<div class="slotContainer">
|
|
@@ -139,19 +627,480 @@ export class DeesSimpleLogin extends DeesElement {
|
|
|
139
627
|
`;
|
|
140
628
|
}
|
|
141
629
|
|
|
142
|
-
|
|
630
|
+
private renderMethods(): TemplateResult {
|
|
631
|
+
if (this.isUnavailable) {
|
|
632
|
+
return html`
|
|
633
|
+
<div class="methods">
|
|
634
|
+
<div class="unavailable">
|
|
635
|
+
${this.copy('unavailable', 'No sign-in method is available in this browser.')}
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
const methods = this.resolvedMethods;
|
|
641
|
+
return html`
|
|
642
|
+
<div class="methods">
|
|
643
|
+
${methods.map(
|
|
644
|
+
(methodArg, index) => html`
|
|
645
|
+
${index === 0
|
|
646
|
+
? ''
|
|
647
|
+
: html`<div class="divider">${this.copy('divider', 'or')}</div>`}
|
|
648
|
+
${this.renderMethod(methodArg)}
|
|
649
|
+
`,
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private renderMethod(methodArg: TDeesLoginMethod): TemplateResult {
|
|
656
|
+
if (methodArg === 'passkey') {
|
|
657
|
+
return this.renderPasskeyMethod();
|
|
658
|
+
}
|
|
659
|
+
if (methodArg === 'provider') {
|
|
660
|
+
return this.renderProviderMethod();
|
|
661
|
+
}
|
|
662
|
+
return this.renderPasswordMethod();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private renderPasskeyMethod(): TemplateResult {
|
|
666
|
+
const intents = this.resolvedPasskeyIntents;
|
|
667
|
+
// Concurrent WebAuthn calls reject each other, so a running ceremony locks the *other*
|
|
668
|
+
// passkey intent — providers and the password form stay usable. The busy button itself
|
|
669
|
+
// is never marked disabled: dees-button dims a disabled face to 50% opacity, which
|
|
670
|
+
// would wash out the pending spinner. handlePasskey() guards the re-entry instead.
|
|
671
|
+
const anyPasskeyBusy = intents.some((intentArg) =>
|
|
672
|
+
this.isBusy({ method: 'passkey', intent: intentArg }),
|
|
673
|
+
);
|
|
674
|
+
return html`
|
|
675
|
+
<div class="method">
|
|
676
|
+
<div class="methodActions passkeyActions">
|
|
677
|
+
${intents.map((intentArg) => {
|
|
678
|
+
const busy = this.isBusy({ method: 'passkey', intent: intentArg });
|
|
679
|
+
const isRegister = intentArg === 'register';
|
|
680
|
+
return html`
|
|
681
|
+
<dees-button
|
|
682
|
+
full-width
|
|
683
|
+
.type=${isRegister ? 'outline' : 'accent'}
|
|
684
|
+
.icon=${busy ? '' : isRegister ? 'lucide:keyRound' : 'lucide:fingerprint'}
|
|
685
|
+
.text=${isRegister
|
|
686
|
+
? this.copy('passkeyRegister', 'Create a passkey')
|
|
687
|
+
: this.copy('passkeyAuthenticate', 'Sign in with a passkey')}
|
|
688
|
+
.status=${busy ? 'pending' : 'normal'}
|
|
689
|
+
.disabled=${anyPasskeyBusy && !busy}
|
|
690
|
+
@clicked=${() => this.handlePasskey(intentArg)}
|
|
691
|
+
></dees-button>
|
|
692
|
+
`;
|
|
693
|
+
})}
|
|
694
|
+
</div>
|
|
695
|
+
${intents.map((intentArg) =>
|
|
696
|
+
this.renderError(this.getError({ method: 'passkey', intent: intentArg })),
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Past three providers the buttons pair up into two columns, and the "Continue with"
|
|
704
|
+
* prefix is dropped so a bare provider name still fits its half-width button.
|
|
705
|
+
*/
|
|
706
|
+
private get denseProviders(): boolean {
|
|
707
|
+
return this.providers.length >= 4;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private renderProviderMethod(): TemplateResult {
|
|
711
|
+
const dense = this.denseProviders;
|
|
712
|
+
// Half-width buttons cannot carry "Continue with …", so the prefix is dropped when the
|
|
713
|
+
// grid kicks in — unless the consumer stated one explicitly, which always wins.
|
|
714
|
+
const prefix = this.copy('providerPrefix', dense ? '' : 'Continue with');
|
|
715
|
+
return html`
|
|
716
|
+
<div class="method">
|
|
717
|
+
<div class="methodActions providerActions ${dense ? 'dense' : ''}">
|
|
718
|
+
${this.providers.map((providerArg) => {
|
|
719
|
+
const busy = this.isBusy({ method: 'provider', providerId: providerArg.id });
|
|
720
|
+
return html`
|
|
721
|
+
<dees-button
|
|
722
|
+
full-width
|
|
723
|
+
title=${dense ? providerArg.label : ''}
|
|
724
|
+
.type=${'secondary'}
|
|
725
|
+
.icon=${busy ? '' : providerArg.icon || ''}
|
|
726
|
+
.text=${prefix ? `${prefix} ${providerArg.label}` : providerArg.label}
|
|
727
|
+
.status=${busy ? 'pending' : 'normal'}
|
|
728
|
+
.disabled=${Boolean(providerArg.disabled)}
|
|
729
|
+
@clicked=${() => this.handleProvider(providerArg)}
|
|
730
|
+
></dees-button>
|
|
731
|
+
`;
|
|
732
|
+
})}
|
|
733
|
+
</div>
|
|
734
|
+
${this.renderError(this.getError({ method: 'provider' }))}
|
|
735
|
+
${this.providers.map((providerArg) => {
|
|
736
|
+
const message = this.getError({ method: 'provider', providerId: providerArg.id });
|
|
737
|
+
if (!message) {
|
|
738
|
+
return '';
|
|
739
|
+
}
|
|
740
|
+
return this.renderError(
|
|
741
|
+
this.providers.length > 1 ? `${providerArg.label}: ${message}` : message,
|
|
742
|
+
);
|
|
743
|
+
})}
|
|
744
|
+
</div>
|
|
745
|
+
`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private renderPasswordMethod(): TemplateResult {
|
|
749
|
+
// The submit label is slotted, not bound: consumers call dees-form.setStatus(), which
|
|
750
|
+
// writes dees-form-submit.text imperatively. A property binding here would clobber
|
|
751
|
+
// their status text on the next render.
|
|
752
|
+
const form = html`
|
|
753
|
+
<dees-form @formData=${this.handleFormData}>
|
|
754
|
+
<dees-input-text
|
|
755
|
+
key="username"
|
|
756
|
+
.label=${this.copy('usernameLabel', 'Username')}
|
|
757
|
+
.autocomplete=${this.shouldOfferPasskeyAutofill ? 'username webauthn' : 'username'}
|
|
758
|
+
required
|
|
759
|
+
></dees-input-text>
|
|
760
|
+
<dees-input-text
|
|
761
|
+
key="password"
|
|
762
|
+
.label=${this.copy('passwordLabel', 'Password')}
|
|
763
|
+
.autocomplete=${'current-password'}
|
|
764
|
+
isPasswordBool
|
|
765
|
+
required
|
|
766
|
+
></dees-input-text>
|
|
767
|
+
<dees-form-submit full-width>${this.copy('passwordSubmit', 'Sign in')}</dees-form-submit>
|
|
768
|
+
</dees-form>
|
|
769
|
+
`;
|
|
770
|
+
// The tile is what gives a lone credentials form its card. Once the column holds other
|
|
771
|
+
// methods the column *is* the card, and a second frame around only the fields reads as
|
|
772
|
+
// a stray box — so the form joins the column bare and keeps the same 360px edge.
|
|
773
|
+
return html`
|
|
774
|
+
<div class="method">
|
|
775
|
+
${this.resolvedMethods.length === 1
|
|
776
|
+
? html`
|
|
777
|
+
<dees-tile .heading=${this.copy('credentialsHeading', 'Credentials')}>
|
|
778
|
+
${form}
|
|
779
|
+
</dees-tile>
|
|
780
|
+
`
|
|
781
|
+
: html`<div class="credentials">${form}</div>`}
|
|
782
|
+
${this.renderError(this.getError('password'))}
|
|
783
|
+
</div>
|
|
784
|
+
`;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
private renderError(messageArg: string | undefined): TemplateResult | string {
|
|
788
|
+
if (!messageArg) {
|
|
789
|
+
return '';
|
|
790
|
+
}
|
|
791
|
+
return html`
|
|
792
|
+
<div class="methodError" role="alert">
|
|
793
|
+
<dees-icon .icon=${'lucide:circleAlert'} .iconSize=${14}></dees-icon>
|
|
794
|
+
<span>${messageArg}</span>
|
|
795
|
+
</div>
|
|
796
|
+
`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ------------------------------------------------------------------------------------
|
|
800
|
+
// lifecycle
|
|
801
|
+
// ------------------------------------------------------------------------------------
|
|
802
|
+
|
|
803
|
+
public async connectedCallback(): Promise<void> {
|
|
804
|
+
await super.connectedCallback();
|
|
805
|
+
this.detectPasskeySupportSync();
|
|
806
|
+
void this.detectPasskeySupportAsync();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
public async firstUpdated(
|
|
810
|
+
_changedProperties: Map<string | number | symbol, unknown>,
|
|
811
|
+
): Promise<void> {
|
|
143
812
|
super.firstUpdated(_changedProperties);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
813
|
+
this.observeContainerInteractivity();
|
|
814
|
+
void this.maybeStartPasskeyAutofill();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
public updated(_changedProperties: Map<string | number | symbol, unknown>): void {
|
|
818
|
+
super.updated(_changedProperties);
|
|
819
|
+
// Only take over the submit button's visual state when this component actually owns
|
|
820
|
+
// the credential ceremony. Legacy consumers drive it through dees-form.setStatus().
|
|
821
|
+
if (this.passwordLoginHandler) {
|
|
822
|
+
const submitButton = this.shadowRoot?.querySelector('dees-form-submit');
|
|
823
|
+
if (submitButton) {
|
|
824
|
+
submitButton.status = this.isBusy('password') ? 'pending' : 'normal';
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
void this.maybeStartPasskeyAutofill();
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
public async disconnectedCallback(): Promise<void> {
|
|
831
|
+
await super.disconnectedCallback();
|
|
832
|
+
this.abortCeremonies();
|
|
833
|
+
this.interactivityObserver?.disconnect();
|
|
834
|
+
this.interactivityObserver = undefined;
|
|
835
|
+
// re-arm autofill if this element is attached again
|
|
836
|
+
this.autofillStarted = false;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private detectPasskeySupportSync(): void {
|
|
840
|
+
const available =
|
|
841
|
+
typeof window !== 'undefined' &&
|
|
842
|
+
window.isSecureContext === true &&
|
|
843
|
+
typeof (window as unknown as { PublicKeyCredential?: unknown }).PublicKeyCredential ===
|
|
844
|
+
'function' &&
|
|
845
|
+
typeof navigator !== 'undefined' &&
|
|
846
|
+
typeof navigator.credentials?.get === 'function';
|
|
847
|
+
if (this.detectedPasskeySupport.available !== available) {
|
|
848
|
+
this.detectedPasskeySupport = { ...this.detectedPasskeySupport, available };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private async detectPasskeySupportAsync(): Promise<void> {
|
|
853
|
+
if (!this.detectedPasskeySupport.available) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const publicKeyCredential = (
|
|
857
|
+
window as unknown as {
|
|
858
|
+
PublicKeyCredential: {
|
|
859
|
+
isConditionalMediationAvailable?: () => Promise<boolean>;
|
|
860
|
+
isUserVerifyingPlatformAuthenticatorAvailable?: () => Promise<boolean>;
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
).PublicKeyCredential;
|
|
864
|
+
const probe = async (probeArg?: () => Promise<boolean>): Promise<boolean> => {
|
|
865
|
+
if (typeof probeArg !== 'function') {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
return (await probeArg.call(publicKeyCredential)) === true;
|
|
870
|
+
} catch {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
const [conditionalMediation, platformAuthenticator] = await Promise.all([
|
|
875
|
+
probe(publicKeyCredential.isConditionalMediationAvailable),
|
|
876
|
+
probe(publicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable),
|
|
877
|
+
]);
|
|
878
|
+
this.detectedPasskeySupport = {
|
|
879
|
+
available: true,
|
|
880
|
+
conditionalMediation,
|
|
881
|
+
platformAuthenticator,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ------------------------------------------------------------------------------------
|
|
886
|
+
// interactions
|
|
887
|
+
// ------------------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
private handleFormData = async (eventArg: Event): Promise<void> => {
|
|
890
|
+
const detail = (eventArg as CustomEvent).detail;
|
|
891
|
+
if (this.isBusy('password')) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
this.clearError('password');
|
|
895
|
+
// Unchanged legacy contract: `login` carries dees-form's detail verbatim.
|
|
896
|
+
this.dispatchEvent(
|
|
897
|
+
new CustomEvent('login', {
|
|
898
|
+
detail,
|
|
899
|
+
bubbles: true,
|
|
900
|
+
composed: true,
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
903
|
+
if (!this.passwordLoginHandler) {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const handler = this.passwordLoginHandler;
|
|
907
|
+
await this.runCeremony({ method: 'password' }, () =>
|
|
908
|
+
handler({ data: (detail?.data as Record<string, unknown>) || {} }),
|
|
909
|
+
);
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
private handlePasskey = async (intentArg: TDeesLoginPasskeyIntent): Promise<void> => {
|
|
913
|
+
const target: IDeesLoginTarget = { method: 'passkey', intent: intentArg };
|
|
914
|
+
if (this.resolvedPasskeyIntents.some((candidate) =>
|
|
915
|
+
this.isBusy({ method: 'passkey', intent: candidate }),
|
|
916
|
+
)) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
// An explicit ceremony supersedes a silent autofill request; leaving both in flight
|
|
920
|
+
// makes the browser reject one of them.
|
|
921
|
+
this.abortAutofill();
|
|
922
|
+
this.clearError(target);
|
|
923
|
+
const handler =
|
|
924
|
+
intentArg === 'register' ? this.passkeyRegisterHandler : this.passkeyLoginHandler;
|
|
925
|
+
if (!handler) {
|
|
926
|
+
this.dispatchEvent(
|
|
927
|
+
new CustomEvent(intentArg === 'register' ? 'passkey-register' : 'passkey-login', {
|
|
928
|
+
detail: { intent: intentArg, username: this.readUsername() },
|
|
929
|
+
bubbles: true,
|
|
930
|
+
composed: true,
|
|
931
|
+
}),
|
|
932
|
+
);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const key = DeesSimpleLogin.targetKey(target);
|
|
936
|
+
const controller = new AbortController();
|
|
937
|
+
this.ceremonyControllers.set(key, controller);
|
|
938
|
+
try {
|
|
939
|
+
await this.runCeremony(target, () =>
|
|
940
|
+
handler({
|
|
941
|
+
intent: intentArg,
|
|
942
|
+
username: this.readUsername(),
|
|
943
|
+
mediation: 'optional',
|
|
944
|
+
signal: controller.signal,
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
} finally {
|
|
948
|
+
if (this.ceremonyControllers.get(key) === controller) {
|
|
949
|
+
this.ceremonyControllers.delete(key);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
private handleProvider = async (providerArg: IDeesLoginProvider): Promise<void> => {
|
|
955
|
+
const target: IDeesLoginTarget = { method: 'provider', providerId: providerArg.id };
|
|
956
|
+
if (providerArg.disabled || this.isBusy(target)) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
this.clearError(target);
|
|
960
|
+
if (!this.providerLoginHandler) {
|
|
961
|
+
this.dispatchEvent(
|
|
962
|
+
new CustomEvent('provider-login', {
|
|
963
|
+
detail: { providerId: providerArg.id, provider: providerArg },
|
|
964
|
+
bubbles: true,
|
|
965
|
+
composed: true,
|
|
966
|
+
}),
|
|
967
|
+
);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const handler = this.providerLoginHandler;
|
|
971
|
+
await this.runCeremony(target, () =>
|
|
972
|
+
handler({ providerId: providerArg.id, provider: providerArg }),
|
|
973
|
+
);
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
private async runCeremony(
|
|
977
|
+
targetArg: IDeesLoginTarget,
|
|
978
|
+
ceremonyArg: () => unknown | Promise<unknown>,
|
|
979
|
+
): Promise<void> {
|
|
980
|
+
this.setBusy(targetArg, true);
|
|
981
|
+
try {
|
|
982
|
+
await ceremonyArg();
|
|
983
|
+
} catch (error) {
|
|
984
|
+
if (!DeesSimpleLogin.isAbortError(error)) {
|
|
985
|
+
this.reportError(targetArg, DeesSimpleLogin.toErrorMessage(error));
|
|
986
|
+
}
|
|
987
|
+
} finally {
|
|
988
|
+
this.setBusy(targetArg, false);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Starts the browser's passkey autofill. Conditional mediation has to be requested
|
|
994
|
+
* before the user touches anything, and it must stay silent — a background ceremony that
|
|
995
|
+
* finds no credential is not an error the user should see.
|
|
996
|
+
*/
|
|
997
|
+
private async maybeStartPasskeyAutofill(): Promise<void> {
|
|
998
|
+
if (this.autofillStarted || !this.shouldOfferPasskeyAutofill) {
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const handler = this.passkeyLoginHandler;
|
|
1002
|
+
if (!handler) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
this.autofillStarted = true;
|
|
1006
|
+
const controller = new AbortController();
|
|
1007
|
+
this.autofillController = controller;
|
|
1008
|
+
try {
|
|
1009
|
+
await handler({
|
|
1010
|
+
intent: 'authenticate',
|
|
1011
|
+
username: undefined,
|
|
1012
|
+
mediation: 'conditional',
|
|
1013
|
+
signal: controller.signal,
|
|
1014
|
+
});
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
if (!DeesSimpleLogin.isAbortError(error)) {
|
|
1017
|
+
console.warn('dees-simple-login: passkey autofill ceremony failed', error);
|
|
1018
|
+
}
|
|
1019
|
+
} finally {
|
|
1020
|
+
if (this.autofillController === controller) {
|
|
1021
|
+
this.autofillController = undefined;
|
|
1022
|
+
}
|
|
1023
|
+
// deliberately not reset: one silent attempt per connection, so a rejecting handler
|
|
1024
|
+
// can never be retried in a render loop
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private abortAutofill(): void {
|
|
1029
|
+
this.autofillController?.abort();
|
|
1030
|
+
this.autofillController = undefined;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private abortCeremonies(): void {
|
|
1034
|
+
this.abortAutofill();
|
|
1035
|
+
for (const controller of this.ceremonyControllers.values()) {
|
|
1036
|
+
controller.abort();
|
|
1037
|
+
}
|
|
1038
|
+
this.ceremonyControllers.clear();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
private readUsername(): string | undefined {
|
|
1042
|
+
const input = this.shadowRoot?.querySelector('dees-input-text[key="username"]') as
|
|
1043
|
+
| { value?: unknown }
|
|
1044
|
+
| null;
|
|
1045
|
+
const value = input?.value;
|
|
1046
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private static isAbortError(errorArg: unknown): boolean {
|
|
1050
|
+
return Boolean(errorArg) && (errorArg as { name?: string }).name === 'AbortError';
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private static toErrorMessage(errorArg: unknown): string {
|
|
1054
|
+
if (errorArg instanceof Error) {
|
|
1055
|
+
return errorArg.message;
|
|
1056
|
+
}
|
|
1057
|
+
return String(errorArg);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* The login card and the slotted app are both permanently in the DOM, stacked and
|
|
1062
|
+
* cross-faded with opacity plus `pointer-events`. That was enough while nothing inside
|
|
1063
|
+
* either half could hold focus — but `dees-button` faces are focusable now, and
|
|
1064
|
+
* `pointer-events: none` does not remove anything from the tab order. Without this, a
|
|
1065
|
+
* keyboard user could Tab out of the password form straight into the invisible app shell,
|
|
1066
|
+
* and after signing in could Tab back into the invisible login card and start a passkey
|
|
1067
|
+
* ceremony there. `inert` is what actually takes a hidden subtree out of focus and the
|
|
1068
|
+
* accessibility tree.
|
|
1069
|
+
*
|
|
1070
|
+
* Inertness is derived from each container's effective `pointer-events` rather than from
|
|
1071
|
+
* an internal flag, because consumers reverse this transition by hand — cloudly's
|
|
1072
|
+
* `switchToLoginContent()` writes these inline styles directly and knows nothing about
|
|
1073
|
+
* `inert`. Keying off a flag they cannot reset would leave their login card permanently
|
|
1074
|
+
* inert. A MutationObserver on the inline styles keeps both directions correct no matter
|
|
1075
|
+
* who wrote them.
|
|
1076
|
+
*/
|
|
1077
|
+
private syncContainerInertness = (): void => {
|
|
1078
|
+
for (const selector of ['.loginContainer', '.slotContainer']) {
|
|
1079
|
+
const container = this.shadowRoot?.querySelector(selector);
|
|
1080
|
+
if (!(container instanceof HTMLElement)) {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
container.inert = getComputedStyle(container).pointerEvents === 'none';
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
private observeContainerInteractivity(): void {
|
|
1088
|
+
if (this.interactivityObserver) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.interactivityObserver = new MutationObserver(() => this.syncContainerInertness());
|
|
1092
|
+
for (const selector of ['.loginContainer', '.slotContainer']) {
|
|
1093
|
+
const container = this.shadowRoot?.querySelector(selector);
|
|
1094
|
+
if (container) {
|
|
1095
|
+
// only the inline style matters; `inert` reflects to an attribute of its own and
|
|
1096
|
+
// must not re-trigger this observer
|
|
1097
|
+
this.interactivityObserver.observe(container, {
|
|
1098
|
+
attributes: true,
|
|
1099
|
+
attributeFilter: ['style'],
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
154
1102
|
}
|
|
1103
|
+
this.syncContainerInertness();
|
|
155
1104
|
}
|
|
156
1105
|
|
|
157
1106
|
/**
|