pryv 3.12.0 → 3.13.0

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE)
4
+ */
5
+
6
+ /**
7
+ * The accounts a sign-in button remembers for one app, as stored in its
8
+ * authorization data:
9
+ * `{ apiEndpoint, username, actingAs?, authUrl?, profiles: [...] }`.
10
+ * The top-level `apiEndpoint` / `username` are the active account (what
11
+ * versions without profiles read); `profiles` lists every remembered
12
+ * account, most recently used first, the active one included.
13
+ * A profile is `{ username, apiEndpoint, actingAs?, unavailable? }`, where
14
+ * `actingAs = { username, delegate }` marks an account used through account
15
+ * delegation (`delegate` is the username of the person acting).
16
+ * @memberof pryv.Auth
17
+ */
18
+ module.exports = {
19
+ DEFAULT_LIMIT: 5,
20
+ read,
21
+ write,
22
+ activate,
23
+ remove,
24
+ markUnavailable,
25
+ profileOf,
26
+ fromAccepted
27
+ };
28
+
29
+ /**
30
+ * Parse stored authorization data (any version) into
31
+ * `{ active, profiles, authUrl }`; `active` is `profiles[0]` or null.
32
+ * @param {Object|null|undefined} stored
33
+ */
34
+ function read (stored) {
35
+ const store = { active: null, profiles: [], authUrl: undefined };
36
+ if (stored == null || typeof stored !== 'object' || stored.deleted === true) return store;
37
+ if (typeof stored.authUrl === 'string') store.authUrl = stored.authUrl;
38
+ if (Array.isArray(stored.profiles)) {
39
+ store.profiles = stored.profiles.filter(isProfile).map(profileOf);
40
+ }
41
+ if (isProfile(stored)) {
42
+ const active = profileOf(stored);
43
+ store.profiles = [active].concat(store.profiles.filter((p) => p.username !== active.username));
44
+ store.active = active;
45
+ }
46
+ return store;
47
+ }
48
+
49
+ /**
50
+ * The authorization data to store for `store`.
51
+ * @returns {Object|null} null when nothing is left to remember
52
+ */
53
+ function write (store) {
54
+ if (store.profiles.length === 0) return null;
55
+ const data = {};
56
+ if (store.active != null) {
57
+ data.apiEndpoint = store.active.apiEndpoint;
58
+ data.username = store.active.username;
59
+ if (store.active.actingAs != null) data.actingAs = store.active.actingAs;
60
+ }
61
+ if (store.authUrl != null) data.authUrl = store.authUrl;
62
+ data.profiles = store.profiles.map(profileOf);
63
+ return data;
64
+ }
65
+
66
+ /**
67
+ * Make `profile` the active account (replacing a stored one with the same
68
+ * username), then forget the least recently used accounts beyond `limit`.
69
+ */
70
+ function activate (store, profile, limit) {
71
+ const max = Math.max(1, limit || module.exports.DEFAULT_LIMIT);
72
+ const active = profileOf(profile);
73
+ const profiles = [active].concat(store.profiles.filter((p) => p.username !== active.username));
74
+ return Object.assign({}, store, { active, profiles: profiles.slice(0, max) });
75
+ }
76
+
77
+ /** Forget `username`; forgetting the active account leaves none active. */
78
+ function remove (store, username) {
79
+ const profiles = store.profiles.filter((p) => p.username !== username);
80
+ const active = (store.active != null && store.active.username !== username) ? store.active : null;
81
+ return Object.assign({}, store, { active, profiles });
82
+ }
83
+
84
+ /** Keep `username` listed, marked as no longer usable (revoked, detached). */
85
+ function markUnavailable (store, username) {
86
+ const mark = (p) => (p.username === username ? Object.assign(profileOf(p), { unavailable: true }) : p);
87
+ const profiles = store.profiles.map(mark);
88
+ const active = (store.active != null && store.active.username === username) ? null : store.active;
89
+ return Object.assign({}, store, { active, profiles });
90
+ }
91
+
92
+ /** The stored fields of a profile, nothing else (credentials included). */
93
+ function profileOf (p) {
94
+ const profile = { username: p.username, apiEndpoint: p.apiEndpoint };
95
+ if (p.actingAs != null && typeof p.actingAs.delegate === 'string') {
96
+ profile.actingAs = { username: p.username, delegate: p.actingAs.delegate };
97
+ }
98
+ if (p.unavailable === true) profile.unavailable = true;
99
+ return profile;
100
+ }
101
+
102
+ function isProfile (p) {
103
+ return p != null && typeof p.username === 'string' && typeof p.apiEndpoint === 'string';
104
+ }
105
+
106
+ /**
107
+ * The profile of an ACCEPTED auth-request body. Its `delegation` block is a
108
+ * display hint posted by the auth page; `accessInfo().delegation` is
109
+ * authoritative and replaces it when the profile is next activated.
110
+ */
111
+ function fromAccepted (body) {
112
+ const profile = { username: body.username, apiEndpoint: body.apiEndpoint };
113
+ const d = body.delegation;
114
+ if (d != null && d.isDelegatedAccess === true && d.controlledUsername === body.username &&
115
+ typeof d.delegate?.username === 'string') {
116
+ profile.actingAs = { username: body.username, delegate: d.delegate.username };
117
+ }
118
+ return profile;
119
+ }
@@ -5,10 +5,16 @@
5
5
  const Cookies = require('./CookieUtils');
6
6
  const AuthStates = require('../Auth/AuthStates');
7
7
  const AuthController = require('../Auth/AuthController');
8
+ const ProfileStore = require('../Auth/ProfileStore');
8
9
  const Messages = require('../Auth/LoginMessages');
9
10
  const utils = require('../utils');
10
11
 
11
- /* global location, confirm */
12
+ /* global location */
13
+
14
+ /** Suffix of the cookie that holds the remembered accounts. */
15
+ const PROFILES_COOKIE_SUFFIX = '-profiles';
16
+ /** Encoded length kept under the ~4096-byte cookie limit (name and attributes included). */
17
+ const PROFILES_COOKIE_MAX_LENGTH = 3500;
12
18
 
13
19
  /**
14
20
  * @memberof pryv.Browser
@@ -64,19 +70,29 @@ class LoginButton {
64
70
  }
65
71
  break;
66
72
  }
67
- case AuthStates.AUTHORIZED:
68
- this.text = state.username;
69
- this.saveAuthorizationData({
70
- apiEndpoint: state.apiEndpoint,
71
- username: state.username
72
- });
73
+ case AuthStates.AUTHORIZED: {
74
+ const profile = state.profile || ProfileStore.fromAccepted(state);
75
+ this.text = profileLabel(this, profile);
76
+ const store = ProfileStore.read(this.getAuthorizationData());
77
+ // Kept to locate the account app after a reload (see AuthController.accountUrl).
78
+ const authUrl = withoutQuery(state.authUrl || this.auth?._authUrl) || store.authUrl;
79
+ this.saveAuthorizationData(ProfileStore.write(Object.assign(
80
+ ProfileStore.activate(store, profile, this.authSettings.maxProfiles),
81
+ { authUrl }
82
+ )));
83
+ break;
84
+ }
85
+ case AuthStates.SWITCHING:
86
+ this.text = this.messages.SWITCHING || '...';
73
87
  break;
74
88
  case AuthStates.SIGNOUT: {
75
- const message = this.messages.SIGNOUT_CONFIRM ? this.messages.SIGNOUT_CONFIRM : 'Logout ?';
76
- if (confirm(message)) {
77
- this.deleteAuthorizationData();
78
- this.auth.init();
79
- }
89
+ // A confirmed logout (the menu's "Log out", or `auth.signOut()`)
90
+ // clears the credentials itself.
91
+ if (this.auth?._signingOut) break;
92
+ // Menu disabled (`settings.menu: false`): confirm in a small built-in
93
+ // dialog, then clear the credentials (see buildMenu: the re-init is
94
+ // awaited there, never left running in the background).
95
+ this.openMenu({ confirmLogout: true });
80
96
  break;
81
97
  }
82
98
  case AuthStates.ERROR:
@@ -92,16 +108,104 @@ class LoginButton {
92
108
  }
93
109
  }
94
110
 
111
+ /**
112
+ * Open the account menu (signed-in account, "Manage my account",
113
+ * "Log out"). Returns false, and opens nothing, when the menu is disabled
114
+ * with `settings.menu: false`: the caller then falls back to the logout
115
+ * confirmation.
116
+ * @returns {boolean}
117
+ */
118
+ showMenu () {
119
+ if (this.authSettings.menu === false) return false;
120
+ return this.openMenu({ confirmLogout: false });
121
+ }
122
+
123
+ /**
124
+ * @private Open the account menu, or (`confirmLogout`) the plain "Log out?"
125
+ * confirmation used when the menu is disabled.
126
+ */
127
+ openMenu ({ confirmLogout }) {
128
+ if (typeof document === 'undefined') return false;
129
+ this.closeMenu();
130
+ this.menu = buildMenu(this, confirmLogout);
131
+ document.body.appendChild(this.menu.overlay);
132
+ this.menu.focusables()[0]?.focus();
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * Close the account menu if it is open. Dismissing the "Log out?" question
138
+ * (anything but "Log out") returns to the signed-in state: SIGNOUT was
139
+ * already emitted by the click, and the button would otherwise stay inert.
140
+ * @param {boolean} [loggingOut] - closed by the "Log out" action
141
+ * @returns {Promise<void>} resolves when any re-init it started is done
142
+ */
143
+ closeMenu (loggingOut) {
144
+ if (this.menu == null) return Promise.resolve();
145
+ const menu = this.menu;
146
+ this.menu = null;
147
+ document.removeEventListener('keydown', menu.onKeyDown, true);
148
+ if (menu.overlay.parentNode != null) menu.overlay.parentNode.removeChild(menu.overlay);
149
+ const previousFocus = /** @type {HTMLElement|null} */ (menu.previousFocus);
150
+ if (previousFocus != null && typeof previousFocus.focus === 'function') {
151
+ previousFocus.focus();
152
+ }
153
+ if (menu.confirmLogout && !loggingOut) {
154
+ // init() reports its own failures as the ERROR state
155
+ this.pending = this.auth.init().then(() => {}, (e) => { console.log('Error while re-initializing', e); });
156
+ return this.pending;
157
+ }
158
+ return Promise.resolve();
159
+ }
160
+
161
+ /**
162
+ * The stored sign-in: the active account from the main cookie (the only
163
+ * cookie versions without account switching read) and the remembered
164
+ * accounts from a second one.
165
+ */
95
166
  getAuthorizationData () {
96
- return Cookies.get(this._cookieKey);
167
+ const active = Cookies.get(this._cookieKey);
168
+ const remembered = Cookies.get(this._cookieKey + PROFILES_COOKIE_SUFFIX);
169
+ if (remembered == null || !Array.isArray(remembered.profiles)) return active;
170
+ const data = Object.assign({}, active != null && active.deleted !== true ? active : {}, { profiles: remembered.profiles });
171
+ if (data.authUrl == null && remembered.authUrl != null) data.authUrl = remembered.authUrl;
172
+ return data;
97
173
  }
98
174
 
175
+ /**
176
+ * Store the sign-in. The main cookie holds the active account only (or is
177
+ * removed when none is active), so an older version never reads a list of
178
+ * remembered accounts as a signed-in one.
179
+ */
99
180
  saveAuthorizationData (authData) {
100
- Cookies.set(this._cookieKey, authData);
181
+ if (authData == null) {
182
+ Cookies.del(this._cookieKey);
183
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
184
+ return;
185
+ }
186
+ const { profiles, ...active } = authData;
187
+ if (typeof active.username === 'string' && typeof active.apiEndpoint === 'string') {
188
+ Cookies.set(this._cookieKey, active);
189
+ } else {
190
+ Cookies.del(this._cookieKey);
191
+ }
192
+ if (Array.isArray(profiles)) {
193
+ const remembered = Object.assign({ profiles: profiles.slice() }, active.authUrl != null ? { authUrl: active.authUrl } : {});
194
+ // A browser drops a cookie over ~4 KB: forget the least recently used
195
+ // accounts (the list is most recent first) until it fits.
196
+ while (remembered.profiles.length > 1 &&
197
+ encodeURIComponent(JSON.stringify(remembered)).length > PROFILES_COOKIE_MAX_LENGTH) {
198
+ remembered.profiles.pop();
199
+ }
200
+ Cookies.set(this._cookieKey + PROFILES_COOKIE_SUFFIX, remembered);
201
+ } else {
202
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
203
+ }
101
204
  }
102
205
 
103
206
  async deleteAuthorizationData () {
104
207
  Cookies.del(this._cookieKey);
208
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
105
209
  }
106
210
 
107
211
  /**
@@ -119,6 +223,7 @@ class LoginButton {
119
223
  if (pollUrl !== null) {
120
224
  try {
121
225
  const { body } = await utils.fetchGet(pollUrl);
226
+ if (body?.status === AuthStates.AUTHORIZED && typeof body.username === 'string') body.profile = ProfileStore.fromAccepted(body);
122
227
  authController.state = body;
123
228
  } catch (e) {
124
229
  authController.state = {
@@ -182,6 +287,270 @@ async function startLoginScreen (loginButton, authUrl) {
182
287
  }
183
288
  }
184
289
 
290
+ const MENU_OPTIONS = ['logout', 'account', 'switch', 'info'];
291
+
292
+ /** `kim-doe (via parent-doe)` for an account used through delegation. */
293
+ function profileLabel (loginButton, profile) {
294
+ if (profile?.actingAs == null) return profile?.username;
295
+ return profile.username + ' (' + loginButton.messages.VIA + ' ' + profile.actingAs.delegate + ')';
296
+ }
297
+
298
+ /** The auth page URL without its query and fragment (they may carry a key). */
299
+ function withoutQuery (url) {
300
+ if (typeof url !== 'string') return undefined;
301
+ try {
302
+ const u = new URL(url);
303
+ return u.origin + u.pathname;
304
+ } catch (e) {
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Which menu entries to show: all by default; `settings.menu.<option>: false`
311
+ * or `settings.menu.hide: ['<option>', ...]` hides one.
312
+ */
313
+ function menuOptions (menuSettings) {
314
+ const options = {};
315
+ MENU_OPTIONS.forEach((o) => { options[o] = true; });
316
+ if (menuSettings != null && typeof menuSettings === 'object') {
317
+ MENU_OPTIONS.forEach((o) => { if (menuSettings[o] === false) options[o] = false; });
318
+ if (Array.isArray(menuSettings.hide)) {
319
+ menuSettings.hide.forEach((o) => { if (o in options) options[o] = false; });
320
+ }
321
+ }
322
+ return options;
323
+ }
324
+
325
+ const MENU_CSS = `
326
+ .pryv-menu-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 2147483000; display: flex;
327
+ align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.35); }
328
+ .pryv-menu { background: #fff; color: #222; min-width: 280px; max-width: 90vw; border-radius: 8px;
329
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); font: 14px/1.4 system-ui, sans-serif; }
330
+ .pryv-menu-header { display: flex; align-items: flex-start; justify-content: space-between;
331
+ gap: 16px; padding: 16px 16px 8px; }
332
+ .pryv-menu-username { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; }
333
+ .pryv-menu-info { color: #666; font-size: 13px; padding: 0 16px 12px; overflow-wrap: anywhere; }
334
+ .pryv-menu-close { border: 0; background: none; font-size: 20px; line-height: 1; cursor: pointer; color: #666; }
335
+ .pryv-menu-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
336
+ padding: 12px 16px 16px; border-top: 1px solid #eee; }
337
+ .pryv-menu-actions button { padding: 6px 12px; border-radius: 4px; border: 1px solid #ccc;
338
+ background: #f7f7f7; cursor: pointer; font: inherit; }
339
+ .pryv-menu-actions button:focus-visible, .pryv-menu-close:focus-visible,
340
+ .pryv-menu-switch button:focus-visible { outline: 2px solid #4a90d9; }
341
+ .pryv-menu-acting { font-weight: 400; color: #666; font-size: 13px; }
342
+ .pryv-menu-switch { padding: 8px 16px 12px; border-top: 1px solid #eee; }
343
+ .pryv-menu-switch-title { color: #666; font-size: 13px; margin-bottom: 4px; }
344
+ .pryv-menu-switch button { display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0;
345
+ border-radius: 4px; background: none; cursor: pointer; font: inherit; color: inherit; }
346
+ .pryv-menu-switch button:hover { background: #f2f2f2; }
347
+ .pryv-menu-switch button[aria-current="true"] { font-weight: 600; cursor: default; }
348
+ .pryv-menu-switch .pryv-menu-note { color: #888; font-size: 12px; }
349
+ `;
350
+
351
+ /** The built-in menu style, added once. A service's button CSS can override
352
+ * the `.pryv-menu*` classes. */
353
+ function ensureMenuStyle () {
354
+ if (document.getElementById('pryv-menu-style') != null) return;
355
+ const style = document.createElement('style');
356
+ style.id = 'pryv-menu-style';
357
+ style.textContent = MENU_CSS;
358
+ // First in <head>: a service stylesheet loaded before or after the menu
359
+ // opens then wins at equal specificity.
360
+ document.head.insertBefore(style, document.head.firstChild);
361
+ }
362
+
363
+ function menuElement (tag, className, text) {
364
+ const el = document.createElement(tag);
365
+ if (className) el.className = className;
366
+ if (text != null) el.textContent = text;
367
+ return el;
368
+ }
369
+
370
+ /**
371
+ * Build the account menu: a modal dialog, closed by its close button,
372
+ * Escape, or a click outside it, with focus kept inside while open.
373
+ * `confirmLogout`: the same dialog reduced to the "Log out?" question, for
374
+ * `settings.menu: false` (SIGNOUT was already emitted by the click).
375
+ */
376
+ function buildMenu (loginBtn, confirmLogout) {
377
+ ensureMenuStyle();
378
+ const auth = loginBtn.auth;
379
+ const messages = Object.assign({}, loginBtn.messages, auth.messages);
380
+ const options = confirmLogout
381
+ ? { logout: true, account: false, info: false }
382
+ : menuOptions(loginBtn.authSettings.menu);
383
+ const username = confirmLogout
384
+ ? (messages.SIGNOUT_CONFIRM || 'Logout?')
385
+ : (auth.state?.username || '');
386
+
387
+ const overlay = menuElement('div', 'pryv-menu-overlay');
388
+ const dialog = menuElement('div', 'pryv-menu');
389
+ // A question that needs an answer is an alertdialog; the menu is a dialog.
390
+ dialog.setAttribute('role', confirmLogout ? 'alertdialog' : 'dialog');
391
+ dialog.setAttribute('aria-modal', 'true');
392
+ dialog.setAttribute('aria-labelledby', 'pryv-menu-username');
393
+ overlay.appendChild(dialog);
394
+
395
+ const header = menuElement('div', 'pryv-menu-header');
396
+ const title = menuElement('div', 'pryv-menu-username', username || messages.MENU_TITLE);
397
+ title.id = 'pryv-menu-username';
398
+ const current = confirmLogout ? null : auth.currentProfile();
399
+ const actingAs = current?.actingAs;
400
+ if (actingAs != null) {
401
+ title.appendChild(menuElement('span', 'pryv-menu-acting',
402
+ ' (' + messages.ACTING_AS + ', ' + messages.VIA + ' ' + actingAs.delegate + ')'));
403
+ }
404
+ header.appendChild(title);
405
+ const close = menuElement('button', 'pryv-menu-close', '×');
406
+ close.type = 'button';
407
+ close.setAttribute('aria-label', messages.CLOSE);
408
+ close.addEventListener('click', () => loginBtn.closeMenu());
409
+ header.appendChild(close);
410
+ dialog.appendChild(header);
411
+
412
+ if (options.info) {
413
+ const serviceName = loginBtn.serviceInfo?.name || '';
414
+ const appId = loginBtn.authSettings.authRequest.requestingAppId;
415
+ dialog.appendChild(menuElement('div', 'pryv-menu-info', serviceName + ' · ' + messages.APP + ': ' + appId));
416
+ }
417
+
418
+ const profiles = confirmLogout ? [] : auth.profiles();
419
+ if (options.switch) {
420
+ const section = switchSection(loginBtn, messages, current, profiles);
421
+ if (section != null) dialog.appendChild(section);
422
+ }
423
+
424
+ const actions = menuElement('div', 'pryv-menu-actions');
425
+ if (options.account && auth.accountUrl() != null) {
426
+ const manageText = actingAs != null
427
+ ? (messages.MANAGE_ACCOUNT_OF || '').replace('{username}', current.username)
428
+ : messages.MANAGE_ACCOUNT;
429
+ const manage = menuElement('button', 'pryv-menu-account', manageText + ' ');
430
+ const arrow = menuElement('span', null, '↗');
431
+ arrow.setAttribute('aria-hidden', 'true');
432
+ manage.appendChild(arrow);
433
+ manage.type = 'button';
434
+ manage.addEventListener('click', () => {
435
+ auth.openAccountApp();
436
+ loginBtn.closeMenu();
437
+ });
438
+ actions.appendChild(manage);
439
+ }
440
+ if (options.logout) {
441
+ const logout = menuElement('button', 'pryv-menu-logout', messages.LOGOUT);
442
+ logout.type = 'button';
443
+ logout.addEventListener('click', () => {
444
+ loginBtn.closeMenu(true);
445
+ // Kept on the button (`pending`) so the re-init is awaited by whoever
446
+ // needs it, never left running unnoticed: a stray re-init would later
447
+ // consume whatever poll URL the page shows.
448
+ loginBtn.pending = confirmLogout
449
+ // SIGNOUT was emitted by the click already (legacy contract).
450
+ ? loginBtn.deleteAuthorizationData().then(() => auth.init()).then(() => {})
451
+ : auth.signOut();
452
+ });
453
+ if (confirmLogout) {
454
+ const cancel = menuElement('button', 'pryv-menu-cancel', messages.CANCEL);
455
+ cancel.type = 'button';
456
+ cancel.addEventListener('click', () => loginBtn.closeMenu());
457
+ actions.appendChild(cancel);
458
+ }
459
+ actions.appendChild(logout);
460
+ // "Log out" leaves the other remembered accounts; this one forgets them all
461
+ if (profiles.length > 1) {
462
+ const logoutAll = menuElement('button', 'pryv-menu-logout-all', messages.LOGOUT_ALL);
463
+ logoutAll.type = 'button';
464
+ logoutAll.addEventListener('click', () => {
465
+ loginBtn.closeMenu(true);
466
+ loginBtn.pending = auth.signOut({ all: true });
467
+ });
468
+ actions.appendChild(logoutAll);
469
+ }
470
+ }
471
+ dialog.appendChild(actions);
472
+
473
+ // Close on a click outside the dialog, not on a text selection that
474
+ // started inside it and ended outside.
475
+ let pressedOutside = false;
476
+ overlay.addEventListener('mousedown', (event) => { pressedOutside = event.target === overlay; });
477
+ overlay.addEventListener('click', (event) => {
478
+ if (event.target === overlay && pressedOutside) loginBtn.closeMenu();
479
+ pressedOutside = false;
480
+ });
481
+
482
+ const focusables = () => Array.from(dialog.querySelectorAll('button'));
483
+ const onKeyDown = (event) => {
484
+ if (event.key === 'Escape') {
485
+ event.preventDefault();
486
+ loginBtn.closeMenu();
487
+ } else if (event.key === 'Tab') {
488
+ const list = focusables();
489
+ if (list.length === 0) return;
490
+ const first = list[0];
491
+ const last = list[list.length - 1];
492
+ const active = document.activeElement;
493
+ if (event.shiftKey && (active === first || !dialog.contains(active))) {
494
+ event.preventDefault();
495
+ last.focus();
496
+ } else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
497
+ event.preventDefault();
498
+ first.focus();
499
+ }
500
+ }
501
+ };
502
+ document.addEventListener('keydown', onKeyDown, true);
503
+
504
+ return { overlay, dialog, onKeyDown, focusables, previousFocus: document.activeElement, confirmLogout };
505
+ }
506
+
507
+ /**
508
+ * The account switcher: "Switch back to <delegate>" while acting for an
509
+ * account; otherwise the remembered accounts and "Another account..." (when
510
+ * the platform has account delegation). Null when there is nothing to offer.
511
+ */
512
+ function switchSection (loginBtn, messages, current, profiles) {
513
+ const auth = loginBtn.auth;
514
+ const run = (action) => {
515
+ loginBtn.closeMenu();
516
+ loginBtn.pending = action().catch((e) => { console.log('Error while switching account', e); });
517
+ };
518
+ const section = menuElement('div', 'pryv-menu-switch');
519
+ if (current?.actingAs != null) {
520
+ const back = menuElement('button', 'pryv-menu-switch-back',
521
+ (messages.SWITCH_BACK || '').replace('{username}', current.actingAs.delegate));
522
+ back.type = 'button';
523
+ back.addEventListener('click', () => run(() => auth.switchTo(null)));
524
+ section.appendChild(back);
525
+ return section;
526
+ }
527
+ const delegation = loginBtn.serviceInfo?.features?.delegation === true;
528
+ if (profiles.length < 2 && !delegation) return null;
529
+ section.appendChild(menuElement('div', 'pryv-menu-switch-title', messages.USE_FOR));
530
+ profiles.forEach((p) => {
531
+ const item = menuElement('button', 'pryv-menu-profile', p.username + ' ');
532
+ item.type = 'button';
533
+ const note = p.actingAs != null
534
+ ? messages.VIA + ' ' + p.actingAs.delegate
535
+ : '(' + messages.ME + ')';
536
+ item.appendChild(menuElement('span', 'pryv-menu-note',
537
+ note + (p.available ? '' : ' · ' + messages.UNAVAILABLE)));
538
+ if (p.active) {
539
+ item.setAttribute('aria-current', 'true');
540
+ } else {
541
+ item.addEventListener('click', () => run(() => auth.switchTo(p.username)));
542
+ }
543
+ section.appendChild(item);
544
+ });
545
+ if (delegation) {
546
+ const other = menuElement('button', 'pryv-menu-add-account', messages.OTHER_ACCOUNT);
547
+ other.type = 'button';
548
+ other.addEventListener('click', () => run(() => auth.addAccount()));
549
+ section.appendChild(other);
550
+ }
551
+ return section;
552
+ }
553
+
185
554
  function setupButton (loginBtn) {
186
555
  loginBtn.loginButtonSpan = document.getElementById(loginBtn.authSettings.spanButtonID);
187
556
 
package/src/Service.js CHANGED
@@ -5,6 +5,7 @@
5
5
  const utils = require('./utils.js');
6
6
  const PryvError = require('./lib/PryvError.js');
7
7
  const MfaRequiredError = require('./lib/MfaRequiredError.js');
8
+ const handoff = require('./lib/handoff.js');
8
9
  // Connection is required at the end of this file to allow circular requires.
9
10
  const Assets = require('./ServiceAssets.js');
10
11
 
@@ -537,7 +538,9 @@ class Service {
537
538
  *
538
539
  * Returns the raw body. Inspect `body.status` to drive the flow:
539
540
  * - `'NEED_SIGNIN'` → user has not interacted yet; keep polling.
540
- * - `'ACCEPTED'` → `body.apiEndpoint` + `body.username` + `body.token` are set.
541
+ * - `'ACCEPTED'` → `body.apiEndpoint` + `body.username`, plus either
542
+ * `body.token` (inline) or a one-time `body.handoff` key (shared-secret
543
+ * delivery). Prefer `connectFromKey`, which handles both shapes.
541
544
  * - `'REFUSED'` → user declined.
542
545
  *
543
546
  * @param {string} keyOrPollUrl
@@ -573,23 +576,45 @@ class Service {
573
576
  * apiEndpoint), and uses this method to build a working `Connection`.
574
577
  *
575
578
  * The implementation polls `<access>/<key>` once; the call MUST be
576
- * made while the access is still in the ACCEPTED state (which
577
- * persists until expiry — see `expireAfter` on the access request).
579
+ * made while the access request is still readable in the ACCEPTED
580
+ * state. Servers keep a decided request only for a short retention
581
+ * window after it is first polled (default 2 minutes, operator setting
582
+ * `access.terminalRetentionMs`), so call this promptly after the flow
583
+ * completes; afterwards the key is unknown. (`expireAfter` on the
584
+ * access request is the lifetime of the access created, not of the key.)
585
+ *
586
+ * When the request asked for shared-secret delivery the ACCEPTED body
587
+ * carries a one-time `handoff` key instead of the token; this redeems it
588
+ * exactly once and caches the result keyed by `key`, so it is safe to call
589
+ * more than once for the same key (the second call reuses the cache rather
590
+ * than hitting the already-consumed one-time secret). A retrieve that finds
591
+ * the secret gone throws a `PryvError` (id `credential-handoff-failed`);
592
+ * restart the auth request.
578
593
  *
579
594
  * @param {string} key - polling key from `startAccessRequest`
580
595
  * @returns {Promise<Connection>}
581
596
  * @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR)
597
+ * or the hand-off secret could not be retrieved
582
598
  */
583
599
  async connectFromKey (key) {
584
600
  if (!key) {
585
601
  throw new PryvError('connectFromKey requires a key');
586
602
  }
603
+ // A prior resolve (this call, or the AuthController polling loop) may have
604
+ // already redeemed the one-time secret; reuse it rather than re-polling.
605
+ const cached = handoff.cacheGet(key);
606
+ if (cached != null) return new Connection(cached.apiEndpoint, this);
607
+
587
608
  const body = await this.pollAccessRequest(key);
588
609
  if (body.status !== 'ACCEPTED') {
589
610
  throw new PryvError(
590
611
  'connectFromKey: access is not ACCEPTED (status=' + body.status + ')'
591
612
  );
592
613
  }
614
+ if (handoff.isHandoffBody(body)) {
615
+ const entry = await handoff.resolveHandoff(body, key);
616
+ return new Connection(entry.apiEndpoint, this);
617
+ }
593
618
  if (!body.apiEndpoint) {
594
619
  throw new PryvError(
595
620
  'connectFromKey: ACCEPTED response missing apiEndpoint'
@@ -132,6 +132,9 @@ class ServiceAssets {
132
132
  module.exports = ServiceAssets;
133
133
 
134
134
  function loadCSS (url) {
135
+ // Once per page: the controller re-initializes (and reloads assets) on
136
+ // logout and on a cancelled logout.
137
+ if (document.getElementById(url) != null) return;
135
138
  const head = document.getElementsByTagName('head')[0];
136
139
  const link = document.createElement('link');
137
140
  link.id = url;
@@ -157,7 +157,10 @@ async function retrieve (apiEndpoint, key, options = {}) {
157
157
  const err = /** @type {Error & { id?: string, returnUrl?: string }} */ (
158
158
  new Error(parsed?.error?.message || 'Shared secret unavailable.')
159
159
  );
160
- err.id = parsed?.error?.id;
160
+ // Prefer the fine machine id the method carries in `data.id`
161
+ // (e.g. `shared-secret-unavailable`) over the coarse HTTP id (`forbidden`),
162
+ // so a caller can tell a consumed/expired secret from a generic refusal.
163
+ err.id = parsed?.error?.data?.id || parsed?.error?.id;
161
164
  err.returnUrl = parsed?.error?.data?.returnUrl;
162
165
  throw err;
163
166
  }