@arcblock/did-connect-core 4.1.52 → 4.1.54

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;
235
+ }
236
+ else {
237
+ target.removeAttribute('data-theme');
145
238
  }
146
- // 'dark' is the default — no data-theme attribute needed
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,20 @@ 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
+ if (!(this.options.showLocaleSelector ?? this.options.mode !== 'dialog'))
272
+ return;
273
+ const tools = document.createElement('div');
274
+ tools.className = 'card-tools';
275
+ this.renderLocaleSwitcher(tools);
276
+ card.appendChild(tools);
277
+ }
166
278
  renderLocaleSwitcher(card) {
167
279
  const passed = this.options.locales;
168
280
  if (passed) {
@@ -201,7 +313,7 @@ export class LoginPage {
201
313
  trigger.className = 'locale-trigger';
202
314
  trigger.setAttribute('aria-haspopup', 'menu');
203
315
  trigger.setAttribute('aria-expanded', 'false');
204
- trigger.innerHTML = `${ICON_GLOBE} ${escapeHtml(current.label)}`;
316
+ trigger.innerHTML = `${ICON_GLOBE} <span class="locale-label">${escapeHtml(current.label)}</span>`;
205
317
  trigger.title = current.label;
206
318
  const menu = document.createElement('div');
207
319
  menu.className = 'locale-menu';
@@ -215,6 +327,8 @@ export class LoginPage {
215
327
  trigger.addEventListener('click', (event) => {
216
328
  event.stopPropagation();
217
329
  setOpen(menu.hidden);
330
+ if (!menu.hidden)
331
+ menu.querySelector('[aria-checked="true"]')?.focus();
218
332
  });
219
333
  for (const item of locales) {
220
334
  const checked = item.code === current.code;
@@ -224,12 +338,13 @@ export class LoginPage {
224
338
  btn.setAttribute('role', 'menuitemradio');
225
339
  btn.setAttribute('aria-checked', checked ? 'true' : 'false');
226
340
  btn.dataset.locale = item.code;
341
+ btn.tabIndex = -1;
227
342
  btn.title = item.label;
228
- if (checked) {
343
+ {
229
344
  const mark = document.createElement('span');
230
345
  mark.className = 'locale-check';
231
346
  mark.setAttribute('aria-hidden', 'true');
232
- mark.textContent = '✓';
347
+ mark.textContent = checked ? '✓' : '';
233
348
  btn.appendChild(mark);
234
349
  }
235
350
  const label = document.createElement('span');
@@ -241,9 +356,36 @@ export class LoginPage {
241
356
  });
242
357
  menu.appendChild(btn);
243
358
  }
244
- const onOutsideClick = (event) => {
245
- if (!wrapper.contains(event.target))
359
+ wrapper.addEventListener('keydown', (event) => {
360
+ const items = [...menu.querySelectorAll('.locale-btn')];
361
+ const active = this.shadow?.activeElement ?? document.activeElement;
362
+ const index = items.indexOf(active);
363
+ if (event.key === 'Escape') {
364
+ event.preventDefault();
365
+ event.stopPropagation();
246
366
  setOpen(false);
367
+ trigger.focus();
368
+ }
369
+ else if (event.key === 'Tab')
370
+ setOpen(false);
371
+ else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
372
+ event.preventDefault();
373
+ setOpen(true);
374
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : index < 0 ? (event.key === 'ArrowUp' ? items.length - 1 : 0) : (index + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
375
+ items[next]?.focus();
376
+ }
377
+ });
378
+ const onOutsideClick = (event) => {
379
+ const path = event.composedPath();
380
+ if (path.includes(wrapper))
381
+ return;
382
+ // Shadow clicks are retargeted to the host, so contains(event.target)
383
+ // is always false and the menu would close on every click. A click
384
+ // whose path includes the host is still inside the component. Light
385
+ // DOM has isolate false, so a click on the card still closes the menu.
386
+ if (this.isolate && path.includes(this.container))
387
+ return;
388
+ setOpen(false);
247
389
  };
248
390
  document.addEventListener('click', onOutsideClick);
249
391
  this.localeOutsideClick = onOutsideClick;
@@ -268,10 +410,14 @@ export class LoginPage {
268
410
  this.tokenSession = null;
269
411
  }
270
412
  }
413
+ this.generation++;
414
+ this.oauthCleanup?.();
415
+ this.abort?.abort();
416
+ clearInterval(this.emailTimer);
271
417
  this.currentView = view;
272
418
  this.cardEl.innerHTML = '';
273
419
  this.cardEl.dataset.view = view;
274
- this.renderLocaleSwitcher(this.cardEl);
420
+ this.renderCardTools(this.cardEl);
275
421
  switch (view) {
276
422
  case 'methods':
277
423
  this.renderMethodsView(this.cardEl, locale, methods ?? this.filterMethods(this.options.methods));
@@ -284,10 +430,26 @@ export class LoginPage {
284
430
  break;
285
431
  }
286
432
  }
433
+ /** Keep recognition next to the action, without a second page subtitle. */
434
+ markLastUsed(button, method, locale) {
435
+ if (!this.options.preferenceKey)
436
+ return;
437
+ try {
438
+ if (localStorage.getItem(`did-login-method:${this.options.preferenceKey}`) !== method)
439
+ return;
440
+ const hint = document.createElement('span');
441
+ hint.className = 'last-used';
442
+ hint.textContent = t('lastUsed', locale);
443
+ button.classList.add('has-last-used');
444
+ button.appendChild(hint);
445
+ }
446
+ catch { /* Storage may be unavailable in private mode. */ }
447
+ }
287
448
  renderMethodsView(card, locale, methods) {
288
449
  // ── Escape hatch ("Cancel") — top-left, mirrors the locale switcher
289
450
  // (top-right). Standard placement for a standalone auth card. ──
290
- this.renderHomeLink(card, locale);
451
+ if (this.options.mode !== 'dialog')
452
+ this.renderHomeLink(card, locale);
291
453
  // ── Brand header ──
292
454
  this.renderBrandHeader(card, locale);
293
455
  // Passkey unsupported warning
@@ -298,6 +460,12 @@ export class LoginPage {
298
460
  warn.textContent = t('passkeyUnsupported', locale);
299
461
  card.appendChild(warn);
300
462
  }
463
+ const socialFirst = this.options.preferredLayout !== 'passkey-first' && methods.includes('oauth') && this.options.oauthProviders?.some(p => p.enabled && ['google', 'apple'].includes(p.id));
464
+ card.classList.toggle('social-first', !!socialFirst);
465
+ if (socialFirst) {
466
+ this.renderSecondaryGrid(card, locale, ['oauth'], 'primary');
467
+ this.renderDivider(card, locale);
468
+ }
301
469
  // ── Primary group: passkey ──
302
470
  const hasPrimary = methods.includes('passkey');
303
471
  if (hasPrimary) {
@@ -309,7 +477,21 @@ export class LoginPage {
309
477
  this.renderDivider(card, locale);
310
478
  }
311
479
  if (secondary.length > 0) {
312
- this.renderSecondaryGrid(card, locale, methods);
480
+ if (socialFirst) {
481
+ if (methods.includes('email'))
482
+ this.renderSecondaryGrid(card, locale, ['email']);
483
+ const details = document.createElement('details');
484
+ details.className = 'other-methods';
485
+ const summary = document.createElement('summary');
486
+ summary.textContent = t('otherMethods', locale);
487
+ details.appendChild(summary);
488
+ this.renderSecondaryGrid(details, locale, methods.filter(m => m !== 'email'), 'secondary');
489
+ if (details.querySelector('button'))
490
+ card.appendChild(details);
491
+ }
492
+ else {
493
+ this.renderSecondaryGrid(card, locale, methods);
494
+ }
313
495
  }
314
496
  // If no methods available at all, show a message
315
497
  if (methods.length === 0 && !requestedPasskey) {
@@ -345,11 +527,15 @@ export class LoginPage {
345
527
  walletBtn.id = 'did-wallet-open-btn';
346
528
  walletBtn.textContent = t('openInWallet', locale);
347
529
  walletBtn.disabled = true; // enabled once URL is ready
348
- card.appendChild(walletBtn);
530
+ if (isMobile())
531
+ card.insertBefore(walletBtn, qrContainer);
532
+ else
533
+ card.appendChild(walletBtn);
349
534
  // Status
350
535
  const statusEl = document.createElement('div');
351
536
  statusEl.id = 'did-connect-status';
352
537
  statusEl.className = 'status';
538
+ statusEl.setAttribute('role', 'status');
353
539
  card.appendChild(statusEl);
354
540
  // Trust footer
355
541
  this.renderTrustFooter(card, locale);
@@ -365,10 +551,25 @@ export class LoginPage {
365
551
  title.className = 'view-title';
366
552
  title.textContent = t('emailTitle', locale);
367
553
  card.appendChild(title);
554
+ const emailLabel = document.createElement('label');
555
+ emailLabel.className = 'input-label';
556
+ emailLabel.htmlFor = 'login-email-address';
557
+ emailLabel.textContent = t('emailPlaceholder', locale);
558
+ card.appendChild(emailLabel);
559
+ const emailHelp = document.createElement('p');
560
+ emailHelp.className = 'email-help';
561
+ emailHelp.textContent = t('emailExplanation', locale);
562
+ card.appendChild(emailHelp);
368
563
  // Email input
369
564
  const emailInput = document.createElement('input');
370
565
  emailInput.className = 'input';
566
+ emailInput.id = 'login-email-address';
371
567
  emailInput.type = 'email';
568
+ emailInput.required = true;
569
+ emailInput.autocomplete = 'email';
570
+ emailInput.value = this.emailDraft;
571
+ emailInput.setAttribute('aria-label', t('emailPlaceholder', locale));
572
+ emailInput.addEventListener('input', () => { this.emailDraft = emailInput.value; });
372
573
  emailInput.placeholder = t('emailPlaceholder', locale);
373
574
  card.appendChild(emailInput);
374
575
  // Submit button
@@ -379,16 +580,24 @@ export class LoginPage {
379
580
  // Status
380
581
  const status = document.createElement('div');
381
582
  status.className = 'status';
583
+ status.setAttribute('role', 'status');
382
584
  card.appendChild(status);
383
585
  // ── Code verification section (hidden initially) ──
384
586
  const codeSection = document.createElement('div');
385
587
  codeSection.className = 'email-code-section';
386
588
  codeSection.style.display = 'none';
589
+ const codeLabel = document.createElement('label');
590
+ codeLabel.className = 'input-label';
591
+ codeLabel.htmlFor = 'login-email-code';
592
+ codeLabel.textContent = t('emailCodePlaceholder', locale);
593
+ codeSection.appendChild(codeLabel);
387
594
  const codeInput = document.createElement('input');
595
+ codeInput.id = 'login-email-code';
388
596
  codeInput.className = 'input';
389
597
  codeInput.type = 'text';
390
598
  codeInput.inputMode = 'numeric';
391
599
  codeInput.maxLength = 8;
600
+ codeInput.setAttribute('aria-label', t('emailCodePlaceholder', locale));
392
601
  codeInput.autocomplete = 'one-time-code';
393
602
  codeInput.placeholder = t('emailCodePlaceholder', locale);
394
603
  codeSection.appendChild(codeInput);
@@ -404,6 +613,27 @@ export class LoginPage {
404
613
  card.insertBefore(codeSection, status);
405
614
  // Trust footer
406
615
  this.renderTrustFooter(card, locale);
616
+ const epoch = this.generation;
617
+ const isCurrent = () => this.mounted && epoch === this.generation;
618
+ const editBtn = document.createElement('button');
619
+ editBtn.className = 'passkey-alt';
620
+ editBtn.textContent = t('emailEdit', locale);
621
+ codeSection.appendChild(editBtn);
622
+ editBtn.addEventListener('click', () => this.switchView('email'));
623
+ const cooldown = () => {
624
+ let remaining = 60;
625
+ clearInterval(this.emailTimer);
626
+ resendBtn.disabled = true;
627
+ resendBtn.textContent = t('emailCooldown', locale, { seconds: String(remaining) });
628
+ this.emailTimer = setInterval(() => {
629
+ remaining--;
630
+ resendBtn.textContent = remaining > 0 ? t('emailCooldown', locale, { seconds: String(remaining) }) : t('emailResend', locale);
631
+ if (remaining <= 0) {
632
+ clearInterval(this.emailTimer);
633
+ resendBtn.disabled = false;
634
+ }
635
+ }, 1000);
636
+ };
407
637
  let currentEmail = '';
408
638
  let currentCodeId;
409
639
  const servicePrefix = this.config.servicePrefix ?? '/.well-known/service';
@@ -412,7 +642,7 @@ export class LoginPage {
412
642
  if (sendBtn.disabled)
413
643
  return;
414
644
  const email = emailInput.value.trim();
415
- if (!email?.includes('@')) {
645
+ if (!emailInput.checkValidity()) {
416
646
  status.textContent = t('emailInvalid', locale);
417
647
  status.className = 'status error';
418
648
  return;
@@ -420,12 +650,18 @@ export class LoginPage {
420
650
  sendBtn.disabled = true;
421
651
  status.textContent = t('emailSending', locale);
422
652
  status.className = 'status';
653
+ status.setAttribute('role', 'status');
423
654
  try {
424
655
  const sendResult = await this.http.post(`${servicePrefix}/api/email/sendCode`, { email, locale });
656
+ if (!isCurrent())
657
+ return;
658
+ cooldown();
425
659
  currentEmail = email;
426
660
  currentCodeId = sendResult?.id;
427
661
  // Switch to code input phase
428
662
  emailInput.style.display = 'none';
663
+ emailLabel.hidden = true;
664
+ emailHelp.hidden = true;
429
665
  sendBtn.style.display = 'none';
430
666
  codeSection.style.display = '';
431
667
  title.textContent = t('emailVerify', locale);
@@ -434,6 +670,8 @@ export class LoginPage {
434
670
  codeInput.focus();
435
671
  }
436
672
  catch (err) {
673
+ if (!isCurrent())
674
+ return;
437
675
  const msg = err instanceof Error ? err.message : 'Failed to send email';
438
676
  status.textContent = msg;
439
677
  status.className = 'status error';
@@ -444,7 +682,7 @@ export class LoginPage {
444
682
  if (verifyBtn.disabled)
445
683
  return;
446
684
  const code = codeInput.value.trim();
447
- if (!code || code.length !== 8) {
685
+ if (!/^\d{8}$/.test(code)) {
448
686
  status.textContent = t('emailCodeInvalid', locale);
449
687
  status.className = 'status error';
450
688
  return;
@@ -454,11 +692,15 @@ export class LoginPage {
454
692
  status.className = 'status';
455
693
  try {
456
694
  await this.http.post(`${servicePrefix}/api/email/login`, { code, id: currentCodeId });
695
+ if (!isCurrent())
696
+ return;
457
697
  status.textContent = t('success', locale);
458
698
  status.className = 'status';
459
699
  this.handleSuccess();
460
700
  }
461
701
  catch (err) {
702
+ if (!isCurrent())
703
+ return;
462
704
  if (err instanceof Error && 'code' in err && err.code === 'FORBIDDEN') {
463
705
  this.showFullError(friendlyError(err, locale));
464
706
  }
@@ -479,28 +721,36 @@ export class LoginPage {
479
721
  codeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
480
722
  verifyCode(); });
481
723
  resendBtn.addEventListener('click', async () => {
724
+ if (resendBtn.disabled)
725
+ return;
482
726
  resendBtn.disabled = true;
483
727
  status.textContent = t('emailSending', locale);
484
728
  status.className = 'status';
485
729
  try {
486
730
  const resendResult = await this.http.post(`${servicePrefix}/api/email/sendCode`, { email: currentEmail, locale });
731
+ if (!isCurrent())
732
+ return;
733
+ cooldown();
487
734
  currentCodeId = resendResult?.id;
488
735
  status.textContent = t('emailSent', locale, { email: currentEmail });
489
736
  status.className = 'status';
490
737
  }
491
738
  catch (err) {
739
+ if (!isCurrent())
740
+ return;
741
+ resendBtn.disabled = false;
492
742
  const msg = err instanceof Error ? err.message : 'Failed to resend';
493
743
  status.textContent = msg;
494
744
  status.className = 'status error';
495
745
  }
496
- resendBtn.disabled = false;
497
746
  });
498
747
  }
499
- renderBackButton(parent, locale) {
748
+ renderBackButton(parent, locale, onBack = () => this.switchView('methods')) {
500
749
  const btn = document.createElement('button');
750
+ btn.type = 'button';
501
751
  btn.className = 'back-btn';
502
752
  btn.innerHTML = `${ICON_BACK_ARROW} ${escapeHtml(t('back', locale))}`;
503
- btn.addEventListener('click', () => this.switchView('methods'));
753
+ btn.addEventListener('click', onBack);
504
754
  parent.appendChild(btn);
505
755
  }
506
756
  /**
@@ -515,6 +765,8 @@ export class LoginPage {
515
765
  * sub-view back buttons that mean "return to the method list".
516
766
  */
517
767
  renderHomeLink(parent, locale) {
768
+ if (this.options.dismissible === false)
769
+ return;
518
770
  const { homeUrl, onCancel } = this.options;
519
771
  if (!onCancel && !homeUrl)
520
772
  return;
@@ -524,7 +776,7 @@ export class LoginPage {
524
776
  btn.type = 'button';
525
777
  btn.className = 'home-link';
526
778
  btn.innerHTML = label;
527
- btn.addEventListener('click', () => this.options.onCancel?.());
779
+ btn.addEventListener('click', () => { this.emit('cancel'); this.options.onCancel?.(); });
528
780
  parent.appendChild(btn);
529
781
  }
530
782
  else {
@@ -556,7 +808,16 @@ export class LoginPage {
556
808
  else {
557
809
  h1.textContent = t('signIn', locale);
558
810
  }
811
+ if (this.options.purpose)
812
+ h1.textContent = this.options.purpose.title;
813
+ h1.id = 'login-title';
559
814
  header.appendChild(h1);
815
+ if (this.options.purpose?.description) {
816
+ const description = document.createElement('p');
817
+ description.className = 'subtitle';
818
+ description.textContent = this.options.purpose.description;
819
+ header.appendChild(description);
820
+ }
560
821
  // Subtitle: only the federation hint ("Authentication provided by X") carries
561
822
  // real information. The generic "Sign in to continue" just echoes the title,
562
823
  // so it's omitted — the title + auth buttons speak for themselves.
@@ -602,6 +863,12 @@ export class LoginPage {
602
863
  }
603
864
  footer.appendChild(links);
604
865
  }
866
+ if (this.currentView !== 'methods' && this.options.mode !== 'dialog') {
867
+ this.renderHomeLink(footer, locale);
868
+ const cancel = footer.querySelector('.home-link');
869
+ if (cancel)
870
+ cancel.style.cssText = 'position:static;display:inline-flex;margin-top:12px';
871
+ }
605
872
  parent.appendChild(footer);
606
873
  }
607
874
  renderDivider(parent, locale) {
@@ -665,15 +932,25 @@ export class LoginPage {
665
932
  retryBtn.addEventListener('click', () => doSignIn());
666
933
  const createLink = document.createElement('button');
667
934
  createLink.type = 'button';
668
- createLink.className = 'passkey-alt';
935
+ createLink.className = 'passkey-alt passkey-create-entry';
669
936
  createLink.textContent = t('passkeyCreateInstead', locale);
670
937
  section.appendChild(createLink);
671
938
  createLink.addEventListener('click', () => renderCreateState());
672
939
  };
673
940
  // ── State 1: explicit create (new user declared intent) ──
674
941
  const renderCreateState = () => {
942
+ this.cardEl?.classList.add('passkey-creating');
943
+ this.abort?.abort();
944
+ this.generation++;
675
945
  section.innerHTML = '';
676
946
  clearStatus();
947
+ const heading = this.cardEl?.querySelector('h1');
948
+ if (heading)
949
+ heading.textContent = t('passkeyCreateAccount', locale);
950
+ const explanation = document.createElement('p');
951
+ explanation.className = 'subtitle';
952
+ explanation.textContent = t('passkeyExplanation', locale);
953
+ section.appendChild(explanation);
677
954
  const nameRow = document.createElement('div');
678
955
  nameRow.className = 'name-row visible';
679
956
  nameRow.id = 'passkey-name-row';
@@ -681,6 +958,7 @@ export class LoginPage {
681
958
  nameInput.className = 'input';
682
959
  nameInput.id = 'passkey-name-input';
683
960
  nameInput.type = 'text';
961
+ nameInput.setAttribute('aria-label', t('namePlaceholder', locale));
684
962
  nameInput.placeholder = t('namePlaceholder', locale);
685
963
  nameInput.autocomplete = 'name';
686
964
  nameRow.appendChild(nameInput);
@@ -690,17 +968,17 @@ export class LoginPage {
690
968
  createBtn.id = 'passkey-btn';
691
969
  createBtn.innerHTML = `${ICON_PASSKEY} ${escapeHtml(t('passkeyCreate', locale))}`;
692
970
  section.appendChild(createBtn);
693
- const backLink = document.createElement('button');
694
- backLink.type = 'button';
695
- backLink.className = 'passkey-alt';
696
- backLink.textContent = t('passkeyBackToSignIn', locale);
697
- section.appendChild(backLink);
971
+ parent.querySelector('.back-btn')?.remove();
972
+ this.renderBackButton(parent, locale, () => renderSignInState());
698
973
  const doCreate = async () => {
699
974
  if (createBtn.disabled)
700
975
  return; // Enter-key auto-repeat on the name input
701
976
  createBtn.disabled = true;
977
+ this.select('passkey');
978
+ const epoch = this.generation;
702
979
  const result = await runPasskeyAuth({
703
980
  registerOnly: true,
981
+ signal: this.abort?.signal,
704
982
  name: nameInput.value.trim(),
705
983
  onStatus: setStatus,
706
984
  texts: statusTexts,
@@ -708,6 +986,8 @@ export class LoginPage {
708
986
  prefix: this.options.passkeyPrefix,
709
987
  invitationId: this.options.invitationId,
710
988
  });
989
+ if (!this.mounted || epoch !== this.generation || this.abort?.signal.aborted)
990
+ return;
711
991
  if (handleResult(result))
712
992
  return;
713
993
  // Cancelled or other error — stay in create state, let the user retry.
@@ -718,22 +998,28 @@ export class LoginPage {
718
998
  createBtn.addEventListener('click', doCreate);
719
999
  nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
720
1000
  doCreate(); });
721
- backLink.addEventListener('click', () => renderSignInState());
722
- nameInput.focus();
1001
+ createBtn.focus();
723
1002
  };
724
1003
  // ── State 0: sign-in (default) ──
725
1004
  const doSignIn = async () => {
726
1005
  const btn = section.querySelector('#passkey-btn');
1006
+ if (btn?.disabled)
1007
+ return;
727
1008
  if (btn)
728
1009
  btn.disabled = true;
1010
+ this.select('passkey');
1011
+ const epoch = this.generation;
729
1012
  const result = await runPasskeyAuth({
730
1013
  authOnly: true,
1014
+ signal: this.abort?.signal,
731
1015
  onStatus: setStatus,
732
1016
  texts: statusTexts,
733
1017
  locale,
734
1018
  prefix: this.options.passkeyPrefix,
735
1019
  invitationId: this.options.invitationId,
736
1020
  });
1021
+ if (!this.mounted || epoch !== this.generation || this.abort?.signal.aborted)
1022
+ return;
737
1023
  if (handleResult(result))
738
1024
  return;
739
1025
  if (result.error && result.error !== 'no_passkey') {
@@ -745,20 +1031,29 @@ export class LoginPage {
745
1031
  }
746
1032
  // no_passkey (or user cancelled) — fork to cross-device / create choice
747
1033
  // instead of silently creating a second credential.
1034
+ this.emit('incomplete');
748
1035
  renderCrossDeviceState();
749
1036
  };
750
1037
  const renderSignInState = () => {
1038
+ this.cardEl?.classList.remove('passkey-creating');
1039
+ parent.querySelector('.back-btn')?.remove();
1040
+ this.abort?.abort();
1041
+ this.generation++;
1042
+ const heading = this.cardEl?.querySelector('h1');
1043
+ if (heading)
1044
+ heading.textContent = this.options.purpose?.title ?? (this.options.appName ? t('signInTo', locale, { appName: this.options.appName }) : t('signIn', locale));
751
1045
  section.innerHTML = '';
752
1046
  clearStatus();
753
1047
  const signInBtn = document.createElement('button');
754
- signInBtn.className = 'btn';
1048
+ signInBtn.className = this.cardEl?.classList.contains('social-first') ? 'btn btn-secondary' : 'btn';
755
1049
  signInBtn.id = 'passkey-btn';
756
1050
  signInBtn.innerHTML = `${ICON_PASSKEY} ${escapeHtml(t('passkeySignIn', locale))}`;
1051
+ this.markLastUsed(signInBtn, 'passkey', locale);
757
1052
  section.appendChild(signInBtn);
758
1053
  signInBtn.addEventListener('click', () => doSignIn());
759
1054
  const createLink = document.createElement('button');
760
1055
  createLink.type = 'button';
761
- createLink.className = 'passkey-alt';
1056
+ createLink.className = 'passkey-alt passkey-create-entry';
762
1057
  createLink.textContent = t('passkeyFirstTime', locale);
763
1058
  section.appendChild(createLink);
764
1059
  createLink.addEventListener('click', () => renderCreateState());
@@ -775,147 +1070,237 @@ export class LoginPage {
775
1070
  // methods now render through renderSecondaryGrid)
776
1071
  // ─── Private: OAuth popup flow ──────────────────────────────────────
777
1072
  startOAuthPopup(providerId, btn) {
1073
+ this.oauthCleanup?.();
1074
+ const epoch = this.generation;
1075
+ const locale = this.options.locale;
778
1076
  const servicePrefix = this.config.servicePrefix ?? '/.well-known/service';
779
- // Federation: popup opens Master's OAuth login URL (cross-domain callback)
780
1077
  const oauthBase = this.options.masterOAuthOrigin
781
- ? `${this.options.masterOAuthOrigin}${servicePrefix}`
782
- : servicePrefix;
1078
+ ? `${this.options.masterOAuthOrigin}${servicePrefix}` : servicePrefix;
783
1079
  const oauthOrigin = new URL(oauthBase, window.location.href).origin;
784
- const loginUrl = `${oauthBase}/api/oauth/${encodeURIComponent(providerId)}/login?returnUrl=${encodeURIComponent(window.location.href)}`;
785
- // Open centered popup — compact size, enough for provider auth pages
786
- const width = 500;
787
- const height = 620;
788
- const left = window.screenX + (window.innerWidth - width) / 2;
789
- const top = window.screenY + (window.innerHeight - height) / 2;
790
- const popup = window.open(loginUrl, 'oauth-login:popup', `left=${left},top=${top},width=${width},height=${height},resizable,scrollbars=yes,status=1,popup`);
791
- if (!popup) {
792
- window.location.href = loginUrl;
793
- return;
1080
+ let returnUrl = window.location.href;
1081
+ if (this.options.mode !== 'dialog' && window.location.pathname === `${servicePrefix}/login`) {
1082
+ const requested = new URLSearchParams(window.location.search).get('return_to');
1083
+ try {
1084
+ const target = new URL(requested || '/', window.location.origin);
1085
+ returnUrl = target.origin === window.location.origin && !/^\/[/\\]/.test(target.pathname) ? target.href : `${window.location.origin}/`;
1086
+ }
1087
+ catch {
1088
+ returnUrl = `${window.location.origin}/`;
1089
+ }
794
1090
  }
795
- // Save original button content and enter loading state
1091
+ const loginUrl = `${oauthBase}/api/oauth/${encodeURIComponent(providerId)}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
1092
+ const provider = this.options.oauthProviders?.find(p => p.id === providerId)?.name ?? providerId;
796
1093
  const originalHTML = btn.innerHTML;
797
- btn.innerHTML = `<span class="oauth-spinner"></span> Authorizing...`;
798
- btn.disabled = true;
799
- btn.classList.add('oauth-btn-loading');
800
- const restore = () => {
1094
+ let popup = null;
1095
+ let settled = false;
1096
+ let exchanging = false;
1097
+ let probing = false;
1098
+ let pollTimer;
1099
+ let sessionTimer;
1100
+ let abandonTimer;
1101
+ const current = () => !settled && this.mounted && epoch === this.generation;
1102
+ const locked = [];
1103
+ const lockMethods = () => {
1104
+ for (const button of this.cardEl?.querySelectorAll('.method-btn, .passkey-create-entry, #passkey-btn, .locale-trigger, .locale-btn') ?? []) {
1105
+ locked.push({ button, disabled: button.disabled });
1106
+ button.disabled = true;
1107
+ }
1108
+ };
1109
+ const recovery = document.createElement('div');
1110
+ recovery.className = 'oauth-recovery';
1111
+ recovery.setAttribute('role', 'status');
1112
+ const hint = document.createElement('p');
1113
+ const actions = document.createElement('div');
1114
+ actions.className = 'oauth-recovery-actions';
1115
+ const reopen = document.createElement('button');
1116
+ reopen.type = 'button';
1117
+ reopen.className = 'oauth-retry';
1118
+ reopen.textContent = t('oauthReopen', locale);
1119
+ reopen.hidden = true;
1120
+ reopen.onclick = () => { this.select('oauth'); this.activeProvider = providerId; this.startOAuthPopup(providerId, btn); };
1121
+ const fullPage = document.createElement('a');
1122
+ fullPage.href = loginUrl;
1123
+ fullPage.textContent = t('oauthFullPage', locale);
1124
+ fullPage.addEventListener('click', event => {
1125
+ const reconcile = recovery.dataset.state === 'oauthUnconfirmed';
1126
+ if (exchanging && !settled) {
1127
+ event.preventDefault();
1128
+ return;
1129
+ }
1130
+ if (reconcile)
1131
+ event.preventDefault();
1132
+ this.options.onBeforeRedirect?.();
1133
+ this.oauthCleanup?.();
1134
+ if (reconcile)
1135
+ window.location.reload();
1136
+ });
1137
+ const cancel = document.createElement('button');
1138
+ cancel.type = 'button';
1139
+ cancel.className = 'oauth-cancel';
1140
+ cancel.textContent = t('cancel', locale);
1141
+ actions.append(reopen, fullPage, cancel);
1142
+ recovery.append(hint, actions);
1143
+ btn.parentElement?.after(recovery);
1144
+ const restoreButton = () => {
801
1145
  btn.innerHTML = originalHTML;
802
1146
  btn.disabled = false;
803
1147
  btn.classList.remove('oauth-btn-loading');
804
1148
  };
805
- let settled = false;
806
- let sessionPollTimer;
1149
+ const closePopup = () => { try {
1150
+ popup?.close();
1151
+ }
1152
+ catch { /* COOP can sever the window reference. */ } };
1153
+ const cleanup = () => {
1154
+ settled = true;
1155
+ clearInterval(pollTimer);
1156
+ clearInterval(sessionTimer);
1157
+ clearTimeout(abandonTimer);
1158
+ window.removeEventListener('message', onMessage);
1159
+ for (const item of locked.splice(0))
1160
+ item.button.disabled = item.disabled;
1161
+ restoreButton();
1162
+ };
1163
+ const showRecovery = (key) => {
1164
+ hint.textContent = t(key, locale, { provider });
1165
+ recovery.dataset.state = key;
1166
+ actions.hidden = false;
1167
+ reopen.hidden = false;
1168
+ cancel.hidden = true;
1169
+ restoreButton();
1170
+ };
1171
+ const fail = (key) => {
1172
+ if (!current())
1173
+ return;
1174
+ cleanup();
1175
+ closePopup();
1176
+ showRecovery(key);
1177
+ this.emit('incomplete');
1178
+ };
807
1179
  const succeed = () => {
808
- if (settled)
1180
+ if (!current())
809
1181
  return;
810
- settled = true;
811
1182
  cleanup();
812
- btn.innerHTML = `<span class="oauth-check">✓</span> Success`;
1183
+ closePopup();
1184
+ recovery.remove();
1185
+ btn.textContent = t('success', locale);
1186
+ btn.disabled = true;
813
1187
  this.handleSuccess();
814
1188
  };
1189
+ cancel.onclick = () => { if (exchanging)
1190
+ return; fail('oauthCancelled'); btn.focus(); };
815
1191
  const onMessage = async (event) => {
816
- if (event.origin !== oauthOrigin)
817
- return;
818
- if (!event.data || event.data.type !== 'authorization_response')
1192
+ if (event.origin !== oauthOrigin || event.source !== popup || !popup)
819
1193
  return;
820
- if (settled)
1194
+ if (!event.data || event.data.type !== 'authorization_response' || !current() || exchanging)
821
1195
  return;
822
- // Stop the closed-poll only. Do NOT drop this listener from popup.closed —
823
- // GitHub/Twitter COOP reports closed while the provider page is still up,
824
- // and the callback also window.close()s itself after postMessage.
825
- if (pollTimer !== undefined) {
826
- clearInterval(pollTimer);
827
- pollTimer = undefined;
828
- }
829
- try {
830
- popup.close();
831
- }
832
- catch {
833
- /* ignore */
834
- }
1196
+ clearInterval(pollTimer);
1197
+ clearInterval(sessionTimer);
835
1198
  if (event.data.error) {
836
- restore();
837
- cleanup();
1199
+ fail(event.data.error === 'access_denied' ? 'oauthDenied' : event.data.error === 'cancelled' ? 'oauthCancelled' : 'oauthFailed');
838
1200
  return;
839
1201
  }
840
1202
  const { code, state } = event.data.response ?? event.data;
841
- if (!code) {
842
- restore();
843
- cleanup();
1203
+ if (typeof code !== 'string' || !code || typeof state !== 'string' || !state) {
1204
+ fail('oauthFailed');
844
1205
  return;
845
1206
  }
846
- btn.innerHTML = `<span class="oauth-spinner"></span> Signing in...`;
1207
+ exchanging = true;
1208
+ lockMethods();
1209
+ actions.hidden = true;
1210
+ closePopup();
1211
+ hint.textContent = t('verifying', locale);
1212
+ btn.textContent = t('verifying', locale);
847
1213
  btn.disabled = true;
848
1214
  try {
849
1215
  const oauthBody = { provider: providerId, code, state };
850
1216
  if (this.options.invitationId)
851
1217
  oauthBody.invitationId = this.options.invitationId;
852
- await this.http.post(`${servicePrefix}/api/oauth/login`, oauthBody);
1218
+ await this.http.post(`${servicePrefix}/api/oauth/login`, oauthBody, { signal: this.abort?.signal });
853
1219
  succeed();
854
1220
  }
855
- catch (oauthErr) {
856
- const errMsg = oauthErr instanceof Error ? oauthErr.message : '';
857
- const errCode = oauthErr instanceof Error && 'code' in oauthErr ? oauthErr.code : '';
858
- if (errCode === 'FORBIDDEN' || errMsg === 'registration_closed' || errCode === 'registration_closed') {
859
- settled = true;
1221
+ catch (err) {
1222
+ if (!current())
1223
+ return;
1224
+ const code = err instanceof Error && 'code' in err ? String(err.code) : '';
1225
+ const message = err instanceof Error ? err.message : '';
1226
+ if (code === 'FORBIDDEN' || code === 'registration_closed' || message === 'registration_closed') {
860
1227
  cleanup();
861
- this.showFullError(friendlyError(oauthErr, this.options.locale));
862
- }
863
- else {
864
- restore();
1228
+ recovery.remove();
1229
+ this.showFullError(friendlyError(err, locale));
865
1230
  }
1231
+ else
1232
+ fail('oauthFailed');
1233
+ }
1234
+ };
1235
+ this.oauthCleanup = () => { cleanup(); recovery.remove(); closePopup(); };
1236
+ window.addEventListener('message', onMessage);
1237
+ try {
1238
+ popup = window.open(loginUrl, 'oauth-login:popup', `left=${window.screenX + (window.innerWidth - 500) / 2},top=${window.screenY + (window.innerHeight - 620) / 2},width=500,height=620,resizable,scrollbars=yes,status=1,popup`);
1239
+ }
1240
+ catch { /* Treat a browser refusal like a blocked popup. */ }
1241
+ if (!popup) {
1242
+ fail('oauthBlocked');
1243
+ return;
1244
+ }
1245
+ hint.textContent = t('oauthWaiting', locale, { provider });
1246
+ btn.textContent = t('oauthWaiting', locale, { provider });
1247
+ btn.disabled = true;
1248
+ btn.classList.add('oauth-btn-loading');
1249
+ // COOP can report closed before the callback arrives. Keep the listener,
1250
+ // but show recovery immediately and never overlap server session probes.
1251
+ const checkSession = async () => {
1252
+ if (!current() || exchanging || probing)
1253
+ return;
1254
+ probing = true;
1255
+ try {
1256
+ const session = await this.http.get(`${servicePrefix}/api/did/session`);
1257
+ if (current() && !exchanging && session?.authenticated === true)
1258
+ succeed();
1259
+ }
1260
+ catch { /* A later probe or callback may still complete this attempt. */ }
1261
+ finally {
1262
+ probing = false;
866
1263
  }
867
1264
  };
868
- // popup.closed is not "user cancelled". Provider COOP and the callback's
869
- // own window.close() both flip it. Dropping the message listener here is
870
- // what made GitHub login fail: authorize succeeds, parent already gave up.
871
- let pollTimer = setInterval(() => {
1265
+ pollTimer = setInterval(() => {
872
1266
  let closed = false;
873
1267
  try {
874
- closed = popup.closed;
1268
+ closed = !!popup?.closed;
875
1269
  }
876
1270
  catch {
877
1271
  return;
878
1272
  }
879
- if (!closed)
1273
+ if (!closed || !current())
880
1274
  return;
881
- if (pollTimer !== undefined) {
882
- clearInterval(pollTimer);
883
- pollTimer = undefined;
884
- }
885
- restore();
886
- const checkSession = () => {
887
- void this.http
888
- .get(`${servicePrefix}/api/did/session`)
889
- .then((session) => {
890
- if (session?.authenticated)
891
- succeed();
892
- })
893
- .catch(() => {
894
- /* keep waiting for postMessage or the next session probe */
895
- });
896
- };
897
- checkSession();
898
- sessionPollTimer = setInterval(checkSession, 500);
1275
+ clearInterval(pollTimer);
1276
+ showRecovery('oauthIncomplete');
1277
+ // Remain explicitly cancellable while a COOP callback may still arrive.
1278
+ cancel.hidden = false;
1279
+ void checkSession();
1280
+ sessionTimer = setInterval(() => { void checkSession(); }, 2000);
899
1281
  }, 500);
900
- const abandonTimer = setTimeout(() => {
901
- if (settled)
1282
+ abandonTimer = setTimeout(() => {
1283
+ if (!current())
1284
+ return;
1285
+ if (!exchanging) {
1286
+ fail('oauthTimedOut');
902
1287
  return;
1288
+ }
1289
+ // A request may have reached the server even if its response never arrived.
1290
+ // Do not promise cancellation or start a second exchange in that ambiguity.
1291
+ this.abort?.abort();
903
1292
  cleanup();
904
- restore();
1293
+ closePopup();
1294
+ lockMethods();
1295
+ hint.textContent = t('oauthUnconfirmed', locale);
1296
+ recovery.dataset.state = 'oauthUnconfirmed';
1297
+ actions.hidden = false;
1298
+ reopen.hidden = true;
1299
+ cancel.hidden = true;
1300
+ fullPage.href = window.location.href;
1301
+ fullPage.textContent = t('oauthCheckSession', locale);
1302
+ this.emit('incomplete');
905
1303
  }, 5 * 60 * 1000);
906
- const cleanup = () => {
907
- if (pollTimer !== undefined) {
908
- clearInterval(pollTimer);
909
- pollTimer = undefined;
910
- }
911
- if (sessionPollTimer !== undefined) {
912
- clearInterval(sessionPollTimer);
913
- sessionPollTimer = undefined;
914
- }
915
- clearTimeout(abandonTimer);
916
- window.removeEventListener('message', onMessage);
917
- };
918
- window.addEventListener('message', onMessage);
919
1304
  }
920
1305
  // (removed dead code: renderEmailButton — email now renders through renderSecondaryGrid)
921
1306
  /**
@@ -924,20 +1309,28 @@ export class LoginPage {
924
1309
  * Even count: all items in 2 columns (each half width).
925
1310
  * Odd count: DID Wallet spans full width at the end.
926
1311
  */
927
- renderSecondaryGrid(parent, locale, methods) {
1312
+ renderSecondaryGrid(parent, locale, methods, group) {
928
1313
  const grid = document.createElement('div');
929
1314
  grid.className = 'methods-grid';
930
1315
  // 1. OAuth buttons (each provider is one grid item)
931
1316
  if (methods.includes('oauth') && this.options.oauthProviders?.length) {
932
- const enabledProviders = (this.options.oauthProviders ?? []).filter((p) => p.enabled);
1317
+ const order = this.options.oauthProviderOrder ?? ['google', 'apple', 'github'];
1318
+ const rank = (id) => order.includes(id) ? order.indexOf(id) : order.length;
1319
+ const explicitOrder = !!this.options.oauthProviderOrder || this.options.oauthProviders?.some(p => p.order !== undefined && p.order !== 999);
1320
+ const enabledProviders = (this.options.oauthProviders ?? []).filter((p) => p.enabled && (!group || ((explicitOrder || ['google', 'apple'].includes(p.id)) === (group === 'primary')))).sort((a, b) => this.options.oauthProviderOrder
1321
+ ? rank(a.id) - rank(b.id)
1322
+ : (a.order ?? 999) - (b.order ?? 999) || rank(a.id) - rank(b.id));
933
1323
  for (const provider of enabledProviders) {
934
1324
  const btn = document.createElement('button');
935
1325
  btn.className = 'method-btn';
936
1326
  btn.dataset.provider = provider.id;
937
1327
  const icon = getOAuthIcon(provider.id);
938
- btn.innerHTML = `${icon} ${escapeHtml(provider.name)}`;
1328
+ btn.innerHTML = `${icon} ${escapeHtml(t('continueWith', locale, { provider: provider.name }))}`;
1329
+ this.markLastUsed(btn, `oauth:${provider.id}`, locale);
939
1330
  grid.appendChild(btn);
940
1331
  btn.addEventListener('click', () => {
1332
+ this.select('oauth');
1333
+ this.activeProvider = provider.id;
941
1334
  this.startOAuthPopup(provider.id, btn);
942
1335
  });
943
1336
  }
@@ -948,8 +1341,10 @@ export class LoginPage {
948
1341
  btn.className = 'method-btn';
949
1342
  btn.id = 'email-btn';
950
1343
  btn.innerHTML = `${ICON_EMAIL} ${escapeHtml(t('emailButton', locale))}`;
1344
+ this.markLastUsed(btn, 'email', locale);
951
1345
  grid.appendChild(btn);
952
1346
  btn.addEventListener('click', () => {
1347
+ this.select('email');
953
1348
  this.switchView('email');
954
1349
  });
955
1350
  }
@@ -961,8 +1356,10 @@ export class LoginPage {
961
1356
  // Use short label when sharing grid with other methods
962
1357
  const walletLabel = grid.children.length > 0 ? t('didWalletButtonShort', locale) : t('didWalletButton', locale);
963
1358
  btn.innerHTML = `${ICON_DID_WALLET} ${escapeHtml(walletLabel)}`;
1359
+ this.markLastUsed(btn, 'did-connect', locale);
964
1360
  grid.appendChild(btn);
965
1361
  btn.addEventListener('click', () => {
1362
+ this.select('did-connect');
966
1363
  this.switchView('qr');
967
1364
  });
968
1365
  }
@@ -971,11 +1368,15 @@ export class LoginPage {
971
1368
  // ─── Private: DID Connect flow (QR view) ───────────────────────────
972
1369
  async startDIDConnectInView(statusEl, qrContainer, walletBtn, locale) {
973
1370
  statusEl.textContent = t('connecting', locale);
1371
+ const epoch = this.generation;
1372
+ const isCurrent = () => this.mounted && epoch === this.generation;
974
1373
  try {
975
1374
  // Clean up previous session if any
976
1375
  this.tokenSession?.destroy();
977
1376
  this.tokenSession = new TokenSession(this.config, this.http);
978
1377
  this.tokenSession.on('succeed', async () => {
1378
+ if (!isCurrent())
1379
+ return;
979
1380
  // Transition to result state: hide QR + wallet button, show verifying
980
1381
  this.showConnectResult(qrContainer, walletBtn, statusEl, t('verifying', locale));
981
1382
  try {
@@ -983,11 +1384,15 @@ export class LoginPage {
983
1384
  await this.http.post(`${servicePrefix}/api/did/connect/complete`, {
984
1385
  token: this.tokenSession.state.token,
985
1386
  });
1387
+ if (!isCurrent())
1388
+ return;
986
1389
  // Show success state with green checkmark
987
1390
  this.showConnectSuccess(qrContainer, walletBtn, statusEl, locale);
988
1391
  this.handleSuccess();
989
1392
  }
990
1393
  catch (err) {
1394
+ if (!isCurrent())
1395
+ return;
991
1396
  this.showConnectResult(qrContainer, walletBtn, statusEl, friendlyError(err, locale), true);
992
1397
  }
993
1398
  });
@@ -1020,13 +1425,15 @@ export class LoginPage {
1020
1425
  }
1021
1426
  });
1022
1427
  const state = await this.tokenSession.create({ locale });
1428
+ if (!isCurrent())
1429
+ return;
1023
1430
  if (state.url) {
1024
1431
  renderQR(qrContainer, state.url);
1025
1432
  // Enable "Open in DID Wallet" button with platform-specific handler
1026
1433
  walletBtn.disabled = false;
1027
1434
  walletBtn.addEventListener('click', () => {
1028
1435
  // Immediately show connecting state when user taps to open wallet
1029
- this.showConnectScanned(qrContainer, walletBtn, statusEl, locale);
1436
+ statusEl.textContent = t('walletRetry', locale);
1030
1437
  this.openInWallet(state.url, locale);
1031
1438
  });
1032
1439
  }
@@ -1034,6 +1441,8 @@ export class LoginPage {
1034
1441
  this.tokenSession.startPolling();
1035
1442
  }
1036
1443
  catch (err) {
1444
+ if (!isCurrent())
1445
+ return;
1037
1446
  statusEl.textContent = friendlyError(err, locale);
1038
1447
  clearQR(qrContainer);
1039
1448
  }
@@ -1054,7 +1463,7 @@ export class LoginPage {
1054
1463
  }
1055
1464
  this.cardEl.innerHTML = '';
1056
1465
  this.cardEl.dataset.view = 'error';
1057
- this.renderLocaleSwitcher(this.cardEl);
1466
+ this.renderCardTools(this.cardEl);
1058
1467
  const resultContainer = document.createElement('div');
1059
1468
  resultContainer.className = 'connect-result';
1060
1469
  resultContainer.innerHTML = `<div class="result-icon result-error">✕</div>`;
@@ -1209,8 +1618,25 @@ export class LoginPage {
1209
1618
  }
1210
1619
  // ─── Private: success handler ────────────────────────────────────────
1211
1620
  handleSuccess() {
1621
+ if (!this.mounted || this.completed)
1622
+ return;
1623
+ this.completed = true;
1624
+ this.emit('success');
1625
+ if (this.options.preferenceKey && this.activeMethod) {
1626
+ try {
1627
+ localStorage.setItem(`did-login-method:${this.options.preferenceKey}`, this.activeMethod === 'oauth' && this.activeProvider ? `oauth:${this.activeProvider}` : this.activeMethod);
1628
+ }
1629
+ catch { /* private mode */ }
1630
+ }
1212
1631
  if (this.options.onSuccess) {
1213
- this.options.onSuccess();
1632
+ const epoch = this.generation;
1633
+ Promise.resolve().then(() => { if (this.mounted && epoch === this.generation)
1634
+ return this.options.onSuccess?.(); }).catch(() => {
1635
+ if (!this.mounted || epoch !== this.generation)
1636
+ return;
1637
+ this.completed = false;
1638
+ this.showFullError(t('authFailed', this.options.locale));
1639
+ });
1214
1640
  }
1215
1641
  else {
1216
1642
  setTimeout(() => location.reload(), 300);