@oceanum/oceanumlab 4.7.0 → 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.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +18 -2
- package/lib/DatameshWidget.js +70 -11
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +1 -1
- package/lib/aiBackend.js +1 -1
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +13 -1
- package/lib/chatRouter.js +9 -2
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +6 -6
- package/lib/index.d.ts +8 -1
- package/lib/index.js +56 -17
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +124 -19
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +5 -6
- package/src/__tests__/chatRouter.spec.ts +9 -15
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +1 -1
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +85 -36
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ISpecSummary, listNotebooks, partitionNotebooks } from '../specStore';
|
|
2
|
+
|
|
3
|
+
function summary(overrides: Partial<ISpecSummary> = {}): ISpecSummary {
|
|
4
|
+
return {
|
|
5
|
+
id: 'nb-1',
|
|
6
|
+
name: 'Wave analysis',
|
|
7
|
+
description: null,
|
|
8
|
+
modified: '2026-05-17T12:00:00',
|
|
9
|
+
creator: null,
|
|
10
|
+
...overrides
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function response(body: unknown, status = 200): Response {
|
|
15
|
+
return {
|
|
16
|
+
ok: status >= 200 && status < 300,
|
|
17
|
+
status,
|
|
18
|
+
json: async () => body
|
|
19
|
+
} as unknown as Response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('listNotebooks', () => {
|
|
23
|
+
it('asks the notebook spec type with the token as a bearer credential', async () => {
|
|
24
|
+
const calls: [string, RequestInit | undefined][] = [];
|
|
25
|
+
const fetcher = (async (url: string, init?: RequestInit) => {
|
|
26
|
+
calls.push([url, init]);
|
|
27
|
+
return response([]);
|
|
28
|
+
}) as unknown as typeof fetch;
|
|
29
|
+
|
|
30
|
+
await listNotebooks('https://specs.example.com', 'jwt', fetcher);
|
|
31
|
+
|
|
32
|
+
expect(calls[0][0]).toBe('https://specs.example.com/specs/notebook');
|
|
33
|
+
expect((calls[0][1]?.headers as Record<string, string>).Authorization).toBe(
|
|
34
|
+
'Bearer jwt'
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('tolerates a trailing slash on the service url', async () => {
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
const fetcher = (async (url: string) => {
|
|
41
|
+
calls.push(url);
|
|
42
|
+
return response([]);
|
|
43
|
+
}) as unknown as typeof fetch;
|
|
44
|
+
|
|
45
|
+
await listNotebooks('https://specs.example.com//', 'jwt', fetcher);
|
|
46
|
+
|
|
47
|
+
expect(calls[0]).toBe('https://specs.example.com/specs/notebook');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('reports the status rather than returning an empty list', async () => {
|
|
51
|
+
// The store answers a bad or expired token with 400, so silently returning []
|
|
52
|
+
// would look exactly like an account with no notebooks.
|
|
53
|
+
const fetcher = (async () =>
|
|
54
|
+
response({ detail: 'bad' }, 400)) as unknown as typeof fetch;
|
|
55
|
+
await expect(
|
|
56
|
+
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
57
|
+
).rejects.toThrow('HTTP 400');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a body that is not a list', async () => {
|
|
61
|
+
const fetcher = (async () =>
|
|
62
|
+
response({ detail: 'nope' })) as unknown as typeof fetch;
|
|
63
|
+
await expect(
|
|
64
|
+
listNotebooks('https://specs.example.com', 'jwt', fetcher)
|
|
65
|
+
).rejects.toThrow('unexpected response');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('drops entries that are not summaries rather than rendering undefined', async () => {
|
|
69
|
+
const fetcher = (async () =>
|
|
70
|
+
response([
|
|
71
|
+
summary({ id: 'good' }),
|
|
72
|
+
{ id: 'bad' },
|
|
73
|
+
null
|
|
74
|
+
])) as unknown as typeof fetch;
|
|
75
|
+
const items = await listNotebooks(
|
|
76
|
+
'https://specs.example.com',
|
|
77
|
+
'jwt',
|
|
78
|
+
fetcher
|
|
79
|
+
);
|
|
80
|
+
expect(items.map(item => item.id)).toEqual(['good']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('partitionNotebooks', () => {
|
|
85
|
+
it('splits the caller own records from the rest', () => {
|
|
86
|
+
const mine = summary({ id: 'mine', creator: 'dave@oceanum.science' });
|
|
87
|
+
const theirs = summary({ id: 'theirs', creator: null });
|
|
88
|
+
const { mine: own, shared } = partitionNotebooks(
|
|
89
|
+
[mine, theirs],
|
|
90
|
+
'dave@oceanum.science'
|
|
91
|
+
);
|
|
92
|
+
expect(own.map(i => i.id)).toEqual(['mine']);
|
|
93
|
+
expect(shared.map(i => i.id)).toEqual(['theirs']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('compares addresses case-insensitively', () => {
|
|
97
|
+
// The store matches entities as case-sensitive globs, so the claim and the stored
|
|
98
|
+
// creator can differ in case for the same person.
|
|
99
|
+
const { mine } = partitionNotebooks(
|
|
100
|
+
[summary({ creator: 'Dave@Oceanum.Science' })],
|
|
101
|
+
'dave@oceanum.science'
|
|
102
|
+
);
|
|
103
|
+
expect(mine).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('treats everything as shared when nobody is signed in', () => {
|
|
107
|
+
const { mine, shared } = partitionNotebooks(
|
|
108
|
+
[summary({ creator: null }), summary({ id: 'b', creator: 'a@b.com' })],
|
|
109
|
+
null
|
|
110
|
+
);
|
|
111
|
+
expect(mine).toEqual([]);
|
|
112
|
+
expect(shared).toHaveLength(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('orders each group newest first without mutating the input', () => {
|
|
116
|
+
const input = [
|
|
117
|
+
summary({ id: 'old', modified: '2026-01-01T00:00:00' }),
|
|
118
|
+
summary({ id: 'new', modified: '2026-05-17T00:00:00' })
|
|
119
|
+
];
|
|
120
|
+
expect(partitionNotebooks(input, null).shared.map(i => i.id)).toEqual([
|
|
121
|
+
'new',
|
|
122
|
+
'old'
|
|
123
|
+
]);
|
|
124
|
+
expect(input.map(i => i.id)).toEqual(['old', 'new']);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/aiBackend.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface IAiCredential {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* The backend address for
|
|
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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
6
|
+
import { PageConfig, URLExt } from '@jupyterlab/coreutils';
|
|
7
|
+
import { ServerConnection } from '@jupyterlab/services';
|
|
8
|
+
|
|
9
|
+
import { AccountWidget, CommandIDs, SignInBody } from './accountWidget';
|
|
10
|
+
import {
|
|
11
|
+
AuthRequest,
|
|
12
|
+
readServerAuthOption,
|
|
13
|
+
SERVER_AUTH_OPTION,
|
|
14
|
+
ServerAuth
|
|
15
|
+
} from './serverAuth';
|
|
16
|
+
import { IOceanumAuth } from './tokens';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The id other deployments disable. notebook.oceanum.io provides IOceanumAuth from its own
|
|
20
|
+
* Oceanum widget extension, and Lumino lets a second provider silently replace the first
|
|
21
|
+
* rather than refusing it, so that site lists this id under `disabledExtensions`.
|
|
22
|
+
*/
|
|
23
|
+
export const DEVICE_AUTH_PLUGIN_ID = '@oceanum/oceanumlab:device-auth';
|
|
24
|
+
|
|
25
|
+
function serverRequest(settings: ServerConnection.ISettings): AuthRequest {
|
|
26
|
+
return async (action, method) => {
|
|
27
|
+
const url = URLExt.join(settings.baseUrl, 'oceanum', 'auth', action);
|
|
28
|
+
const response = await ServerConnection.makeRequest(
|
|
29
|
+
url,
|
|
30
|
+
{ method },
|
|
31
|
+
settings
|
|
32
|
+
);
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new ServerConnection.ResponseError(response);
|
|
35
|
+
}
|
|
36
|
+
return response.json();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show the code and the link until the sign-in completes, fails, or is cancelled. */
|
|
41
|
+
async function showSignIn(auth: ServerAuth): Promise<void> {
|
|
42
|
+
const pending = auth.pending;
|
|
43
|
+
if (!pending) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const dialog = new Dialog({
|
|
47
|
+
title: 'Sign in to Oceanum.io',
|
|
48
|
+
body: new SignInBody(pending),
|
|
49
|
+
buttons: [Dialog.cancelButton()]
|
|
50
|
+
});
|
|
51
|
+
const closeWhenSettled = (): void => {
|
|
52
|
+
if (auth.pending === null) {
|
|
53
|
+
dialog.reject();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
auth.stateChanged.connect(closeWhenSettled);
|
|
57
|
+
auth.userChanged.connect(closeWhenSettled);
|
|
58
|
+
try {
|
|
59
|
+
await dialog.launch();
|
|
60
|
+
} finally {
|
|
61
|
+
auth.stateChanged.disconnect(closeWhenSettled);
|
|
62
|
+
auth.userChanged.disconnect(closeWhenSettled);
|
|
63
|
+
}
|
|
64
|
+
if (auth.pending) {
|
|
65
|
+
// Still waiting, so it was the user who closed it.
|
|
66
|
+
await auth.cancelSignIn();
|
|
67
|
+
} else if (!auth.user && auth.error) {
|
|
68
|
+
Notification.warning(`Oceanum.io sign-in did not complete: ${auth.error}`, {
|
|
69
|
+
autoClose: false
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const deviceAuthPlugin: JupyterFrontEndPlugin<IOceanumAuth> = {
|
|
75
|
+
id: DEVICE_AUTH_PLUGIN_ID,
|
|
76
|
+
description:
|
|
77
|
+
'Oceanum.io sign-in by device code, run by the Jupyter server extension.',
|
|
78
|
+
autoStart: true,
|
|
79
|
+
provides: IOceanumAuth,
|
|
80
|
+
optional: [ICommandPalette],
|
|
81
|
+
activate: (
|
|
82
|
+
app: JupyterFrontEnd,
|
|
83
|
+
palette: ICommandPalette | null
|
|
84
|
+
): IOceanumAuth => {
|
|
85
|
+
const environment = readServerAuthOption(
|
|
86
|
+
PageConfig.getOption(SERVER_AUTH_OPTION),
|
|
87
|
+
window.location.hostname
|
|
88
|
+
);
|
|
89
|
+
const auth = new ServerAuth({
|
|
90
|
+
environment,
|
|
91
|
+
request: serverRequest(app.serviceManager.serverSettings),
|
|
92
|
+
onSessionEnded: message =>
|
|
93
|
+
Notification.warning(message, { autoClose: false })
|
|
94
|
+
});
|
|
95
|
+
if (!environment) {
|
|
96
|
+
// The server offers no sign-in (`c.OceanumLab.sign_in = "off"`), or there is no
|
|
97
|
+
// oceanumlab server extension at all. Consumers see an auth with no environment and
|
|
98
|
+
// say sign-in is not configured; nothing is added to the top bar.
|
|
99
|
+
return auth;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
document.addEventListener('visibilitychange', () => {
|
|
103
|
+
if (document.visibilityState === 'visible') {
|
|
104
|
+
void auth.refresh();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { commands } = app;
|
|
109
|
+
let dialogOpen = false;
|
|
110
|
+
commands.addCommand(CommandIDs.signIn, {
|
|
111
|
+
label: 'Sign in to Oceanum.io',
|
|
112
|
+
isVisible: () => !auth.user,
|
|
113
|
+
execute: async () => {
|
|
114
|
+
await auth.signIn();
|
|
115
|
+
if (auth.pending && !dialogOpen) {
|
|
116
|
+
dialogOpen = true;
|
|
117
|
+
try {
|
|
118
|
+
await showSignIn(auth);
|
|
119
|
+
} finally {
|
|
120
|
+
dialogOpen = false;
|
|
121
|
+
}
|
|
122
|
+
} else if (!auth.user && !auth.pending && auth.error) {
|
|
123
|
+
Notification.warning(
|
|
124
|
+
`Oceanum.io sign-in could not start: ${auth.error}`,
|
|
125
|
+
{ autoClose: false }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
commands.addCommand(CommandIDs.signOut, {
|
|
131
|
+
label: 'Sign out of Oceanum.io',
|
|
132
|
+
isVisible: () => auth.user !== null,
|
|
133
|
+
execute: () => auth.signOut()
|
|
134
|
+
});
|
|
135
|
+
commands.addCommand(CommandIDs.account, {
|
|
136
|
+
label: () =>
|
|
137
|
+
auth.user ? `Signed in as ${auth.user.email}` : 'Not signed in',
|
|
138
|
+
isEnabled: () => false,
|
|
139
|
+
execute: () => undefined
|
|
140
|
+
});
|
|
141
|
+
commands.addCommand(CommandIDs.accessToken, {
|
|
142
|
+
label: 'Oceanum.io access token',
|
|
143
|
+
isVisible: () => false,
|
|
144
|
+
execute: () => auth.getAccessToken().catch((): null => null)
|
|
145
|
+
});
|
|
146
|
+
auth.userChanged.connect(() => commands.notifyCommandChanged());
|
|
147
|
+
|
|
148
|
+
if (palette) {
|
|
149
|
+
palette.addItem({ command: CommandIDs.signIn, category: 'Oceanum' });
|
|
150
|
+
palette.addItem({ command: CommandIDs.signOut, category: 'Oceanum' });
|
|
151
|
+
}
|
|
152
|
+
app.shell.add(new AccountWidget(auth, commands), 'top', { rank: 1000 });
|
|
153
|
+
return auth;
|
|
154
|
+
}
|
|
155
|
+
};
|