@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.
- 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 +120 -32
- package/dist/ui/i18n.js.map +1 -1
- package/dist/ui/index.bundle.js +1051 -191
- 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 +210 -0
- package/dist/ui/login-dialog.js.map +1 -0
- package/dist/ui/login-page.d.ts +77 -2
- package/dist/ui/login-page.d.ts.map +1 -1
- package/dist/ui/login-page.js +572 -146
- 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 +113 -22
- 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;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
target.removeAttribute('data-theme');
|
|
145
238
|
}
|
|
146
|
-
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
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',
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
806
|
-
|
|
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 (
|
|
1180
|
+
if (!current())
|
|
809
1181
|
return;
|
|
810
|
-
settled = true;
|
|
811
1182
|
cleanup();
|
|
812
|
-
|
|
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 (
|
|
1194
|
+
if (!event.data || event.data.type !== 'authorization_response' || !current() || exchanging)
|
|
821
1195
|
return;
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
843
|
-
cleanup();
|
|
1203
|
+
if (typeof code !== 'string' || !code || typeof state !== 'string' || !state) {
|
|
1204
|
+
fail('oauthFailed');
|
|
844
1205
|
return;
|
|
845
1206
|
}
|
|
846
|
-
|
|
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 (
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
901
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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);
|