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.
- package/README.md +42 -8
- package/package.json +1 -1
- package/src/Auth/AuthController.js +366 -13
- package/src/Auth/AuthStates.js +4 -2
- package/src/Auth/LoginMessages.js +70 -1
- package/src/Auth/ProfileStore.js +137 -0
- package/src/Auth/index.js +5 -2
- package/src/Browser/LoginButton.js +509 -33
- package/src/Connection.js +19 -13
- package/src/Service.js +49 -16
- package/src/ServiceAssets.js +3 -0
- package/src/SharedSecrets.js +4 -1
- package/src/index.d.ts +172 -11
- package/src/lib/handoff.js +139 -0
- package/src/lib/pollUrls.js +71 -0
- package/src/utils.js +7 -1
- package/test/AuthController.test.js +321 -5
- package/test/Connection.apiOneError.test.js +105 -0
- package/test/LoginButton.test.js +573 -10
- package/test/LoginButton.urlCleanup.test.js +453 -14
- package/test/LoginMessages.test.js +8 -0
- package/test/ProfileStore.test.js +88 -0
- package/test/Service.accessRequest.test.js +4 -4
- package/test/Service.accessRequestHandoff.test.js +153 -0
- package/test/Service.errorBodies.test.js +82 -0
- package/test/Service.mfa.test.js +4 -4
- package/test/Service.pollUrlByKey.test.js +114 -0
- package/test/browser-index.html +1 -0
|
@@ -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
|
|
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
|
-
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
if (
|
|
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
|
-
|
|
125
|
-
authController.state = body;
|
|
263
|
+
({ response, body } = await utils.fetchGet(pollUrl));
|
|
126
264
|
} catch (e) {
|
|
127
|
-
|
|
265
|
+
body = {
|
|
128
266
|
status: AuthStates.ERROR,
|
|
129
267
|
message: 'Cannot fetch result',
|
|
130
268
|
error: e
|
|
131
269
|
};
|
|
132
270
|
}
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return
|
|
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
|
|
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}"
|
|
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
|
|
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
|
|
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
|