@jupyter/collaboration 4.1.0-beta.0 → 4.1.0-rc.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.
package/lib/sharedlink.js CHANGED
@@ -16,17 +16,372 @@ export async function showSharedLinkDialog({ translator }) {
16
16
  const url = new URL(URLExt.normalize(PageConfig.getUrl({
17
17
  workspace: PageConfig.defaultWorkspace
18
18
  })));
19
- return showDialog({
20
- title: trans.__('Share Jupyter Server Link'),
21
- body: new SharedLinkBody(url.toString(), token, PageConfig.getOption('hubUser') !== '', trans),
22
- buttons: [
23
- Dialog.cancelButton(),
24
- Dialog.okButton({
25
- label: trans.__('Copy Link'),
26
- caption: trans.__('Copy the link to the Jupyter Server')
19
+ let canCreateShare = false;
20
+ let canListUsers = false;
21
+ let canListGroups = false;
22
+ let canControlServer = false;
23
+ let hubApiUrl = '';
24
+ let serverName = '';
25
+ let serverOwner = '';
26
+ // If hubUser is set, we are behind a JupyterHub
27
+ if (PageConfig.getOption('hubUser') !== '') {
28
+ // Get server name and owner
29
+ serverOwner = PageConfig.getOption('hubServerUser');
30
+ serverName = PageConfig.getOption('hubServerName');
31
+ // Prepare the Hub API URL
32
+ const protocol = window.location.protocol;
33
+ const hostname = PageConfig.getOption('hubHost') || window.location.hostname;
34
+ const port = window.location.port;
35
+ const prefix = PageConfig.getOption('hubPrefix');
36
+ hubApiUrl = `${protocol}//${hostname}:${port}${prefix}api`;
37
+ // Check Hub version for share compatibility (>= 5.0)
38
+ const response = await fetch(hubApiUrl, {
39
+ // A GET request on base API url returns the Hub version
40
+ headers: {
41
+ Authorization: `token ${token}`
42
+ }
43
+ });
44
+ const data = await response.json();
45
+ const hubVersion = data.version;
46
+ const [major] = hubVersion.split('.').map(Number);
47
+ if (major >= 5) {
48
+ // The Hub version is compatible with the share feature, but we need to check if the user has rights to create a share
49
+ const userResponse = await fetch(`${hubApiUrl}/user`, {
50
+ headers: {
51
+ Authorization: `token ${token}`
52
+ }
53
+ });
54
+ const userData = await userResponse.json();
55
+ // The permissions needed are "read:users:name" (to be able to get a user by his/her name) and "shares!user=owner" (to be able to manage shares)
56
+ if (userData.scopes.includes('read:users:name')) {
57
+ // Check for shares permission (shares!user or shares!user=owner or shares!server=owner/serverName)
58
+ if ((serverOwner === PageConfig.getOption('hubUser') &&
59
+ userData.scopes.includes('shares!user')) ||
60
+ userData.scopes.includes('shares!user=' + serverOwner) ||
61
+ userData.scopes.includes('shares!server=' + serverOwner + '/' + serverName)) {
62
+ // If the user has the required permissions, we can create a share
63
+ canCreateShare = true;
64
+ // Check if the user has the correct scope to list all users (not mandatory, but makes the UI easier to use)
65
+ if (userData.scopes.includes('list:users') ||
66
+ userData.scopes.includes('read:users') ||
67
+ userData.scopes.includes('admin:users')) {
68
+ canListUsers = true;
69
+ }
70
+ // Check if the user has the correct scope to list all groups (not mandatory, but makes the UI easier to use)
71
+ if (userData.scopes.includes('list:groups') ||
72
+ userData.scopes.includes('read:groups') ||
73
+ userData.scopes.includes('admin:groups')) {
74
+ canListGroups = true;
75
+ }
76
+ // Check if the user has the correct scope to control the server (not mandatory, but allows other users to start/stop the server)
77
+ if ((serverOwner === PageConfig.getOption('hubUser') &&
78
+ userData.scopes.includes('servers!user')) ||
79
+ userData.scopes.includes('servers!user=' + serverOwner) ||
80
+ userData.scopes.includes('servers!server=' + serverOwner + '/' + serverName + '/') ||
81
+ userData.scopes.includes('admin:servers')) {
82
+ canControlServer = true;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ // If we can create a share, open the proper UI
89
+ if (canCreateShare) {
90
+ let readableServerName = serverName;
91
+ if (readableServerName === '') {
92
+ readableServerName = 'default';
93
+ }
94
+ return showDialog({
95
+ title: trans.__('Share Jupyter Server %1', readableServerName),
96
+ body: new ManageSharesBody(url.toString(), serverName, canListUsers, canListGroups, canControlServer, serverOwner, hubApiUrl, token, trans),
97
+ buttons: [
98
+ Dialog.cancelButton({
99
+ label: trans.__('Close'),
100
+ caption: trans.__('Close the dialog')
101
+ }),
102
+ Dialog.okButton({
103
+ label: trans.__('Copy Link'),
104
+ caption: trans.__('Copy the link to the Jupyter Server')
105
+ })
106
+ ]
107
+ });
108
+ // If we can't create a real share, we show the legacy dialog that just copies the URL with the server owner's token
109
+ }
110
+ else {
111
+ return showDialog({
112
+ title: trans.__('Share Jupyter Server Link'),
113
+ body: new SharedLinkBody(url.toString(), token, PageConfig.getOption('hubUser') !== '', trans),
114
+ buttons: [
115
+ Dialog.cancelButton(),
116
+ Dialog.okButton({
117
+ label: trans.__('Copy Link'),
118
+ caption: trans.__('Copy the link to the Jupyter Server')
119
+ })
120
+ ]
121
+ });
122
+ }
123
+ }
124
+ class ManageSharesBody extends Widget {
125
+ constructor(_url, _serverName, _canListUsers, _canListGroups, _canControlServer, _serverOwner, _hubApiUrl, _token, _trans) {
126
+ super();
127
+ this._url = _url;
128
+ this._serverName = _serverName;
129
+ this._canListUsers = _canListUsers;
130
+ this._canListGroups = _canListGroups;
131
+ this._canControlServer = _canControlServer;
132
+ this._serverOwner = _serverOwner;
133
+ this._hubApiUrl = _hubApiUrl;
134
+ this._token = _token;
135
+ this._trans = _trans;
136
+ this._recipients = [];
137
+ this._shares = [];
138
+ this._searchInput = null;
139
+ this._searchResults = null;
140
+ this._sharesContainer = null;
141
+ this._populateBody(this.node);
142
+ this.addClass('jp-shared-link-body');
143
+ this._loadShares().then(() => {
144
+ this._updateSharesList().then(() => {
145
+ this._loadUsers();
146
+ });
147
+ });
148
+ }
149
+ /**
150
+ * Returns the input value.
151
+ */
152
+ getValue() {
153
+ return this._url;
154
+ }
155
+ onAfterAttach(msg) {
156
+ super.onAfterAttach(msg);
157
+ }
158
+ onBeforeDetach(msg) {
159
+ super.onBeforeDetach(msg);
160
+ }
161
+ async _loadUsers() {
162
+ // If possible, download the users list for the UI
163
+ if (this._canListUsers) {
164
+ let offset = 0;
165
+ const limit = 200;
166
+ let usersData = [];
167
+ let hasMore = true;
168
+ while (hasMore) {
169
+ const usersResponse = await fetch(`${this._hubApiUrl}/users?limit=${limit}&offset=${offset}`, {
170
+ headers: {
171
+ Authorization: `token ${this._token}`
172
+ }
173
+ });
174
+ const data = await usersResponse.json();
175
+ usersData = usersData.concat(data);
176
+ hasMore = data.length === limit;
177
+ offset += limit;
178
+ }
179
+ const sharedUserNames = new Set(this._shares
180
+ .filter(share => share.type === 'user')
181
+ .map(share => share.name));
182
+ // We remove from the list the current user and the users that already have a share
183
+ this._recipients = usersData
184
+ .filter((user) => user.name !== this._serverOwner && !sharedUserNames.has(user.name))
185
+ .map((user) => ({ ...user, type: 'user' }));
186
+ }
187
+ // If possible, download the groups list for the UI and add them to the users list
188
+ if (this._canListGroups) {
189
+ const groupsResponse = await fetch(`${this._hubApiUrl}/groups`, {
190
+ headers: {
191
+ Authorization: `token ${this._token}`
192
+ }
193
+ });
194
+ const groupsData = await groupsResponse.json();
195
+ const sharedGroupNames = new Set(this._shares
196
+ .filter(share => share.type === 'group')
197
+ .map(share => share.name));
198
+ this._recipients = this._recipients.concat(groupsData
199
+ .filter((group) => !sharedGroupNames.has(group.name))
200
+ .map((group) => ({ name: group.name, type: 'group' })));
201
+ }
202
+ // Sort users and groups by name in alphabetical order
203
+ this._recipients.sort((a, b) => a.name.localeCompare(b.name));
204
+ this._updateSearchResults();
205
+ }
206
+ async _loadShares() {
207
+ const sharesResponse = await fetch(`${this._hubApiUrl}/shares/${this._serverOwner}/${this._serverName}`, {
208
+ headers: {
209
+ Authorization: `token ${this._token}`
210
+ }
211
+ });
212
+ const sharesData = await sharesResponse.json();
213
+ this._shares = sharesData.items.map((item) => {
214
+ var _a, _b;
215
+ return ({
216
+ name: ((_a = item.user) === null || _a === void 0 ? void 0 : _a.name) || ((_b = item.group) === null || _b === void 0 ? void 0 : _b.name),
217
+ createdAt: item.created_at,
218
+ type: item.user ? 'user' : 'group'
219
+ });
220
+ });
221
+ }
222
+ async _createShare(sharewith, type) {
223
+ // If the issuer can control the server, we add the "servers!server" scope to the share to let other users start/stop the server
224
+ const scopes = [
225
+ 'access:servers!server=' + this._serverOwner + '/' + this._serverName
226
+ ];
227
+ if (this._canControlServer) {
228
+ scopes.push('servers!server=' + this._serverOwner + '/' + this._serverName);
229
+ }
230
+ await fetch(`${this._hubApiUrl}/shares/${this._serverOwner}/${this._serverName}`, {
231
+ method: 'POST',
232
+ headers: {
233
+ Authorization: `token ${this._token}`,
234
+ 'Content-Type': 'application/json'
235
+ },
236
+ body: JSON.stringify({
237
+ [type]: sharewith.name,
238
+ scopes
239
+ })
240
+ });
241
+ }
242
+ async _deleteShare(sharewith, type) {
243
+ await fetch(`${this._hubApiUrl}/shares/${this._serverOwner}/${this._serverName}`, {
244
+ method: 'PATCH',
245
+ headers: {
246
+ Authorization: `token ${this._token}`
247
+ },
248
+ body: JSON.stringify({
249
+ [type]: sharewith.name
27
250
  })
28
- ]
29
- });
251
+ });
252
+ }
253
+ _populateBody(dialogBody) {
254
+ // Add search input
255
+ const searchContainer = document.createElement('div');
256
+ searchContainer.classList.add('jp-ManageSharesBody-search-container');
257
+ this._searchInput = document.createElement('input');
258
+ this._searchInput.type = 'text';
259
+ this._searchInput.classList.add('jp-ManageSharesBody-search-input');
260
+ this._searchInput.placeholder = this._trans.__('Type to search for a user or a group to share your server with...');
261
+ this._searchInput.addEventListener('input', () => {
262
+ this._updateSearchResults();
263
+ });
264
+ searchContainer.appendChild(this._searchInput);
265
+ dialogBody.appendChild(searchContainer);
266
+ // Add search results container
267
+ this._searchResults = document.createElement('div');
268
+ this._searchResults.classList.add('jp-ManageSharesBody-search-results');
269
+ dialogBody.appendChild(this._searchResults);
270
+ // Add selected users container
271
+ this._sharesContainer = document.createElement('div');
272
+ this._sharesContainer.classList.add('jp-ManageSharesBody-selected-users');
273
+ dialogBody.appendChild(this._sharesContainer);
274
+ const input = document.createElement('input');
275
+ input.classList.add('jp-ManageSharesBody-url-input');
276
+ input.readOnly = true;
277
+ input.value = this._url;
278
+ dialogBody.appendChild(input);
279
+ dialogBody.insertAdjacentHTML('beforeend', '<br>');
280
+ dialogBody.insertAdjacentText('beforeend', this._trans.__('Warning: Anyone you share this server with will have access to all your files, not just the currently open file.'));
281
+ dialogBody.insertAdjacentHTML('beforeend', '<br>');
282
+ dialogBody.insertAdjacentText('beforeend', this._trans.__('If your Hub administrator allows it, you can create another server dedicated to sharing a specific project.'));
283
+ }
284
+ _updateSearchResults() {
285
+ var _a;
286
+ if (!this._searchResults) {
287
+ return;
288
+ }
289
+ this._searchResults.innerHTML = '';
290
+ const query = ((_a = this._searchInput) === null || _a === void 0 ? void 0 : _a.value) || '';
291
+ const filteredUsers = this._recipients.filter(user => user.name.toLowerCase().includes(query.toLowerCase()) || query === '');
292
+ filteredUsers.forEach(user => {
293
+ var _a;
294
+ const userElement = document.createElement('div');
295
+ userElement.classList.add('jp-ManageSharesBody-user-item');
296
+ if (user.type === 'group') {
297
+ userElement.textContent = this._trans.__('Group %1', user.name);
298
+ }
299
+ else {
300
+ userElement.textContent = user.name;
301
+ }
302
+ userElement.addEventListener('click', async () => {
303
+ await this._createShare(user, user.type);
304
+ await this._loadShares();
305
+ await this._updateSharesList();
306
+ // Removing the new user from the search results
307
+ const sharedUserNames = new Set(this._shares.map(share => share.name));
308
+ this._recipients = this._recipients.filter((user) => user.name !== this._serverOwner && !sharedUserNames.has(user.name));
309
+ this._updateSearchResults();
310
+ });
311
+ (_a = this._searchResults) === null || _a === void 0 ? void 0 : _a.appendChild(userElement);
312
+ });
313
+ }
314
+ async _updateSharesList() {
315
+ if (!this._sharesContainer) {
316
+ return;
317
+ }
318
+ this._sharesContainer.innerHTML = '';
319
+ const table = document.createElement('table');
320
+ table.classList.add('jp-ManageSharesBody-shares-table');
321
+ const headerRow = document.createElement('tr');
322
+ const thSharedWith = document.createElement('th');
323
+ thSharedWith.textContent = this._trans.__('Shared with');
324
+ const thSharedSince = document.createElement('th');
325
+ thSharedSince.textContent = this._trans.__('Shared since');
326
+ const thActions = document.createElement('th');
327
+ thActions.textContent = this._trans.__('Actions');
328
+ headerRow.appendChild(thSharedWith);
329
+ headerRow.appendChild(thSharedSince);
330
+ headerRow.appendChild(thActions);
331
+ table.appendChild(headerRow);
332
+ if (this._shares.length === 0) {
333
+ const row = document.createElement('tr');
334
+ const cell = document.createElement('td');
335
+ cell.colSpan = 3;
336
+ cell.textContent = this._trans.__('Your server is not shared to anybody yet. You can search for users and groups above.');
337
+ row.appendChild(cell);
338
+ table.appendChild(row);
339
+ }
340
+ else {
341
+ this._shares.forEach(share => {
342
+ const row = document.createElement('tr');
343
+ // Shared with cell
344
+ const sharedWithCell = document.createElement('td');
345
+ if (share.type === 'group') {
346
+ sharedWithCell.textContent = this._trans.__('Group %1', share.name);
347
+ }
348
+ else {
349
+ sharedWithCell.textContent = share.name;
350
+ }
351
+ row.appendChild(sharedWithCell);
352
+ // Shared since cell
353
+ const sharedSinceCell = document.createElement('td');
354
+ const formattedDate = new Date(share.createdAt).toLocaleString([], {
355
+ hour: '2-digit',
356
+ minute: '2-digit',
357
+ year: 'numeric',
358
+ month: '2-digit',
359
+ day: '2-digit'
360
+ });
361
+ sharedSinceCell.textContent = formattedDate;
362
+ row.appendChild(sharedSinceCell);
363
+ // Actions cell
364
+ const actionsCell = document.createElement('td');
365
+ const revokeButton = document.createElement('button');
366
+ revokeButton.textContent = this._trans.__('Revoke');
367
+ revokeButton.classList.add('jp-mod-styled');
368
+ revokeButton.addEventListener('click', async () => {
369
+ await this._deleteShare(share, share.type);
370
+ await this._loadShares();
371
+ await this._updateSharesList();
372
+ await this._loadUsers();
373
+ // Removing the new user from the search results
374
+ const sharedUserNames = new Set(this._shares.map(share => share.name));
375
+ this._recipients = this._recipients.filter((user) => user.name !== this._serverOwner && !sharedUserNames.has(user.name));
376
+ this._updateSearchResults();
377
+ });
378
+ actionsCell.appendChild(revokeButton);
379
+ row.appendChild(actionsCell);
380
+ table.appendChild(row);
381
+ });
382
+ }
383
+ this._sharesContainer.appendChild(table);
384
+ }
30
385
  }
31
386
  class SharedLinkBody extends Widget {
32
387
  constructor(_url, _token, _behindHub, _trans) {
@@ -36,12 +391,12 @@ class SharedLinkBody extends Widget {
36
391
  this._behindHub = _behindHub;
37
392
  this._trans = _trans;
38
393
  this._tokenCheckbox = null;
39
- this.onTokenChange = (e) => {
394
+ this._onTokenChange = (e) => {
40
395
  const target = e.target;
41
- this.updateContent(target === null || target === void 0 ? void 0 : target.checked);
396
+ this._updateContent(target === null || target === void 0 ? void 0 : target.checked);
42
397
  };
43
398
  this._warning = document.createElement('div');
44
- this.populateBody(this.node);
399
+ this._populateBody(this.node);
45
400
  this.addClass('jp-shared-link-body');
46
401
  }
47
402
  /**
@@ -62,14 +417,14 @@ class SharedLinkBody extends Widget {
62
417
  onAfterAttach(msg) {
63
418
  var _a;
64
419
  super.onAfterAttach(msg);
65
- (_a = this._tokenCheckbox) === null || _a === void 0 ? void 0 : _a.addEventListener('change', this.onTokenChange);
420
+ (_a = this._tokenCheckbox) === null || _a === void 0 ? void 0 : _a.addEventListener('change', this._onTokenChange);
66
421
  }
67
422
  onBeforeDetach(msg) {
68
423
  var _a;
69
- (_a = this._tokenCheckbox) === null || _a === void 0 ? void 0 : _a.removeEventListener('change', this.onTokenChange);
424
+ (_a = this._tokenCheckbox) === null || _a === void 0 ? void 0 : _a.removeEventListener('change', this._onTokenChange);
70
425
  super.onBeforeDetach(msg);
71
426
  }
72
- updateContent(withToken) {
427
+ _updateContent(withToken) {
73
428
  this._warning.innerHTML = '';
74
429
  const urlInput = this.node.querySelector('input[readonly]');
75
430
  if (withToken) {
@@ -108,15 +463,18 @@ class SharedLinkBody extends Widget {
108
463
  }
109
464
  }
110
465
  }
111
- populateBody(dialogBody) {
112
- dialogBody.insertAdjacentHTML('afterbegin', `<input readonly value="${this._url}">`);
466
+ _populateBody(dialogBody) {
467
+ const input = document.createElement('input');
468
+ input.readOnly = true;
469
+ input.value = this._url;
470
+ dialogBody.appendChild(input);
113
471
  if (this._token) {
114
472
  const label = dialogBody.appendChild(document.createElement('label'));
115
473
  label.insertAdjacentHTML('beforeend', '<input type="checkbox">');
116
474
  this._tokenCheckbox = label.firstChild;
117
475
  label.insertAdjacentText('beforeend', this._trans.__('Include token in URL'));
118
476
  dialogBody.insertAdjacentElement('beforeend', this._warning);
119
- this.updateContent(false);
477
+ this._updateContent(false);
120
478
  }
121
479
  }
122
480
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/collaboration",
3
- "version": "4.1.0-beta.0",
3
+ "version": "4.1.0-rc.0",
4
4
  "description": "JupyterLab - Real-Time Collaboration Widgets",
5
5
  "homepage": "https://github.com/jupyterlab/jupyter-collaboration",
6
6
  "bugs": {
package/style/base.css CHANGED
@@ -6,6 +6,7 @@
6
6
  @import url('./menu.css');
7
7
  @import url('./sidepanel.css');
8
8
  @import url('./users-item.css');
9
+ @import url('./sharedlink.css');
9
10
 
10
11
  .jp-shared-link-body {
11
12
  user-select: none;
@@ -0,0 +1,62 @@
1
+ /* -----------------------------------------------------------------------------
2
+ | Copyright (c) Jupyter Development Team.
3
+ | Distributed under the terms of the Modified BSD License.
4
+ |---------------------------------------------------------------------------- */
5
+
6
+ .jp-shared-link-body {
7
+ user-select: none;
8
+ }
9
+
10
+ .jp-ManageSharesBody-search-container {
11
+ margin-bottom: 10px;
12
+ }
13
+
14
+ .jp-ManageSharesBody-search-input {
15
+ width: 100%;
16
+ padding: 5px;
17
+ margin-top: 5px;
18
+ }
19
+
20
+ .jp-ManageSharesBody-search-results {
21
+ height: 10em;
22
+ overflow-y: auto;
23
+ border: 1px solid var(--jp-border-color0);
24
+ padding: 5px;
25
+ flex-shrink: 0;
26
+ }
27
+
28
+ .jp-ManageSharesBody-user-item {
29
+ padding: 5px;
30
+ cursor: pointer;
31
+ }
32
+
33
+ .jp-ManageSharesBody-user-item:hover {
34
+ background-color: var(--jp-border-color3);
35
+ }
36
+
37
+ .jp-ManageSharesBody-selected-users {
38
+ margin-top: 10px;
39
+ height: 10em;
40
+ overflow-y: auto;
41
+ border: 1px solid var(--jp-border-color0);
42
+ flex-shrink: 0;
43
+ }
44
+
45
+ .jp-ManageSharesBody-url-input {
46
+ width: 100%;
47
+ padding: 5px;
48
+ margin-top: 10px;
49
+ }
50
+
51
+ .jp-ManageSharesBody-shares-table {
52
+ width: 100%;
53
+ }
54
+
55
+ .jp-ManageSharesBody-shares-table td:nth-child(2),
56
+ .jp-ManageSharesBody-shares-table td:nth-child(3) {
57
+ text-align: center;
58
+ }
59
+
60
+ .jp-Dialog-content:has(.jp-shared-link-body) {
61
+ max-height: 750px;
62
+ }