瀏覽代碼

Merge pull request #7989 from datalayer-contrib/bw-list

Black and White listings for the Extension Manager
Saul Shanabrook 5 年之前
父節點
當前提交
846570897e
共有 44 個文件被更改,包括 1294 次插入102 次删除
  1. 1 0
      .eslintignore
  2. 1 0
      .gitignore
  3. 1 0
      .prettierignore
  4. 9 0
      docs/source/developer/extension_dev.rst
  5. 0 1
      docs/source/index.rst
  6. 193 2
      docs/source/user/extensions.rst
  7. 二進制
      docs/source/user/images/listings/disclaimer_checked.png
  8. 二進制
      docs/source/user/images/listings/disclaimer_hidden.png
  9. 二進制
      docs/source/user/images/listings/disclaimer_unchecked.png
  10. 二進制
      docs/source/user/images/listings/disclaimer_unchecked_noinstall.png
  11. 二進制
      docs/source/user/images/listings/installed_blacklisted.png
  12. 二進制
      docs/source/user/images/listings/installed_whitelisted.png
  13. 2 1
      jupyterlab/extension.py
  14. 31 0
      packages/extensionmanager-extension/examples/listings/Makefile
  15. 21 0
      packages/extensionmanager-extension/examples/listings/README.md
  16. 54 0
      packages/extensionmanager-extension/examples/listings/index.js
  17. 二進制
      packages/extensionmanager-extension/examples/listings/jupyter.png
  18. 39 0
      packages/extensionmanager-extension/examples/listings/list/blacklist.json
  19. 25 0
      packages/extensionmanager-extension/examples/listings/list/whitelist.json
  20. 46 0
      packages/extensionmanager-extension/examples/listings/main.py
  21. 63 0
      packages/extensionmanager-extension/examples/listings/package.json
  22. 15 0
      packages/extensionmanager-extension/examples/listings/settings/@jupyterlab/extensionmanager-extension/plugin.jupyterlab-settings
  23. 59 0
      packages/extensionmanager-extension/examples/listings/templates/error.html
  24. 29 0
      packages/extensionmanager-extension/examples/listings/templates/index.html
  25. 70 0
      packages/extensionmanager-extension/examples/listings/webpack.config.js
  26. 1 0
      packages/extensionmanager-extension/package.json
  27. 6 0
      packages/extensionmanager-extension/schema/plugin.json
  28. 25 17
      packages/extensionmanager-extension/src/index.ts
  29. 3 0
      packages/extensionmanager/package.json
  30. 2 1
      packages/extensionmanager/src/index.ts
  31. 131 0
      packages/extensionmanager/src/listings.ts
  32. 126 14
      packages/extensionmanager/src/model.ts
  33. 3 3
      packages/extensionmanager/src/npm.ts
  34. 212 54
      packages/extensionmanager/src/widget.tsx
  35. 47 1
      packages/extensionmanager/style/base.css
  36. 6 0
      packages/extensionmanager/tsconfig.json
  37. 1 1
      packages/terminal-extension/src/index.ts
  38. 8 0
      packages/ui-components/src/blueprint.tsx
  39. 2 0
      packages/ui-components/src/icon/iconimports.ts
  40. 4 0
      packages/ui-components/style/deprecated.css
  41. 0 5
      packages/ui-components/style/deprecated/search-white.svg
  42. 1 1
      packages/ui-components/style/deprecatedExtra.css
  43. 56 0
      packages/ui-components/style/icons/listings/listings-info.svg
  44. 1 1
      setup.py

+ 1 - 0
.eslintignore

@@ -19,6 +19,7 @@ examples/chrome-example-test.js
 jupyterlab/chrome-test.js
 jupyterlab/geckodriver
 jupyterlab/schemas
+packages/extensionmanager-extension/examples/listings
 jupyterlab/staging/index.js
 jupyterlab/staging/yarn.js
 jupyterlab/themes

+ 1 - 0
.gitignore

@@ -10,6 +10,7 @@ jupyterlab/themes
 jupyterlab/geckodriver
 
 dev_mode/schemas
+dev_mode/listings
 dev_mode/static
 dev_mode/themes
 dev_mode/workspaces

+ 1 - 0
.prettierignore

@@ -22,6 +22,7 @@ jupyterlab/geckodriver
 jupyterlab/staging/yarn.js
 jupyterlab/staging/index.js
 packages/ui-components/src/icon/iconimports.ts
+packages/extensionmanager/examples/listings
 
 # jetbrains IDE stuff
 .idea/

+ 9 - 0
docs/source/developer/extension_dev.rst

@@ -722,3 +722,12 @@ release process, but this could also be done manually.
 Technically, a package that contains only a JupyterLab extension could be created
 and published on ``conda-forge``, but it would not be discoverable by the JupyterLab
 extension manager.
+
+
+Listings
+^^^^^^^^
+
+You can develop on the extension manager package and :ref:`listings` with the 
+example shipped in the ``packages/extensionmanager-extension/examples/listings`` folder.
+
+Follow the ``README.md`` instructions in that folder.

+ 0 - 1
docs/source/index.rst

@@ -44,7 +44,6 @@ JupyterLab is the next-generation web-based user interface for Project Jupyter.
    user/jupyterhub
    user/export
 
-
 .. toctree::
    :maxdepth: 1
    :caption: Developer Guide

+ 193 - 2
docs/source/user/extensions.rst

@@ -12,6 +12,10 @@ extensions to use and can depend on other extensions. In fact, the whole of
 JupyterLab itself is simply a collection of extensions that are no more powerful
 or privileged than any custom extension.
 
+.. contents:: Table of contents
+    :local:
+    :depth: 1
+
 JupyterLab extensions are `npm <https://www.npmjs.com/>`__ packages (the
 standard package format in Javascript development). You can search for the
 keyword `jupyterlab-extension
@@ -74,12 +78,61 @@ Once enabled, you should see a new tab appear in the :ref:`left sidebar <left-si
 
 .. figure:: images/extension_manager_default.png
    :align: center
-   :class: jp-screenshot
+   :class: jp-screenshotls 
 
    **Figure:** The default view has three components: a search bar, an "Installed"
    section, and a "Discover" section.
 
 
+Disclaimer
+^^^^^^^^^^
+
+.. danger::
+
+    Installing an extension allows it to execute arbitrary code on the
+    server, kernel, and in the client's browser. Therefor we ask you 
+    to explictitly acknowledge this.
+
+
+By default, the disclaimer is not checked.
+
+.. figure:: images/listings/disclaimer_unchecked.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** User has not checked the disclaimer
+
+
+As the disclaimer is not checked, you can search for an extension,
+but can not install it (no Install button is available).
+
+.. figure:: images/listings/disclaimer_unchecked_noinstall.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** With Disclaimer unchecked, you can not install an extension
+
+
+To install an extensino, you first have to explicitly check the disclaimer.
+Once done, this will remain across sessions and the user does not have to 
+check it again.
+
+.. figure:: images/listings/disclaimer_checked.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** Disclaimer checked
+
+For ease of use, you can hide the disclaimer so it takes less space on
+your screen.
+
+.. figure:: images/listings/disclaimer_hidden.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** Disclaimer is hidden
+
+
 Finding Extensions
 ^^^^^^^^^^^^^^^^^^
 
@@ -108,6 +161,8 @@ performs a free-text search of JupyterLab extensions on the NPM registry.
    :alt: Screenshot showing an example search result
 
 
+.. _listings:
+
 Installing an Extension
 ^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -196,6 +251,143 @@ will be up to you to take these into account or not.
 
 
 
+Listings
+~~~~~~~~
+
+When searching extensions, JupyterLab displays the complete search result and 
+the user is free to install any extension. This is the :ref:`default_mode`.
+
+To bring more security, you or your administrator can enable ``blacklists`` or ``whitelists``
+mode. JupyterLab will check the extensions against the defined listings.
+
+Only one mode at a time is allowed.
+
+The following details the behavior for the :ref:`blacklist_mode` and the :ref:`whitelist_mode`.
+The details to enable configure the listings can be read :ref:`listings_conf`. 
+
+.. _default_mode:
+
+Default mode
+^^^^^^^^^^^^
+
+In the ``default`` mode, no listing is enabled and the search behavior is unchanged and
+is the one described previously.
+
+.. _blacklist_mode:
+
+Blacklist mode
+^^^^^^^^^^^^^^
+
+Extensions can be freely downloaded without going through a vetting process.
+However, users can add malicious extensions to a blacklist. The extension manager 
+will show all extensions except for those that have 
+been explicitly added to the blacklist. Therfore, the extension manager 
+does not allow you to install blacklisted extensions.
+
+If you, or your administrator, has enabled the blacklist mode,
+JupyterLab will use the blacklist and remove all blacklisted
+extensions from your search result.
+
+If you have installed an extension before it has been blacklisted,
+the extension entry in the installed list will be highlighted
+in red. It is recommended that you uninstall it. You can move
+your mouse on the question mark icon to read the instructions.
+
+.. figure:: images/listings/installed_blacklisted.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** Blacklisted installed extension which should be removed
+
+
+.. _whitelist_mode:
+
+Whitelist mode
+^^^^^^^^^^^^^^
+
+A whitelist maintains a set of approved extensions that users can freely 
+search and install. Extensions need to go through some sort of vetting process 
+before they are added to the whitelist. When using a whitelist, the extension manager 
+will only show extensions that have been explicitly added to the whitelist.
+
+If you, or your administrator, has enabled the whitelist mode
+JupyterLab will use the whitelist and only show extensions present
+in the withelist. The other extensions will not be show in the search result.
+
+If you have installed an whitelisted extension and at some point
+in time that extension is removed from the whitelist, the extension entry 
+in the installed list will be highlighted in red. It is recommended that 
+you uninstall it. You can move your mouse on the question mark icon to
+read the instructions.
+
+.. figure:: images/listings/installed_whitelisted.png
+   :align: center
+   :class: jp-screenshot
+
+   **Figure:** Whitelisted installed extension which should be removed
+
+.. _listings_conf:
+
+Listing Configuration
+^^^^^^^^^^^^^^^^^^^^^
+
+You or your administrator can use the following traits to define the listings loading.
+
+- ``blacklist_uris``: A list of comma-separated URIs to get the blacklist
+- ``whitelist_uris``: A list of comma-separated URIs to get the whitelist
+- ``listings_refresh_seconds``: The interval delay in seconds to refresh the lists
+- ``listings_request_options``: The optional kwargs to use for the listings HTTP requests
+
+For example, to enable blacklist, launch the server with ``--LabServerApp.blacklist_uris``.
+
+The details for the listings_request_options are listed
+on the `this page <https://2.python-requests.org/en/v2.7.0/api/#requests.request>`__  
+(for example, you could pass ``{'timeout': 10}`` to change the HTTP request timeout value).
+
+The listings are json files hosted on the URIs you have given.
+
+For each entry, you have to define the `name` of the extension as published in the NPM registry.
+The ``name`` attribute support regular expressions.
+
+Optionally, you can also add some more fields for your records (``type``, ``reason``, ``creation_date``,
+``last_update_date``). These optional fields are not used in the user interface.
+
+This is an example of a blacklist file.
+
+.. code:: json
+
+   {
+   "blacklist": [
+      {
+         "name": "@jupyterlab-examples/launcher",
+         "type": "jupyterlab",
+         "reason": "@jupyterlab-examples/launcher is blacklisted for test purpose - Do NOT take this for granted!!!",
+         "creation_date": "2020-03-11T03:28:56.782Z",
+         "last_update_date":  "2020-03-11T03:28:56.782Z"
+      }
+   ]
+   }
+
+
+In the following whitelist example a ``@jupyterlab/*`` will whitelist 
+all jupyterlab organization extensions.
+
+.. code:: json
+
+   {
+   "whitelist": [
+      {
+         "name": "@jupyterlab/*",
+         "type": "jupyterlab",
+         "reason": "All @jupyterlab org extensions are whitelisted, of course...",
+         "creation_date": "2020-03-11T03:28:56.782Z",
+         "last_update_date":  "2020-03-11T03:28:56.782Z"
+      }
+   ]
+   }
+
+
+
 Using the Terminal
 ~~~~~~~~~~~~~~~~~~~~~
 
@@ -532,4 +724,3 @@ By default, the location is ``~/.jupyter/lab/workspaces/``, where ``~`` is the u
 because these files are typically shared across Python environments.
 The location can be modified using the ``JUPYTERLAB_WORKSPACES_DIR`` environment variable. These files can be imported and exported to create default "profiles",
 using the :ref:`workspace command line tool <url-workspaces-cli>`.
-

二進制
docs/source/user/images/listings/disclaimer_checked.png


二進制
docs/source/user/images/listings/disclaimer_hidden.png


二進制
docs/source/user/images/listings/disclaimer_unchecked.png


二進制
docs/source/user/images/listings/disclaimer_unchecked_noinstall.png


二進制
docs/source/user/images/listings/installed_blacklisted.png


二進制
docs/source/user/images/listings/installed_whitelisted.png


+ 2 - 1
jupyterlab/extension.py

@@ -54,6 +54,7 @@ def load_config(nbapp):
     config.app_version = info['version']
     config.cache_files = True
     config.schemas_dir = pjoin(app_dir, 'schemas')
+    config.listings_dir = pjoin(app_dir, 'listings')
     config.templates_dir = pjoin(app_dir, 'static')
     config.themes_dir = pjoin(app_dir, 'themes')
     config.user_settings_dir = user_settings_dir
@@ -96,7 +97,7 @@ def load_jupyter_server_extension(nbapp):
     web_app = nbapp.web_app
     logger = nbapp.log
     base_url = nbapp.base_url
-
+    
     # Handle the app_dir
     app_dir = getattr(nbapp, 'app_dir', get_app_dir())
 

+ 31 - 0
packages/extensionmanager-extension/examples/listings/Makefile

@@ -0,0 +1,31 @@
+export BLACK_LIST_URIS="https://raw.githubusercontent.com/datalayer-jupyterlab/jupyterlab-listings-example/master/blacklist_simple.json"
+# export BLACK_LIST_URIS=""
+# export WHITE_LIST_URIS="https://raw.githubusercontent.com/datalayer-jupyterlab/jupyterlab-listings-example/master/whitelist_only_jlab.json"
+export WHITE_LIST_URIS=""
+export LISTINGS_REFRESH_SECONDS=120
+export LISTINGS_REQUEST_OPTS="{'timeout': 10}"
+
+listings-uris:
+	@exec echo Using blacklist URIs: ${BLACK_LIST_URIS}
+	@exec echo Using whitelist URIs: ${WHITE_LIST_URIS}
+	@exec echo Refreshing lists every ${LISTINGS_REFRESH_SECONDS} seconds
+	@exec echo Using ${LISTINGS_REQUEST_OPTS} for the HTTP requests
+
+dev: listings-uris
+	@exec python main.py \
+	  --dev \
+	  --no-browser \
+	  --LabServerApp.blacklist_uris=${BLACK_LIST_URIS} \
+	  --LabServerApp.whitelist_uris=${WHITE_LIST_URIS} \
+	  --LabServerApp.listings_refresh_seconds=${LISTINGS_REFRESH_SECONDS} \
+	  --LabServerApp.listings_request_options=${LISTINGS_REQUEST_OPTS}
+
+watch: listings-uris
+	@exec python main.py \
+	  --dev \
+	  --no-browser \
+	  --watch \
+	  --LabServerApp.blacklist_uris=${BLACK_LIST_URIS} \
+	  --LabServerApp.whitelist_uris=${WHITE_LIST_URIS} \
+	  --LabServerApp.listings_refresh_seconds=${LISTINGS_REFRESH_SECONDS} \
+	  --LabServerApp.listings_request_options=${LISTINGS_REQUEST_OPTS}

+ 21 - 0
packages/extensionmanager-extension/examples/listings/README.md

@@ -0,0 +1,21 @@
+# JupyterLab Listings Example
+
+This example allows you to develop the black/white listings features
+
+First build JupyterLab in dev mode.
+
+```bash
+jupyter lab build --dev-mode
+```
+
+For iterative development, launch in `watch` mode.
+
+```bash
+make watch
+```
+
+Otherwise, launch in `dev` mode.
+
+```bash
+make dev
+```

+ 54 - 0
packages/extensionmanager-extension/examples/listings/index.js

@@ -0,0 +1,54 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { PageConfig } from '@jupyterlab/coreutils';
+// eslint-disable-next-line
+__webpack_public_path__ = PageConfig.getOption('fullStaticUrl') + '/';
+
+// This must be after the public path is set.
+// This cannot be extracted because the public path is dynamic.
+require('./build/imports.css');
+
+window.addEventListener('load', async function() {
+  var JupyterLab = require('@jupyterlab/application').JupyterLab;
+
+  var mods = [
+    require('@jupyterlab/application-extension'),
+    require('@jupyterlab/apputils-extension'),
+    require('@jupyterlab/codemirror-extension'),
+    require('@jupyterlab/completer-extension'),
+    require('@jupyterlab/console-extension'),
+    require('@jupyterlab/csvviewer-extension'),
+    require('@jupyterlab/docmanager-extension'),
+    require('@jupyterlab/extensionmanager-extension'),
+    require('@jupyterlab/fileeditor-extension'),
+    require('@jupyterlab/filebrowser-extension'),
+    require('@jupyterlab/help-extension'),
+    require('@jupyterlab/imageviewer-extension'),
+    require('@jupyterlab/inspector-extension'),
+    require('@jupyterlab/launcher-extension'),
+    require('@jupyterlab/mainmenu-extension'),
+    require('@jupyterlab/markdownviewer-extension'),
+    require('@jupyterlab/mathjax2-extension'),
+    require('@jupyterlab/notebook-extension'),
+    require('@jupyterlab/rendermime-extension'),
+    require('@jupyterlab/running-extension'),
+    require('@jupyterlab/settingeditor-extension'),
+    require('@jupyterlab/shortcuts-extension'),
+    require('@jupyterlab/statusbar-extension'),
+    require('@jupyterlab/tabmanager-extension'),
+    require('@jupyterlab/terminal-extension'),
+    require('@jupyterlab/theme-dark-extension'),
+    require('@jupyterlab/theme-light-extension'),
+    require('@jupyterlab/tooltip-extension'),
+    require('@jupyterlab/ui-components-extension')
+  ];
+  var lab = new JupyterLab();
+  lab.registerPluginModules(mods);
+  /* eslint-disable no-console */
+  console.log('Starting app');
+  await lab.start();
+  console.log('App started, waiting for restore');
+  await lab.restored;
+  console.log('Example started!');
+});

二進制
packages/extensionmanager-extension/examples/listings/jupyter.png


+ 39 - 0
packages/extensionmanager-extension/examples/listings/list/blacklist.json

@@ -0,0 +1,39 @@
+{
+  "blacklist": [
+    {
+      "name": "@jupyterlab-examples/launcher",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "@tpoff/jupyterlab-tpoff_xkcd",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "@mohansrk/test",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "custom-git",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "jupyterlabtd_git",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    }
+  ]
+}

+ 25 - 0
packages/extensionmanager-extension/examples/listings/list/whitelist.json

@@ -0,0 +1,25 @@
+{
+  "whitelist": [
+    {
+      "name": "@jupyterlab/*",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "@jupyterlab/git",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    },
+    {
+      "name": "@krassowski/jupyterlab_go_to_definition",
+      "type": "jupyterlab",
+      "reason": "No reason, this is just a test",
+      "creation_date": "2020-03-11T03:28:56.782Z",
+      "last_update_date": "2020-03-11T03:28:56.782Z"
+    }
+  ]
+}

+ 46 - 0
packages/extensionmanager-extension/examples/listings/main.py

@@ -0,0 +1,46 @@
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+
+import os
+
+HERE = os.path.dirname(__file__)
+
+os.environ['JUPYTERLAB_SETTINGS_DIR'] = str(os.path.join(HERE, 'settings'))
+
+from jupyterlab.labapp import LabApp
+from jupyterlab_server import LabServerApp, LabConfig
+from notebook.base.handlers import IPythonHandler, FileFindHandler
+from notebook.utils import url_path_join as ujoin
+import json
+from traitlets import Unicode
+
+with open(os.path.join(HERE, 'package.json')) as fid:
+    version = json.load(fid)['version']
+
+class ListingsApp(LabApp):
+    base_url = '/'
+    default_url = Unicode('/lab',
+                          help='The default URL to redirect to from `/`')
+
+    def init_webapp(self):
+        """initialize tornado webapp and httpserver.
+        """
+        super().init_webapp()
+        default_handlers = [
+            (
+                ujoin(self.base_url, r"/listings/(.*)"), FileFindHandler,
+                 {'path': os.path.join(HERE, 'list')}
+            )
+        ]
+        self.web_app.add_handlers('.*$', default_handlers)
+
+    def start(self):
+        settings = self.web_app.settings
+
+        # By default, make terminals available.
+        settings.setdefault('terminals_available', True)
+
+        super().start()
+
+if __name__ == '__main__':
+    ListingsApp.launch_instance()

+ 63 - 0
packages/extensionmanager-extension/examples/listings/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@jupyterlab/listings-example",
+  "version": "2.0.0",
+  "private": true,
+  "scripts": {
+    "build": "webpack",
+    "clean": "rimraf build",
+    "prepublishOnly": "npm run build"
+  },
+  "dependencies": {
+    "@jupyterlab/application": "^2.0.2",
+    "@jupyterlab/application-extension": "^2.0.2",
+    "@jupyterlab/apputils-extension": "^2.0.2",
+    "@jupyterlab/buildutils": "^2.0.2",
+    "@jupyterlab/codemirror-extension": "^2.0.2",
+    "@jupyterlab/completer-extension": "^2.0.2",
+    "@jupyterlab/console-extension": "^2.0.2",
+    "@jupyterlab/csvviewer-extension": "^2.0.2",
+    "@jupyterlab/docmanager-extension": "^2.0.2",
+    "@jupyterlab/extensionmanager-extension": "^2.0.2",
+    "@jupyterlab/filebrowser-extension": "^2.0.2",
+    "@jupyterlab/fileeditor-extension": "^2.0.2",
+    "@jupyterlab/help-extension": "^2.0.2",
+    "@jupyterlab/imageviewer-extension": "^2.0.2",
+    "@jupyterlab/inspector-extension": "^2.0.2",
+    "@jupyterlab/launcher-extension": "^2.0.2",
+    "@jupyterlab/mainmenu-extension": "^2.0.2",
+    "@jupyterlab/markdownviewer-extension": "^2.0.2",
+    "@jupyterlab/mathjax2-extension": "^2.0.2",
+    "@jupyterlab/notebook-extension": "^2.0.2",
+    "@jupyterlab/rendermime-extension": "^2.0.2",
+    "@jupyterlab/running-extension": "^2.0.2",
+    "@jupyterlab/settingeditor-extension": "^2.0.2",
+    "@jupyterlab/shortcuts-extension": "^2.0.2",
+    "@jupyterlab/statusbar-extension": "^2.0.2",
+    "@jupyterlab/tabmanager-extension": "^2.0.2",
+    "@jupyterlab/terminal-extension": "^2.0.2",
+    "@jupyterlab/theme-dark-extension": "^2.0.2",
+    "@jupyterlab/theme-light-extension": "^2.0.2",
+    "@jupyterlab/tooltip-extension": "^2.0.2",
+    "@jupyterlab/ui-components-extension": "^2.0.2",
+    "es6-promise": "~4.2.8",
+    "react": "~16.9.0",
+    "react-dom": "~16.9.0"
+  },
+  "devDependencies": {
+    "css-loader": "~3.2.0",
+    "file-loader": "~5.0.2",
+    "fs-extra": "^8.1.0",
+    "glob": "~7.1.6",
+    "mini-css-extract-plugin": "~0.8.0",
+    "raw-loader": "~4.0.0",
+    "read-package-tree": "^5.3.1",
+    "rimraf": "~3.0.0",
+    "style-loader": "~1.0.1",
+    "svg-url-loader": "~3.0.3",
+    "url-loader": "~3.0.0",
+    "watch": "~1.0.2",
+    "webpack": "^4.41.2",
+    "webpack-cli": "^3.3.10",
+    "whatwg-fetch": "^3.0.0"
+  }
+}

+ 15 - 0
packages/extensionmanager-extension/examples/listings/settings/@jupyterlab/extensionmanager-extension/plugin.jupyterlab-settings

@@ -0,0 +1,15 @@
+{
+    // Extension Manager
+    // @jupyterlab/extensionmanager-extension:plugin
+    // Extension manager settings.
+    // *********************************************
+
+    // Disclaimed Status
+    // Whether the user understand that extensions managed through this interface run arbitrary code that may be dangerous
+    "disclaimed": true,
+
+    // Enabled Status
+    // Enables extension manager (requires Node.js/npm).
+    // WARNING: installing untrusted extensions may be unsafe.
+    "enabled": true
+}

+ 59 - 0
packages/extensionmanager-extension/examples/listings/templates/error.html

@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<!--
+Copyright (c) Jupyter Development Team.
+Distributed under the terms of the Modified BSD License.
+-->
+<html>
+
+<head>
+  <meta charset="utf-8">
+
+  <title>{% block title %}{{page_title | e}}{% endblock %}</title>
+
+  {% block favicon %}<link rel="shortcut icon" type="image/x-icon" href="/static/base/images/favicon.ico">{% endblock %}
+
+</head>
+
+<body>
+
+{% block stylesheet %}
+<style type="text/css">
+/* disable initial hide */
+div#header, div#site {
+    display: block;
+}
+</style>
+{% endblock %}
+{% block site %}
+
+<div class="error">
+    {% block h1_error %}
+    <h1>{{status_code | e}} : {{status_message | e}}</h1>
+    {% endblock h1_error %}
+    {% block error_detail %}
+    {% if message %}
+    <p>The error was:</p>
+    <div class="traceback-wrapper">
+    <pre class="traceback">{{message | e}}</pre>
+    </div>
+    {% endif %}
+    {% endblock %}
+</header>
+
+{% endblock %}
+
+{% block script %}
+<script type='text/javascript'>
+window.onload = function () {
+  var tb = document.getElementsByClassName('traceback')[0];
+  tb.scrollTop = tb.scrollHeight;
+  {% if message %}
+  console.error("{{message | e}}")
+  {% endif %}
+};
+</script>
+{% endblock script %}
+
+</body>
+
+</html>

+ 29 - 0
packages/extensionmanager-extension/examples/listings/templates/index.html

@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>{{page_config['appName'] | e}}</title>
+</head>
+<body>
+    {# Copy so we do not modify the page_config with updates. #}
+    {% set page_config_full = page_config.copy() %}
+    
+    {# Set a dummy variable - we just want the side effect of the update. #}
+    {% set _ = page_config_full.update(baseUrl=base_url, wsUrl=ws_url) %}
+    
+      <script id="jupyter-config-data" type="application/json">
+        {{ page_config_full | tojson }}
+      </script>
+  <script src="{{page_config['fullStaticUrl'] | e}}/bundle.js" main="index"></script>
+
+  <script type="text/javascript">
+    /* Remove token from URL. */
+    (function () {
+      var parsedUrl = new URL(window.location.href);
+      if (parsedUrl.searchParams.get('token')) {
+        parsedUrl.searchParams.delete('token');
+        window.history.replaceState({ }, '', parsedUrl.href);
+      }
+    })();
+  </script>
+</body>
+</html>

+ 70 - 0
packages/extensionmanager-extension/examples/listings/webpack.config.js

@@ -0,0 +1,70 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+var data = require('./package.json');
+var Build = require('@jupyterlab/buildutils').Build;
+
+var names = Object.keys(data.dependencies).filter(function(name) {
+  var packageData = require(name + '/package.json');
+  return packageData.jupyterlab !== undefined;
+});
+
+var extras = Build.ensureAssets({
+  packageNames: names,
+  output: './build'
+});
+
+module.exports = [
+  {
+    entry: ['whatwg-fetch', './index.js'],
+    output: {
+      path: __dirname + '/build',
+      filename: 'bundle.js'
+    },
+    node: {
+      fs: 'empty'
+    },
+    bail: true,
+    devtool: 'source-map',
+    mode: 'development',
+    module: {
+      rules: [
+        { test: /\.css$/, use: ['style-loader', 'css-loader'] },
+        { test: /\.html$/, use: 'file-loader' },
+        { test: /\.md$/, use: 'raw-loader' },
+        { test: /\.(jpg|png|gif)$/, use: 'file-loader' },
+        { test: /\.js.map$/, use: 'file-loader' },
+        {
+          test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/,
+          use: 'url-loader?limit=10000&mimetype=application/font-woff'
+        },
+        {
+          test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,
+          use: 'url-loader?limit=10000&mimetype=application/font-woff'
+        },
+        {
+          test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
+          use: 'url-loader?limit=10000&mimetype=application/octet-stream'
+        },
+        { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, use: 'file-loader' },
+        {
+          // In .css files, svg is loaded as a data URI.
+          test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
+          issuer: { test: /\.css$/ },
+          use: {
+            loader: 'svg-url-loader',
+            options: { encoding: 'none', limit: 10000 }
+          }
+        },
+        {
+          // In .ts and .tsx files (both of which compile to .js), svg files
+          // must be loaded as a raw string instead of data URIs.
+          test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
+          issuer: { test: /\.js$/ },
+          use: {
+            loader: 'raw-loader'
+          }
+        }
+      ]
+    }
+  }
+].concat(extras);

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

@@ -17,6 +17,7 @@
     "lib/*.js.map",
     "lib/*.js",
     "schema/*.json",
+    "listing/*.json",
     "style/*.css"
   ],
   "sideEffects": [

+ 6 - 0
packages/extensionmanager-extension/schema/plugin.json

@@ -9,6 +9,12 @@
       "description": "Enables extension manager (requires Node.js/npm).\nWARNING: installing untrusted extensions may be unsafe.",
       "default": false,
       "type": "boolean"
+    },
+    "disclaimed": {
+      "title": "Disclaimed Status",
+      "description": "Whether the user understand that extensions managed through this interface run arbitrary code that may be dangerous",
+      "default": false,
+      "type": "boolean"
     }
   },
   "additionalProperties": false,

+ 25 - 17
packages/extensionmanager-extension/src/index.ts

@@ -13,6 +13,8 @@ import { IMainMenu } from '@jupyterlab/mainmenu';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { extensionIcon } from '@jupyterlab/ui-components';
 
+const PLUGIN_ID = '@jupyterlab/extensionmanager-extension:plugin';
+
 /**
  * IDs of the commands added by this extension.
  */
@@ -24,7 +26,7 @@ namespace CommandIDs {
  * The extension manager plugin.
  */
 const plugin: JupyterFrontEndPlugin<void> = {
-  id: '@jupyterlab/extensionmanager-extension:plugin',
+  id: PLUGIN_ID,
   autoStart: true,
   requires: [ISettingRegistry],
   optional: [ILabShell, ILayoutRestorer, IMainMenu, ICommandPalette],
@@ -43,7 +45,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
     let view: ExtensionView | undefined;
 
     const createView = () => {
-      const v = new ExtensionView(serviceManager);
+      const v = new ExtensionView(serviceManager, settings);
       v.id = 'extensionmanager.main-view';
       v.title.icon = extensionIcon;
       v.title.caption = 'Extension Manager';
@@ -60,23 +62,29 @@ const plugin: JupyterFrontEndPlugin<void> = {
 
     // If the extension is enabled or disabled,
     // add or remove it from the left area.
-    void app.restored.then(() => {
-      settings.changed.connect(async () => {
-        enabled = settings.composite['enabled'] === true;
-        if (enabled && (!view || (view && !view.isAttached))) {
-          const accepted = await Private.showWarning();
-          if (!accepted) {
-            void settings.set('enabled', false);
-            return;
+    Promise.all([app.restored, registry.load(PLUGIN_ID)])
+      .then(([, settings]) => {
+        settings.changed.connect(async () => {
+          enabled = settings.composite['enabled'] === true;
+          if (enabled && (!view || (view && !view.isAttached))) {
+            const accepted = await Private.showWarning();
+            if (!accepted) {
+              void settings.set('enabled', false);
+              return;
+            }
+            view = view || createView();
+            shell.add(view, 'left');
+          } else if (!enabled && view && view.isAttached) {
+            app.commands.notifyCommandChanged(CommandIDs.toggle);
+            view.close();
           }
-          view = view || createView();
-          shell.add(view, 'left');
-        } else if (!enabled && view && view.isAttached) {
-          app.commands.notifyCommandChanged(CommandIDs.toggle);
-          view.close();
-        }
+        });
+      })
+      .catch(reason => {
+        console.error(
+          `Something went wrong when reading the settings.\n${reason}`
+        );
       });
-    });
 
     commands.addCommand(CommandIDs.toggle, {
       label: 'Enable Extension Manager (experimental)',

+ 3 - 0
packages/extensionmanager/package.json

@@ -36,10 +36,13 @@
   },
   "dependencies": {
     "@jupyterlab/apputils": "^2.0.2",
+    "@jupyterlab/coreutils": "^4.0.2",
     "@jupyterlab/services": "^5.0.2",
+    "@jupyterlab/settingregistry": "^2.0.1",
     "@jupyterlab/ui-components": "^2.0.2",
     "@lumino/messaging": "^1.3.3",
     "@lumino/polling": "^1.1.1",
+    "@lumino/signaling": "^1.3.5",
     "react": "~16.9.0",
     "react-paginate": "^6.3.2",
     "semver": "^6.3.0"

+ 2 - 1
packages/extensionmanager/src/index.ts

@@ -2,5 +2,6 @@
 // Distributed under the terms of the Modified BSD License.
 
 export * from './model';
-export * from './query';
+export * from './npm';
+export * from './listings';
 export * from './widget';

+ 131 - 0
packages/extensionmanager/src/listings.ts

@@ -0,0 +1,131 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { ISignal, Signal } from '@lumino/signaling';
+
+import { URLExt } from '@jupyterlab/coreutils';
+
+import { ServerConnection } from '@jupyterlab/services';
+
+/***
+ * Information about a listed entry.
+ */
+export interface IListEntry {
+  /**
+   * The name of the extension.
+   */
+  name: string;
+  regexp: RegExp | undefined;
+  type: string | undefined;
+  reason: string | undefined;
+  creation_date: string | undefined;
+  last_update_date: string | undefined;
+}
+
+/**
+ * Listing search result type.
+ *
+ * - The mode for the listings, can be black or white.
+ * - A collection of URIs for black or white listings, depending
+ * on the mode.
+ * - A collection of black or white listed extensions, depending
+ * on the mode.
+ *
+ */
+export type ListResult = null | {
+  mode: 'white' | 'black' | 'default';
+  uris: string[];
+  entries: IListEntry[];
+};
+
+export interface IListingApi {
+  blacklist_uris: string[];
+  whitelist_uris: string[];
+  blacklist: IListEntry[];
+  whitelist: IListEntry[];
+}
+
+/**
+ * An object for getting listings from the server API.
+ */
+export class Lister {
+  /**
+   * Create a Lister object.
+   */
+  constructor() {
+    requestAPI<IListingApi>(
+      '@jupyterlab/extensionmanager-extension/listings.json'
+    )
+      .then(data => {
+        this._listings = {
+          mode: 'default',
+          uris: [],
+          entries: []
+        };
+        if (data.blacklist_uris.length > 0 && data.whitelist_uris.length > 0) {
+          console.warn(
+            'Simultaneous black and white list are not allowed. Continuing with default mode.'
+          );
+        } else if (
+          data.blacklist_uris.length > 0 ||
+          data.whitelist_uris.length > 0
+        ) {
+          this._listings = {
+            mode: data.blacklist_uris.length > 0 ? 'black' : 'white',
+            uris:
+              data.blacklist_uris.length > 0
+                ? data.blacklist_uris
+                : data.whitelist_uris,
+            entries:
+              data.blacklist_uris.length > 0 ? data.blacklist : data.whitelist
+          };
+        }
+        this._listingsLoaded.emit(this._listings);
+      })
+      .catch(error => {
+        console.error(error);
+      });
+  }
+
+  get listingsLoaded(): ISignal<this, ListResult> {
+    return this._listingsLoaded;
+  }
+
+  private _listings: ListResult = null;
+
+  /**
+   */
+  private _listingsLoaded = new Signal<this, ListResult>(this);
+}
+
+/**
+ * Call the listings API REST handler.
+ *
+ * @param endPoint API REST end point for the extension
+ * @param init Initial values for the request
+ * @returns The response body interpreted as JSON
+ */
+async function requestAPI<T>(
+  endPoint: string = '',
+  init: RequestInit = {}
+): Promise<T> {
+  // Make request to Jupyter API
+  const settings = ServerConnection.makeSettings();
+  const requestUrl = URLExt.join(
+    settings.baseUrl,
+    settings.appUrl,
+    'api/listings/',
+    endPoint
+  );
+  let response: Response;
+  try {
+    response = await ServerConnection.makeRequest(requestUrl, init, settings);
+  } catch (error) {
+    throw new ServerConnection.NetworkError(error);
+  }
+  const data = await response.json();
+  if (!response.ok) {
+    throw new ServerConnection.ResponseError(response, data.message);
+  }
+  return data;
+}

+ 126 - 14
packages/extensionmanager/src/model.ts

@@ -8,6 +8,7 @@ import {
   ServerConnection,
   ServiceManager
 } from '@jupyterlab/services';
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
 
 import { Debouncer } from '@lumino/polling';
 
@@ -23,7 +24,9 @@ import {
 
 import { reportInstallError } from './dialog';
 
-import { Searcher, ISearchResult, isJupyterOrg } from './query';
+import { Searcher, ISearchResult, isJupyterOrg } from './npm';
+
+import { Lister, ListResult, IListEntry } from './listings';
 
 /**
  * Information about an extension.
@@ -68,6 +71,10 @@ export interface IEntry {
    * The installed version of the extension.
    */
   installed_version: string;
+
+  blacklistEntry: IListEntry | undefined;
+
+  whitelistEntry: IListEntry | undefined;
 }
 
 /**
@@ -144,13 +151,53 @@ export type Action = 'install' | 'uninstall' | 'enable' | 'disable';
  * Model for an extension list.
  */
 export class ListModel extends VDomModel {
-  constructor(serviceManager: ServiceManager) {
+  constructor(
+    serviceManager: ServiceManager,
+    settings: ISettingRegistry.ISettings
+  ) {
     super();
     this._installed = [];
     this._searchResult = [];
     this.serviceManager = serviceManager;
     this.serverConnectionSettings = ServerConnection.makeSettings();
     this._debouncedUpdate = new Debouncer(this.update.bind(this), 1000);
+    this.lister.listingsLoaded.connect(this._listingIsLoaded, this);
+    _isDisclaimed = settings.composite['disclaimed'] === true;
+    settings.changed.connect(() => {
+      _isDisclaimed = settings.composite['disclaimed'] === true;
+      void this.update();
+    });
+  }
+
+  private _listingIsLoaded(_: Lister, listings: ListResult) {
+    this._listMode = listings!.mode;
+    this._blacklistArray = new Array<IListEntry>();
+    if (this._listMode === 'black') {
+      listings!.entries.map(e => {
+        this._blacklistArray.push({
+          name: e.name,
+          regexp: new RegExp(e.name),
+          type: e.type,
+          reason: e.reason,
+          creation_date: e.creation_date,
+          last_update_date: e.last_update_date
+        });
+      });
+    }
+    this._whitelistArray = new Array<IListEntry>();
+    if (this._listMode === 'white') {
+      listings!.entries.map(e => {
+        this._whitelistArray.push({
+          name: e.name,
+          regexp: new RegExp(e.name),
+          type: e.type,
+          reason: e.reason,
+          creation_date: e.creation_date,
+          last_update_date: e.last_update_date
+        });
+      });
+    }
+    void this.initialize();
   }
 
   /**
@@ -219,6 +266,27 @@ export class ListModel extends VDomModel {
     return this._totalEntries;
   }
 
+  /**
+   * The list mode.
+   */
+  get listMode(): 'black' | 'white' | 'default' {
+    return this._listMode;
+  }
+
+  /**
+   * The total number of blacklisted results in the current search.
+   */
+  get totalBlacklistedFound(): number {
+    return this._totalBlacklistedFound;
+  }
+
+  /**
+   * The total number of whitelisted results in the current search.
+   */
+  get totalWhitelistedFound(): number {
+    return this._totalWhitelistedFound;
+  }
+
   /**
    * Initialize the model.
    */
@@ -402,11 +470,23 @@ export class ListModel extends VDomModel {
     res: Promise<ISearchResult>
   ): Promise<{ [key: string]: IEntry }> {
     let entries: { [key: string]: IEntry } = {};
+    this._totalBlacklistedFound = 0;
+    this._totalWhitelistedFound = 0;
+    this._totalEntries = 0;
     for (let obj of (await res).objects) {
       let pkg = obj.package;
       if (pkg.keywords.indexOf('deprecated') >= 0) {
         continue;
       }
+      this._totalEntries = this._totalEntries + 1;
+      const isBlacklisted = this.isListed(pkg.name, this._blacklistArray);
+      if (isBlacklisted) {
+        this._totalBlacklistedFound = this._totalBlacklistedFound + 1;
+      }
+      const isWhitelisted = this.isListed(pkg.name, this._whitelistArray);
+      if (isWhitelisted) {
+        this._totalWhitelistedFound = this._totalWhitelistedFound + 1;
+      }
       entries[pkg.name] = {
         name: pkg.name,
         description: pkg.description,
@@ -420,7 +500,9 @@ export class ListModel extends VDomModel {
         enabled: false,
         status: null,
         latest_version: pkg.version,
-        installed_version: ''
+        installed_version: '',
+        blacklistEntry: isBlacklisted,
+        whitelistEntry: isWhitelisted
       };
     }
     return entries;
@@ -447,7 +529,9 @@ export class ListModel extends VDomModel {
             enabled: pkg.enabled,
             status: pkg.status,
             latest_version: pkg.latest_version,
-            installed_version: pkg.installed_version
+            installed_version: pkg.installed_version,
+            blacklistEntry: this.isListed(pkg.name, this._blacklistArray),
+            whitelistEntry: this.isListed(pkg.name, this._whitelistArray)
           };
         })
       );
@@ -457,6 +541,19 @@ export class ListModel extends VDomModel {
     });
   }
 
+  private isListed(
+    name: string,
+    listArray: Array<IListEntry>
+  ): IListEntry | undefined {
+    let entry: IListEntry | undefined = undefined;
+    listArray.forEach((listEntry: IListEntry) => {
+      if (listEntry.regexp && listEntry.regexp?.test(name)) {
+        entry = listEntry;
+      }
+    });
+    return entry;
+  }
+
   /**
    * Make a request to the server for info about its installed extensions.
    */
@@ -498,10 +595,7 @@ export class ListModel extends VDomModel {
    */
   protected async performSearch(): Promise<{ [key: string]: IEntry }> {
     if (this.query === null) {
-      this._searchResult = [];
-      this._totalEntries = 0;
-      this.searchError = null;
-      return {};
+      this.query = '';
     }
 
     // Start the search without waiting for it:
@@ -521,12 +615,6 @@ export class ListModel extends VDomModel {
       this.searchError = reason.toString();
     }
 
-    try {
-      this._totalEntries = (await search).total;
-    } catch (error) {
-      this._totalEntries = 0;
-    }
-
     return searchMap;
   }
 
@@ -562,6 +650,7 @@ export class ListModel extends VDomModel {
    */
   protected async update(refreshInstalled = false) {
     // Start both queries before awaiting:
+
     const searchMapPromise = this.performSearch();
     const installedMapPromise = this.queryInstalled(refreshInstalled);
 
@@ -664,6 +753,11 @@ export class ListModel extends VDomModel {
    */
   searchError: string | null = null;
 
+  /**
+   * Contains an error message if an error occurred when searching for lists.
+   */
+  blacklistError: string | null = null;
+
   /**
    * Contains an error message if an error occurred when querying the server extension.
    */
@@ -694,6 +788,8 @@ export class ListModel extends VDomModel {
    */
   protected searcher = new Searcher();
 
+  protected lister = new Lister();
+
   /**
    * The service manager to use for building.
    */
@@ -708,8 +804,16 @@ export class ListModel extends VDomModel {
   private _searchResult: IEntry[];
   private _pendingActions: Promise<any>[] = [];
   private _debouncedUpdate: Debouncer<void, void>;
+
+  private _listMode: 'black' | 'white' | 'default';
+  private _blacklistArray: Array<IListEntry>;
+  private _whitelistArray: Array<IListEntry>;
+  private _totalBlacklistedFound: number = 0;
+  private _totalWhitelistedFound: number = 0;
 }
 
+let _isDisclaimed = false;
+
 /**
  * ListModel statics.
  */
@@ -725,6 +829,14 @@ export namespace ListModel {
     }
     return semver.lt(entry.installed_version, entry.latest_version);
   }
+
+  export function isDisclaimed() {
+    return _isDisclaimed;
+  }
+
+  export function toogleDisclaimed() {
+    _isDisclaimed = !_isDisclaimed;
+  }
 }
 
 /**

+ 3 - 3
packages/extensionmanager/src/query.ts → packages/extensionmanager/src/npm.ts

@@ -288,15 +288,15 @@ export class Searcher {
  */
 export function isJupyterOrg(name: string): boolean {
   /**
-   * A list of whitelisted NPM orgs.
+   * A list of jupyterlab NPM orgs.
    */
-  const whitelist = ['jupyterlab', 'jupyter-widgets'];
+  const jupyterOrg = ['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.
+    jupyterOrg.indexOf(first.slice(1)) !== -1 // in the org whitelist.
   );
 }

+ 212 - 54
packages/extensionmanager/src/widget.tsx

@@ -3,6 +3,7 @@
 
 import { VDomRenderer, ToolbarButtonComponent } from '@jupyterlab/apputils';
 import { ServiceManager } from '@jupyterlab/services';
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import {
   Button,
   caretDownIcon,
@@ -10,6 +11,7 @@ import {
   Collapse,
   InputGroup,
   jupyterIcon,
+  listingsInfoIcon,
   refreshIcon
 } from '@jupyterlab/ui-components';
 
@@ -18,7 +20,7 @@ import * as React from 'react';
 import ReactPaginate from 'react-paginate';
 
 import { ListModel, IEntry, Action } from './model';
-import { isJupyterOrg } from './query';
+import { isJupyterOrg } from './npm';
 
 // TODO: Replace pagination with lazy loading of lower search results
 
@@ -53,16 +55,69 @@ export class SearchBar extends React.Component<
    */
   render(): React.ReactNode {
     return (
-      <div className="jp-extensionmanager-search-bar">
-        <InputGroup
-          className="jp-extensionmanager-search-wrapper"
-          type="text"
-          placeholder={this.props.placeholder}
-          onChange={this.handleChange}
-          value={this.state.value}
-          rightIcon="search"
-        />
-      </div>
+      <>
+        <div className="jp-extensionmanager-search-bar">
+          <InputGroup
+            className="jp-extensionmanager-search-wrapper"
+            type="text"
+            placeholder={this.props.placeholder}
+            onChange={this.handleChange}
+            value={this.state.value}
+            rightIcon="search"
+            disabled={this.props.disabled}
+          />
+        </div>
+        <CollapsibleSection
+          key="warning-section"
+          isOpen={true}
+          disabled={false}
+          header={'Warning'}
+        >
+          <div className="jp-extensionmanager-disclaimer">
+            <div>
+              Extensions installed contain arbitrary code that can execute on
+              your machine that may contain malicious code.
+            </div>
+            <div style={{ paddingTop: 8 }}>
+              I understand extensions contain arbitrary code.
+            </div>
+            <div style={{ paddingTop: 8 }}>
+              {ListModel.isDisclaimed() && (
+                <Button
+                  className="jp-extensionmanager-disclaimer-disable"
+                  onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
+                    this.props.settings
+                      .set('disclaimed', false)
+                      .catch(reason => {
+                        console.error(
+                          `Something went wrong when setting disclaimed.\n${reason}`
+                        );
+                      });
+                  }}
+                >
+                  Disable
+                </Button>
+              )}
+              {!ListModel.isDisclaimed() && (
+                <Button
+                  className="jp-extensionmanager-disclaimer-enable"
+                  onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
+                    this.props.settings
+                      .set('disclaimed', true)
+                      .catch(reason => {
+                        console.error(
+                          `Something went wrong when setting disclaimed.\n${reason}`
+                        );
+                      });
+                  }}
+                >
+                  Enable
+                </Button>
+              )}
+            </div>
+          </div>
+        </CollapsibleSection>
+      </>
     );
   }
 
@@ -89,6 +144,10 @@ export namespace SearchBar {
      * The placeholder string to use in the search bar input field when empty.
      */
     placeholder: string;
+
+    disabled: boolean;
+
+    settings: ISettingRegistry.ISettings;
   }
 
   /**
@@ -147,15 +206,34 @@ namespace BuildPrompt {
  * VDOM for visualizing an extension entry.
  */
 function ListEntry(props: ListEntry.IProperties): React.ReactElement<any> {
-  const { entry } = props;
+  const { entry, listMode, viewType } = props;
   const flagClasses = [];
   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)`;
+    flagClasses.push(`jp-extensionmanager-entry-mod-jupyterlab-org`);
+  }
+  if (
+    listMode === 'black' &&
+    entry.blacklistEntry &&
+    viewType === 'searchResult'
+  ) {
+    return <li></li>;
+  }
+  if (
+    listMode === 'white' &&
+    !entry.whitelistEntry &&
+    viewType === 'searchResult'
+  ) {
+    return <li></li>;
+  }
+  if (listMode === 'black' && entry.blacklistEntry?.name) {
+    flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
+  }
+  if (listMode === 'white' && !entry.whitelistEntry) {
+    flagClasses.push(`jp-extensionmanager-entry-should-be-uninstalled`);
   }
   return (
     <li
@@ -168,36 +246,66 @@ function ListEntry(props: ListEntry.IProperties): React.ReactElement<any> {
             {entry.name}
           </a>
         </div>
-        <jupyterIcon.react
-          className="jp-extensionmanager-entry-jupyter-org"
-          top="1px"
-          height="auto"
-          width="1em"
-        />
+        {isJupyterOrg(entry.name) && (
+          <ToolbarButtonComponent
+            icon={jupyterIcon}
+            iconLabel={entry.name + ' (Developed by Project Jupyter)'}
+          />
+        )}
+        {entry.blacklistEntry && (
+          <ToolbarButtonComponent
+            icon={listingsInfoIcon}
+            iconLabel={`${entry.name} extension has been blacklisted since install. Please uninstall immediately and contact your blacklist administrator.`}
+            onClick={() =>
+              window.open(
+                'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html'
+              )
+            }
+          />
+        )}
+        {!entry.whitelistEntry &&
+          viewType === 'installed' &&
+          listMode === 'white' && (
+            <ToolbarButtonComponent
+              icon={listingsInfoIcon}
+              iconLabel={`${entry.name} extension has been removed from the whitelist since installation. Please uninstall immediately and contact your whitelist administrator.`}
+              onClick={() =>
+                window.open(
+                  'https://jupyterlab.readthedocs.io/en/stable/user/extensions.html'
+                )
+              }
+            />
+          )}
       </div>
       <div className="jp-extensionmanager-entry-content">
         <div className="jp-extensionmanager-entry-description">
           {entry.description}
         </div>
         <div className="jp-extensionmanager-entry-buttons">
-          {!entry.installed && (
-            <Button
-              onClick={() => props.performAction('install', entry)}
-              minimal
-              small
-            >
-              Install
-            </Button>
-          )}
-          {ListModel.entryHasUpdate(entry) && (
-            <Button
-              onClick={() => props.performAction('install', entry)}
-              minimal
-              small
-            >
-              Update
-            </Button>
-          )}
+          {!entry.installed &&
+            !entry.blacklistEntry &&
+            !(!entry.whitelistEntry && listMode === 'white') &&
+            ListModel.isDisclaimed() && (
+              <Button
+                onClick={() => props.performAction('install', entry)}
+                minimal
+                small
+              >
+                Install
+              </Button>
+            )}
+          {ListModel.entryHasUpdate(entry) &&
+            !entry.blacklistEntry &&
+            !(!entry.whitelistEntry && listMode === 'white') &&
+            ListModel.isDisclaimed() && (
+              <Button
+                onClick={() => props.performAction('install', entry)}
+                minimal
+                small
+              >
+                Update
+              </Button>
+            )}
           {entry.installed && (
             <Button
               onClick={() => props.performAction('uninstall', entry)}
@@ -241,6 +349,16 @@ export namespace ListEntry {
      */
     entry: IEntry;
 
+    /**
+     * The list mode to apply.
+     */
+    listMode: 'black' | 'white' | 'default';
+
+    /**
+     * The requested view type.
+     */
+    viewType: 'installed' | 'searchResult';
+
     /**
      * Callback to use for performing an action on the entry.
      */
@@ -257,6 +375,8 @@ export function ListView(props: ListView.IProperties): React.ReactElement<any> {
     entryViews.push(
       <ListEntry
         entry={entry}
+        listMode={props.listMode}
+        viewType={props.viewType}
         key={entry.name}
         performAction={props.performAction}
       />
@@ -315,6 +435,16 @@ export namespace ListView {
      */
     numPages: number;
 
+    /**
+     * The list mode to apply.
+     */
+    listMode: 'black' | 'white' | 'default';
+
+    /**
+     * The requested view type.
+     */
+    viewType: 'installed' | 'searchResult';
+
     /**
      * The callback to use for changing the page
      */
@@ -351,7 +481,7 @@ export class CollapsibleSection extends React.Component<
   constructor(props: CollapsibleSection.IProperties) {
     super(props);
     this.state = {
-      isOpen: props.isOpen || true
+      isOpen: props.isOpen ? true : false
     };
   }
 
@@ -359,23 +489,27 @@ export class CollapsibleSection extends React.Component<
    * Render the collapsible section using the virtual DOM.
    */
   render(): React.ReactNode {
+    let icon = this.state.isOpen ? caretDownIconStyled : caretRightIconStyled;
+    let isOpen = this.state.isOpen;
+    let className = 'jp-extensionmanager-headerText';
+    if (this.props.disabled) {
+      icon = caretRightIconStyled;
+      isOpen = false;
+      className = 'jp-extensionmanager-headerTextDisabled';
+    }
     return (
       <>
         <header>
           <ToolbarButtonComponent
-            icon={
-              this.state.isOpen ? caretDownIconStyled : caretRightIconStyled
-            }
+            icon={icon}
             onClick={() => {
               this.handleCollapse();
             }}
           />
-          <span className="jp-extensionmanager-headerText">
-            {this.props.header}
-          </span>
-          {this.props.headerElements}
+          <span className={className}>{this.props.header}</span>
+          {!this.props.disabled && this.props.headerElements}
         </header>
-        <Collapse isOpen={this.state.isOpen}>{this.props.children}</Collapse>
+        <Collapse isOpen={isOpen}>{this.props.children}</Collapse>
       </>
     );
   }
@@ -429,6 +563,12 @@ export namespace CollapsibleSection {
      * If given, this will be diplayed instead of the children.
      */
     errorMessage?: string | null;
+
+    /**
+     * If true, the section will be collapsed and will not respond
+     * to open nor close actions.
+     */
+    disabled?: boolean;
   }
 
   /**
@@ -446,8 +586,13 @@ export namespace CollapsibleSection {
  * The main view for the discovery extension.
  */
 export class ExtensionView extends VDomRenderer<ListModel> {
-  constructor(serviceManager: ServiceManager) {
-    super(new ListModel(serviceManager));
+  private _settings: ISettingRegistry.ISettings;
+  constructor(
+    serviceManager: ServiceManager,
+    settings: ISettingRegistry.ISettings
+  ) {
+    super(new ListModel(serviceManager, settings));
+    this._settings = settings;
     this.addClass('jp-extensionmanager-view');
   }
 
@@ -466,11 +611,18 @@ export class ExtensionView extends VDomRenderer<ListModel> {
   protected render(): React.ReactElement<any>[] {
     const model = this.model!;
     let pages = Math.ceil(model.totalEntries / model.pagination);
-    let elements = [<SearchBar key="searchbar" placeholder="SEARCH" />];
+    let elements = [
+      <SearchBar
+        key="searchbar"
+        placeholder="SEARCH"
+        disabled={!ListModel.isDisclaimed()}
+        settings={this._settings}
+      />
+    ];
     if (model.promptBuild) {
       elements.push(
         <BuildPrompt
-          key="buildpromt"
+          key="promt"
           performBuild={() => {
             model.performBuild();
           }}
@@ -491,7 +643,7 @@ export class ExtensionView extends VDomRenderer<ListModel> {
     );
     const content = [];
     if (!model.initialized) {
-      void model.initialize();
+      //      void model.initialize();
       content.push(
         <div key="loading-placeholder" className="jp-extensionmanager-loader">
           Updating extensions list
@@ -536,6 +688,8 @@ export class ExtensionView extends VDomRenderer<ListModel> {
         installedContent.push(
           <ListView
             key="installed-items"
+            listMode={model.listMode}
+            viewType={'installed'}
             entries={model.installed}
             numPages={1}
             onPage={value => {
@@ -549,7 +703,8 @@ export class ExtensionView extends VDomRenderer<ListModel> {
       content.push(
         <CollapsibleSection
           key="installed-section"
-          isOpen={true}
+          isOpen={ListModel.isDisclaimed()}
+          disabled={!ListModel.isDisclaimed()}
           header="Installed"
           headerElements={
             <ToolbarButtonComponent
@@ -579,6 +734,8 @@ export class ExtensionView extends VDomRenderer<ListModel> {
         searchContent.push(
           <ListView
             key="search-items"
+            listMode={model.listMode}
+            viewType={'searchResult'}
             // Filter out installed extensions:
             entries={model.searchResult.filter(
               entry => model.installed.indexOf(entry) === -1
@@ -595,7 +752,8 @@ export class ExtensionView extends VDomRenderer<ListModel> {
       content.push(
         <CollapsibleSection
           key="search-section"
-          isOpen={false}
+          isOpen={ListModel.isDisclaimed()}
+          disabled={!ListModel.isDisclaimed()}
           header={model.query ? 'Search Results' : 'Discover'}
           onCollapse={(isOpen: boolean) => {
             if (isOpen && model.query === null) {

+ 47 - 1
packages/extensionmanager/style/base.css

@@ -65,6 +65,12 @@
   margin: 0px 4px;
 }
 
+.jp-extensionmanager-view header .jp-extensionmanager-headerTextDisabled {
+  flex: 1 0 auto;
+  margin: 0px 4px;
+  color: var(--jp-layout-color3);
+}
+
 .jp-extensionmanager-view header > .jp-ToolbarButtonComponent {
   border: 1px solid transparent;
   background-color: var(--jp-layout-color1);
@@ -75,6 +81,17 @@
   /* border: 1px solid var(--jp-brand-color1); */
 }
 
+/*
+  Listing messages
+*/
+.jp-extensionmanager-listingmessage {
+  background-color: var(--jp-brand-color1);
+  color: var(--jp-ui-inverse-font-color1);
+  padding: 4px 8px;
+  font-size: var(--jp-ui-font-size1);
+  font-weight: 400;
+}
+
 /*
   Error messages
 */
@@ -90,6 +107,13 @@
   padding: 4px;
 }
 
+.jp-extensionmanager-disclaimer {
+  color: var(--jp-ui-font-color1);
+  font-size: var(--jp-ui-font-size1);
+  font-weight: normal;
+  margin: 8px 8px 8px 32px;
+}
+
 .jp-extensionmanager-search-wrapper input {
   background: transparent;
   font-size: var(--jp-ui-font-size1);
@@ -159,11 +183,15 @@
   display: none;
 }
 
-.jp-extensionmanager-entry.jp-extensionmanager-entry-mod-whitelisted
+.jp-extensionmanager-entry.jp-extensionmanager-entry-mod-jupyterlab-org
   .jp-extensionmanager-entry-jupyter-org {
   display: inline;
 }
 
+.jp-extensionmanager-entry.jp-extensionmanager-entry-should-be-uninstalled {
+  background-color: var(--jp-error-color3);
+}
+
 /* Precedence order update/error/warning matters! */
 .jp-extensionmanager-entry.jp-extensionmanager-entry-update {
   border-left: solid 8px var(--jp-brand-color2);
@@ -269,6 +297,24 @@
   }
 }
 
+/*
+  Disclaimer buttons
+*/
+
+.jp-extensionmanager-disclaimer-disable {
+  background-color: var(--jp-brand-color1) !important;
+  color: white !important;
+  border: 0;
+  background-image: none !important;
+}
+
+.jp-extensionmanager-disclaimer-enable {
+  background-color: var(--jp-error-color1) !important;
+  color: white !important;
+  border: 0;
+  background-image: none !important;
+}
+
 /*
   Entry buttons layout and styling
 */

+ 6 - 0
packages/extensionmanager/tsconfig.json

@@ -9,9 +9,15 @@
     {
       "path": "../apputils"
     },
+    {
+      "path": "../coreutils"
+    },
     {
       "path": "../services"
     },
+    {
+      "path": "../settingregistry"
+    },
     {
       "path": "../ui-components"
     }

+ 1 - 1
packages/terminal-extension/src/index.ts

@@ -112,7 +112,7 @@ function activate(
    */
   function updateOptions(settings: ISettingRegistry.ISettings): void {
     // Update the cached options by doing a shallow copy of key/values.
-    // This is needed because options is passed and used in addCommands and needs
+    // This is needed because options is passed and used in addcommand-palette and needs
     // to reflect the current cached values.
     Object.keys(settings.composite).forEach((key: keyof ITerminal.IOptions) => {
       (options as any)[key] = settings.composite[key];

+ 8 - 0
packages/ui-components/src/blueprint.tsx

@@ -22,6 +22,10 @@ import {
   Select as BPSelect,
   ISelectProps
 } from '@blueprintjs/select/lib/cjs/components/select/select';
+import {
+  Checkbox as BPCheckbox,
+  ICheckboxProps
+} from '@blueprintjs/core/lib/cjs/components/forms/controls';
 export { Intent } from '@blueprintjs/core/lib/cjs/common/intent';
 
 import { classes } from './utils';
@@ -77,3 +81,7 @@ export const Collapse = (props: ICollapseProps & CommonProps<any>) => (
 export const Select = (props: ISelectProps<any> & CommonProps<any>) => (
   <BPSelect {...props} className={classes(props.className, 'jp-Select')} />
 );
+
+export const Checkbox = (props: ICheckboxProps & CommonProps<any>) => (
+  <BPCheckbox {...props} className={classes(props.className, 'jp-Checkbox')} />
+);

+ 2 - 0
packages/ui-components/src/icon/iconimports.ts

@@ -49,6 +49,7 @@ import launcherSvgstr from '../../style/icons/filetype/launcher.svg';
 import lineFormSvgstr from '../../style/icons/statusbar/line-form.svg';
 import linkSvgstr from '../../style/icons/toolbar/link.svg';
 import listSvgstr from '../../style/icons/statusbar/list.svg';
+import listingsInfoSvgstr from '../../style/icons/listings/listings-info.svg';
 import markdownSvgstr from '../../style/icons/filetype/markdown.svg';
 import newFolderSvgstr from '../../style/icons/toolbar/new-folder.svg';
 import notTrustedSvgstr from '../../style/icons/statusbar/not-trusted.svg';
@@ -117,6 +118,7 @@ export const launcherIcon = new LabIcon({ name: 'ui-components:launcher', svgstr
 export const lineFormIcon = new LabIcon({ name: 'ui-components:line-form', svgstr: lineFormSvgstr });
 export const linkIcon = new LabIcon({ name: 'ui-components:link', svgstr: linkSvgstr });
 export const listIcon = new LabIcon({ name: 'ui-components:list', svgstr: listSvgstr });
+export const listingsInfoIcon = new LabIcon({ name: 'ui-components:listings-info', svgstr: listingsInfoSvgstr });
 export const markdownIcon = new LabIcon({ name: 'ui-components:markdown', svgstr: markdownSvgstr });
 export const newFolderIcon = new LabIcon({ name: 'ui-components:new-folder', svgstr: newFolderSvgstr });
 export const notTrustedIcon = new LabIcon({ name: 'ui-components:not-trusted', svgstr: notTrustedSvgstr });

+ 4 - 0
packages/ui-components/style/deprecated.css

@@ -53,6 +53,7 @@
   --jp-icon-line-form: url('icons/statusbar/line-form.svg');
   --jp-icon-link: url('icons/toolbar/link.svg');
   --jp-icon-list: url('icons/statusbar/list.svg');
+  --jp-icon-listings-info: url('icons/listings/listings-info.svg');
   --jp-icon-markdown: url('icons/filetype/markdown.svg');
   --jp-icon-new-folder: url('icons/toolbar/new-folder.svg');
   --jp-icon-not-trusted: url('icons/statusbar/not-trusted.svg');
@@ -205,6 +206,9 @@
 .jp-ListIcon {
   background-image: var(--jp-icon-list);
 }
+.jp-ListingsInfoIcon {
+  background-image: var(--jp-icon-listings-info);
+}
 .jp-MarkdownIcon {
   background-image: var(--jp-icon-markdown);
 }

+ 0 - 5
packages/ui-components/style/deprecated/search-white.svg

@@ -1,5 +0,0 @@
-<svg viewBox="0 0 18 18" height="18" width="18" xmlns="http://www.w3.org/2000/svg">
-  <g fill="#fff">
-    <path d="M12.1,10.9h-0.7l-0.2-0.2c0.8-0.9,1.3-2.2,1.3-3.5c0-3-2.4-5.4-5.4-5.4S1.8,4.2,1.8,7.1s2.4,5.4,5.4,5.4 c1.3,0,2.5-0.5,3.5-1.3l0.2,0.2v0.7l4.1,4.1l1.2-1.2L12.1,10.9z M7.1,10.9c-2.1,0-3.7-1.7-3.7-3.7s1.7-3.7,3.7-3.7s3.7,1.7,3.7,3.7 S9.2,10.9,7.1,10.9z"/>
-  </g>
-</svg>

+ 1 - 1
packages/ui-components/style/deprecatedExtra.css

@@ -8,7 +8,7 @@
  */
 
 :root {
-  --jp-icon-search-white: url('deprecated/search-white.svg');
+  --jp-icon-search-white: url('icons/toolbar/search.svg');
 }
 
 .jp-Icon,

+ 56 - 0
packages/ui-components/style/icons/listings/listings-info.svg

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50.978 50.978" style="enable-background:new 0 0 50.978 50.978;" xml:space="preserve">
+<g>
+	<g>
+		<g>
+			<path style="fill:#010002;" d="M43.52,7.458C38.711,2.648,32.307,0,25.489,0C18.67,0,12.266,2.648,7.458,7.458
+				c-9.943,9.941-9.943,26.119,0,36.062c4.809,4.809,11.212,7.456,18.031,7.458c0,0,0.001,0,0.002,0
+				c6.816,0,13.221-2.648,18.029-7.458c4.809-4.809,7.457-11.212,7.457-18.03C50.977,18.67,48.328,12.266,43.52,7.458z
+				 M42.106,42.105c-4.432,4.431-10.332,6.872-16.615,6.872h-0.002c-6.285-0.001-12.187-2.441-16.617-6.872
+				c-9.162-9.163-9.162-24.071,0-33.233C13.303,4.44,19.204,2,25.489,2c6.284,0,12.186,2.44,16.617,6.872
+				c4.431,4.431,6.871,10.332,6.871,16.617C48.977,31.772,46.536,37.675,42.106,42.105z"/>
+		</g>
+		<g>
+			<path style="fill:#010002;" d="M23.578,32.218c-0.023-1.734,0.143-3.059,0.496-3.972c0.353-0.913,1.11-1.997,2.272-3.253
+				c0.468-0.536,0.923-1.062,1.367-1.575c0.626-0.753,1.104-1.478,1.436-2.175c0.331-0.707,0.495-1.541,0.495-2.5
+				c0-1.096-0.26-2.088-0.779-2.979c-0.565-0.879-1.501-1.336-2.806-1.369c-1.802,0.057-2.985,0.667-3.55,1.832
+				c-0.301,0.535-0.503,1.141-0.607,1.814c-0.139,0.707-0.207,1.432-0.207,2.174h-2.937c-0.091-2.208,0.407-4.114,1.493-5.719
+				c1.062-1.64,2.855-2.481,5.378-2.527c2.16,0.023,3.874,0.608,5.141,1.758c1.278,1.16,1.929,2.764,1.95,4.811
+				c0,1.142-0.137,2.111-0.41,2.911c-0.309,0.845-0.731,1.593-1.268,2.243c-0.492,0.65-1.068,1.318-1.73,2.002
+				c-0.65,0.697-1.313,1.479-1.987,2.346c-0.239,0.377-0.429,0.777-0.565,1.199c-0.16,0.959-0.217,1.951-0.171,2.979
+				C26.589,32.218,23.578,32.218,23.578,32.218z M23.578,38.22v-3.484h3.076v3.484H23.578z"/>
+		</g>
+	</g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>

+ 1 - 1
setup.py

@@ -139,7 +139,7 @@ setup_args = dict(
 setup_args['install_requires'] = [
     'notebook>=4.3.1',
     'tornado!=6.0.0, !=6.0.1, !=6.0.2',
-    'jupyterlab_server~=1.0.0',
+    'jupyterlab_server@ git+https://github.com/datalayer-contrib/jupyterlab-server@bw-list',
     'jinja2>=2.10'
 ]