@oceanum/oceanumlab 4.7.0 → 4.7.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.
Files changed (81) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +18 -2
  5. package/lib/DatameshWidget.js +80 -12
  6. package/lib/StoredNotebooks.d.ts +20 -0
  7. package/lib/StoredNotebooks.js +112 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +254 -0
  20. package/lib/auth/startSignIn.d.ts +11 -0
  21. package/lib/auth/startSignIn.js +16 -0
  22. package/lib/auth/tokens.d.ts +102 -0
  23. package/lib/auth/tokens.js +9 -0
  24. package/lib/chatRouter.d.ts +13 -1
  25. package/lib/chatRouter.js +9 -2
  26. package/lib/constants.d.ts +1 -1
  27. package/lib/constants.js +1 -1
  28. package/lib/datameshUiUrl.d.ts +2 -2
  29. package/lib/datameshUiUrl.js +6 -6
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +58 -17
  32. package/lib/share/client.d.ts +84 -0
  33. package/lib/share/client.js +205 -0
  34. package/lib/share/events.d.ts +7 -0
  35. package/lib/share/events.js +9 -0
  36. package/lib/share/notebook.d.ts +184 -0
  37. package/lib/share/notebook.js +277 -0
  38. package/lib/share/oceanumOnly.d.ts +49 -0
  39. package/lib/share/oceanumOnly.js +90 -0
  40. package/lib/share/plugin.d.ts +21 -0
  41. package/lib/share/plugin.js +883 -0
  42. package/lib/share/widgets.d.ts +25 -0
  43. package/lib/share/widgets.js +154 -0
  44. package/package.json +13 -1
  45. package/schema/datamesh-connect.json +3 -12
  46. package/src/DatameshUI.tsx +1 -10
  47. package/src/DatameshWidget.tsx +143 -19
  48. package/src/StoredNotebooks.tsx +206 -0
  49. package/src/Tabs.tsx +102 -0
  50. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  51. package/src/__tests__/OceanumPanel.spec.tsx +157 -0
  52. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  53. package/src/__tests__/Tabs.spec.tsx +168 -0
  54. package/src/__tests__/aiBackend.spec.ts +5 -6
  55. package/src/__tests__/chatRouter.spec.ts +9 -15
  56. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  57. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  58. package/src/__tests__/pluginArity.spec.ts +24 -0
  59. package/src/__tests__/serverAuth.spec.ts +277 -0
  60. package/src/__tests__/settingsSchema.spec.ts +73 -0
  61. package/src/__tests__/shareClient.spec.ts +314 -0
  62. package/src/__tests__/shareNotebook.spec.ts +451 -0
  63. package/src/__tests__/sharePlugin.spec.ts +426 -0
  64. package/src/aiBackend.ts +1 -1
  65. package/src/auth/accountWidget.ts +113 -0
  66. package/src/auth/claims.ts +30 -0
  67. package/src/auth/plugin.ts +155 -0
  68. package/src/auth/serverAuth.ts +342 -0
  69. package/src/auth/startSignIn.ts +23 -0
  70. package/src/auth/tokens.ts +111 -0
  71. package/src/chatRouter.ts +8 -4
  72. package/src/constants.ts +1 -1
  73. package/src/datameshUiUrl.ts +6 -6
  74. package/src/index.ts +87 -36
  75. package/src/share/client.ts +288 -0
  76. package/src/share/events.ts +10 -0
  77. package/src/share/notebook.ts +416 -0
  78. package/src/share/oceanumOnly.ts +126 -0
  79. package/src/share/plugin.ts +1096 -0
  80. package/src/share/widgets.ts +217 -0
  81. package/style/index.css +266 -13
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The share plugin's commands, driven through a real CommandRegistry against fakes.
3
+ *
4
+ * These cover what reading cannot: which record or notebook a command decides to act
5
+ * on. Two of them are regressions for defects a review found, both marked below.
6
+ */
7
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
8
+ import type { IDocumentManager } from '@jupyterlab/docmanager';
9
+ import type { INotebookContent } from '@jupyterlab/nbformat';
10
+ import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
11
+ import { CommandRegistry } from '@lumino/commands';
12
+ import type { Widget } from '@lumino/widgets';
13
+ import { Signal } from '@lumino/signaling';
14
+
15
+ import type { IOceanumAuth, IOceanumUser } from '../auth/tokens';
16
+ import type { ISpecRecord } from '../share/notebook';
17
+ import { CommandIDs, sharePlugin } from '../share/plugin';
18
+
19
+ /** What every dialog answers. Accepting with no value is "chose nothing". */
20
+ const mockDialog: { accept: boolean; value: unknown } = {
21
+ accept: true,
22
+ value: null
23
+ };
24
+
25
+ jest.mock('@jupyterlab/apputils', () => {
26
+ const actual = jest.requireActual('@jupyterlab/apputils');
27
+ const answer = async () => ({
28
+ button: { accept: mockDialog.accept },
29
+ value: mockDialog.value
30
+ });
31
+ const quiet = (): void => undefined;
32
+ return {
33
+ ...actual,
34
+ showDialog: answer,
35
+ InputDialog: { ...actual.InputDialog, getText: answer },
36
+ Notification: {
37
+ ...actual.Notification,
38
+ error: quiet,
39
+ success: quiet,
40
+ warning: quiet,
41
+ info: quiet
42
+ }
43
+ };
44
+ });
45
+
46
+ const originalFetch = globalThis.fetch;
47
+ afterEach(() => {
48
+ globalThis.fetch = originalFetch;
49
+ mockDialog.accept = true;
50
+ mockDialog.value = null;
51
+ });
52
+
53
+ const SPECS = 'https://specs.example.com';
54
+ const ID_A = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
55
+ const ID_B = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
56
+
57
+ const USER: IOceanumUser = {
58
+ sub: 'auth0|1',
59
+ email: 'me@example.com',
60
+ name: null,
61
+ activeOrg: null,
62
+ colorScheme: null
63
+ };
64
+
65
+ const notebook = (link?: string): INotebookContent => ({
66
+ nbformat: 4,
67
+ nbformat_minor: 5,
68
+ cells: [],
69
+ metadata: link ? { oceanum: { spec_id: link, specs_url: SPECS } } : {}
70
+ });
71
+
72
+ const record = (id: string, name: string): ISpecRecord => ({
73
+ id,
74
+ name,
75
+ description: null,
76
+ modified: '2026-09-12T10:00:00',
77
+ creator: 'me@example.com',
78
+ spec: notebook()
79
+ });
80
+
81
+ /** Records the spec store calls a command makes, and answers them. */
82
+ function store() {
83
+ const calls: string[] = [];
84
+ const bodies: Record<string, unknown>[] = [];
85
+ const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
86
+ const fetcher = async (
87
+ input: RequestInfo | URL,
88
+ init?: RequestInit
89
+ ): Promise<Response> => {
90
+ const url = String(input);
91
+ const method = init?.method ?? 'GET';
92
+ const id = url.split('/').pop() ?? '';
93
+ calls.push(`${method} ${id}`);
94
+ if (init?.body) {
95
+ bodies.push(JSON.parse(String(init.body)));
96
+ }
97
+ return {
98
+ ok: true,
99
+ status: method === 'DELETE' ? 204 : 200,
100
+ json: async () =>
101
+ method === 'GET' && id === 'notebook'
102
+ ? [record(ID_A, 'Alpha'), record(ID_B, 'Beta')]
103
+ : record(id, names[id] ?? 'Alpha')
104
+ } as unknown as Response;
105
+ };
106
+ return { calls, bodies, fetcher };
107
+ }
108
+
109
+ /** A notebook panel with just the surface the plugin touches. */
110
+ function panel(id: string, path: string, link?: string): NotebookPanel {
111
+ const saveState = new Signal<unknown, string>({});
112
+ const metadata: Record<string, unknown> = link
113
+ ? { oceanum: { spec_id: link, specs_url: SPECS } }
114
+ : {};
115
+ return {
116
+ id,
117
+ context: {
118
+ path,
119
+ ready: Promise.resolve(),
120
+ saveState,
121
+ save: async (): Promise<void> => undefined,
122
+ model: {
123
+ toJSON: () => notebook(link),
124
+ getMetadata: (key: string) => metadata[key],
125
+ setMetadata: (key: string, value: unknown) => {
126
+ metadata[key] = value;
127
+ },
128
+ deleteMetadata: (key: string) => {
129
+ delete metadata[key];
130
+ },
131
+ dirty: false
132
+ }
133
+ }
134
+ } as unknown as NotebookPanel;
135
+ }
136
+
137
+ interface IHarness {
138
+ commands: CommandRegistry;
139
+ calls: string[];
140
+ bodies: Record<string, unknown>[];
141
+ saved: string[];
142
+ opened: string[];
143
+ renamed: [string, string][];
144
+ /** What a right-click last landed on; never cleared, as JupyterLab does not. */
145
+ setHit: (node: HTMLElement | null) => void;
146
+ setCurrent: (panel: NotebookPanel | null) => void;
147
+ }
148
+
149
+ function activate(
150
+ options: {
151
+ user?: IOceanumUser | null;
152
+ panels?: NotebookPanel[];
153
+ current?: NotebookPanel | null;
154
+ folder?: Record<string, unknown>;
155
+ } = {}
156
+ ): IHarness {
157
+ const commands = new CommandRegistry();
158
+ const { calls, bodies, fetcher } = store();
159
+ const saved: string[] = [];
160
+ const opened: string[] = [];
161
+ const renamed: [string, string][] = [];
162
+ const panels = options.panels ?? [];
163
+ let current = options.current ?? null;
164
+ let hit: HTMLElement | null = null;
165
+ const folder = options.folder ?? {};
166
+
167
+ const auth = {
168
+ environment: { fileManagement: 'local' },
169
+ urls: { specs: SPECS },
170
+ ready: Promise.resolve(),
171
+ user: options.user === undefined ? USER : options.user,
172
+ userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
173
+ {} as IOceanumAuth
174
+ ),
175
+ tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
176
+ signIn: async (): Promise<void> => undefined,
177
+ signOut: async (): Promise<void> => undefined,
178
+ getAccessToken: async (): Promise<string> => 'tok'
179
+ } as unknown as IOceanumAuth;
180
+
181
+ const app = {
182
+ commands,
183
+ restored: Promise.resolve(),
184
+ shell: {
185
+ get currentWidget() {
186
+ return current;
187
+ },
188
+ activateById: (): void => undefined,
189
+ widgets: (): Widget[] => []
190
+ },
191
+ contextMenu: { addItem: (): void => undefined },
192
+ contextMenuHitTest: (test: (node: HTMLElement) => boolean) => {
193
+ for (let node = hit; node; node = node.parentElement) {
194
+ if (test(node)) {
195
+ return node;
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+ } as unknown as JupyterFrontEnd;
201
+
202
+ const contents = {
203
+ get: async (path: string) => {
204
+ if (path === 'Oceanum') {
205
+ return {
206
+ type: 'directory',
207
+ content: Object.keys(folder).map(name => ({ name }))
208
+ };
209
+ }
210
+ const name = path.replace('Oceanum/', '');
211
+ if (!(name in folder)) {
212
+ throw new Error('not found');
213
+ }
214
+ return { type: 'notebook', content: folder[name] };
215
+ },
216
+ save: async (path: string) => {
217
+ saved.push(path);
218
+ return { path };
219
+ },
220
+ newUntitled: async () => ({ path: 'Untitled Folder' }),
221
+ rename: async () => ({ path: 'Oceanum' })
222
+ };
223
+ const docManager = {
224
+ services: { contents },
225
+ openOrReveal: (path: string) => {
226
+ opened.push(path);
227
+ return {};
228
+ },
229
+ rename: async (from: string, to: string): Promise<void> => {
230
+ renamed.push([from, to]);
231
+ },
232
+ deleteFile: async (): Promise<void> => undefined
233
+ } as unknown as IDocumentManager;
234
+
235
+ const tracker = {
236
+ currentWidget: null as NotebookPanel | null,
237
+ forEach: (fn: (p: NotebookPanel) => void) => panels.forEach(fn),
238
+ find: (test: (p: NotebookPanel) => boolean) => panels.find(test) ?? null,
239
+ widgetAdded: new Signal<INotebookTracker, NotebookPanel>(
240
+ {} as INotebookTracker
241
+ ),
242
+ currentChanged: new Signal<INotebookTracker, NotebookPanel | null>(
243
+ {} as INotebookTracker
244
+ )
245
+ } as unknown as INotebookTracker;
246
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget = current;
247
+
248
+ // The plugin builds its own client; point its fetch at the fake store. A top-level
249
+ // afterEach puts the real one back.
250
+ globalThis.fetch = fetcher as unknown as typeof fetch;
251
+
252
+ const palette = { addItem: (): void => undefined };
253
+ sharePlugin.activate(
254
+ app,
255
+ auth,
256
+ docManager,
257
+ tracker,
258
+ palette,
259
+ null,
260
+ null
261
+ ) as void;
262
+
263
+ return {
264
+ commands,
265
+ calls,
266
+ bodies,
267
+ saved,
268
+ opened,
269
+ renamed,
270
+ setHit: node => {
271
+ hit = node;
272
+ },
273
+ setCurrent: value => {
274
+ current = value;
275
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget =
276
+ value;
277
+ }
278
+ };
279
+ }
280
+
281
+ describe('renaming a record', () => {
282
+ it('patches just the name, and moves the open notebook to match', async () => {
283
+ const open = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
284
+ const harness = activate({ current: open, panels: [open] });
285
+ mockDialog.value = 'Renamed';
286
+
287
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
288
+
289
+ // Read once for the dialog's current name, then one patch. No PUT: the notebook
290
+ // is not sent back, so a change another writer made to it cannot be overwritten.
291
+ expect(harness.calls).toEqual([`GET ${ID_A}`, `PATCH ${ID_A}`]);
292
+ expect(harness.bodies).toEqual([{ name: 'Renamed' }]);
293
+ expect(harness.renamed).toEqual([
294
+ ['notebooks/Alpha.ipynb', 'notebooks/Renamed.ipynb']
295
+ ]);
296
+ });
297
+
298
+ it('leaves everything alone when the name is unchanged', async () => {
299
+ const harness = activate();
300
+ mockDialog.value = 'Alpha';
301
+
302
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
303
+
304
+ expect(harness.calls).toEqual([`GET ${ID_A}`]);
305
+ });
306
+ });
307
+
308
+ /** A Notebooks tab row, as StoredNotebooks renders it. */
309
+ function row(id: string): HTMLElement {
310
+ const node = document.createElement('button');
311
+ node.className = 'oceanum-notebooks-item';
312
+ node.setAttribute('data-spec-id', id);
313
+ return node;
314
+ }
315
+
316
+ describe('the share commands, invoked from the File menu or the palette', () => {
317
+ // Regression: JupyterLab keeps the last contextmenu event for the life of the page
318
+ // and never clears it, so a hit test answers long after its menu has gone. Reading
319
+ // it unconditionally let the last right-clicked row decide what File > … acted on.
320
+ it('ignore a row that was right-clicked earlier', async () => {
321
+ const current = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
322
+ const harness = activate({ current, panels: [current] });
323
+ harness.setHit(row(ID_B));
324
+
325
+ expect(harness.commands.label(CommandIDs.open)).toBe('Open from Oceanum…');
326
+ expect(harness.commands.label(CommandIDs.delete)).toBe(
327
+ 'Delete from Oceanum'
328
+ );
329
+
330
+ await harness.commands.execute(CommandIDs.delete);
331
+
332
+ // The current notebook's record, not the stale row's.
333
+ expect(harness.calls).toContain(`GET ${ID_A}`);
334
+ expect(harness.calls).not.toContain(`GET ${ID_B}`);
335
+ });
336
+
337
+ it('ignore a tab that was right-clicked earlier when saving', async () => {
338
+ const a = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
339
+ const b = panel('panel-b', 'notebooks/Beta.ipynb', ID_B);
340
+ const harness = activate({ current: b, panels: [a, b] });
341
+ const tab = document.createElement('li');
342
+ tab.dataset.id = 'panel-a';
343
+ harness.setHit(tab);
344
+
345
+ await harness.commands.execute(CommandIDs.save);
346
+
347
+ expect(harness.calls).toEqual([`PUT ${ID_B}`]);
348
+ });
349
+
350
+ it('open the picker rather than a stale row', async () => {
351
+ const harness = activate();
352
+ harness.setHit(row(ID_B));
353
+
354
+ await harness.commands.execute(CommandIDs.open);
355
+
356
+ // The picker lists; it does not read one record.
357
+ expect(harness.calls).toEqual(['GET notebook']);
358
+ });
359
+ });
360
+
361
+ describe('the share commands, invoked from a Notebooks row', () => {
362
+ it('act on the row under the pointer', async () => {
363
+ const harness = activate();
364
+ harness.setHit(row(ID_B));
365
+
366
+ expect(harness.commands.label(CommandIDs.open, { source: 'row' })).toBe(
367
+ 'Open'
368
+ );
369
+ expect(
370
+ harness.commands.isEnabled(CommandIDs.delete, { source: 'row' })
371
+ ).toBe(true);
372
+
373
+ await harness.commands.execute(CommandIDs.open, { source: 'row' });
374
+
375
+ expect(harness.calls).toContain(`GET ${ID_B}`);
376
+ });
377
+
378
+ it('are disabled while signed out', async () => {
379
+ const harness = activate({ user: null });
380
+ harness.setHit(row(ID_B));
381
+
382
+ for (const command of Object.values(CommandIDs)) {
383
+ expect(harness.commands.isEnabled(command, { source: 'row' })).toBe(
384
+ false
385
+ );
386
+ }
387
+ });
388
+ });
389
+
390
+ describe('opening a record whose local copy already exists', () => {
391
+ // Regression: a second copy is named "<name> (1)", and a record takes its name from
392
+ // its file on every save, so opening the same notebook twice renamed it on Oceanum.
393
+ it('reuses the file an earlier session left, so the record keeps its name', async () => {
394
+ const harness = activate({
395
+ folder: { 'Alpha.ipynb': notebook(ID_A) }
396
+ });
397
+
398
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
399
+
400
+ expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
401
+ expect(harness.opened).toEqual(['Oceanum/Alpha.ipynb']);
402
+ });
403
+
404
+ it('finds the copy under a numbered name too', async () => {
405
+ const harness = activate({
406
+ folder: {
407
+ 'Alpha.ipynb': notebook(ID_B),
408
+ 'Alpha (1).ipynb': notebook(ID_A)
409
+ }
410
+ });
411
+
412
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
413
+
414
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
415
+ });
416
+
417
+ it('still takes a free name when the file belongs to another record', async () => {
418
+ const harness = activate({
419
+ folder: { 'Alpha.ipynb': notebook(ID_B) }
420
+ });
421
+
422
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
423
+
424
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
425
+ });
426
+ });
package/src/aiBackend.ts CHANGED
@@ -47,7 +47,7 @@ export interface IAiCredential {
47
47
  }
48
48
 
49
49
  /**
50
- * The backend address for an `aiBackendUrl` setting value, without a trailing
50
+ * The backend address for a configured value, without a trailing
51
51
  * slash so a path can be appended. An invalid value falls back to the default.
52
52
  */
53
53
  export function aiBackendUrl(value: string | null | undefined): string {
@@ -0,0 +1,113 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { Menu, Widget } from '@lumino/widgets';
3
+
4
+ import { IPendingSignIn, ServerAuth } from './serverAuth';
5
+
6
+ export const CommandIDs = {
7
+ signIn: 'oceanum-auth:sign-in',
8
+ signOut: 'oceanum-auth:sign-out',
9
+ account: 'oceanum-auth:account',
10
+ /**
11
+ * For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
12
+ * the current access token, or `null` when signed out. Never rejects.
13
+ */
14
+ accessToken: 'oceanum-auth:access-token'
15
+ } as const;
16
+
17
+ /**
18
+ * The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
19
+ * menu when signed in.
20
+ */
21
+ export class AccountWidget extends Widget {
22
+ constructor(auth: ServerAuth, commands: CommandRegistry) {
23
+ super({ node: document.createElement('button') });
24
+ this._auth = auth;
25
+ this._commands = commands;
26
+ this._menu = new Menu({ commands });
27
+ this._menu.addItem({ command: CommandIDs.account });
28
+ this._menu.addItem({ type: 'separator' });
29
+ this._menu.addItem({ command: CommandIDs.signOut });
30
+ // The application shell refuses widgets without an id.
31
+ this.id = 'oceanum-account';
32
+ this.addClass('oc-Account');
33
+ this.node.setAttribute('type', 'button');
34
+ this.node.addEventListener('click', this._onClick);
35
+ auth.userChanged.connect(this._render, this);
36
+ auth.stateChanged.connect(this._render, this);
37
+ void auth.ready.then(() => this._render());
38
+ this._render();
39
+ }
40
+
41
+ dispose(): void {
42
+ if (this.isDisposed) {
43
+ return;
44
+ }
45
+ this.node.removeEventListener('click', this._onClick);
46
+ this._menu.dispose();
47
+ super.dispose();
48
+ }
49
+
50
+ private _onClick = (): void => {
51
+ if (!this._auth.user) {
52
+ void this._commands.execute(CommandIDs.signIn);
53
+ return;
54
+ }
55
+ const rect = this.node.getBoundingClientRect();
56
+ this._menu.open(rect.left, rect.bottom);
57
+ };
58
+
59
+ private _render(): void {
60
+ const user = this._auth.user;
61
+ const waiting = !user && this._auth.pending !== null;
62
+ this.node.textContent = user
63
+ ? user.email
64
+ : waiting
65
+ ? 'Signing in to Oceanum.io…'
66
+ : 'Sign in to Oceanum.io';
67
+ this.node.title = user
68
+ ? `Signed in to Oceanum.io as ${user.email}`
69
+ : 'Sign in to Oceanum.io';
70
+ this.toggleClass('oc-mod-signedIn', user !== null);
71
+ }
72
+
73
+ private _auth: ServerAuth;
74
+ private _commands: CommandRegistry;
75
+ private _menu: Menu;
76
+ }
77
+
78
+ /**
79
+ * What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
80
+ * it. A real link rather than `window.open`, which a popup blocker stops once there has
81
+ * been an `await` between the click and the call -- and there always has, because the code
82
+ * comes from the server.
83
+ */
84
+ export class SignInBody extends Widget {
85
+ constructor(pending: IPendingSignIn) {
86
+ super();
87
+ this.addClass('oc-SignIn');
88
+
89
+ const intro = document.createElement('p');
90
+ intro.textContent =
91
+ 'Open the Oceanum.io sign-in page, and check that it shows this code:';
92
+
93
+ const code = document.createElement('div');
94
+ code.className = 'oc-SignIn-code';
95
+ code.textContent = pending.userCode;
96
+
97
+ const link = document.createElement('a');
98
+ link.className = 'oc-SignIn-link jp-mod-styled jp-mod-accept';
99
+ link.href = pending.verificationUriComplete;
100
+ link.target = '_blank';
101
+ link.rel = 'noopener noreferrer';
102
+ link.textContent = 'Open the sign-in page';
103
+
104
+ const note = document.createElement('p');
105
+ note.className = 'oc-SignIn-note';
106
+ const minutes = Math.max(1, Math.round(pending.expiresIn / 60));
107
+ note.textContent =
108
+ `This closes by itself once you have signed in. The code lasts ${minutes} ` +
109
+ `minute${minutes === 1 ? '' : 's'}. Only confirm a code you asked for yourself.`;
110
+
111
+ this.node.append(intro, code, link, note);
112
+ }
113
+ }
@@ -0,0 +1,30 @@
1
+ import { ColorScheme, IOceanumUser } from './tokens';
2
+
3
+ const CLAIM = 'https://oceanum.io/';
4
+ const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark', 'auto'];
5
+
6
+ /** Build the user from Auth0 ID token claims; `null` if the claims lack an identity. */
7
+ export function userFromClaims(
8
+ claims: Record<string, unknown> | null | undefined
9
+ ): IOceanumUser | null {
10
+ if (!claims) {
11
+ return null;
12
+ }
13
+ const str = (value: unknown): string | null =>
14
+ typeof value === 'string' && value.length > 0 ? value : null;
15
+ const sub = str(claims.sub);
16
+ const email = str(claims.email) ?? str(claims[`${CLAIM}email`]);
17
+ if (!sub || !email) {
18
+ return null;
19
+ }
20
+ const scheme = claims[`${CLAIM}color_scheme`];
21
+ return {
22
+ sub,
23
+ email,
24
+ name: str(claims.name),
25
+ activeOrg: str(claims[`${CLAIM}active_org`]),
26
+ colorScheme: COLOR_SCHEMES.includes(scheme as ColorScheme)
27
+ ? (scheme as ColorScheme)
28
+ : null
29
+ };
30
+ }