Browse Source

Extension disclaimers (#4925)

* React to extension manager settings change without refreshing the page.

* Add warning dialog upon enabling the extension manager..

* Add some branding for extensions developed under a Jupyter org.

* Make styling a bit more consistent.

* Most builds produce a warning, so the visual is kind of confusing.
Remove warning styling for now.

* Sort Jupyter-developed extensions to the top.

* Change to warn button.

* Style tweaks.

* Move header outside of scroll.

* Label buttons 'Enable' and 'Disable'.

* Expand the extension manager schema description.
Ian Rose 6 years ago
parent
commit
f1540643c2

+ 1 - 0
packages/extensionmanager-extension/package.json

@@ -32,6 +32,7 @@
   },
   "dependencies": {
     "@jupyterlab/application": "^0.17.0-1",
+    "@jupyterlab/apputils": "^0.17.0-1",
     "@jupyterlab/coreutils": "^2.0.0-1",
     "@jupyterlab/extensionmanager": "^0.17.0-1"
   },

+ 2 - 1
packages/extensionmanager-extension/schema/plugin.json

@@ -6,7 +6,8 @@
   "properties": {
     "enabled": {
       "title": "Enabled Status",
-      "description": "Requires Node.js/npm so the manager disabled by default.",
+      "description":
+        "Enables the extension manager (requires Node.js/npm, so disabled by default). WARNING: installing untrusted extensions can introduce security issues.",
       "default": false,
       "type": "boolean"
     }

+ 59 - 13
packages/extensionmanager-extension/src/index.ts

@@ -8,6 +8,8 @@ import {
   JupyterLabPlugin
 } from '@jupyterlab/application';
 
+import { Dialog, showDialog } from '@jupyterlab/apputils';
+
 import { ISettingRegistry } from '@jupyterlab/coreutils';
 
 import { ExtensionView } from '@jupyterlab/extensionmanager';
@@ -39,18 +41,7 @@ const plugin: JupyterLabPlugin<void> = {
     router: IRouter
   ) => {
     const settings = await registry.load(plugin.id);
-    const enabled = settings.composite['enabled'] === true;
-
-    // If the extension is enabled or disabled, refresh the page.
-    app.restored.then(() => {
-      settings.changed.connect(() => {
-        router.reload();
-      });
-    });
-
-    if (!enabled) {
-      return;
-    }
+    let enabled = settings.composite['enabled'] === true;
 
     const { shell, serviceManager } = app;
     const view = new ExtensionView(serviceManager);
@@ -58,7 +49,29 @@ const plugin: JupyterLabPlugin<void> = {
     view.id = 'extensionmanager.main-view';
     view.title.label = 'Extensions';
     restorer.add(view, view.id);
-    shell.addToLeftArea(view);
+
+    if (enabled) {
+      shell.addToLeftArea(view);
+    }
+
+    // If the extension is enabled or disabled,
+    // add or remove it from the left area.
+    app.restored.then(() => {
+      settings.changed.connect(async () => {
+        enabled = settings.composite['enabled'] === true;
+        if (enabled && !view.isAttached) {
+          const accepted = await Private.showWarning();
+          if (!accepted) {
+            settings.set('enabled', false);
+            return;
+          }
+          shell.addToLeftArea(view);
+        } else if (!enabled && view.isAttached) {
+          view.close();
+        }
+      });
+    });
+
     addCommands(app, view);
   }
 };
@@ -101,3 +114,36 @@ function addCommands(app: JupyterLab, view: ExtensionView): void {
  * Export the plugin as the default.
  */
 export default plugin;
+
+/**
+ * A namespace for module-private functions.
+ */
+namespace Private {
+  /**
+   * Show a warning dialog about extension security.
+   *
+   * @returns whether the user accepted the dialog.
+   */
+  export async function showWarning(): Promise<boolean> {
+    return showDialog({
+      title: 'Enable Extension Manager?',
+      body:
+        "Thanks for trying out JupyterLab's extension manager. " +
+        'The JupyterLab development team is excited to have a robust ' +
+        'third-party extension community. ' +
+        'However, we cannot vouch for every extension, ' +
+        'and some may introduce security risks. ' +
+        'Do you want to continue?',
+      buttons: [
+        Dialog.cancelButton({ label: 'DISABLE' }),
+        Dialog.warnButton({ label: 'ENABLE' })
+      ]
+    }).then(result => {
+      if (result.button.accept) {
+        return true;
+      } else {
+        return false;
+      }
+    });
+  }
+}

+ 28 - 3
packages/extensionmanager/src/model.ts

@@ -17,7 +17,7 @@ import {
 
 import { reportInstallError } from './dialog';
 
-import { Searcher, ISearchResult } from './query';
+import { Searcher, ISearchResult, isJupyterOrg } from './query';
 
 /**
  * Information about an extension.
@@ -344,7 +344,7 @@ export class ListModel extends VDomModel {
     for (let key of Object.keys(installedMap)) {
       installed.push(installedMap[key]);
     }
-    this._installed = installed;
+    this._installed = installed.sort(Private.comparator);
 
     let searchResult: IEntry[] = [];
     for (let key of Object.keys(searchMap)) {
@@ -354,7 +354,7 @@ export class ListModel extends VDomModel {
         searchResult.push(installedMap[key]);
       }
     }
-    this._searchResult = searchResult;
+    this._searchResult = searchResult.sort(Private.comparator);
     try {
       this._totalEntries = (await search).total;
     } catch (error) {
@@ -679,3 +679,28 @@ function handleError(response: Response): Response {
   }
   return response;
 }
+
+/**
+ * A namespace for private functionality.
+ */
+namespace Private {
+  /**
+   * A comparator function that sorts whitelisted orgs to the top.
+   */
+  export function comparator(a: IEntry, b: IEntry): number {
+    if (a.name === b.name) {
+      return 0;
+    }
+
+    let testA = isJupyterOrg(a.name);
+    let testB = isJupyterOrg(b.name);
+
+    if (testA === testB) {
+      return a.name.localeCompare(b.name);
+    } else if (testA && !testB) {
+      return -1;
+    } else {
+      return 1;
+    }
+  }
+}

+ 18 - 0
packages/extensionmanager/src/query.ts

@@ -282,3 +282,21 @@ export class Searcher {
    */
   cdnUri: string;
 }
+
+/**
+ * Check whether the NPM org is a Jupyter one.
+ */
+export function isJupyterOrg(name: string): boolean {
+  /**
+   * A list of whitelisted NPM orgs.
+   */
+  const whitelist = ['jupyterlab', 'jupyter-widgets'];
+  const parts = name.split('/');
+  const first = parts[0];
+  return (
+    parts.length > 1 && // Has a first part
+    first && // with a finite length
+    first[0] === '@' && // corresponding to an org name
+    whitelist.indexOf(first.slice(1)) !== -1 // in the org whitelist.
+  );
+}

+ 20 - 5
packages/extensionmanager/src/widget.tsx

@@ -13,6 +13,8 @@ import ReactPaginate from 'react-paginate';
 
 import { ListModel, IEntry, Action } from './model';
 
+import { isJupyterOrg } from './query';
+
 // TODO: Replace pagination with lazy loading of lower search results
 
 /**
@@ -149,9 +151,20 @@ function ListEntry(props: ListEntry.IProperties): React.ReactElement<any> {
   if (entry.status && ['ok', 'warning', 'error'].indexOf(entry.status) !== -1) {
     flagClasses.push(`jp-extensionmanager-entry-${entry.status}`);
   }
+  let title = entry.name;
+  if (isJupyterOrg(entry.name)) {
+    flagClasses.push(`jp-extensionmanager-entry-mod-whitelisted`);
+    title = `${entry.name} (Developed by Project Jupyter)`;
+  }
   return (
-    <li className={`jp-extensionmanager-entry ${flagClasses.join(' ')}`}>
-      <div className="jp-extensionmanager-entry-name">{entry.name}</div>
+    <li
+      className={`jp-extensionmanager-entry ${flagClasses.join(' ')}`}
+      title={title}
+    >
+      <div className="jp-extensionmanager-entry-title">
+        <div className="jp-extensionmanager-entry-name">{entry.name}</div>
+        <div className="jp-extensionmanager-entry-jupyter-org" />
+      </div>
       <div className="jp-extensionmanager-entry-content">
         <div className="jp-extensionmanager-entry-description">
           {entry.description}
@@ -359,7 +372,7 @@ export class ExtensionView extends VDomRenderer<ListModel> {
         </div>
       );
     } else if (!model.query && model.installed.length) {
-      content.push(
+      elements.push(
         <header key="installed-header">
           Installed<button
             className="jp-extensionmanager-refresh"
@@ -369,7 +382,9 @@ export class ExtensionView extends VDomRenderer<ListModel> {
           >
             &#8635;
           </button>
-        </header>,
+        </header>
+      );
+      content.push(
         <ListView
           key="installed"
           entries={model.installed}
@@ -381,8 +396,8 @@ export class ExtensionView extends VDomRenderer<ListModel> {
         />
       );
     } else if (model.searchError === null) {
+      elements.push(<header key="installable-header">Search results</header>);
       content.push(
-        <header key="installable-header">Search results</header>,
         <ListView
           key="installable"
           entries={model.searchResult}

+ 32 - 5
packages/extensionmanager/style/index.css

@@ -8,6 +8,7 @@
   background: var(--jp-layout-color1);
   display: flex;
   flex-direction: column;
+  font-size: var(--jp-ui-font-size1);
 }
 
 .jp-extensionmanager-content {
@@ -28,7 +29,14 @@
 }
 
 .jp-extensionmanager-view header {
-  padding: 0 5px;
+  flex: 0 0 auto;
+  margin: 4px 0px;
+  padding: 12px 0 4px 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+  border-bottom: var(--jp-border-width) solid var(--jp-border-color2);
+  letter-spacing: 1px;
+  font-size: var(--jp-ui-font-size0);
 }
 
 .jp-extensionmanager-listview {
@@ -51,7 +59,7 @@
 
 .jp-extensionmanager-search-bar {
   padding: 8px;
-  background-color: var(--jp-border-color3);
+  background-color: var(--jp-layout-color2);
   border-bottom: 1px solid var(--jp-border-color1);
   box-shadow: var(--jp-toolbar-box-shadow);
   z-index: 2;
@@ -73,7 +81,7 @@
   overflow: overlay;
   padding: 0px 8px;
   border: 1px solid var(--jp-border-color0);
-  background-color: var(--jp-input-background-color-active);
+  background-color: var(--jp-input-active-background);
   height: 30px;
 }
 
@@ -178,6 +186,26 @@
   border-bottom: solid var(--jp-border-width) var(--jp-border-color2);
 }
 
+.jp-extensionmanager-entry-title {
+  display: flex;
+  flex-direction: row;
+}
+
+.jp-extensionmanager-entry-jupyter-org {
+  background-image: var(--jp-image-jupyter);
+  background-size: 1em;
+  background-repeat: no-repeat;
+  width: 1em;
+  display: none;
+  position: relative;
+  top: 3px;
+}
+
+.jp-extensionmanager-entry.jp-extensionmanager-entry-mod-whitelisted
+  .jp-extensionmanager-entry-jupyter-org {
+  display: inline;
+}
+
 /* Precedence order update/error/warning matters! */
 .jp-extensionmanager-entry.jp-extensionmanager-entry-update {
   border-left: solid 8px var(--jp-brand-color2);
@@ -190,13 +218,12 @@
 }
 
 .jp-extensionmanager-entry.jp-extensionmanager-entry-warning {
-  border-left: solid 8px var(--jp-warn-color2);
-  padding-left: 4px;
 }
 
 .jp-extensionmanager-entry-name {
   font-size: var(--jp-ui-font-size1);
   font-weight: 600;
+  padding: 0 8px 0 0;
   margin-bottom: 3px;
 }
 .jp-extensionmanager-entry-description {