pryv 3.12.1 → 3.14.2

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.
@@ -5,10 +5,18 @@
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');
10
+ const handoff = require('../lib/handoff');
11
+ const pollUrls = require('../lib/pollUrls');
9
12
  const utils = require('../utils');
10
13
 
11
- /* global location, confirm */
14
+ /* global location */
15
+
16
+ /** Suffix of the cookie that holds the remembered accounts. */
17
+ const PROFILES_COOKIE_SUFFIX = '-profiles';
18
+ /** Encoded length kept under the ~4096-byte cookie limit (name and attributes included). */
19
+ const PROFILES_COOKIE_MAX_LENGTH = 3500;
12
20
 
13
21
  /**
14
22
  * @memberof pryv.Browser
@@ -57,6 +65,10 @@ class LoginButton {
57
65
  case AuthStates.NEED_SIGNIN: {
58
66
  const loginUrl = state.authUrl || state.url; // url is deprecated
59
67
  if (this.authSettings.authRequest.returnURL) { // open on same page (no Popup)
68
+ // Remember the request this page started: on the way back, only
69
+ // its key is accepted and only its (server-issued) poll URL is
70
+ // fetched (see finishAuthProcessAfterRedirection).
71
+ writeAuthFlow(this._cookieKey, { key: state.key, poll: state.poll, authUrl: loginUrl });
60
72
  location.href = loginUrl;
61
73
  return;
62
74
  } else {
@@ -64,22 +76,32 @@ class LoginButton {
64
76
  }
65
77
  break;
66
78
  }
67
- case AuthStates.AUTHORIZED:
68
- this.text = state.username;
69
- this.saveAuthorizationData({
70
- apiEndpoint: state.apiEndpoint,
71
- username: state.username
72
- });
79
+ case AuthStates.AUTHORIZED: {
80
+ const profile = state.profile || ProfileStore.fromAccepted(state);
81
+ this.text = profileLabel(this, profile);
82
+ // Never remember a sign-in that cannot call the API (it would be
83
+ // restored on every page load).
84
+ if (!ProfileStore.carriesToken(profile?.apiEndpoint)) break;
85
+ const store = ProfileStore.read(this.getAuthorizationData());
86
+ // Kept to locate the account app after a reload (see AuthController.accountUrl).
87
+ const authUrl = withoutQuery(state.authUrl || this.auth?._authUrl) || store.authUrl;
88
+ this.saveAuthorizationData(ProfileStore.write(Object.assign(
89
+ ProfileStore.activate(store, profile, this.authSettings.maxProfiles),
90
+ { authUrl }
91
+ )));
92
+ break;
93
+ }
94
+ case AuthStates.SWITCHING:
95
+ this.text = this.messages.SWITCHING || '...';
73
96
  break;
74
97
  case AuthStates.SIGNOUT: {
75
- const message = this.messages.SIGNOUT_CONFIRM ? this.messages.SIGNOUT_CONFIRM : 'Logout ?';
76
- if (confirm(message)) {
77
- // Awaited: a re-init left running in the background resumes later
78
- // (after loading assets) and consumes whatever poll URL the page shows
79
- // by then, racing any other sign-in in progress.
80
- await this.deleteAuthorizationData();
81
- await this.auth.init();
82
- }
98
+ // A confirmed logout (the menu's "Log out", or `auth.signOut()`)
99
+ // clears the credentials itself.
100
+ if (this.auth?._signingOut) break;
101
+ // Menu disabled (`settings.menu: false`): confirm in a small built-in
102
+ // dialog, then clear the credentials (see buildMenu: the re-init is
103
+ // awaited there, never left running in the background).
104
+ this.openMenu({ confirmLogout: true });
83
105
  break;
84
106
  }
85
107
  case AuthStates.ERROR:
@@ -95,16 +117,104 @@ class LoginButton {
95
117
  }
96
118
  }
97
119
 
120
+ /**
121
+ * Open the account menu (signed-in account, "Manage my account",
122
+ * "Log out"). Returns false, and opens nothing, when the menu is disabled
123
+ * with `settings.menu: false`: the caller then falls back to the logout
124
+ * confirmation.
125
+ * @returns {boolean}
126
+ */
127
+ showMenu () {
128
+ if (this.authSettings.menu === false) return false;
129
+ return this.openMenu({ confirmLogout: false });
130
+ }
131
+
132
+ /**
133
+ * @private Open the account menu, or (`confirmLogout`) the plain "Log out?"
134
+ * confirmation used when the menu is disabled.
135
+ */
136
+ openMenu ({ confirmLogout }) {
137
+ if (typeof document === 'undefined') return false;
138
+ this.closeMenu();
139
+ this.menu = buildMenu(this, confirmLogout);
140
+ document.body.appendChild(this.menu.overlay);
141
+ this.menu.focusables()[0]?.focus();
142
+ return true;
143
+ }
144
+
145
+ /**
146
+ * Close the account menu if it is open. Dismissing the "Log out?" question
147
+ * (anything but "Log out") returns to the signed-in state: SIGNOUT was
148
+ * already emitted by the click, and the button would otherwise stay inert.
149
+ * @param {boolean} [loggingOut] - closed by the "Log out" action
150
+ * @returns {Promise<void>} resolves when any re-init it started is done
151
+ */
152
+ closeMenu (loggingOut) {
153
+ if (this.menu == null) return Promise.resolve();
154
+ const menu = this.menu;
155
+ this.menu = null;
156
+ document.removeEventListener('keydown', menu.onKeyDown, true);
157
+ if (menu.overlay.parentNode != null) menu.overlay.parentNode.removeChild(menu.overlay);
158
+ const previousFocus = /** @type {HTMLElement|null} */ (menu.previousFocus);
159
+ if (previousFocus != null && typeof previousFocus.focus === 'function') {
160
+ previousFocus.focus();
161
+ }
162
+ if (menu.confirmLogout && !loggingOut) {
163
+ // init() reports its own failures as the ERROR state
164
+ this.pending = this.auth.init().then(() => {}, (e) => { console.log('Error while re-initializing', e); });
165
+ return this.pending;
166
+ }
167
+ return Promise.resolve();
168
+ }
169
+
170
+ /**
171
+ * The stored sign-in: the active account from the main cookie (the only
172
+ * cookie versions without account switching read) and the remembered
173
+ * accounts from a second one.
174
+ */
98
175
  getAuthorizationData () {
99
- return Cookies.get(this._cookieKey);
176
+ const active = Cookies.get(this._cookieKey);
177
+ const remembered = Cookies.get(this._cookieKey + PROFILES_COOKIE_SUFFIX);
178
+ if (remembered == null || !Array.isArray(remembered.profiles)) return active;
179
+ const data = Object.assign({}, active != null && active.deleted !== true ? active : {}, { profiles: remembered.profiles });
180
+ if (data.authUrl == null && remembered.authUrl != null) data.authUrl = remembered.authUrl;
181
+ return data;
100
182
  }
101
183
 
184
+ /**
185
+ * Store the sign-in. The main cookie holds the active account only (or is
186
+ * removed when none is active), so an older version never reads a list of
187
+ * remembered accounts as a signed-in one.
188
+ */
102
189
  saveAuthorizationData (authData) {
103
- Cookies.set(this._cookieKey, authData);
190
+ if (authData == null) {
191
+ Cookies.del(this._cookieKey);
192
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
193
+ return;
194
+ }
195
+ const { profiles, ...active } = authData;
196
+ if (typeof active.username === 'string' && typeof active.apiEndpoint === 'string') {
197
+ Cookies.set(this._cookieKey, active);
198
+ } else {
199
+ Cookies.del(this._cookieKey);
200
+ }
201
+ if (Array.isArray(profiles)) {
202
+ const remembered = Object.assign({ profiles: profiles.slice() }, active.authUrl != null ? { authUrl: active.authUrl } : {});
203
+ // A browser drops a cookie over ~4 KB: forget the least recently used
204
+ // accounts (the list is most recent first) until it fits.
205
+ while (remembered.profiles.length > 1 &&
206
+ encodeURIComponent(JSON.stringify(remembered)).length > PROFILES_COOKIE_MAX_LENGTH) {
207
+ remembered.profiles.pop();
208
+ }
209
+ Cookies.set(this._cookieKey + PROFILES_COOKIE_SUFFIX, remembered);
210
+ } else {
211
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
212
+ }
104
213
  }
105
214
 
106
215
  async deleteAuthorizationData () {
107
216
  Cookies.del(this._cookieKey);
217
+ Cookies.del(this._cookieKey + PROFILES_COOKIE_SUFFIX);
108
218
  }
109
219
 
110
220
  /**
@@ -118,45 +228,147 @@ class LoginButton {
118
228
  // 3. Check if there is a pryvKey / pryvPoll (or legacy prYvkey /
119
229
  // prYvpoll) as result of "out of page login"
120
230
  const url = window.location.href;
121
- const pollUrl = retrievePollUrl(url);
122
- if (pollUrl !== null) {
231
+ const key = retrieveKey(url);
232
+ if (key !== null) {
233
+ // Already signed in (from the stored sign-in): a return that does not
234
+ // end in a new sign-in (an account switch refused or failed, a stray
235
+ // link) leaves that account in place, as the popup path does for a
236
+ // switch. The state is then left as it is (no second AUTHORIZED).
237
+ const signedIn = authController.state?.status === AuthStates.AUTHORIZED;
238
+ // Only finish the auth request this page started (kept across the
239
+ // redirect by onStateChange), and poll the URL the server gave for
240
+ // it: a link carrying another key or poll URL must not sign the page
241
+ // in (to a foreign host, or to someone else's account).
242
+ const flow = readAuthFlow(this._cookieKey);
243
+ if (flow == null || flow.key !== key || typeof flow.poll !== 'string') {
244
+ console.warn('pryv: ignoring a sign-in return for a request this page did not start');
245
+ if (!signedIn) {
246
+ authController.state = {
247
+ status: AuthStates.ERROR,
248
+ message: 'Sign-in return does not match a sign-in started on this page',
249
+ error: { id: 'unexpected-auth-return' }
250
+ };
251
+ }
252
+ cleanUrl();
253
+ return;
254
+ }
255
+ clearAuthFlow(this._cookieKey);
256
+ const pollUrl = flow.poll;
257
+ // an app's connectFromKey(key) on this page then polls the same core
258
+ pollUrls.remember(key, pollUrl);
259
+ // the flow of this sign-in: a sign-out clears its cached credential
260
+ authController._authFlowKey = key;
261
+ let response, body;
123
262
  try {
124
- const { body } = await utils.fetchGet(pollUrl);
125
- authController.state = body;
263
+ ({ response, body } = await utils.fetchGet(pollUrl));
126
264
  } catch (e) {
127
- authController.state = {
265
+ body = {
128
266
  status: AuthStates.ERROR,
129
267
  message: 'Cannot fetch result',
130
268
  error: e
131
269
  };
132
270
  }
133
- // These params are one-shot; leaving them in the visible URL puts
134
- // stale auth state into bookmarks / copied links.
271
+ if (response?.status === 403 && body?.status === 'REFUSED') {
272
+ // refused on the auth page: back to the sign-in button (as the popup path)
273
+ body = { status: AuthStates.INITIALIZED, serviceInfo: authController.serviceInfo };
274
+ } else if (body?.status == null) {
275
+ // unknown or expired key, or no answer the button can show
276
+ body = { status: AuthStates.ERROR, message: 'Cannot fetch result', error: body?.error ?? body };
277
+ }
278
+ // Shared-secret delivery: the ACCEPTED body carries a one-time
279
+ // `handoff` key, not the token. Redeem it exactly as the polling path
280
+ // does, under the flow key, and never report a token-less AUTHORIZED.
281
+ // Unlike the polling path, the state keeps its legacy shape (no `key`,
282
+ // credentials included), which existing redirect apps read.
283
+ if (handoff.isHandoffBody(body)) {
284
+ try {
285
+ const entry = await handoff.resolveHandoff(body, key);
286
+ body.apiEndpoint = entry.apiEndpoint;
287
+ body.token = entry.token;
288
+ body.username = entry.username;
289
+ delete body.handoff;
290
+ } catch (e) {
291
+ body = { status: AuthStates.ERROR, message: 'Credential hand-off failed', error: e };
292
+ }
293
+ }
294
+ if (body?.status === AuthStates.AUTHORIZED && typeof body.username === 'string') {
295
+ body.profile = ProfileStore.fromAccepted(body);
296
+ // the auth page of this sign-in locates the account app
297
+ if (typeof flow.authUrl === 'string') authController._authUrl = flow.authUrl;
298
+ } else if (signedIn) {
299
+ // refused or failed: stay on the account already signed in
300
+ console.warn('pryv: sign-in by redirection did not complete (' +
301
+ (body?.error?.id ?? body?.message ?? body?.status) + '); keeping the signed-in account');
302
+ cleanUrl();
303
+ return;
304
+ }
305
+ authController.state = body;
306
+ cleanUrl();
307
+ } else if (utils.cleanURLFromPrYvParams(url) !== url) {
308
+ // leftover one-shot params without a key (e.g. only `prYvstatus`)
309
+ cleanUrl();
310
+ }
311
+
312
+ // These params are one-shot; leaving them in the visible URL puts
313
+ // stale auth state into bookmarks / copied links.
314
+ function cleanUrl () {
135
315
  if (window.history && typeof window.history.replaceState === 'function') {
136
316
  window.history.replaceState(null, '', utils.cleanURLFromPrYvParams(url));
137
317
  }
138
318
  }
139
319
 
140
- function retrievePollUrl (url) {
320
+ /** The key of the returning auth request, or null when the URL is not a return. */
321
+ function retrieveKey (url) {
141
322
  // Modern lowercase form (pryvKey / pryvPoll) is preferred; the
142
323
  // capital-Y form (prYvkey / prYvpoll) is accepted for back-compat
143
324
  // with apps emitting the legacy URL contract — see
144
325
  // [DEPRECATED] notes on cleanURLFromPrYvParams.
145
326
  const params = utils.getQueryParamsFromURL(url);
146
- let pollUrl = null;
147
327
  const key = params.pryvKey || params.prYvkey;
148
- if (key) {
149
- pollUrl = authController.serviceInfo.access + key;
150
- }
328
+ if (key) return key;
151
329
  const poll = params.pryvPoll || params.prYvpoll;
152
- if (poll) {
153
- pollUrl = poll;
154
- }
155
- return pollUrl;
330
+ // the poll URL ends with the key (`<access>/<key>`); only the key is
331
+ // used, the poll URL fetched is the one stored when the flow started
332
+ if (poll) return poll.split(/[?#]/)[0].split('/').filter(Boolean).pop() || null;
333
+ return null;
156
334
  }
157
335
  }
158
336
  }
159
337
 
338
+ // ---- the auth request started by this page, kept across the redirect ----
339
+ // sessionStorage: per tab and origin, gone when the tab closes, readable by
340
+ // no other origin. Any failure (storage blocked) reads as "no flow".
341
+
342
+ function authFlowStorageKey (cookieKey) {
343
+ return cookieKey + '-authflow';
344
+ }
345
+
346
+ function writeAuthFlow (cookieKey, flow) {
347
+ try {
348
+ window.sessionStorage.setItem(authFlowStorageKey(cookieKey), JSON.stringify(flow));
349
+ } catch (e) {
350
+ // the return will then be refused, as for any unknown request
351
+ }
352
+ }
353
+
354
+ function readAuthFlow (cookieKey) {
355
+ try {
356
+ const raw = window.sessionStorage.getItem(authFlowStorageKey(cookieKey));
357
+ const flow = raw == null ? null : JSON.parse(raw);
358
+ return flow != null && typeof flow === 'object' ? flow : null;
359
+ } catch (e) {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ function clearAuthFlow (cookieKey) {
365
+ try {
366
+ window.sessionStorage.removeItem(authFlowStorageKey(cookieKey));
367
+ } catch (e) {
368
+ // nothing stored
369
+ }
370
+ }
371
+
160
372
  module.exports = LoginButton;
161
373
 
162
374
  async function startLoginScreen (loginButton, authUrl) {
@@ -185,6 +397,270 @@ async function startLoginScreen (loginButton, authUrl) {
185
397
  }
186
398
  }
187
399
 
400
+ const MENU_OPTIONS = ['logout', 'account', 'switch', 'info'];
401
+
402
+ /** `kim-doe (via parent-doe)` for an account used through delegation. */
403
+ function profileLabel (loginButton, profile) {
404
+ if (profile?.actingAs == null) return profile?.username;
405
+ return profile.username + ' (' + loginButton.messages.VIA + ' ' + profile.actingAs.delegate + ')';
406
+ }
407
+
408
+ /** The auth page URL without its query and fragment (they may carry a key). */
409
+ function withoutQuery (url) {
410
+ if (typeof url !== 'string') return undefined;
411
+ try {
412
+ const u = new URL(url);
413
+ return u.origin + u.pathname;
414
+ } catch (e) {
415
+ return undefined;
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Which menu entries to show: all by default; `settings.menu.<option>: false`
421
+ * or `settings.menu.hide: ['<option>', ...]` hides one.
422
+ */
423
+ function menuOptions (menuSettings) {
424
+ const options = {};
425
+ MENU_OPTIONS.forEach((o) => { options[o] = true; });
426
+ if (menuSettings != null && typeof menuSettings === 'object') {
427
+ MENU_OPTIONS.forEach((o) => { if (menuSettings[o] === false) options[o] = false; });
428
+ if (Array.isArray(menuSettings.hide)) {
429
+ menuSettings.hide.forEach((o) => { if (o in options) options[o] = false; });
430
+ }
431
+ }
432
+ return options;
433
+ }
434
+
435
+ const MENU_CSS = `
436
+ .pryv-menu-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 2147483000; display: flex;
437
+ align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.35); }
438
+ .pryv-menu { background: #fff; color: #222; min-width: 280px; max-width: 90vw; border-radius: 8px;
439
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); font: 14px/1.4 system-ui, sans-serif; }
440
+ .pryv-menu-header { display: flex; align-items: flex-start; justify-content: space-between;
441
+ gap: 16px; padding: 16px 16px 8px; }
442
+ .pryv-menu-username { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; }
443
+ .pryv-menu-info { color: #666; font-size: 13px; padding: 0 16px 12px; overflow-wrap: anywhere; }
444
+ .pryv-menu-close { border: 0; background: none; font-size: 20px; line-height: 1; cursor: pointer; color: #666; }
445
+ .pryv-menu-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;
446
+ padding: 12px 16px 16px; border-top: 1px solid #eee; }
447
+ .pryv-menu-actions button { padding: 6px 12px; border-radius: 4px; border: 1px solid #ccc;
448
+ background: #f7f7f7; cursor: pointer; font: inherit; }
449
+ .pryv-menu-actions button:focus-visible, .pryv-menu-close:focus-visible,
450
+ .pryv-menu-switch button:focus-visible { outline: 2px solid #4a90d9; }
451
+ .pryv-menu-acting { font-weight: 400; color: #666; font-size: 13px; }
452
+ .pryv-menu-switch { padding: 8px 16px 12px; border-top: 1px solid #eee; }
453
+ .pryv-menu-switch-title { color: #666; font-size: 13px; margin-bottom: 4px; }
454
+ .pryv-menu-switch button { display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0;
455
+ border-radius: 4px; background: none; cursor: pointer; font: inherit; color: inherit; }
456
+ .pryv-menu-switch button:hover { background: #f2f2f2; }
457
+ .pryv-menu-switch button[aria-current="true"] { font-weight: 600; cursor: default; }
458
+ .pryv-menu-switch .pryv-menu-note { color: #888; font-size: 12px; }
459
+ `;
460
+
461
+ /** The built-in menu style, added once. A service's button CSS can override
462
+ * the `.pryv-menu*` classes. */
463
+ function ensureMenuStyle () {
464
+ if (document.getElementById('pryv-menu-style') != null) return;
465
+ const style = document.createElement('style');
466
+ style.id = 'pryv-menu-style';
467
+ style.textContent = MENU_CSS;
468
+ // First in <head>: a service stylesheet loaded before or after the menu
469
+ // opens then wins at equal specificity.
470
+ document.head.insertBefore(style, document.head.firstChild);
471
+ }
472
+
473
+ function menuElement (tag, className, text) {
474
+ const el = document.createElement(tag);
475
+ if (className) el.className = className;
476
+ if (text != null) el.textContent = text;
477
+ return el;
478
+ }
479
+
480
+ /**
481
+ * Build the account menu: a modal dialog, closed by its close button,
482
+ * Escape, or a click outside it, with focus kept inside while open.
483
+ * `confirmLogout`: the same dialog reduced to the "Log out?" question, for
484
+ * `settings.menu: false` (SIGNOUT was already emitted by the click).
485
+ */
486
+ function buildMenu (loginBtn, confirmLogout) {
487
+ ensureMenuStyle();
488
+ const auth = loginBtn.auth;
489
+ const messages = Object.assign({}, loginBtn.messages, auth.messages);
490
+ const options = confirmLogout
491
+ ? { logout: true, account: false, info: false }
492
+ : menuOptions(loginBtn.authSettings.menu);
493
+ const username = confirmLogout
494
+ ? (messages.SIGNOUT_CONFIRM || 'Logout?')
495
+ : (auth.state?.username || '');
496
+
497
+ const overlay = menuElement('div', 'pryv-menu-overlay');
498
+ const dialog = menuElement('div', 'pryv-menu');
499
+ // A question that needs an answer is an alertdialog; the menu is a dialog.
500
+ dialog.setAttribute('role', confirmLogout ? 'alertdialog' : 'dialog');
501
+ dialog.setAttribute('aria-modal', 'true');
502
+ dialog.setAttribute('aria-labelledby', 'pryv-menu-username');
503
+ overlay.appendChild(dialog);
504
+
505
+ const header = menuElement('div', 'pryv-menu-header');
506
+ const title = menuElement('div', 'pryv-menu-username', username || messages.MENU_TITLE);
507
+ title.id = 'pryv-menu-username';
508
+ const current = confirmLogout ? null : auth.currentProfile();
509
+ const actingAs = current?.actingAs;
510
+ if (actingAs != null) {
511
+ title.appendChild(menuElement('span', 'pryv-menu-acting',
512
+ ' (' + messages.ACTING_AS + ', ' + messages.VIA + ' ' + actingAs.delegate + ')'));
513
+ }
514
+ header.appendChild(title);
515
+ const close = menuElement('button', 'pryv-menu-close', '×');
516
+ close.type = 'button';
517
+ close.setAttribute('aria-label', messages.CLOSE);
518
+ close.addEventListener('click', () => loginBtn.closeMenu());
519
+ header.appendChild(close);
520
+ dialog.appendChild(header);
521
+
522
+ if (options.info) {
523
+ const serviceName = loginBtn.serviceInfo?.name || '';
524
+ const appId = loginBtn.authSettings.authRequest.requestingAppId;
525
+ dialog.appendChild(menuElement('div', 'pryv-menu-info', serviceName + ' · ' + messages.APP + ': ' + appId));
526
+ }
527
+
528
+ const profiles = confirmLogout ? [] : auth.profiles();
529
+ if (options.switch) {
530
+ const section = switchSection(loginBtn, messages, current, profiles);
531
+ if (section != null) dialog.appendChild(section);
532
+ }
533
+
534
+ const actions = menuElement('div', 'pryv-menu-actions');
535
+ if (options.account && auth.accountUrl() != null) {
536
+ const manageText = actingAs != null
537
+ ? (messages.MANAGE_ACCOUNT_OF || '').replace('{username}', current.username)
538
+ : messages.MANAGE_ACCOUNT;
539
+ const manage = menuElement('button', 'pryv-menu-account', manageText + ' ');
540
+ const arrow = menuElement('span', null, '↗');
541
+ arrow.setAttribute('aria-hidden', 'true');
542
+ manage.appendChild(arrow);
543
+ manage.type = 'button';
544
+ manage.addEventListener('click', () => {
545
+ auth.openAccountApp();
546
+ loginBtn.closeMenu();
547
+ });
548
+ actions.appendChild(manage);
549
+ }
550
+ if (options.logout) {
551
+ const logout = menuElement('button', 'pryv-menu-logout', messages.LOGOUT);
552
+ logout.type = 'button';
553
+ logout.addEventListener('click', () => {
554
+ loginBtn.closeMenu(true);
555
+ // Kept on the button (`pending`) so the re-init is awaited by whoever
556
+ // needs it, never left running unnoticed: a stray re-init would later
557
+ // consume whatever poll URL the page shows.
558
+ loginBtn.pending = confirmLogout
559
+ // SIGNOUT was emitted by the click already (legacy contract).
560
+ ? loginBtn.deleteAuthorizationData().then(() => auth.init()).then(() => {})
561
+ : auth.signOut();
562
+ });
563
+ if (confirmLogout) {
564
+ const cancel = menuElement('button', 'pryv-menu-cancel', messages.CANCEL);
565
+ cancel.type = 'button';
566
+ cancel.addEventListener('click', () => loginBtn.closeMenu());
567
+ actions.appendChild(cancel);
568
+ }
569
+ actions.appendChild(logout);
570
+ // "Log out" leaves the other remembered accounts; this one forgets them all
571
+ if (profiles.length > 1) {
572
+ const logoutAll = menuElement('button', 'pryv-menu-logout-all', messages.LOGOUT_ALL);
573
+ logoutAll.type = 'button';
574
+ logoutAll.addEventListener('click', () => {
575
+ loginBtn.closeMenu(true);
576
+ loginBtn.pending = auth.signOut({ all: true });
577
+ });
578
+ actions.appendChild(logoutAll);
579
+ }
580
+ }
581
+ dialog.appendChild(actions);
582
+
583
+ // Close on a click outside the dialog, not on a text selection that
584
+ // started inside it and ended outside.
585
+ let pressedOutside = false;
586
+ overlay.addEventListener('mousedown', (event) => { pressedOutside = event.target === overlay; });
587
+ overlay.addEventListener('click', (event) => {
588
+ if (event.target === overlay && pressedOutside) loginBtn.closeMenu();
589
+ pressedOutside = false;
590
+ });
591
+
592
+ const focusables = () => Array.from(dialog.querySelectorAll('button'));
593
+ const onKeyDown = (event) => {
594
+ if (event.key === 'Escape') {
595
+ event.preventDefault();
596
+ loginBtn.closeMenu();
597
+ } else if (event.key === 'Tab') {
598
+ const list = focusables();
599
+ if (list.length === 0) return;
600
+ const first = list[0];
601
+ const last = list[list.length - 1];
602
+ const active = document.activeElement;
603
+ if (event.shiftKey && (active === first || !dialog.contains(active))) {
604
+ event.preventDefault();
605
+ last.focus();
606
+ } else if (!event.shiftKey && (active === last || !dialog.contains(active))) {
607
+ event.preventDefault();
608
+ first.focus();
609
+ }
610
+ }
611
+ };
612
+ document.addEventListener('keydown', onKeyDown, true);
613
+
614
+ return { overlay, dialog, onKeyDown, focusables, previousFocus: document.activeElement, confirmLogout };
615
+ }
616
+
617
+ /**
618
+ * The account switcher: "Switch back to <delegate>" while acting for an
619
+ * account; otherwise the remembered accounts and "Another account..." (when
620
+ * the platform has account delegation). Null when there is nothing to offer.
621
+ */
622
+ function switchSection (loginBtn, messages, current, profiles) {
623
+ const auth = loginBtn.auth;
624
+ const run = (action) => {
625
+ loginBtn.closeMenu();
626
+ loginBtn.pending = action().catch((e) => { console.log('Error while switching account', e); });
627
+ };
628
+ const section = menuElement('div', 'pryv-menu-switch');
629
+ if (current?.actingAs != null) {
630
+ const back = menuElement('button', 'pryv-menu-switch-back',
631
+ (messages.SWITCH_BACK || '').replace('{username}', current.actingAs.delegate));
632
+ back.type = 'button';
633
+ back.addEventListener('click', () => run(() => auth.switchTo(null)));
634
+ section.appendChild(back);
635
+ return section;
636
+ }
637
+ const delegation = loginBtn.serviceInfo?.features?.delegation === true;
638
+ if (profiles.length < 2 && !delegation) return null;
639
+ section.appendChild(menuElement('div', 'pryv-menu-switch-title', messages.USE_FOR));
640
+ profiles.forEach((p) => {
641
+ const item = menuElement('button', 'pryv-menu-profile', p.username + ' ');
642
+ item.type = 'button';
643
+ const note = p.actingAs != null
644
+ ? messages.VIA + ' ' + p.actingAs.delegate
645
+ : '(' + messages.ME + ')';
646
+ item.appendChild(menuElement('span', 'pryv-menu-note',
647
+ note + (p.available ? '' : ' · ' + messages.UNAVAILABLE)));
648
+ if (p.active) {
649
+ item.setAttribute('aria-current', 'true');
650
+ } else {
651
+ item.addEventListener('click', () => run(() => auth.switchTo(p.username)));
652
+ }
653
+ section.appendChild(item);
654
+ });
655
+ if (delegation) {
656
+ const other = menuElement('button', 'pryv-menu-add-account', messages.OTHER_ACCOUNT);
657
+ other.type = 'button';
658
+ other.addEventListener('click', () => run(() => auth.addAccount()));
659
+ section.appendChild(other);
660
+ }
661
+ return section;
662
+ }
663
+
188
664
  function setupButton (loginBtn) {
189
665
  loginBtn.loginButtonSpan = document.getElementById(loginBtn.authSettings.spanButtonID);
190
666
 
package/src/Connection.js CHANGED
@@ -116,7 +116,10 @@ class Connection {
116
116
  * @param {Object|Array} [params={}] - The params associated with this method
117
117
  * @param {string} [expectedKey] - If given, returns the value of this key or throws an error if not present
118
118
  * @returns {Promise<Object>} Promise resolving to the API result or the value of expectedKey
119
- * @throws {Error} If .error is present in the response or expectedKey is missing
119
+ * @throws {Error} If .error is present in the response or expectedKey is missing.
120
+ * The message names the method and the server's error id and message; the full
121
+ * error (or result) is on `innerObject`. The call's params are never in the message:
122
+ * they can carry passwords and tokens, and error messages end up in logs and on screen.
120
123
  */
121
124
  async apiOne (method, params = {}, expectedKey) {
122
125
  const result = await this.api([{ method, params }]);
@@ -125,11 +128,18 @@ class Connection {
125
128
  result[0].error ||
126
129
  (expectedKey != null && result[0][expectedKey] == null)
127
130
  ) {
128
- const innerObject = result[0]?.error || result;
131
+ const error = result[0]?.error;
132
+ const innerObject = error || result;
133
+ let reason;
134
+ if (error) {
135
+ reason = [error.id, error.message].filter((v) => v != null && v !== '').join(': ') || 'error answer';
136
+ } else if (result[0] == null) {
137
+ reason = 'no result';
138
+ } else {
139
+ reason = `"${expectedKey}" missing in result`;
140
+ }
129
141
  throw new PryvError(
130
- `Error for api method: "${method}" with params: ${JSON.stringify(
131
- params
132
- )} >> Result: ${JSON.stringify(innerObject)}"`,
142
+ `Error for api method: "${method}" >> ${reason}`,
133
143
  innerObject
134
144
  );
135
145
  }
@@ -197,17 +207,13 @@ class Connection {
197
207
  });
198
208
  }
199
209
  const resRequest = await callHandler(thisBatch);
200
- // result checks
210
+ // result checks: the answer rides on `innerObject`, never in the message
211
+ // (results can hold tokens, e.g. from accesses.*)
201
212
  if (!resRequest || !Array.isArray(resRequest.results)) {
202
- throw new Error(
203
- 'API call result is not an Array: ' + JSON.stringify(resRequest)
204
- );
213
+ throw new PryvError('API call result is not an Array', resRequest);
205
214
  }
206
215
  if (resRequest.results.length !== thisBatch.length) {
207
- throw new Error(
208
- 'API call result Array does not match request: ' +
209
- JSON.stringify(resRequest)
210
- );
216
+ throw new PryvError('API call result Array does not match request', resRequest);
211
217
  }
212
218
 
213
219
  // eventually call handleResult