@arcblock/did-connect-core 4.1.53 → 4.1.55

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.
@@ -268,6 +268,8 @@ export class LoginPage {
268
268
  }
269
269
  /** Locale sits on the card's top-right row, level with Back / Cancel. */
270
270
  renderCardTools(card) {
271
+ if (!(this.options.showLocaleSelector ?? this.options.mode !== 'dialog'))
272
+ return;
271
273
  const tools = document.createElement('div');
272
274
  tools.className = 'card-tools';
273
275
  this.renderLocaleSwitcher(tools);
@@ -325,6 +327,8 @@ export class LoginPage {
325
327
  trigger.addEventListener('click', (event) => {
326
328
  event.stopPropagation();
327
329
  setOpen(menu.hidden);
330
+ if (!menu.hidden)
331
+ menu.querySelector('[aria-checked="true"]')?.focus();
328
332
  });
329
333
  for (const item of locales) {
330
334
  const checked = item.code === current.code;
@@ -334,12 +338,13 @@ export class LoginPage {
334
338
  btn.setAttribute('role', 'menuitemradio');
335
339
  btn.setAttribute('aria-checked', checked ? 'true' : 'false');
336
340
  btn.dataset.locale = item.code;
341
+ btn.tabIndex = -1;
337
342
  btn.title = item.label;
338
- if (checked) {
343
+ {
339
344
  const mark = document.createElement('span');
340
345
  mark.className = 'locale-check';
341
346
  mark.setAttribute('aria-hidden', 'true');
342
- mark.textContent = '✓';
347
+ mark.textContent = checked ? '✓' : '';
343
348
  btn.appendChild(mark);
344
349
  }
345
350
  const label = document.createElement('span');
@@ -351,6 +356,25 @@ export class LoginPage {
351
356
  });
352
357
  menu.appendChild(btn);
353
358
  }
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();
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
+ });
354
378
  const onOutsideClick = (event) => {
355
379
  const path = event.composedPath();
356
380
  if (path.includes(wrapper))
@@ -406,6 +430,21 @@ export class LoginPage {
406
430
  break;
407
431
  }
408
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
+ }
409
448
  renderMethodsView(card, locale, methods) {
410
449
  // ── Escape hatch ("Cancel") — top-left, mirrors the locale switcher
411
450
  // (top-right). Standard placement for a standalone auth card. ──
@@ -413,21 +452,6 @@ export class LoginPage {
413
452
  this.renderHomeLink(card, locale);
414
453
  // ── Brand header ──
415
454
  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
- }
431
455
  // Passkey unsupported warning
432
456
  const requestedPasskey = this.options.methods.includes('passkey');
433
457
  if (requestedPasskey && !methods.includes('passkey')) {
@@ -721,11 +745,12 @@ export class LoginPage {
721
745
  }
722
746
  });
723
747
  }
724
- renderBackButton(parent, locale) {
748
+ renderBackButton(parent, locale, onBack = () => this.switchView('methods')) {
725
749
  const btn = document.createElement('button');
750
+ btn.type = 'button';
726
751
  btn.className = 'back-btn';
727
752
  btn.innerHTML = `${ICON_BACK_ARROW} ${escapeHtml(t('back', locale))}`;
728
- btn.addEventListener('click', () => this.switchView('methods'));
753
+ btn.addEventListener('click', onBack);
729
754
  parent.appendChild(btn);
730
755
  }
731
756
  /**
@@ -838,12 +863,6 @@ export class LoginPage {
838
863
  }
839
864
  footer.appendChild(links);
840
865
  }
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
- }
847
866
  parent.appendChild(footer);
848
867
  }
849
868
  renderDivider(parent, locale) {
@@ -943,11 +962,8 @@ export class LoginPage {
943
962
  createBtn.id = 'passkey-btn';
944
963
  createBtn.innerHTML = `${ICON_PASSKEY} ${escapeHtml(t('passkeyCreate', locale))}`;
945
964
  section.appendChild(createBtn);
946
- const backLink = document.createElement('button');
947
- backLink.type = 'button';
948
- backLink.className = 'passkey-alt';
949
- backLink.textContent = t('passkeyBackToSignIn', locale);
950
- section.appendChild(backLink);
965
+ parent.querySelector('.back-btn')?.remove();
966
+ this.renderBackButton(parent, locale, () => renderSignInState());
951
967
  const doCreate = async () => {
952
968
  if (createBtn.disabled)
953
969
  return; // Enter-key auto-repeat on the name input
@@ -976,7 +992,6 @@ export class LoginPage {
976
992
  createBtn.addEventListener('click', doCreate);
977
993
  nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter')
978
994
  doCreate(); });
979
- backLink.addEventListener('click', () => renderSignInState());
980
995
  createBtn.focus();
981
996
  };
982
997
  // ── State 0: sign-in (default) ──
@@ -1015,6 +1030,7 @@ export class LoginPage {
1015
1030
  };
1016
1031
  const renderSignInState = () => {
1017
1032
  this.cardEl?.classList.remove('passkey-creating');
1033
+ parent.querySelector('.back-btn')?.remove();
1018
1034
  this.abort?.abort();
1019
1035
  this.generation++;
1020
1036
  const heading = this.cardEl?.querySelector('h1');
@@ -1026,6 +1042,7 @@ export class LoginPage {
1026
1042
  signInBtn.className = this.cardEl?.classList.contains('social-first') ? 'btn btn-secondary' : 'btn';
1027
1043
  signInBtn.id = 'passkey-btn';
1028
1044
  signInBtn.innerHTML = `${ICON_PASSKEY} ${escapeHtml(t('passkeySignIn', locale))}`;
1045
+ this.markLastUsed(signInBtn, 'passkey', locale);
1029
1046
  section.appendChild(signInBtn);
1030
1047
  signInBtn.addEventListener('click', () => doSignIn());
1031
1048
  const createLink = document.createElement('button');
@@ -1049,172 +1066,235 @@ export class LoginPage {
1049
1066
  startOAuthPopup(providerId, btn) {
1050
1067
  this.oauthCleanup?.();
1051
1068
  const epoch = this.generation;
1069
+ const locale = this.options.locale;
1052
1070
  const servicePrefix = this.config.servicePrefix ?? '/.well-known/service';
1053
- // Federation: popup opens Master's OAuth login URL (cross-domain callback)
1054
1071
  const oauthBase = this.options.masterOAuthOrigin
1055
- ? `${this.options.masterOAuthOrigin}${servicePrefix}`
1056
- : servicePrefix;
1072
+ ? `${this.options.masterOAuthOrigin}${servicePrefix}` : servicePrefix;
1057
1073
  const oauthOrigin = new URL(oauthBase, window.location.href).origin;
1058
- const loginUrl = `${oauthBase}/api/oauth/${encodeURIComponent(providerId)}/login?returnUrl=${encodeURIComponent(window.location.href)}`;
1059
- // Open centered popup — compact size, enough for provider auth pages
1060
- const width = 500;
1061
- const height = 620;
1062
- const left = window.screenX + (window.innerWidth - width) / 2;
1063
- const top = window.screenY + (window.innerHeight - height) / 2;
1064
- const popup = window.open(loginUrl, 'oauth-login:popup', `left=${left},top=${top},width=${width},height=${height},resizable,scrollbars=yes,status=1,popup`);
1065
- if (!popup) {
1066
- this.options.onBeforeRedirect?.();
1067
- window.location.href = loginUrl;
1068
- return;
1074
+ let returnUrl = window.location.href;
1075
+ if (this.options.mode !== 'dialog' && window.location.pathname === `${servicePrefix}/login`) {
1076
+ const requested = new URLSearchParams(window.location.search).get('return_to');
1077
+ try {
1078
+ const target = new URL(requested || '/', window.location.origin);
1079
+ returnUrl = target.origin === window.location.origin && !/^\/[/\\]/.test(target.pathname) ? target.href : `${window.location.origin}/`;
1080
+ }
1081
+ catch {
1082
+ returnUrl = `${window.location.origin}/`;
1083
+ }
1069
1084
  }
1070
- // Save original button content and enter loading state
1085
+ const loginUrl = `${oauthBase}/api/oauth/${encodeURIComponent(providerId)}/login?returnUrl=${encodeURIComponent(returnUrl)}`;
1086
+ const provider = this.options.oauthProviders?.find(p => p.id === providerId)?.name ?? providerId;
1071
1087
  const originalHTML = btn.innerHTML;
1072
- btn.textContent = t('oauthWaiting', this.options.locale, { provider: this.options.oauthProviders?.find(p => p.id === providerId)?.name ?? providerId });
1073
- btn.disabled = true;
1074
- btn.classList.add('oauth-btn-loading');
1088
+ let popup = null;
1089
+ let settled = false;
1090
+ let exchanging = false;
1091
+ let probing = false;
1092
+ let pollTimer;
1093
+ let sessionTimer;
1094
+ let abandonTimer;
1095
+ const current = () => !settled && this.mounted && epoch === this.generation;
1096
+ const locked = [];
1097
+ const lockMethods = () => {
1098
+ for (const button of this.cardEl?.querySelectorAll('.method-btn, .passkey-create-entry, #passkey-btn, .locale-trigger, .locale-btn') ?? []) {
1099
+ locked.push({ button, disabled: button.disabled });
1100
+ button.disabled = true;
1101
+ }
1102
+ };
1075
1103
  const recovery = document.createElement('div');
1076
1104
  recovery.className = 'oauth-recovery';
1077
1105
  recovery.setAttribute('role', 'status');
1078
1106
  const hint = document.createElement('p');
1079
- hint.textContent = t('oauthIncomplete', this.options.locale);
1107
+ const actions = document.createElement('div');
1108
+ actions.className = 'oauth-recovery-actions';
1109
+ const reopen = document.createElement('button');
1110
+ reopen.type = 'button';
1111
+ reopen.className = 'oauth-retry';
1112
+ reopen.textContent = t('oauthReopen', locale);
1113
+ reopen.hidden = true;
1114
+ reopen.onclick = () => { this.select('oauth'); this.activeProvider = providerId; this.startOAuthPopup(providerId, btn); };
1080
1115
  const fullPage = document.createElement('a');
1081
1116
  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;
1117
+ fullPage.textContent = t('oauthFullPage', locale);
1118
+ fullPage.addEventListener('click', event => {
1119
+ const reconcile = recovery.dataset.state === 'oauthUnconfirmed';
1120
+ if (exchanging && !settled) {
1121
+ event.preventDefault();
1122
+ return;
1123
+ }
1124
+ if (reconcile)
1125
+ event.preventDefault();
1126
+ this.options.onBeforeRedirect?.();
1127
+ this.oauthCleanup?.();
1128
+ if (reconcile)
1129
+ window.location.reload();
1130
+ });
1131
+ const cancel = document.createElement('button');
1132
+ cancel.type = 'button';
1133
+ cancel.className = 'oauth-cancel';
1134
+ cancel.textContent = t('cancel', locale);
1135
+ actions.append(reopen, fullPage, cancel);
1136
+ recovery.append(hint, actions);
1086
1137
  btn.parentElement?.after(recovery);
1087
- const restore = () => {
1088
- recovery.hidden = false;
1089
- this.emit('incomplete');
1138
+ const restoreButton = () => {
1090
1139
  btn.innerHTML = originalHTML;
1091
1140
  btn.disabled = false;
1092
1141
  btn.classList.remove('oauth-btn-loading');
1093
1142
  };
1094
- let settled = false;
1095
- let sessionPollTimer;
1143
+ const closePopup = () => { try {
1144
+ popup?.close();
1145
+ }
1146
+ catch { /* COOP can sever the window reference. */ } };
1147
+ const cleanup = () => {
1148
+ settled = true;
1149
+ clearInterval(pollTimer);
1150
+ clearInterval(sessionTimer);
1151
+ clearTimeout(abandonTimer);
1152
+ window.removeEventListener('message', onMessage);
1153
+ for (const item of locked.splice(0))
1154
+ item.button.disabled = item.disabled;
1155
+ restoreButton();
1156
+ };
1157
+ const showRecovery = (key) => {
1158
+ hint.textContent = t(key, locale, { provider });
1159
+ recovery.dataset.state = key;
1160
+ actions.hidden = false;
1161
+ reopen.hidden = false;
1162
+ cancel.hidden = true;
1163
+ restoreButton();
1164
+ };
1165
+ const fail = (key) => {
1166
+ if (!current())
1167
+ return;
1168
+ cleanup();
1169
+ closePopup();
1170
+ showRecovery(key);
1171
+ this.emit('incomplete');
1172
+ };
1096
1173
  const succeed = () => {
1097
- if (settled || !this.mounted || epoch !== this.generation)
1174
+ if (!current())
1098
1175
  return;
1099
- settled = true;
1100
1176
  cleanup();
1101
- btn.textContent = t('success', this.options.locale);
1177
+ closePopup();
1178
+ recovery.remove();
1179
+ btn.textContent = t('success', locale);
1180
+ btn.disabled = true;
1102
1181
  this.handleSuccess();
1103
1182
  };
1183
+ cancel.onclick = () => { if (exchanging)
1184
+ return; fail('oauthCancelled'); btn.focus(); };
1104
1185
  const onMessage = async (event) => {
1105
- if (event.origin !== oauthOrigin || event.source !== popup)
1106
- return;
1107
- if (!event.data || event.data.type !== 'authorization_response')
1186
+ if (event.origin !== oauthOrigin || event.source !== popup || !popup)
1108
1187
  return;
1109
- if (settled || !this.mounted || epoch !== this.generation)
1188
+ if (!event.data || event.data.type !== 'authorization_response' || !current() || exchanging)
1110
1189
  return;
1111
- // Stop the closed-poll only. Do NOT drop this listener from popup.closed —
1112
- // GitHub/Twitter COOP reports closed while the provider page is still up,
1113
- // and the callback also window.close()s itself after postMessage.
1114
- if (pollTimer !== undefined) {
1115
- clearInterval(pollTimer);
1116
- pollTimer = undefined;
1117
- }
1118
- try {
1119
- popup.close();
1120
- }
1121
- catch {
1122
- /* ignore */
1123
- }
1190
+ clearInterval(pollTimer);
1191
+ clearInterval(sessionTimer);
1124
1192
  if (event.data.error) {
1125
- restore();
1126
- cleanup();
1193
+ fail(event.data.error === 'access_denied' ? 'oauthDenied' : event.data.error === 'cancelled' ? 'oauthCancelled' : 'oauthFailed');
1127
1194
  return;
1128
1195
  }
1129
1196
  const { code, state } = event.data.response ?? event.data;
1130
- if (!code) {
1131
- restore();
1132
- cleanup();
1197
+ if (typeof code !== 'string' || !code || typeof state !== 'string' || !state) {
1198
+ fail('oauthFailed');
1133
1199
  return;
1134
1200
  }
1135
- btn.textContent = t('verifying', this.options.locale);
1201
+ exchanging = true;
1202
+ lockMethods();
1203
+ actions.hidden = true;
1204
+ closePopup();
1205
+ hint.textContent = t('verifying', locale);
1206
+ btn.textContent = t('verifying', locale);
1136
1207
  btn.disabled = true;
1137
1208
  try {
1138
1209
  const oauthBody = { provider: providerId, code, state };
1139
1210
  if (this.options.invitationId)
1140
1211
  oauthBody.invitationId = this.options.invitationId;
1141
- await this.http.post(`${servicePrefix}/api/oauth/login`, oauthBody);
1212
+ await this.http.post(`${servicePrefix}/api/oauth/login`, oauthBody, { signal: this.abort?.signal });
1142
1213
  succeed();
1143
1214
  }
1144
- catch (oauthErr) {
1145
- if (settled || !this.mounted || epoch !== this.generation)
1215
+ catch (err) {
1216
+ if (!current())
1146
1217
  return;
1147
- const errMsg = oauthErr instanceof Error ? oauthErr.message : '';
1148
- const errCode = oauthErr instanceof Error && 'code' in oauthErr ? oauthErr.code : '';
1149
- if (errCode === 'FORBIDDEN' || errMsg === 'registration_closed' || errCode === 'registration_closed') {
1150
- settled = true;
1218
+ const code = err instanceof Error && 'code' in err ? String(err.code) : '';
1219
+ const message = err instanceof Error ? err.message : '';
1220
+ if (code === 'FORBIDDEN' || code === 'registration_closed' || message === 'registration_closed') {
1151
1221
  cleanup();
1152
- this.showFullError(friendlyError(oauthErr, this.options.locale));
1153
- }
1154
- else {
1155
- restore();
1222
+ recovery.remove();
1223
+ this.showFullError(friendlyError(err, locale));
1156
1224
  }
1225
+ else
1226
+ fail('oauthFailed');
1157
1227
  }
1158
1228
  };
1159
- // popup.closed is not "user cancelled". Provider COOP and the callback's
1160
- // own window.close() both flip it. Dropping the message listener here is
1161
- // what made GitHub login fail: authorize succeeds, parent already gave up.
1162
- let pollTimer = setInterval(() => {
1229
+ this.oauthCleanup = () => { cleanup(); recovery.remove(); closePopup(); };
1230
+ window.addEventListener('message', onMessage);
1231
+ try {
1232
+ 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`);
1233
+ }
1234
+ catch { /* Treat a browser refusal like a blocked popup. */ }
1235
+ if (!popup) {
1236
+ fail('oauthBlocked');
1237
+ return;
1238
+ }
1239
+ hint.textContent = t('oauthWaiting', locale, { provider });
1240
+ btn.textContent = t('oauthWaiting', locale, { provider });
1241
+ btn.disabled = true;
1242
+ btn.classList.add('oauth-btn-loading');
1243
+ // COOP can report closed before the callback arrives. Keep the listener,
1244
+ // but show recovery immediately and never overlap server session probes.
1245
+ const checkSession = async () => {
1246
+ if (!current() || exchanging || probing)
1247
+ return;
1248
+ probing = true;
1249
+ try {
1250
+ const session = await this.http.get(`${servicePrefix}/api/did/session`);
1251
+ if (current() && !exchanging && session?.authenticated === true)
1252
+ succeed();
1253
+ }
1254
+ catch { /* A later probe or callback may still complete this attempt. */ }
1255
+ finally {
1256
+ probing = false;
1257
+ }
1258
+ };
1259
+ pollTimer = setInterval(() => {
1163
1260
  let closed = false;
1164
1261
  try {
1165
- closed = popup.closed;
1262
+ closed = !!popup?.closed;
1166
1263
  }
1167
1264
  catch {
1168
1265
  return;
1169
1266
  }
1170
- if (!closed)
1267
+ if (!closed || !current())
1171
1268
  return;
1172
- if (pollTimer !== undefined) {
1173
- clearInterval(pollTimer);
1174
- pollTimer = undefined;
1175
- }
1176
- restore();
1177
- const checkSession = () => {
1178
- void this.http
1179
- .get(`${servicePrefix}/api/did/session`)
1180
- .then((session) => {
1181
- if (session?.authenticated)
1182
- succeed();
1183
- })
1184
- .catch(() => {
1185
- /* keep waiting for postMessage or the next session probe */
1186
- });
1187
- };
1188
- checkSession();
1189
- sessionPollTimer = setInterval(checkSession, 500);
1269
+ clearInterval(pollTimer);
1270
+ showRecovery('oauthIncomplete');
1271
+ // Remain explicitly cancellable while a COOP callback may still arrive.
1272
+ cancel.hidden = false;
1273
+ void checkSession();
1274
+ sessionTimer = setInterval(() => { void checkSession(); }, 2000);
1190
1275
  }, 500);
1191
- const abandonTimer = setTimeout(() => {
1192
- if (settled)
1276
+ abandonTimer = setTimeout(() => {
1277
+ if (!current())
1193
1278
  return;
1279
+ if (!exchanging) {
1280
+ fail('oauthTimedOut');
1281
+ return;
1282
+ }
1283
+ // A request may have reached the server even if its response never arrived.
1284
+ // Do not promise cancellation or start a second exchange in that ambiguity.
1285
+ this.abort?.abort();
1194
1286
  cleanup();
1195
- restore();
1287
+ closePopup();
1288
+ lockMethods();
1289
+ hint.textContent = t('oauthUnconfirmed', locale);
1290
+ recovery.dataset.state = 'oauthUnconfirmed';
1291
+ actions.hidden = false;
1292
+ reopen.hidden = true;
1293
+ cancel.hidden = true;
1294
+ fullPage.href = window.location.href;
1295
+ fullPage.textContent = t('oauthCheckSession', locale);
1296
+ this.emit('incomplete');
1196
1297
  }, 5 * 60 * 1000);
1197
- const cleanup = () => {
1198
- settled = true;
1199
- btn.innerHTML = originalHTML;
1200
- btn.disabled = false;
1201
- btn.classList.remove('oauth-btn-loading');
1202
- if (pollTimer !== undefined) {
1203
- clearInterval(pollTimer);
1204
- pollTimer = undefined;
1205
- }
1206
- if (sessionPollTimer !== undefined) {
1207
- clearInterval(sessionPollTimer);
1208
- sessionPollTimer = undefined;
1209
- }
1210
- clearTimeout(abandonTimer);
1211
- window.removeEventListener('message', onMessage);
1212
- };
1213
- this.oauthCleanup = () => { cleanup(); recovery.remove(); try {
1214
- popup.close();
1215
- }
1216
- catch { /* COOP */ } };
1217
- window.addEventListener('message', onMessage);
1218
1298
  }
1219
1299
  // (removed dead code: renderEmailButton — email now renders through renderSecondaryGrid)
1220
1300
  /**
@@ -1240,6 +1320,7 @@ export class LoginPage {
1240
1320
  btn.dataset.provider = provider.id;
1241
1321
  const icon = getOAuthIcon(provider.id);
1242
1322
  btn.innerHTML = `${icon} ${escapeHtml(t('continueWith', locale, { provider: provider.name }))}`;
1323
+ this.markLastUsed(btn, `oauth:${provider.id}`, locale);
1243
1324
  grid.appendChild(btn);
1244
1325
  btn.addEventListener('click', () => {
1245
1326
  this.select('oauth');
@@ -1254,6 +1335,7 @@ export class LoginPage {
1254
1335
  btn.className = 'method-btn';
1255
1336
  btn.id = 'email-btn';
1256
1337
  btn.innerHTML = `${ICON_EMAIL} ${escapeHtml(t('emailButton', locale))}`;
1338
+ this.markLastUsed(btn, 'email', locale);
1257
1339
  grid.appendChild(btn);
1258
1340
  btn.addEventListener('click', () => {
1259
1341
  this.select('email');
@@ -1268,6 +1350,7 @@ export class LoginPage {
1268
1350
  // Use short label when sharing grid with other methods
1269
1351
  const walletLabel = grid.children.length > 0 ? t('didWalletButtonShort', locale) : t('didWalletButton', locale);
1270
1352
  btn.innerHTML = `${ICON_DID_WALLET} ${escapeHtml(walletLabel)}`;
1353
+ this.markLastUsed(btn, 'did-connect', locale);
1271
1354
  grid.appendChild(btn);
1272
1355
  btn.addEventListener('click', () => {
1273
1356
  this.select('did-connect');
@@ -1308,8 +1391,9 @@ export class LoginPage {
1308
1391
  }
1309
1392
  });
1310
1393
  this.tokenSession.on('timeout', () => {
1311
- statusEl.textContent = t('timeout', locale);
1312
- // Show overlay on top of the expired QR code with a refresh button
1394
+ statusEl.textContent = '';
1395
+ // Cover the expired code itself. The notice lives on that plate, so the
1396
+ // status line under the wallet button does not repeat it.
1313
1397
  const overlay = document.createElement('div');
1314
1398
  overlay.className = 'qr-timeout-overlay';
1315
1399
  overlay.innerHTML = `<span class="timeout-text">${escapeHtml(t('timeout', locale))}</span>`;
@@ -1324,7 +1408,8 @@ export class LoginPage {
1324
1408
  this.startDIDConnectInView(statusEl, qrContainer, walletBtn, locale);
1325
1409
  });
1326
1410
  overlay.appendChild(refreshBtn);
1327
- qrContainer.appendChild(overlay);
1411
+ const plate = qrContainer.querySelector('.qr-plate');
1412
+ (plate ?? qrContainer).appendChild(overlay);
1328
1413
  });
1329
1414
  this.tokenSession.on('error', (err) => {
1330
1415
  this.showConnectResult(qrContainer, walletBtn, statusEl, friendlyError(err, locale), true);