@arcblock/did-connect-core 4.1.51 → 4.1.53

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.
@@ -53,15 +53,42 @@ export class LoginPage {
53
53
  tokenSession = null;
54
54
  styleEl = null;
55
55
  mounted = false;
56
+ completed = false;
57
+ generation = 0;
58
+ activeMethod;
59
+ activeProvider;
60
+ oauthCleanup;
61
+ emailTimer;
62
+ emailDraft = '';
63
+ abort;
64
+ emit(name) {
65
+ try {
66
+ this.options.onEvent?.({ name, method: this.activeMethod, mode: this.options.mode ?? 'page' });
67
+ }
68
+ catch { /* analytics must not interrupt authentication */ }
69
+ }
70
+ select(method) {
71
+ this.generation++;
72
+ this.oauthCleanup?.();
73
+ this.abort?.abort();
74
+ this.abort = new AbortController();
75
+ this.activeMethod = method;
76
+ this.activeProvider = undefined;
77
+ this.emit('method_selected');
78
+ }
56
79
  currentView = 'methods';
57
80
  cardEl = null;
58
81
  mediaQuery = null;
59
82
  mediaListener = null;
60
83
  localeOutsideClick = null;
84
+ /** Open shadow root when isolate is set. Reused across destroy/mount. */
85
+ shadow = null;
86
+ isolate;
61
87
  constructor(container, config, http, options) {
62
88
  this.container = container;
63
89
  this.config = config;
64
90
  this.http = http;
91
+ this.isolate = options?.isolate === true;
65
92
  this.options = {
66
93
  methods: ['passkey', 'did-connect'],
67
94
  locale: 'en',
@@ -83,11 +110,14 @@ export class LoginPage {
83
110
  if (this.mounted)
84
111
  return;
85
112
  this.mounted = true;
113
+ this.completed = false;
114
+ this.emit('view');
86
115
  // 1. Inject styles
87
116
  this.styleEl = document.createElement('style');
88
117
  this.styleEl.textContent = LOGIN_PAGE_STYLES;
89
- this.container.appendChild(this.styleEl);
90
- // 2. Apply theme
118
+ this.mountRoot().appendChild(this.styleEl);
119
+ // 2. Follow ?theme= / web-mode, then the caller's fallback.
120
+ this.adoptStoredTheme();
91
121
  this.applyTheme();
92
122
  // 3. Feature-detect available methods
93
123
  const available = this.filterMethods(this.options.methods);
@@ -96,24 +126,40 @@ export class LoginPage {
96
126
  }
97
127
  /** Destroy the login UI, clean up timers and listeners. Idempotent. */
98
128
  destroy() {
129
+ this.generation++;
130
+ this.oauthCleanup?.();
131
+ this.abort?.abort();
132
+ clearInterval(this.emailTimer);
99
133
  this.detachLocaleMenu();
100
134
  if (this.tokenSession) {
101
135
  this.tokenSession.destroy();
102
136
  this.tokenSession = null;
103
137
  }
104
- if (this.mediaQuery && this.mediaListener) {
105
- this.mediaQuery.removeEventListener('change', this.mediaListener);
106
- this.mediaQuery = null;
107
- this.mediaListener = null;
138
+ this.detachMedia();
139
+ if (this.isolate) {
140
+ // innerHTML on the host does not clear its shadow root.
141
+ const shadow = this.shadow ?? this.container.shadowRoot;
142
+ if (shadow)
143
+ shadow.innerHTML = '';
144
+ this.container.removeAttribute('data-theme');
145
+ }
146
+ else {
147
+ document.documentElement.removeAttribute('data-theme');
148
+ this.container.innerHTML = '';
108
149
  }
109
- // Clean up theme attribute
110
- document.documentElement.removeAttribute('data-theme');
111
- this.container.innerHTML = '';
112
150
  this.styleEl = null;
113
151
  this.cardEl = null;
114
152
  this.currentView = 'methods';
115
153
  this.mounted = false;
116
154
  }
155
+ /**
156
+ * Apply a light or dark theme without remounting.
157
+ * The host writes `web-mode` itself. Isolate updates the shadow host only.
158
+ */
159
+ setTheme(theme) {
160
+ this.options.theme = theme;
161
+ this.applyTheme();
162
+ }
117
163
  /** Update options and re-render. */
118
164
  update(newOptions) {
119
165
  Object.assign(this.options, newOptions);
@@ -123,27 +169,84 @@ export class LoginPage {
123
169
  }
124
170
  }
125
171
  // ─── Private: theme ────────────────────────────────────────────────
172
+ /** Light DOM themes <html>. Isolate themes the shadow host only. */
173
+ themeTarget() {
174
+ return this.isolate ? this.container : document.documentElement;
175
+ }
176
+ /** Shadow root created once. attachShadow throws if called again. */
177
+ mountRoot() {
178
+ if (!this.isolate)
179
+ return this.container;
180
+ if (this.shadow)
181
+ return this.shadow;
182
+ this.shadow = this.container.shadowRoot ?? this.container.attachShadow({ mode: 'open' });
183
+ return this.shadow;
184
+ }
185
+ detachMedia() {
186
+ if (this.mediaQuery && this.mediaListener) {
187
+ this.mediaQuery.removeEventListener('change', this.mediaListener);
188
+ }
189
+ this.mediaQuery = null;
190
+ this.mediaListener = null;
191
+ }
192
+ /**
193
+ * Same order as theme/init.js: `?theme=` is a preview and wins;
194
+ * otherwise `localStorage["web-mode"]`. Neither write happens here.
195
+ */
196
+ adoptStoredTheme() {
197
+ if (this.options.mode === 'dialog')
198
+ return;
199
+ if (typeof window === 'undefined')
200
+ return;
201
+ const urlTheme = new URLSearchParams(window.location.search).get('theme');
202
+ if (urlTheme === 'light' || urlTheme === 'dark' || urlTheme === 'auto') {
203
+ this.options.theme = urlTheme;
204
+ return;
205
+ }
206
+ try {
207
+ const stored = localStorage.getItem('web-mode');
208
+ if (stored === 'light' || stored === 'dark' || stored === 'auto')
209
+ this.options.theme = stored;
210
+ }
211
+ catch { /* private mode */ }
212
+ }
213
+ /** light sets data-theme. dark removes it. auto follows the OS. */
126
214
  applyTheme() {
215
+ this.detachMedia();
127
216
  const theme = this.options.theme ?? 'dark';
217
+ const target = this.themeTarget();
128
218
  if (theme === 'light') {
129
- document.documentElement.dataset.theme = 'light';
219
+ target.dataset.theme = 'light';
130
220
  }
131
221
  else if (theme === 'auto' && typeof window.matchMedia === 'function') {
132
222
  const mq = window.matchMedia('(prefers-color-scheme: light)');
133
223
  this.mediaQuery = mq;
134
224
  const apply = (light) => {
135
- if (light) {
136
- document.documentElement.dataset.theme = 'light';
137
- }
138
- else {
139
- document.documentElement.removeAttribute('data-theme');
140
- }
225
+ if (light)
226
+ target.dataset.theme = 'light';
227
+ else
228
+ target.removeAttribute('data-theme');
229
+ this.repaintQRPlate();
141
230
  };
142
231
  apply(mq.matches);
143
232
  this.mediaListener = (e) => apply(e.matches);
144
233
  mq.addEventListener('change', this.mediaListener);
234
+ return;
145
235
  }
146
- // 'dark' is the default — no data-theme attribute needed
236
+ else {
237
+ target.removeAttribute('data-theme');
238
+ }
239
+ this.repaintQRPlate();
240
+ }
241
+ /** Redraw a visible QR so its plate matches the theme. Leaves a timeout overlay alone. */
242
+ repaintQRPlate() {
243
+ const qr = this.mountRoot().querySelector('#did-connect-qr');
244
+ if (!(qr instanceof HTMLElement))
245
+ return;
246
+ const url = qr.dataset.qrUrl;
247
+ if (!url || !qr.querySelector('canvas') || qr.querySelector('.qr-timeout-overlay'))
248
+ return;
249
+ renderQR(qr, url);
147
250
  }
148
251
  // ─── Private: rendering ──────────────────────────────────────────────
149
252
  filterMethods(methods) {
@@ -158,11 +261,18 @@ export class LoginPage {
158
261
  }
159
262
  renderCard(methods) {
160
263
  const card = document.createElement('div');
161
- card.className = 'card';
264
+ card.className = this.options.mode === 'dialog' ? 'card card-modal' : 'card';
162
265
  this.cardEl = card;
163
- this.container.appendChild(card);
266
+ this.mountRoot().appendChild(card);
164
267
  this.switchView('methods', methods);
165
268
  }
269
+ /** Locale sits on the card's top-right row, level with Back / Cancel. */
270
+ renderCardTools(card) {
271
+ const tools = document.createElement('div');
272
+ tools.className = 'card-tools';
273
+ this.renderLocaleSwitcher(tools);
274
+ card.appendChild(tools);
275
+ }
166
276
  renderLocaleSwitcher(card) {
167
277
  const passed = this.options.locales;
168
278
  if (passed) {
@@ -201,7 +311,7 @@ export class LoginPage {
201
311
  trigger.className = 'locale-trigger';
202
312
  trigger.setAttribute('aria-haspopup', 'menu');
203
313
  trigger.setAttribute('aria-expanded', 'false');
204
- trigger.innerHTML = `${ICON_GLOBE} ${escapeHtml(current.label)}`;
314
+ trigger.innerHTML = `${ICON_GLOBE} <span class="locale-label">${escapeHtml(current.label)}</span>`;
205
315
  trigger.title = current.label;
206
316
  const menu = document.createElement('div');
207
317
  menu.className = 'locale-menu';
@@ -242,8 +352,16 @@ export class LoginPage {
242
352
  menu.appendChild(btn);
243
353
  }
244
354
  const onOutsideClick = (event) => {
245
- if (!wrapper.contains(event.target))
246
- setOpen(false);
355
+ const path = event.composedPath();
356
+ if (path.includes(wrapper))
357
+ return;
358
+ // Shadow clicks are retargeted to the host, so contains(event.target)
359
+ // is always false and the menu would close on every click. A click
360
+ // whose path includes the host is still inside the component. Light
361
+ // DOM has isolate false, so a click on the card still closes the menu.
362
+ if (this.isolate && path.includes(this.container))
363
+ return;
364
+ setOpen(false);
247
365
  };
248
366
  document.addEventListener('click', onOutsideClick);
249
367
  this.localeOutsideClick = onOutsideClick;
@@ -268,10 +386,14 @@ export class LoginPage {
268
386
  this.tokenSession = null;
269
387
  }
270
388
  }
389
+ this.generation++;
390
+ this.oauthCleanup?.();
391
+ this.abort?.abort();
392
+ clearInterval(this.emailTimer);
271
393
  this.currentView = view;
272
394
  this.cardEl.innerHTML = '';
273
395
  this.cardEl.dataset.view = view;
274
- this.renderLocaleSwitcher(this.cardEl);
396
+ this.renderCardTools(this.cardEl);
275
397
  switch (view) {
276
398
  case 'methods':
277
399
  this.renderMethodsView(this.cardEl, locale, methods ?? this.filterMethods(this.options.methods));
@@ -287,9 +409,25 @@ export class LoginPage {
287
409
  renderMethodsView(card, locale, methods) {
288
410
  // ── Escape hatch ("Cancel") — top-left, mirrors the locale switcher
289
411
  // (top-right). Standard placement for a standalone auth card. ──
290
- this.renderHomeLink(card, locale);
412
+ if (this.options.mode !== 'dialog')
413
+ this.renderHomeLink(card, locale);
291
414
  // ── Brand header ──
292
415
  this.renderBrandHeader(card, locale);
416
+ if (this.options.preferenceKey) {
417
+ try {
418
+ const last = localStorage.getItem(`did-login-method:${this.options.preferenceKey}`);
419
+ const labels = { passkey: 'Passkey', oauth: 'OAuth', email: t('emailButton', locale), 'did-connect': 'DID Wallet' };
420
+ const provider = last?.startsWith('oauth:') ? this.options.oauthProviders?.find(p => p.enabled && `oauth:${p.id}` === last) : undefined;
421
+ const label = provider?.name ?? (last ? labels[last] : undefined);
422
+ if (last && label && (methods.includes(last) || (provider && methods.includes('oauth')))) {
423
+ const hint = document.createElement('p');
424
+ hint.className = 'subtitle';
425
+ hint.textContent = t('lastUsed', locale, { method: label });
426
+ card.appendChild(hint);
427
+ }
428
+ }
429
+ catch { /* private mode */ }
430
+ }
293
431
  // Passkey unsupported warning
294
432
  const requestedPasskey = this.options.methods.includes('passkey');
295
433
  if (requestedPasskey && !methods.includes('passkey')) {
@@ -298,6 +436,12 @@ export class LoginPage {
298
436
  warn.textContent = t('passkeyUnsupported', locale);
299
437
  card.appendChild(warn);
300
438
  }
439
+ const socialFirst = this.options.preferredLayout !== 'passkey-first' && methods.includes('oauth') && this.options.oauthProviders?.some(p => p.enabled && ['google', 'apple'].includes(p.id));
440
+ card.classList.toggle('social-first', !!socialFirst);
441
+ if (socialFirst) {
442
+ this.renderSecondaryGrid(card, locale, ['oauth'], 'primary');
443
+ this.renderDivider(card, locale);
444
+ }
301
445
  // ── Primary group: passkey ──
302
446
  const hasPrimary = methods.includes('passkey');
303
447
  if (hasPrimary) {
@@ -309,7 +453,21 @@ export class LoginPage {
309
453
  this.renderDivider(card, locale);
310
454
  }
311
455
  if (secondary.length > 0) {
312
- this.renderSecondaryGrid(card, locale, methods);
456
+ if (socialFirst) {
457
+ if (methods.includes('email'))
458
+ this.renderSecondaryGrid(card, locale, ['email']);
459
+ const details = document.createElement('details');
460
+ details.className = 'other-methods';
461
+ const summary = document.createElement('summary');
462
+ summary.textContent = t('otherMethods', locale);
463
+ details.appendChild(summary);
464
+ this.renderSecondaryGrid(details, locale, methods.filter(m => m !== 'email'), 'secondary');
465
+ if (details.querySelector('button'))
466
+ card.appendChild(details);
467
+ }
468
+ else {
469
+ this.renderSecondaryGrid(card, locale, methods);
470
+ }
313
471
  }
314
472
  // If no methods available at all, show a message
315
473
  if (methods.length === 0 && !requestedPasskey) {
@@ -345,11 +503,15 @@ export class LoginPage {
345
503
  walletBtn.id = 'did-wallet-open-btn';
346
504
  walletBtn.textContent = t('openInWallet', locale);
347
505
  walletBtn.disabled = true; // enabled once URL is ready
348
- card.appendChild(walletBtn);
506
+ if (isMobile())
507
+ card.insertBefore(walletBtn, qrContainer);
508
+ else
509
+ card.appendChild(walletBtn);
349
510
  // Status
350
511
  const statusEl = document.createElement('div');
351
512
  statusEl.id = 'did-connect-status';
352
513
  statusEl.className = 'status';
514
+ statusEl.setAttribute('role', 'status');
353
515
  card.appendChild(statusEl);
354
516
  // Trust footer
355
517
  this.renderTrustFooter(card, locale);
@@ -365,10 +527,25 @@ export class LoginPage {
365
527
  title.className = 'view-title';
366
528
  title.textContent = t('emailTitle', locale);
367
529
  card.appendChild(title);
530
+ const emailLabel = document.createElement('label');
531
+ emailLabel.className = 'input-label';
532
+ emailLabel.htmlFor = 'login-email-address';
533
+ emailLabel.textContent = t('emailPlaceholder', locale);
534
+ card.appendChild(emailLabel);
535
+ const emailHelp = document.createElement('p');
536
+ emailHelp.className = 'email-help';
537
+ emailHelp.textContent = t('emailExplanation', locale);
538
+ card.appendChild(emailHelp);
368
539
  // Email input
369
540
  const emailInput = document.createElement('input');
370
541
  emailInput.className = 'input';
542
+ emailInput.id = 'login-email-address';
371
543
  emailInput.type = 'email';
544
+ emailInput.required = true;
545
+ emailInput.autocomplete = 'email';
546
+ emailInput.value = this.emailDraft;
547
+ emailInput.setAttribute('aria-label', t('emailPlaceholder', locale));
548
+ emailInput.addEventListener('input', () => { this.emailDraft = emailInput.value; });
372
549
  emailInput.placeholder = t('emailPlaceholder', locale);
373
550
  card.appendChild(emailInput);
374
551
  // Submit button
@@ -379,16 +556,24 @@ export class LoginPage {
379
556
  // Status
380
557
  const status = document.createElement('div');
381
558
  status.className = 'status';
559
+ status.setAttribute('role', 'status');
382
560
  card.appendChild(status);
383
561
  // ── Code verification section (hidden initially) ──
384
562
  const codeSection = document.createElement('div');
385
563
  codeSection.className = 'email-code-section';
386
564
  codeSection.style.display = 'none';
565
+ const codeLabel = document.createElement('label');
566
+ codeLabel.className = 'input-label';
567
+ codeLabel.htmlFor = 'login-email-code';
568
+ codeLabel.textContent = t('emailCodePlaceholder', locale);
569
+ codeSection.appendChild(codeLabel);
387
570
  const codeInput = document.createElement('input');
571
+ codeInput.id = 'login-email-code';
388
572
  codeInput.className = 'input';
389
573
  codeInput.type = 'text';
390
574
  codeInput.inputMode = 'numeric';
391
575
  codeInput.maxLength = 8;
576
+ codeInput.setAttribute('aria-label', t('emailCodePlaceholder', locale));
392
577
  codeInput.autocomplete = 'one-time-code';
393
578
  codeInput.placeholder = t('emailCodePlaceholder', locale);
394
579
  codeSection.appendChild(codeInput);
@@ -404,6 +589,27 @@ export class LoginPage {
404
589
  card.insertBefore(codeSection, status);
405
590
  // Trust footer
406
591
  this.renderTrustFooter(card, locale);
592
+ const epoch = this.generation;
593
+ const isCurrent = () => this.mounted && epoch === this.generation;
594
+ const editBtn = document.createElement('button');
595
+ editBtn.className = 'passkey-alt';
596
+ editBtn.textContent = t('emailEdit', locale);
597
+ codeSection.appendChild(editBtn);
598
+ editBtn.addEventListener('click', () => this.switchView('email'));
599
+ const cooldown = () => {
600
+ let remaining = 60;
601
+ clearInterval(this.emailTimer);
602
+ resendBtn.disabled = true;
603
+ resendBtn.textContent = t('emailCooldown', locale, { seconds: String(remaining) });
604
+ this.emailTimer = setInterval(() => {
605
+ remaining--;
606
+ resendBtn.textContent = remaining > 0 ? t('emailCooldown', locale, { seconds: String(remaining) }) : t('emailResend', locale);
607
+ if (remaining <= 0) {
608
+ clearInterval(this.emailTimer);
609
+ resendBtn.disabled = false;
610
+ }
611
+ }, 1000);
612
+ };
407
613
  let currentEmail = '';
408
614
  let currentCodeId;
409
615
  const servicePrefix = this.config.servicePrefix ?? '/.well-known/service';
@@ -412,7 +618,7 @@ export class LoginPage {
412
618
  if (sendBtn.disabled)
413
619
  return;
414
620
  const email = emailInput.value.trim();
415
- if (!email?.includes('@')) {
621
+ if (!emailInput.checkValidity()) {
416
622
  status.textContent = t('emailInvalid', locale);
417
623
  status.className = 'status error';
418
624
  return;
@@ -420,12 +626,18 @@ export class LoginPage {
420
626
  sendBtn.disabled = true;
421
627
  status.textContent = t('emailSending', locale);
422
628
  status.className = 'status';
629
+ status.setAttribute('role', 'status');
423
630
  try {
424
631
  const sendResult = await this.http.post(`${servicePrefix}/api/email/sendCode`, { email, locale });
632
+ if (!isCurrent())
633
+ return;
634
+ cooldown();
425
635
  currentEmail = email;
426
636
  currentCodeId = sendResult?.id;
427
637
  // Switch to code input phase
428
638
  emailInput.style.display = 'none';
639
+ emailLabel.hidden = true;
640
+ emailHelp.hidden = true;
429
641
  sendBtn.style.display = 'none';
430
642
  codeSection.style.display = '';
431
643
  title.textContent = t('emailVerify', locale);
@@ -434,6 +646,8 @@ export class LoginPage {
434
646
  codeInput.focus();
435
647
  }
436
648
  catch (err) {
649
+ if (!isCurrent())
650
+ return;
437
651
  const msg = err instanceof Error ? err.message : 'Failed to send email';
438
652
  status.textContent = msg;
439
653
  status.className = 'status error';
@@ -444,7 +658,7 @@ export class LoginPage {
444
658
  if (verifyBtn.disabled)
445
659
  return;
446
660
  const code = codeInput.value.trim();
447
- if (!code || code.length !== 8) {
661
+ if (!/^\d{8}$/.test(code)) {
448
662
  status.textContent = t('emailCodeInvalid', locale);
449
663
  status.className = 'status error';
450
664
  return;
@@ -454,11 +668,15 @@ export class LoginPage {
454
668
  status.className = 'status';
455
669
  try {
456
670
  await this.http.post(`${servicePrefix}/api/email/login`, { code, id: currentCodeId });
671
+ if (!isCurrent())
672
+ return;
457
673
  status.textContent = t('success', locale);
458
674
  status.className = 'status';
459
675
  this.handleSuccess();
460
676
  }
461
677
  catch (err) {
678
+ if (!isCurrent())
679
+ return;
462
680
  if (err instanceof Error && 'code' in err && err.code === 'FORBIDDEN') {
463
681
  this.showFullError(friendlyError(err, locale));
464
682
  }
@@ -479,21 +697,28 @@ export class LoginPage {
479
697
  codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
480
698
  verifyCode(); });
481
699
  resendBtn.addEventListener('click', async () => {
700
+ if (resendBtn.disabled)
701
+ return;
482
702
  resendBtn.disabled = true;
483
703
  status.textContent = t('emailSending', locale);
484
704
  status.className = 'status';
485
705
  try {
486
706
  const resendResult = await this.http.post(`${servicePrefix}/api/email/sendCode`, { email: currentEmail, locale });
707
+ if (!isCurrent())
708
+ return;
709
+ cooldown();
487
710
  currentCodeId = resendResult?.id;
488
711
  status.textContent = t('emailSent', locale, { email: currentEmail });
489
712
  status.className = 'status';
490
713
  }
491
714
  catch (err) {
715
+ if (!isCurrent())
716
+ return;
717
+ resendBtn.disabled = false;
492
718
  const msg = err instanceof Error ? err.message : 'Failed to resend';
493
719
  status.textContent = msg;
494
720
  status.className = 'status error';
495
721
  }
496
- resendBtn.disabled = false;
497
722
  });
498
723
  }
499
724
  renderBackButton(parent, locale) {
@@ -515,6 +740,8 @@ export class LoginPage {
515
740
  * sub-view back buttons that mean "return to the method list".
516
741
  */
517
742
  renderHomeLink(parent, locale) {
743
+ if (this.options.dismissible === false)
744
+ return;
518
745
  const { homeUrl, onCancel } = this.options;
519
746
  if (!onCancel && !homeUrl)
520
747
  return;
@@ -524,7 +751,7 @@ export class LoginPage {
524
751
  btn.type = 'button';
525
752
  btn.className = 'home-link';
526
753
  btn.innerHTML = label;
527
- btn.addEventListener('click', () => this.options.onCancel?.());
754
+ btn.addEventListener('click', () => { this.emit('cancel'); this.options.onCancel?.(); });
528
755
  parent.appendChild(btn);
529
756
  }
530
757
  else {
@@ -556,7 +783,16 @@ export class LoginPage {
556
783
  else {
557
784
  h1.textContent = t('signIn', locale);
558
785
  }
786
+ if (this.options.purpose)
787
+ h1.textContent = this.options.purpose.title;
788
+ h1.id = 'login-title';
559
789
  header.appendChild(h1);
790
+ if (this.options.purpose?.description) {
791
+ const description = document.createElement('p');
792
+ description.className = 'subtitle';
793
+ description.textContent = this.options.purpose.description;
794
+ header.appendChild(description);
795
+ }
560
796
  // Subtitle: only the federation hint ("Authentication provided by X") carries
561
797
  // real information. The generic "Sign in to continue" just echoes the title,
562
798
  // so it's omitted — the title + auth buttons speak for themselves.
@@ -602,6 +838,12 @@ export class LoginPage {
602
838
  }
603
839
  footer.appendChild(links);
604
840
  }
841
+ if (this.currentView !== 'methods' && this.options.mode !== 'dialog') {
842
+ this.renderHomeLink(footer, locale);
843
+ const cancel = footer.querySelector('.home-link');
844
+ if (cancel)
845
+ cancel.style.cssText = 'position:static;display:inline-flex;margin-top:12px';
846
+ }
605
847
  parent.appendChild(footer);
606
848
  }
607
849
  renderDivider(parent, locale) {
@@ -665,15 +907,25 @@ export class LoginPage {
665
907
  retryBtn.addEventListener('click', () => doSignIn());
666
908
  const createLink = document.createElement('button');
667
909
  createLink.type = 'button';
668
- createLink.className = 'passkey-alt';
910
+ createLink.className = 'passkey-alt passkey-create-entry';
669
911
  createLink.textContent = t('passkeyCreateInstead', locale);
670
912
  section.appendChild(createLink);
671
913
  createLink.addEventListener('click', () => renderCreateState());
672
914
  };
673
915
  // ── State 1: explicit create (new user declared intent) ──
674
916
  const renderCreateState = () => {
917
+ this.cardEl?.classList.add('passkey-creating');
918
+ this.abort?.abort();
919
+ this.generation++;
675
920
  section.innerHTML = '';
676
921
  clearStatus();
922
+ const heading = this.cardEl?.querySelector('h1');
923
+ if (heading)
924
+ heading.textContent = t('passkeyCreateAccount', locale);
925
+ const explanation = document.createElement('p');
926
+ explanation.className = 'subtitle';
927
+ explanation.textContent = t('passkeyExplanation', locale);
928
+ section.appendChild(explanation);
677
929
  const nameRow = document.createElement('div');
678
930
  nameRow.className = 'name-row visible';
679
931
  nameRow.id = 'passkey-name-row';
@@ -681,6 +933,7 @@ export class LoginPage {
681
933
  nameInput.className = 'input';
682
934
  nameInput.id = 'passkey-name-input';
683
935
  nameInput.type = 'text';
936
+ nameInput.setAttribute('aria-label', t('namePlaceholder', locale));
684
937
  nameInput.placeholder = t('namePlaceholder', locale);
685
938
  nameInput.autocomplete = 'name';
686
939
  nameRow.appendChild(nameInput);
@@ -699,8 +952,11 @@ export class LoginPage {
699
952
  if (createBtn.disabled)
700
953
  return; // Enter-key auto-repeat on the name input
701
954
  createBtn.disabled = true;
955
+ this.select('passkey');
956
+ const epoch = this.generation;
702
957
  const result = await runPasskeyAuth({
703
958
  registerOnly: true,
959
+ signal: this.abort?.signal,
704
960
  name: nameInput.value.trim(),
705
961
  onStatus: setStatus,
706
962
  texts: statusTexts,
@@ -708,6 +964,8 @@ export class LoginPage {
708
964
  prefix: this.options.passkeyPrefix,
709
965
  invitationId: this.options.invitationId,
710
966
  });
967
+ if (!this.mounted || epoch !== this.generation || this.abort?.signal.aborted)
968
+ return;
711
969
  if (handleResult(result))
712
970
  return;
713
971
  // Cancelled or other error — stay in create state, let the user retry.
@@ -719,21 +977,28 @@ export class LoginPage {
719
977
  nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
720
978
  doCreate(); });
721
979
  backLink.addEventListener('click', () => renderSignInState());
722
- nameInput.focus();
980
+ createBtn.focus();
723
981
  };
724
982
  // ── State 0: sign-in (default) ──
725
983
  const doSignIn = async () => {
726
984
  const btn = section.querySelector('#passkey-btn');
985
+ if (btn?.disabled)
986
+ return;
727
987
  if (btn)
728
988
  btn.disabled = true;
989
+ this.select('passkey');
990
+ const epoch = this.generation;
729
991
  const result = await runPasskeyAuth({
730
992
  authOnly: true,
993
+ signal: this.abort?.signal,
731
994
  onStatus: setStatus,
732
995
  texts: statusTexts,
733
996
  locale,
734
997
  prefix: this.options.passkeyPrefix,
735
998
  invitationId: this.options.invitationId,
736
999
  });
1000
+ if (!this.mounted || epoch !== this.generation || this.abort?.signal.aborted)
1001
+ return;
737
1002
  if (handleResult(result))
738
1003
  return;
739
1004
  if (result.error && result.error !== 'no_passkey') {
@@ -745,20 +1010,27 @@ export class LoginPage {
745
1010
  }
746
1011
  // no_passkey (or user cancelled) — fork to cross-device / create choice
747
1012
  // instead of silently creating a second credential.
1013
+ this.emit('incomplete');
748
1014
  renderCrossDeviceState();
749
1015
  };
750
1016
  const renderSignInState = () => {
1017
+ this.cardEl?.classList.remove('passkey-creating');
1018
+ this.abort?.abort();
1019
+ this.generation++;
1020
+ const heading = this.cardEl?.querySelector('h1');
1021
+ if (heading)
1022
+ heading.textContent = this.options.purpose?.title ?? (this.options.appName ? t('signInTo', locale, { appName: this.options.appName }) : t('signIn', locale));
751
1023
  section.innerHTML = '';
752
1024
  clearStatus();
753
1025
  const signInBtn = document.createElement('button');
754
- signInBtn.className = 'btn';
1026
+ signInBtn.className = this.cardEl?.classList.contains('social-first') ? 'btn btn-secondary' : 'btn';
755
1027
  signInBtn.id = 'passkey-btn';
756
1028
  signInBtn.innerHTML = `${ICON_PASSKEY} ${escapeHtml(t('passkeySignIn', locale))}`;
757
1029
  section.appendChild(signInBtn);
758
1030
  signInBtn.addEventListener('click', () => doSignIn());
759
1031
  const createLink = document.createElement('button');
760
1032
  createLink.type = 'button';
761
- createLink.className = 'passkey-alt';
1033
+ createLink.className = 'passkey-alt passkey-create-entry';
762
1034
  createLink.textContent = t('passkeyFirstTime', locale);
763
1035
  section.appendChild(createLink);
764
1036
  createLink.addEventListener('click', () => renderCreateState());
@@ -775,6 +1047,8 @@ export class LoginPage {
775
1047
  // methods now render through renderSecondaryGrid)
776
1048
  // ─── Private: OAuth popup flow ──────────────────────────────────────
777
1049
  startOAuthPopup(providerId, btn) {
1050
+ this.oauthCleanup?.();
1051
+ const epoch = this.generation;
778
1052
  const servicePrefix = this.config.servicePrefix ?? '/.well-known/service';
779
1053
  // Federation: popup opens Master's OAuth login URL (cross-domain callback)
780
1054
  const oauthBase = this.options.masterOAuthOrigin
@@ -789,15 +1063,30 @@ export class LoginPage {
789
1063
  const top = window.screenY + (window.innerHeight - height) / 2;
790
1064
  const popup = window.open(loginUrl, 'oauth-login:popup', `left=${left},top=${top},width=${width},height=${height},resizable,scrollbars=yes,status=1,popup`);
791
1065
  if (!popup) {
1066
+ this.options.onBeforeRedirect?.();
792
1067
  window.location.href = loginUrl;
793
1068
  return;
794
1069
  }
795
1070
  // Save original button content and enter loading state
796
1071
  const originalHTML = btn.innerHTML;
797
- btn.innerHTML = `<span class="oauth-spinner"></span> Authorizing...`;
1072
+ btn.textContent = t('oauthWaiting', this.options.locale, { provider: this.options.oauthProviders?.find(p => p.id === providerId)?.name ?? providerId });
798
1073
  btn.disabled = true;
799
1074
  btn.classList.add('oauth-btn-loading');
1075
+ const recovery = document.createElement('div');
1076
+ recovery.className = 'oauth-recovery';
1077
+ recovery.setAttribute('role', 'status');
1078
+ const hint = document.createElement('p');
1079
+ hint.textContent = t('oauthIncomplete', this.options.locale);
1080
+ const fullPage = document.createElement('a');
1081
+ fullPage.href = loginUrl;
1082
+ fullPage.textContent = t('oauthFullPage', this.options.locale);
1083
+ fullPage.addEventListener('click', () => { this.options.onBeforeRedirect?.(); this.oauthCleanup?.(); });
1084
+ recovery.append(hint, fullPage);
1085
+ recovery.hidden = true;
1086
+ btn.parentElement?.after(recovery);
800
1087
  const restore = () => {
1088
+ recovery.hidden = false;
1089
+ this.emit('incomplete');
801
1090
  btn.innerHTML = originalHTML;
802
1091
  btn.disabled = false;
803
1092
  btn.classList.remove('oauth-btn-loading');
@@ -805,19 +1094,19 @@ export class LoginPage {
805
1094
  let settled = false;
806
1095
  let sessionPollTimer;
807
1096
  const succeed = () => {
808
- if (settled)
1097
+ if (settled || !this.mounted || epoch !== this.generation)
809
1098
  return;
810
1099
  settled = true;
811
1100
  cleanup();
812
- btn.innerHTML = `<span class="oauth-check">✓</span> Success`;
1101
+ btn.textContent = t('success', this.options.locale);
813
1102
  this.handleSuccess();
814
1103
  };
815
1104
  const onMessage = async (event) => {
816
- if (event.origin !== oauthOrigin)
1105
+ if (event.origin !== oauthOrigin || event.source !== popup)
817
1106
  return;
818
1107
  if (!event.data || event.data.type !== 'authorization_response')
819
1108
  return;
820
- if (settled)
1109
+ if (settled || !this.mounted || epoch !== this.generation)
821
1110
  return;
822
1111
  // Stop the closed-poll only. Do NOT drop this listener from popup.closed —
823
1112
  // GitHub/Twitter COOP reports closed while the provider page is still up,
@@ -843,7 +1132,7 @@ export class LoginPage {
843
1132
  cleanup();
844
1133
  return;
845
1134
  }
846
- btn.innerHTML = `<span class="oauth-spinner"></span> Signing in...`;
1135
+ btn.textContent = t('verifying', this.options.locale);
847
1136
  btn.disabled = true;
848
1137
  try {
849
1138
  const oauthBody = { provider: providerId, code, state };
@@ -853,6 +1142,8 @@ export class LoginPage {
853
1142
  succeed();
854
1143
  }
855
1144
  catch (oauthErr) {
1145
+ if (settled || !this.mounted || epoch !== this.generation)
1146
+ return;
856
1147
  const errMsg = oauthErr instanceof Error ? oauthErr.message : '';
857
1148
  const errCode = oauthErr instanceof Error && 'code' in oauthErr ? oauthErr.code : '';
858
1149
  if (errCode === 'FORBIDDEN' || errMsg === 'registration_closed' || errCode === 'registration_closed') {
@@ -904,6 +1195,10 @@ export class LoginPage {
904
1195
  restore();
905
1196
  }, 5 * 60 * 1000);
906
1197
  const cleanup = () => {
1198
+ settled = true;
1199
+ btn.innerHTML = originalHTML;
1200
+ btn.disabled = false;
1201
+ btn.classList.remove('oauth-btn-loading');
907
1202
  if (pollTimer !== undefined) {
908
1203
  clearInterval(pollTimer);
909
1204
  pollTimer = undefined;
@@ -915,6 +1210,10 @@ export class LoginPage {
915
1210
  clearTimeout(abandonTimer);
916
1211
  window.removeEventListener('message', onMessage);
917
1212
  };
1213
+ this.oauthCleanup = () => { cleanup(); recovery.remove(); try {
1214
+ popup.close();
1215
+ }
1216
+ catch { /* COOP */ } };
918
1217
  window.addEventListener('message', onMessage);
919
1218
  }
920
1219
  // (removed dead code: renderEmailButton — email now renders through renderSecondaryGrid)
@@ -924,20 +1223,27 @@ export class LoginPage {
924
1223
  * Even count: all items in 2 columns (each half width).
925
1224
  * Odd count: DID Wallet spans full width at the end.
926
1225
  */
927
- renderSecondaryGrid(parent, locale, methods) {
1226
+ renderSecondaryGrid(parent, locale, methods, group) {
928
1227
  const grid = document.createElement('div');
929
1228
  grid.className = 'methods-grid';
930
1229
  // 1. OAuth buttons (each provider is one grid item)
931
1230
  if (methods.includes('oauth') && this.options.oauthProviders?.length) {
932
- const enabledProviders = (this.options.oauthProviders ?? []).filter((p) => p.enabled);
1231
+ const order = this.options.oauthProviderOrder ?? ['google', 'apple', 'github'];
1232
+ const rank = (id) => order.includes(id) ? order.indexOf(id) : order.length;
1233
+ const explicitOrder = !!this.options.oauthProviderOrder || this.options.oauthProviders?.some(p => p.order !== undefined && p.order !== 999);
1234
+ const enabledProviders = (this.options.oauthProviders ?? []).filter((p) => p.enabled && (!group || ((explicitOrder || ['google', 'apple'].includes(p.id)) === (group === 'primary')))).sort((a, b) => this.options.oauthProviderOrder
1235
+ ? rank(a.id) - rank(b.id)
1236
+ : (a.order ?? 999) - (b.order ?? 999) || rank(a.id) - rank(b.id));
933
1237
  for (const provider of enabledProviders) {
934
1238
  const btn = document.createElement('button');
935
1239
  btn.className = 'method-btn';
936
1240
  btn.dataset.provider = provider.id;
937
1241
  const icon = getOAuthIcon(provider.id);
938
- btn.innerHTML = `${icon} ${escapeHtml(provider.name)}`;
1242
+ btn.innerHTML = `${icon} ${escapeHtml(t('continueWith', locale, { provider: provider.name }))}`;
939
1243
  grid.appendChild(btn);
940
1244
  btn.addEventListener('click', () => {
1245
+ this.select('oauth');
1246
+ this.activeProvider = provider.id;
941
1247
  this.startOAuthPopup(provider.id, btn);
942
1248
  });
943
1249
  }
@@ -950,6 +1256,7 @@ export class LoginPage {
950
1256
  btn.innerHTML = `${ICON_EMAIL} ${escapeHtml(t('emailButton', locale))}`;
951
1257
  grid.appendChild(btn);
952
1258
  btn.addEventListener('click', () => {
1259
+ this.select('email');
953
1260
  this.switchView('email');
954
1261
  });
955
1262
  }
@@ -963,6 +1270,7 @@ export class LoginPage {
963
1270
  btn.innerHTML = `${ICON_DID_WALLET} ${escapeHtml(walletLabel)}`;
964
1271
  grid.appendChild(btn);
965
1272
  btn.addEventListener('click', () => {
1273
+ this.select('did-connect');
966
1274
  this.switchView('qr');
967
1275
  });
968
1276
  }
@@ -971,11 +1279,15 @@ export class LoginPage {
971
1279
  // ─── Private: DID Connect flow (QR view) ───────────────────────────
972
1280
  async startDIDConnectInView(statusEl, qrContainer, walletBtn, locale) {
973
1281
  statusEl.textContent = t('connecting', locale);
1282
+ const epoch = this.generation;
1283
+ const isCurrent = () => this.mounted && epoch === this.generation;
974
1284
  try {
975
1285
  // Clean up previous session if any
976
1286
  this.tokenSession?.destroy();
977
1287
  this.tokenSession = new TokenSession(this.config, this.http);
978
1288
  this.tokenSession.on('succeed', async () => {
1289
+ if (!isCurrent())
1290
+ return;
979
1291
  // Transition to result state: hide QR + wallet button, show verifying
980
1292
  this.showConnectResult(qrContainer, walletBtn, statusEl, t('verifying', locale));
981
1293
  try {
@@ -983,11 +1295,15 @@ export class LoginPage {
983
1295
  await this.http.post(`${servicePrefix}/api/did/connect/complete`, {
984
1296
  token: this.tokenSession.state.token,
985
1297
  });
1298
+ if (!isCurrent())
1299
+ return;
986
1300
  // Show success state with green checkmark
987
1301
  this.showConnectSuccess(qrContainer, walletBtn, statusEl, locale);
988
1302
  this.handleSuccess();
989
1303
  }
990
1304
  catch (err) {
1305
+ if (!isCurrent())
1306
+ return;
991
1307
  this.showConnectResult(qrContainer, walletBtn, statusEl, friendlyError(err, locale), true);
992
1308
  }
993
1309
  });
@@ -1020,13 +1336,15 @@ export class LoginPage {
1020
1336
  }
1021
1337
  });
1022
1338
  const state = await this.tokenSession.create({ locale });
1339
+ if (!isCurrent())
1340
+ return;
1023
1341
  if (state.url) {
1024
1342
  renderQR(qrContainer, state.url);
1025
1343
  // Enable "Open in DID Wallet" button with platform-specific handler
1026
1344
  walletBtn.disabled = false;
1027
1345
  walletBtn.addEventListener('click', () => {
1028
1346
  // Immediately show connecting state when user taps to open wallet
1029
- this.showConnectScanned(qrContainer, walletBtn, statusEl, locale);
1347
+ statusEl.textContent = t('walletRetry', locale);
1030
1348
  this.openInWallet(state.url, locale);
1031
1349
  });
1032
1350
  }
@@ -1034,6 +1352,8 @@ export class LoginPage {
1034
1352
  this.tokenSession.startPolling();
1035
1353
  }
1036
1354
  catch (err) {
1355
+ if (!isCurrent())
1356
+ return;
1037
1357
  statusEl.textContent = friendlyError(err, locale);
1038
1358
  clearQR(qrContainer);
1039
1359
  }
@@ -1054,7 +1374,7 @@ export class LoginPage {
1054
1374
  }
1055
1375
  this.cardEl.innerHTML = '';
1056
1376
  this.cardEl.dataset.view = 'error';
1057
- this.renderLocaleSwitcher(this.cardEl);
1377
+ this.renderCardTools(this.cardEl);
1058
1378
  const resultContainer = document.createElement('div');
1059
1379
  resultContainer.className = 'connect-result';
1060
1380
  resultContainer.innerHTML = `<div class="result-icon result-error">✕</div>`;
@@ -1209,8 +1529,25 @@ export class LoginPage {
1209
1529
  }
1210
1530
  // ─── Private: success handler ────────────────────────────────────────
1211
1531
  handleSuccess() {
1532
+ if (!this.mounted || this.completed)
1533
+ return;
1534
+ this.completed = true;
1535
+ this.emit('success');
1536
+ if (this.options.preferenceKey && this.activeMethod) {
1537
+ try {
1538
+ localStorage.setItem(`did-login-method:${this.options.preferenceKey}`, this.activeMethod === 'oauth' && this.activeProvider ? `oauth:${this.activeProvider}` : this.activeMethod);
1539
+ }
1540
+ catch { /* private mode */ }
1541
+ }
1212
1542
  if (this.options.onSuccess) {
1213
- this.options.onSuccess();
1543
+ const epoch = this.generation;
1544
+ Promise.resolve().then(() => { if (this.mounted && epoch === this.generation)
1545
+ return this.options.onSuccess?.(); }).catch(() => {
1546
+ if (!this.mounted || epoch !== this.generation)
1547
+ return;
1548
+ this.completed = false;
1549
+ this.showFullError(t('authFailed', this.options.locale));
1550
+ });
1214
1551
  }
1215
1552
  else {
1216
1553
  setTimeout(() => location.reload(), 300);