@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.
Files changed (22) hide show
  1. package/dist_bundle/bundle.js +4606 -3404
  2. package/dist_ts_web/00_commitinfo_data.js +1 -1
  3. package/dist_ts_web/elements/00group-button/dees-button/dees-button.d.ts +35 -0
  4. package/dist_ts_web/elements/00group-button/dees-button/dees-button.js +128 -5
  5. package/dist_ts_web/elements/00group-feedback/dees-spinner/dees-spinner.js +12 -3
  6. package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.d.ts +9 -0
  7. package/dist_ts_web/elements/00group-form/dees-form-submit/dees-form-submit.js +25 -2
  8. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.d.ts +15 -0
  9. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.js +36 -2
  10. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.d.ts +246 -0
  11. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.js +439 -21
  12. package/dist_ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.js +857 -23
  13. package/package.json +3 -3
  14. package/readme.hints.md +35 -0
  15. package/readme.md +80 -11
  16. package/ts_web/00_commitinfo_data.ts +1 -1
  17. package/ts_web/elements/00group-button/dees-button/dees-button.ts +128 -2
  18. package/ts_web/elements/00group-feedback/dees-spinner/dees-spinner.ts +11 -2
  19. package/ts_web/elements/00group-form/dees-form-submit/dees-form-submit.ts +18 -0
  20. package/ts_web/elements/00group-input/dees-input-text/dees-input-text.ts +29 -0
  21. package/ts_web/elements/00group-simple/dees-simple-login/dees-simple-login.demo.ts +418 -19
  22. 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">Sign in</div>
125
- <div class="subheader">Enter your credentials to access ${this.name}</div>
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
- <dees-tile .heading=${'Credentials'}>
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
- public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
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
- const form = this.shadowRoot!.querySelector('dees-form') as any;
146
- if (form) {
147
- form.addEventListener('formData', ((event: CustomEvent) => {
148
- this.dispatchEvent(new CustomEvent('login', {
149
- detail: event.detail,
150
- bubbles: true,
151
- composed: true
152
- }));
153
- }) as EventListener);
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
  /**