@oceanum/oceanumlab 4.6.2 → 4.7.1

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 (62) 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 +24 -2
  5. package/lib/DatameshWidget.js +171 -45
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +66 -0
  11. package/lib/aiBackend.js +101 -0
  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 +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +17 -1
  23. package/lib/chatRouter.js +34 -9
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +8 -16
  28. package/lib/httpUrl.d.ts +9 -0
  29. package/lib/httpUrl.js +20 -0
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +67 -15
  32. package/lib/specStore.d.ts +37 -0
  33. package/lib/specStore.js +57 -0
  34. package/package.json +9 -1
  35. package/schema/datamesh-connect.json +3 -6
  36. package/src/DatameshUI.tsx +1 -10
  37. package/src/DatameshWidget.tsx +294 -59
  38. package/src/StoredNotebooks.tsx +166 -0
  39. package/src/Tabs.tsx +102 -0
  40. package/src/__tests__/DatameshWidget.spec.tsx +393 -0
  41. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  42. package/src/__tests__/Tabs.spec.tsx +168 -0
  43. package/src/__tests__/aiBackend.spec.ts +271 -0
  44. package/src/__tests__/chatRouter.spec.ts +253 -17
  45. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  46. package/src/__tests__/pluginArity.spec.ts +24 -0
  47. package/src/__tests__/serverAuth.spec.ts +277 -0
  48. package/src/__tests__/settingsSchema.spec.ts +73 -0
  49. package/src/__tests__/specStore.spec.ts +126 -0
  50. package/src/aiBackend.ts +150 -0
  51. package/src/auth/accountWidget.ts +113 -0
  52. package/src/auth/claims.ts +30 -0
  53. package/src/auth/plugin.ts +155 -0
  54. package/src/auth/serverAuth.ts +341 -0
  55. package/src/auth/tokens.ts +104 -0
  56. package/src/chatRouter.ts +41 -9
  57. package/src/constants.ts +1 -1
  58. package/src/datameshUiUrl.ts +8 -15
  59. package/src/httpUrl.ts +23 -0
  60. package/src/index.ts +99 -33
  61. package/src/specStore.ts +82 -0
  62. package/style/index.css +158 -0
package/lib/Tabs.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import * as React from 'react';
2
+ export interface ITab {
3
+ /** Stable key, also used as the restored selection value. */
4
+ id: string;
5
+ label: string;
6
+ render: () => React.ReactElement;
7
+ }
8
+ export interface ITabsProps {
9
+ tabs: readonly ITab[];
10
+ /** Selected tab id. Falls back to the first tab when it names no tab. */
11
+ selected: string;
12
+ onSelect: (id: string) => void;
13
+ }
14
+ /**
15
+ * A tab strip over a single visible pane.
16
+ *
17
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
18
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
19
+ * to reach the ends.
20
+ *
21
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
22
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
23
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
24
+ * the user is in the middle of.
25
+ */
26
+ export declare function Tabs({ tabs, selected, onSelect }: ITabsProps): React.ReactElement;
package/lib/Tabs.js ADDED
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * A tab strip over a single visible pane.
4
+ *
5
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
6
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
7
+ * to reach the ends.
8
+ *
9
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
10
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
11
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
12
+ * the user is in the middle of.
13
+ */
14
+ export function Tabs({ tabs, selected, onSelect }) {
15
+ const index = Math.max(0, tabs.findIndex(tab => tab.id === selected));
16
+ const active = tabs[index];
17
+ const onKeyDown = (event) => {
18
+ const last = tabs.length - 1;
19
+ let next = null;
20
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
21
+ next = index === last ? 0 : index + 1;
22
+ }
23
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
24
+ next = index === 0 ? last : index - 1;
25
+ }
26
+ else if (event.key === 'Home') {
27
+ next = 0;
28
+ }
29
+ else if (event.key === 'End') {
30
+ next = last;
31
+ }
32
+ if (next === null) {
33
+ return;
34
+ }
35
+ // Otherwise the arrow keys also scroll the panel.
36
+ event.preventDefault();
37
+ onSelect(tabs[next].id);
38
+ };
39
+ return (React.createElement("div", { className: "oceanum-tabs" },
40
+ React.createElement("div", { className: "oceanum-tabs-strip", role: "tablist", "aria-label": "Oceanum.io" }, tabs.map((tab, i) => (React.createElement("button", { key: tab.id, id: `oceanum-tab-${tab.id}`, className: i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab', role: "tab", type: "button", "aria-selected": i === index, "aria-controls": `oceanum-tabpanel-${tab.id}`,
41
+ // Only the selected tab is in the tab order, so Tab moves past the strip
42
+ // rather than through every tab in it.
43
+ tabIndex: i === index ? 0 : -1, onClick: () => onSelect(tab.id), onKeyDown: onKeyDown }, tab.label)))),
44
+ tabs.map(tab => (React.createElement("div", { key: tab.id, className: "oceanum-tabpanel", id: `oceanum-tabpanel-${tab.id}`, role: "tabpanel", "aria-labelledby": `oceanum-tab-${tab.id}`, hidden: tab.id !== active.id }, tab.render())))));
45
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Where the Oceanum AI backend is, and what a request to it is signed with.
3
+ *
4
+ * The backend accepts either a Datamesh token (`X-Datamesh-Token`) or an
5
+ * Oceanum.io access token (`Authorization: Bearer`). A token pasted into the
6
+ * settings is explicit, so it wins; otherwise a host that signs users in to
7
+ * Oceanum.io -- Oceanum Notebook -- lends its sign-in through its commands.
8
+ * Plain JupyterLab has no such commands and needs the pasted token.
9
+ *
10
+ * Whether the user is signed in, or can sign in, is read with the registry's
11
+ * queries (`isVisible`, `isEnabled`), never by running a command: every
12
+ * `execute` emits `commandExecuted`, which closes JupyterLab's command palette.
13
+ * The access-token command is run only when a token is actually needed.
14
+ */
15
+ import type { CommandRegistry } from '@lumino/commands';
16
+ /**
17
+ * The host's command answering with the current Oceanum.io access token (a
18
+ * JWT), or null when signed out. The token is cached and refreshed by the
19
+ * host, so it is asked for on every use rather than kept.
20
+ */
21
+ export declare const ACCESS_TOKEN_COMMAND = "oceanum-auth:access-token";
22
+ /**
23
+ * The host's command that starts signing in to Oceanum.io. Enabled only where
24
+ * sign-in is configured for the site.
25
+ */
26
+ export declare const SIGN_IN_COMMAND = "oceanum-auth:sign-in";
27
+ /** The host's command that signs out. Visible only while signed in. */
28
+ export declare const SIGN_OUT_COMMAND = "oceanum-auth:sign-out";
29
+ /** The part of the command registry the sign-in is reached through. */
30
+ export type AuthCommands = Pick<CommandRegistry, 'hasCommand' | 'execute' | 'isEnabled' | 'isVisible'>;
31
+ export type AiCredentialSource = 'datamesh-token' | 'sign-in';
32
+ export interface IAiCredential {
33
+ /** Which credential it is: the 401 message differs. */
34
+ source: AiCredentialSource;
35
+ headers: Record<string, string>;
36
+ }
37
+ /**
38
+ * The backend address for a configured value, without a trailing
39
+ * slash so a path can be appended. An invalid value falls back to the default.
40
+ */
41
+ export declare function aiBackendUrl(value: string | null | undefined): string;
42
+ /** A `datameshToken` setting value, trimmed; '' when there is none. */
43
+ export declare function pastedToken(datameshToken: unknown): string;
44
+ /** Whether the host can sign the user in to Oceanum.io on this site. */
45
+ export declare function canSignIn(commands: AuthCommands): boolean;
46
+ /** Whether the host says the user is signed in to Oceanum.io. */
47
+ export declare function isSignedIn(commands: AuthCommands): boolean;
48
+ /**
49
+ * Which credential a request would carry, without fetching it: runs no
50
+ * command, so it is safe to ask whenever something changes.
51
+ */
52
+ export declare function aiCredentialSource(datameshToken: unknown, commands: AuthCommands): AiCredentialSource | null;
53
+ /**
54
+ * Asks the host for its Oceanum.io access token; null without the command.
55
+ * This runs a command: call it only when a token is needed for a request.
56
+ */
57
+ export declare function signInToken(commands: AuthCommands): () => Promise<unknown>;
58
+ /**
59
+ * The credential for a request: the pasted Datamesh token if there is one,
60
+ * otherwise the host's sign-in, otherwise null.
61
+ *
62
+ * @param datameshToken The `datameshToken` setting value.
63
+ * @param getSignInToken Asked only without a pasted token. Anything but a
64
+ * non-empty string -- including a rejection -- counts as signed out.
65
+ */
66
+ export declare function resolveAiCredential(datameshToken: unknown, getSignInToken: () => Promise<unknown>): Promise<IAiCredential | null>;
@@ -0,0 +1,101 @@
1
+ import { OCEANUM_AI_BACKEND_URL } from './constants';
2
+ import { validHttpUrl } from './httpUrl';
3
+ /**
4
+ * The host's command answering with the current Oceanum.io access token (a
5
+ * JWT), or null when signed out. The token is cached and refreshed by the
6
+ * host, so it is asked for on every use rather than kept.
7
+ */
8
+ export const ACCESS_TOKEN_COMMAND = 'oceanum-auth:access-token';
9
+ /**
10
+ * The host's command that starts signing in to Oceanum.io. Enabled only where
11
+ * sign-in is configured for the site.
12
+ */
13
+ export const SIGN_IN_COMMAND = 'oceanum-auth:sign-in';
14
+ /** The host's command that signs out. Visible only while signed in. */
15
+ export const SIGN_OUT_COMMAND = 'oceanum-auth:sign-out';
16
+ /**
17
+ * The backend address for a configured value, without a trailing
18
+ * slash so a path can be appended. An invalid value falls back to the default.
19
+ */
20
+ export function aiBackendUrl(value) {
21
+ const url = validHttpUrl(value, OCEANUM_AI_BACKEND_URL);
22
+ return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
23
+ }
24
+ /** A `datameshToken` setting value, trimmed; '' when there is none. */
25
+ export function pastedToken(datameshToken) {
26
+ return typeof datameshToken === 'string' ? datameshToken.trim() : '';
27
+ }
28
+ /**
29
+ * Asks a query of the host's command, treating a missing command, or a query
30
+ * that throws, as false. Queries never emit `commandExecuted`.
31
+ */
32
+ function query(commands, id, which) {
33
+ try {
34
+ return commands.hasCommand(id) && commands[which](id);
35
+ }
36
+ catch (_a) {
37
+ return false;
38
+ }
39
+ }
40
+ /** Whether the host can sign the user in to Oceanum.io on this site. */
41
+ export function canSignIn(commands) {
42
+ return query(commands, SIGN_IN_COMMAND, 'isEnabled');
43
+ }
44
+ /** Whether the host says the user is signed in to Oceanum.io. */
45
+ export function isSignedIn(commands) {
46
+ return query(commands, SIGN_OUT_COMMAND, 'isVisible');
47
+ }
48
+ /**
49
+ * Which credential a request would carry, without fetching it: runs no
50
+ * command, so it is safe to ask whenever something changes.
51
+ */
52
+ export function aiCredentialSource(datameshToken, commands) {
53
+ if (pastedToken(datameshToken)) {
54
+ return 'datamesh-token';
55
+ }
56
+ if (commands.hasCommand(ACCESS_TOKEN_COMMAND) && isSignedIn(commands)) {
57
+ return 'sign-in';
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Asks the host for its Oceanum.io access token; null without the command.
63
+ * This runs a command: call it only when a token is needed for a request.
64
+ */
65
+ export function signInToken(commands) {
66
+ return async () => commands.hasCommand(ACCESS_TOKEN_COMMAND)
67
+ ? commands.execute(ACCESS_TOKEN_COMMAND)
68
+ : null;
69
+ }
70
+ /**
71
+ * The credential for a request: the pasted Datamesh token if there is one,
72
+ * otherwise the host's sign-in, otherwise null.
73
+ *
74
+ * @param datameshToken The `datameshToken` setting value.
75
+ * @param getSignInToken Asked only without a pasted token. Anything but a
76
+ * non-empty string -- including a rejection -- counts as signed out.
77
+ */
78
+ export async function resolveAiCredential(datameshToken, getSignInToken) {
79
+ const pasted = pastedToken(datameshToken);
80
+ if (pasted) {
81
+ return {
82
+ source: 'datamesh-token',
83
+ headers: { 'X-Datamesh-Token': pasted }
84
+ };
85
+ }
86
+ let token;
87
+ try {
88
+ token = await getSignInToken();
89
+ }
90
+ catch (_a) {
91
+ return null;
92
+ }
93
+ const signedIn = typeof token === 'string' ? token.trim() : '';
94
+ if (!signedIn) {
95
+ return null;
96
+ }
97
+ return {
98
+ source: 'sign-in',
99
+ headers: { Authorization: `Bearer ${signedIn}` }
100
+ };
101
+ }
@@ -0,0 +1,35 @@
1
+ import { CommandRegistry } from '@lumino/commands';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { IPendingSignIn, ServerAuth } from './serverAuth';
4
+ export declare const CommandIDs: {
5
+ readonly signIn: "oceanum-auth:sign-in";
6
+ readonly signOut: "oceanum-auth:sign-out";
7
+ readonly account: "oceanum-auth:account";
8
+ /**
9
+ * For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
10
+ * the current access token, or `null` when signed out. Never rejects.
11
+ */
12
+ readonly accessToken: "oceanum-auth:access-token";
13
+ };
14
+ /**
15
+ * The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
16
+ * menu when signed in.
17
+ */
18
+ export declare class AccountWidget extends Widget {
19
+ constructor(auth: ServerAuth, commands: CommandRegistry);
20
+ dispose(): void;
21
+ private _onClick;
22
+ private _render;
23
+ private _auth;
24
+ private _commands;
25
+ private _menu;
26
+ }
27
+ /**
28
+ * What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
29
+ * it. A real link rather than `window.open`, which a popup blocker stops once there has
30
+ * been an `await` between the click and the call -- and there always has, because the code
31
+ * comes from the server.
32
+ */
33
+ export declare class SignInBody extends Widget {
34
+ constructor(pending: IPendingSignIn);
35
+ }
@@ -0,0 +1,95 @@
1
+ import { Menu, Widget } from '@lumino/widgets';
2
+ export const CommandIDs = {
3
+ signIn: 'oceanum-auth:sign-in',
4
+ signOut: 'oceanum-auth:sign-out',
5
+ account: 'oceanum-auth:account',
6
+ /**
7
+ * For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
8
+ * the current access token, or `null` when signed out. Never rejects.
9
+ */
10
+ accessToken: 'oceanum-auth:access-token'
11
+ };
12
+ /**
13
+ * The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
14
+ * menu when signed in.
15
+ */
16
+ export class AccountWidget extends Widget {
17
+ constructor(auth, commands) {
18
+ super({ node: document.createElement('button') });
19
+ this._onClick = () => {
20
+ if (!this._auth.user) {
21
+ void this._commands.execute(CommandIDs.signIn);
22
+ return;
23
+ }
24
+ const rect = this.node.getBoundingClientRect();
25
+ this._menu.open(rect.left, rect.bottom);
26
+ };
27
+ this._auth = auth;
28
+ this._commands = commands;
29
+ this._menu = new Menu({ commands });
30
+ this._menu.addItem({ command: CommandIDs.account });
31
+ this._menu.addItem({ type: 'separator' });
32
+ this._menu.addItem({ command: CommandIDs.signOut });
33
+ // The application shell refuses widgets without an id.
34
+ this.id = 'oceanum-account';
35
+ this.addClass('oc-Account');
36
+ this.node.setAttribute('type', 'button');
37
+ this.node.addEventListener('click', this._onClick);
38
+ auth.userChanged.connect(this._render, this);
39
+ auth.stateChanged.connect(this._render, this);
40
+ void auth.ready.then(() => this._render());
41
+ this._render();
42
+ }
43
+ dispose() {
44
+ if (this.isDisposed) {
45
+ return;
46
+ }
47
+ this.node.removeEventListener('click', this._onClick);
48
+ this._menu.dispose();
49
+ super.dispose();
50
+ }
51
+ _render() {
52
+ const user = this._auth.user;
53
+ const waiting = !user && this._auth.pending !== null;
54
+ this.node.textContent = user
55
+ ? user.email
56
+ : waiting
57
+ ? 'Signing in to Oceanum.io…'
58
+ : 'Sign in to Oceanum.io';
59
+ this.node.title = user
60
+ ? `Signed in to Oceanum.io as ${user.email}`
61
+ : 'Sign in to Oceanum.io';
62
+ this.toggleClass('oc-mod-signedIn', user !== null);
63
+ }
64
+ }
65
+ /**
66
+ * What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
67
+ * it. A real link rather than `window.open`, which a popup blocker stops once there has
68
+ * been an `await` between the click and the call -- and there always has, because the code
69
+ * comes from the server.
70
+ */
71
+ export class SignInBody extends Widget {
72
+ constructor(pending) {
73
+ super();
74
+ this.addClass('oc-SignIn');
75
+ const intro = document.createElement('p');
76
+ intro.textContent =
77
+ 'Open the Oceanum.io sign-in page, and check that it shows this code:';
78
+ const code = document.createElement('div');
79
+ code.className = 'oc-SignIn-code';
80
+ code.textContent = pending.userCode;
81
+ const link = document.createElement('a');
82
+ link.className = 'oc-SignIn-link jp-mod-styled jp-mod-accept';
83
+ link.href = pending.verificationUriComplete;
84
+ link.target = '_blank';
85
+ link.rel = 'noopener noreferrer';
86
+ link.textContent = 'Open the sign-in page';
87
+ const note = document.createElement('p');
88
+ note.className = 'oc-SignIn-note';
89
+ const minutes = Math.max(1, Math.round(pending.expiresIn / 60));
90
+ note.textContent =
91
+ `This closes by itself once you have signed in. The code lasts ${minutes} ` +
92
+ `minute${minutes === 1 ? '' : 's'}. Only confirm a code you asked for yourself.`;
93
+ this.node.append(intro, code, link, note);
94
+ }
95
+ }
@@ -0,0 +1,3 @@
1
+ import { IOceanumUser } from './tokens';
2
+ /** Build the user from Auth0 ID token claims; `null` if the claims lack an identity. */
3
+ export declare function userFromClaims(claims: Record<string, unknown> | null | undefined): IOceanumUser | null;
@@ -0,0 +1,25 @@
1
+ const CLAIM = 'https://oceanum.io/';
2
+ const COLOR_SCHEMES = ['light', 'dark', 'auto'];
3
+ /** Build the user from Auth0 ID token claims; `null` if the claims lack an identity. */
4
+ export function userFromClaims(claims) {
5
+ var _a;
6
+ if (!claims) {
7
+ return null;
8
+ }
9
+ const str = (value) => typeof value === 'string' && value.length > 0 ? value : null;
10
+ const sub = str(claims.sub);
11
+ const email = (_a = str(claims.email)) !== null && _a !== void 0 ? _a : str(claims[`${CLAIM}email`]);
12
+ if (!sub || !email) {
13
+ return null;
14
+ }
15
+ const scheme = claims[`${CLAIM}color_scheme`];
16
+ return {
17
+ sub,
18
+ email,
19
+ name: str(claims.name),
20
+ activeOrg: str(claims[`${CLAIM}active_org`]),
21
+ colorScheme: COLOR_SCHEMES.includes(scheme)
22
+ ? scheme
23
+ : null
24
+ };
25
+ }
@@ -0,0 +1,9 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ import { IOceanumAuth } from './tokens';
3
+ /**
4
+ * The id other deployments disable. notebook.oceanum.io provides IOceanumAuth from its own
5
+ * Oceanum widget extension, and Lumino lets a second provider silently replace the first
6
+ * rather than refusing it, so that site lists this id under `disabledExtensions`.
7
+ */
8
+ export declare const DEVICE_AUTH_PLUGIN_ID = "@oceanum/oceanumlab:device-auth";
9
+ export declare const deviceAuthPlugin: JupyterFrontEndPlugin<IOceanumAuth>;
@@ -0,0 +1,126 @@
1
+ import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
2
+ import { PageConfig, URLExt } from '@jupyterlab/coreutils';
3
+ import { ServerConnection } from '@jupyterlab/services';
4
+ import { AccountWidget, CommandIDs, SignInBody } from './accountWidget';
5
+ import { readServerAuthOption, SERVER_AUTH_OPTION, ServerAuth } from './serverAuth';
6
+ import { IOceanumAuth } from './tokens';
7
+ /**
8
+ * The id other deployments disable. notebook.oceanum.io provides IOceanumAuth from its own
9
+ * Oceanum widget extension, and Lumino lets a second provider silently replace the first
10
+ * rather than refusing it, so that site lists this id under `disabledExtensions`.
11
+ */
12
+ export const DEVICE_AUTH_PLUGIN_ID = '@oceanum/oceanumlab:device-auth';
13
+ function serverRequest(settings) {
14
+ return async (action, method) => {
15
+ const url = URLExt.join(settings.baseUrl, 'oceanum', 'auth', action);
16
+ const response = await ServerConnection.makeRequest(url, { method }, settings);
17
+ if (!response.ok) {
18
+ throw new ServerConnection.ResponseError(response);
19
+ }
20
+ return response.json();
21
+ };
22
+ }
23
+ /** Show the code and the link until the sign-in completes, fails, or is cancelled. */
24
+ async function showSignIn(auth) {
25
+ const pending = auth.pending;
26
+ if (!pending) {
27
+ return;
28
+ }
29
+ const dialog = new Dialog({
30
+ title: 'Sign in to Oceanum.io',
31
+ body: new SignInBody(pending),
32
+ buttons: [Dialog.cancelButton()]
33
+ });
34
+ const closeWhenSettled = () => {
35
+ if (auth.pending === null) {
36
+ dialog.reject();
37
+ }
38
+ };
39
+ auth.stateChanged.connect(closeWhenSettled);
40
+ auth.userChanged.connect(closeWhenSettled);
41
+ try {
42
+ await dialog.launch();
43
+ }
44
+ finally {
45
+ auth.stateChanged.disconnect(closeWhenSettled);
46
+ auth.userChanged.disconnect(closeWhenSettled);
47
+ }
48
+ if (auth.pending) {
49
+ // Still waiting, so it was the user who closed it.
50
+ await auth.cancelSignIn();
51
+ }
52
+ else if (!auth.user && auth.error) {
53
+ Notification.warning(`Oceanum.io sign-in did not complete: ${auth.error}`, {
54
+ autoClose: false
55
+ });
56
+ }
57
+ }
58
+ export const deviceAuthPlugin = {
59
+ id: DEVICE_AUTH_PLUGIN_ID,
60
+ description: 'Oceanum.io sign-in by device code, run by the Jupyter server extension.',
61
+ autoStart: true,
62
+ provides: IOceanumAuth,
63
+ optional: [ICommandPalette],
64
+ activate: (app, palette) => {
65
+ const environment = readServerAuthOption(PageConfig.getOption(SERVER_AUTH_OPTION), window.location.hostname);
66
+ const auth = new ServerAuth({
67
+ environment,
68
+ request: serverRequest(app.serviceManager.serverSettings),
69
+ onSessionEnded: message => Notification.warning(message, { autoClose: false })
70
+ });
71
+ if (!environment) {
72
+ // The server offers no sign-in (`c.OceanumLab.sign_in = "off"`), or there is no
73
+ // oceanumlab server extension at all. Consumers see an auth with no environment and
74
+ // say sign-in is not configured; nothing is added to the top bar.
75
+ return auth;
76
+ }
77
+ document.addEventListener('visibilitychange', () => {
78
+ if (document.visibilityState === 'visible') {
79
+ void auth.refresh();
80
+ }
81
+ });
82
+ const { commands } = app;
83
+ let dialogOpen = false;
84
+ commands.addCommand(CommandIDs.signIn, {
85
+ label: 'Sign in to Oceanum.io',
86
+ isVisible: () => !auth.user,
87
+ execute: async () => {
88
+ await auth.signIn();
89
+ if (auth.pending && !dialogOpen) {
90
+ dialogOpen = true;
91
+ try {
92
+ await showSignIn(auth);
93
+ }
94
+ finally {
95
+ dialogOpen = false;
96
+ }
97
+ }
98
+ else if (!auth.user && !auth.pending && auth.error) {
99
+ Notification.warning(`Oceanum.io sign-in could not start: ${auth.error}`, { autoClose: false });
100
+ }
101
+ }
102
+ });
103
+ commands.addCommand(CommandIDs.signOut, {
104
+ label: 'Sign out of Oceanum.io',
105
+ isVisible: () => auth.user !== null,
106
+ execute: () => auth.signOut()
107
+ });
108
+ commands.addCommand(CommandIDs.account, {
109
+ label: () => auth.user ? `Signed in as ${auth.user.email}` : 'Not signed in',
110
+ isEnabled: () => false,
111
+ execute: () => undefined
112
+ });
113
+ commands.addCommand(CommandIDs.accessToken, {
114
+ label: 'Oceanum.io access token',
115
+ isVisible: () => false,
116
+ execute: () => auth.getAccessToken().catch(() => null)
117
+ });
118
+ auth.userChanged.connect(() => commands.notifyCommandChanged());
119
+ if (palette) {
120
+ palette.addItem({ command: CommandIDs.signIn, category: 'Oceanum' });
121
+ palette.addItem({ command: CommandIDs.signOut, category: 'Oceanum' });
122
+ }
123
+ app.shell.add(new AccountWidget(auth, commands), 'top', { rank: 1000 });
124
+ return auth;
125
+ }
126
+ };
@@ -0,0 +1,79 @@
1
+ import { IDisposable } from '@lumino/disposable';
2
+ import { ISignal } from '@lumino/signaling';
3
+ import { IOceanumAuth, IOceanumEnvironment, IOceanumServiceUrls, IOceanumUser } from './tokens';
4
+ /**
5
+ * The page option the server extension publishes when it runs the device sign-in. Must
6
+ * match SERVER_AUTH_OPTION in oceanumlab/__init__.py.
7
+ */
8
+ export declare const SERVER_AUTH_OPTION = "oceanumServerAuth";
9
+ /** A sign-in waiting on the user: the code to confirm, and where to confirm it. */
10
+ export interface IPendingSignIn {
11
+ readonly userCode: string;
12
+ readonly verificationUri: string;
13
+ /** The verification page with the code already filled in. */
14
+ readonly verificationUriComplete: string;
15
+ /** Seconds until the code lapses. */
16
+ readonly expiresIn: number;
17
+ }
18
+ /** One exchange with the server extension's `/oceanum/auth/<action>` handler. */
19
+ export type AuthRequest = (action: 'status' | 'token' | 'start' | 'cancel' | 'signout', method: 'GET' | 'POST') => Promise<unknown>;
20
+ /**
21
+ * The Oceanum deployment the server signs in to, from the page option, or `null` when the
22
+ * server offers no device sign-in (or the option is malformed, which is treated the same).
23
+ */
24
+ export declare function readServerAuthOption(option: string | undefined, hostname: string): IOceanumEnvironment | null;
25
+ export interface IServerAuthOptions {
26
+ /** The deployment the server signs in to, or `null` when it offers no sign-in. */
27
+ environment: IOceanumEnvironment | null;
28
+ request: AuthRequest;
29
+ /** Called when a session ends other than by `signOut()`. */
30
+ onSessionEnded?: (message: string) => void;
31
+ /** How often to ask the server whether a pending sign-in has completed. */
32
+ pendingPollMs?: number;
33
+ /** How often to pick up a refreshed access token while signed in. */
34
+ tokenPollMs?: number;
35
+ }
36
+ export declare class ServerAuth implements IOceanumAuth, IDisposable {
37
+ constructor(options: IServerAuthOptions);
38
+ readonly environment: IOceanumEnvironment | null;
39
+ get urls(): IOceanumServiceUrls | null;
40
+ get ready(): Promise<void>;
41
+ get user(): IOceanumUser | null;
42
+ get userChanged(): ISignal<IOceanumAuth, IOceanumUser | null>;
43
+ get tokenChanged(): ISignal<IOceanumAuth, string | null>;
44
+ /** The sign-in waiting on the user, if any. */
45
+ get pending(): IPendingSignIn | null;
46
+ /** Why the last sign-in attempt ended, if it failed. */
47
+ get error(): string | null;
48
+ /** Emits when `pending` or `error` changes. */
49
+ get stateChanged(): ISignal<ServerAuth, void>;
50
+ signIn(): Promise<void>;
51
+ /** Abandon a sign-in that is waiting on the user. */
52
+ cancelSignIn(): Promise<void>;
53
+ signOut(): Promise<void>;
54
+ getAccessToken(): Promise<string | null>;
55
+ /** Re-read the server's state, e.g. when the tab becomes visible again. */
56
+ refresh(): Promise<void>;
57
+ get isDisposed(): boolean;
58
+ dispose(): void;
59
+ private _sync;
60
+ private _apply;
61
+ /** Poll quickly while a sign-in is pending, slowly while signed in, not at all otherwise. */
62
+ private _schedule;
63
+ private _clearTimer;
64
+ private _request;
65
+ private _onSessionEnded;
66
+ private _pendingPollMs;
67
+ private _tokenPollMs;
68
+ private _ready;
69
+ private _user;
70
+ private _token;
71
+ private _pending;
72
+ private _error;
73
+ private _signingOut;
74
+ private _disposed;
75
+ private _timer;
76
+ private _userChanged;
77
+ private _tokenChanged;
78
+ private _stateChanged;
79
+ }