@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.
- package/dist/ui/design-tokens.js +4 -4
- package/dist/ui/i18n.d.ts +3 -1
- package/dist/ui/i18n.d.ts.map +1 -1
- package/dist/ui/i18n.js +92 -32
- package/dist/ui/i18n.js.map +1 -1
- package/dist/ui/index.bundle.js +804 -96
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/login-dialog.d.ts +26 -0
- package/dist/ui/login-dialog.d.ts.map +1 -0
- package/dist/ui/login-dialog.js +206 -0
- package/dist/ui/login-dialog.js.map +1 -0
- package/dist/ui/login-page.d.ts +73 -2
- package/dist/ui/login-page.d.ts.map +1 -1
- package/dist/ui/login-page.js +383 -46
- package/dist/ui/login-page.js.map +1 -1
- package/dist/ui/qr-renderer.d.ts.map +1 -1
- package/dist/ui/qr-renderer.js +19 -3
- package/dist/ui/qr-renderer.js.map +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/styles.js +84 -8
- package/dist/ui/styles.js.map +1 -1
- package/dist/ui/webauthn-browser.d.ts +1 -0
- package/dist/ui/webauthn-browser.d.ts.map +1 -1
- package/dist/ui/webauthn-browser.js +19 -9
- package/dist/ui/webauthn-browser.js.map +1 -1
- package/package.json +1 -1
package/dist/ui/login-page.js
CHANGED
|
@@ -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.
|
|
90
|
-
// 2.
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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.
|
|
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}
|
|
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
|
-
|
|
246
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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);
|