Compare commits

...

540 Commits

Author SHA1 Message Date
Min RK
562a24b651 Bump to 3.0.0b1 2022-08-02 15:10:28 +02:00
Simon Li
9318eb3fb2 Merge pull request #3994 from minrk/3.0-changelog
3.0 changelog
2022-08-02 14:05:38 +01:00
Min RK
0590b76cd0 3.0: refresh changelog entries 2022-08-02 14:42:33 +02:00
Min RK
8aac18c96d More documentation for 3.0 role/scope changes
a few more outdated `versionchanged` notes
2022-08-02 14:39:03 +02:00
Erik Sundell
6a6b8567c0 Merge pull request #3909 from minrk/include_stopped_servers
include stopped servers in user model
2022-08-02 14:31:14 +02:00
Min RK
78438bdfcc Begin 3.0 changelog
had to manually remove already-backported PRs
2022-08-02 14:29:16 +02:00
Erik Sundell
2096c956db Merge pull request #3877 from minrk/oauth_config
store scopes on oauth clients, too
2022-08-02 14:12:15 +02:00
Min RK
dfc2d4d4f1 Merge remote-tracking branch 'origin/main' into oauth_config 2022-08-02 13:53:51 +02:00
Min RK
5f57b72d6e fix version change in comment now that it's 3.0 2022-08-02 13:37:33 +02:00
Min RK
6a470b44e7 explicitly support async oauth_client_allowed_scopes 2022-08-02 13:37:32 +02:00
Min RK
a35a2ec8b7 less space
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-08-02 13:34:42 +02:00
Min RK
978b71c8bb Merge pull request #4001 from minrk/info-table-sort
admin: format user/server-info tables
2022-08-02 13:18:06 +02:00
Min RK
f3b328a4d8 format user/server-info tables
- sort keys for consistent presentation
- use text list for roles, groups, which aren't well rendered by the table-formatter (number index isn't helpful)
- render timestamps
- leave empty name for default server, instead of '[MAIN]' which isn't terminology used anywhere else
2022-08-02 12:28:13 +02:00
Min RK
9da78880e6 Merge pull request #4000 from minrk/jsx-deps
[admin] update, clean jsx deps
2022-08-02 12:27:12 +02:00
Min RK
f2871cfc3c update to maintained recompose fork 2022-08-02 11:58:01 +02:00
Min RK
24efd12ab5 jsx: move dev dependencies to devDependencies
doesn't really make a difference since it's not a real package, but cleaner.

updates webpack-dev-server
2022-08-02 11:05:42 +02:00
Erik Sundell
9b2e6b1c1d Merge pull request #3992 from minrk/avoid-deprecated-clear-current-singleuser
Avoid IOLoop.current in singleuser mixins
2022-08-02 10:06:43 +02:00
Erik Sundell
9f62d83568 Merge pull request #3998 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-08-02 08:54:33 +02:00
pre-commit-ci[bot]
7eb3575502 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.2](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.2)
2022-08-01 23:15:08 +00:00
Min RK
6afa0d6311 include stopped servers in user model
opt-in, behind ?include_stopped_servers

Adds `stopped` field to server model to more easily select stopped servers
2022-08-01 15:47:36 +02:00
Erik Sundell
d170601678 Merge pull request #3997 from minrk/inaccurate-cookie
Remove outdated cookie-secret note in security docs
2022-08-01 14:20:20 +02:00
Min RK
01a33d150f Remove outdated cookie-secret note in security docs
this was true when we used shared cookie auth long ago
2022-08-01 13:28:13 +02:00
Simon Li
28b11d2165 Merge pull request #3969 from consideRatio/pr/set-announcement-properly
Fix disabling of individual page template announcements
2022-07-30 17:38:47 +01:00
Erik Sundell
83b5e8f3da Only blank strings to disable a specific page's announcement
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-07-30 11:53:00 +02:00
Min RK
e4e4bf5ff4 next release is 3.0, not 2.4 2022-07-29 15:35:08 +02:00
Min RK
ab3a01b9f6 even latest jupyter-server requires current event loop
for nbconvert handler lock
2022-07-29 15:31:50 +02:00
Min RK
8548472b6e Avoid IOLoop.current in singleuser mixins
- define our own init_ioloop
- call it ASAP
- put init_httpserver in IOLoop.run_sync because instantiating a server accesses the current loop
- run cleanup via asyncio.run
2022-07-29 14:51:30 +02:00
Erik Sundell
ab776e3989 Merge pull request #3974 from minrk/avoid-deprecated-clear-current
Avoid deprecated 'IOLoop.current' method
2022-07-29 12:03:52 +02:00
Min RK
b0b7378e2b Avoid deprecated 'IOLoop.current' method
Deprecated in tornado 6.2, only access running loop from inside coroutines
2022-07-29 11:30:39 +02:00
Erik Sundell
75e03ef1d9 Merge pull request #3976 from minrk/bump-versions
Require Python 3.7
2022-07-29 10:42:15 +02:00
Min RK
986de0b5db use str-format for ssl.Purposes
rather than default, which is a weird repr
2022-07-29 09:26:21 +02:00
Min RK
6959c9dde3 Merge pull request #3985 from jupyterhub/dependabot/npm_and_yarn/jsx/terser-5.14.2
Bump terser from 5.12.1 to 5.14.2 in /jsx
2022-07-29 08:30:05 +02:00
Min RK
53087e50e4 Merge pull request #3989 from rpwagner/patch-1
bump moment.js 2.29.4
2022-07-29 08:27:46 +02:00
Erik Sundell
dee830a56f Merge pull request #3975 from minrk/dockerfile-base
Bump Dockerfile base image to 22.04
2022-07-27 20:38:42 +02:00
Rick Wagner
45179c53b7 bump moment.js 2.29.4 2022-07-26 10:21:55 -07:00
Erik Sundell
6c7cb65224 Merge pull request #3988 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-26 10:30:45 +02:00
pre-commit-ci[bot]
0038b3c2e8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.37.1 → v2.37.2](https://github.com/asottile/pyupgrade/compare/v2.37.1...v2.37.2)
2022-07-25 22:16:38 +00:00
Erik Sundell
17bb8a9ba4 Unpin Dockerfile's ubuntu base image 2022-07-25 15:27:50 +02:00
Erik Sundell
9756c13f13 Merge pull request #3987 from jupyterhub/dependabot/github_actions/docker/build-push-action-3.1.0
Bump docker/build-push-action from 3.0.0 to 3.1.0
2022-07-25 10:03:26 +02:00
dependabot[bot]
be84b06ca6 Bump docker/build-push-action from 3.0.0 to 3.1.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](e551b19e49...1cb9d22b93)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-25 05:30:44 +00:00
dependabot[bot]
3ee88d99da Bump terser from 5.12.1 to 5.14.2 in /jsx
Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-20 22:58:57 +00:00
Erik Sundell
16b0a2ac3b Merge pull request #3978 from minrk/warn-stacklevel
Increase stacklevel for decorated warnings
2022-07-15 08:44:12 +02:00
Min RK
a82d2e4903 Merge pull request #3977 from minrk/unpin-nbclassic
unpin nbclassic
2022-07-14 21:16:54 -07:00
Min RK
cef241a80b test: handle possibility that notebook is unavailable
no longer a strict dependency of other test deps
2022-07-14 21:07:57 -07:00
Min RK
eab6a1a112 typo in jinja env name
use loop variable, not hardcoded string
2022-07-14 20:34:07 -07:00
Min RK
827bfc99ec Merge pull request #3962 from manics/ConfigurableHTTPProxy-log_level
Add ConfigurableHTTPProxy.log_level
2022-07-14 20:22:41 -07:00
Min RK
b5bd307999 urllib.quote doesn't escape ~ starting with Python 3.7
3.7 adds ~ to the 'unreserved' (always safe) set,
but it's not safe in domain names.
so do it ourselves. Formalize in a `_dns_quote` private function,
with notes about issues.

The only usernames that change in this PR are those containing `_` or `/`,
the latter of which would have failed.
2022-07-14 20:19:50 -07:00
Min RK
d02d029e88 adjust Python test order 2022-07-14 16:02:01 -07:00
Min RK
e41885c458 Increase stacklevel for decorated warnings
otherwise warning just shows as being triggered in the decorator function
2022-07-14 16:00:27 -07:00
Simon Li
78ac9946e3 Use CaselessStrEnum instead of Enum 2022-07-14 21:51:14 +01:00
Simon Li
bd8e8eaa09 Add ConfigurableHTTPProxy.log_level 2022-07-14 21:51:12 +01:00
Min RK
a2a01755ec simplify make_ssl_context
pass ssl.Purpose explicitly, deprecate verify/check_hostname

3.10 disallows 'purpose=SERVER_AUTH' from creating server sockets.
Instead:

- pass purpose directly
- always verify
- no need to set check_hostname, already covered by purpose defaults
2022-07-14 11:02:44 -07:00
Min RK
a593c6187f can't test 3.11 yet because greenlet cannot be installed
sqlalchemy requires greenlet on linux, even though we don't use it
2022-07-14 10:10:58 -07:00
Min RK
b159cbfeef unpin nbclassic
0.4.3 is out, see if it fixes things
2022-07-14 09:09:25 -07:00
Min RK
a7cced506b Remove 3.6 compatibility shims
- asyncio.all_tasks/current_task
- pytest-asyncio 0.17
- contextmanager.nullcontext
2022-07-14 09:05:01 -07:00
Min RK
e8469af763 fixup: remove redundant check 2022-07-14 09:05:01 -07:00
Min RK
d2c6b23bf9 update python versions in test matrix 2022-07-14 09:05:01 -07:00
Min RK
c657498d75 require Python 3.7
drops support for Python 3.6
2022-07-14 09:05:01 -07:00
Min RK
001d0c9af1 call legacy notebook matrix entry legacy_notebook
instead of nbclassic, which is explicitly classic nb ui on jupyter-server (the opposite of these tests)
2022-07-14 09:05:01 -07:00
Min RK
0d0368c042 Bump Dockerfile base image to 22.04 2022-07-14 09:03:53 -07:00
Min RK
6fdd0ff7c5 Merge pull request #3967 from minrk/validate-extra-routes
validate proxy.extra_routes
2022-07-14 09:01:12 -07:00
Min RK
dee671b640 Merge pull request #3883 from minrk/async-hub-auth
allow HubAuth to be async
2022-07-13 20:38:36 -07:00
Min RK
c289a422c3 validate proxy.extra_routes
- add trailing slash if missing, and warn
- raise if leading slash is wrong (must not be present with host routing, must be present otherwise)
2022-07-13 20:33:39 -07:00
Erik Sundell
fa47529cf1 Merge pull request #3971 from minrk/nbclassic-renamed
nbclassic extension name has been renamed
2022-07-12 08:06:29 +02:00
Erik Sundell
6769de5b01 Merge pull request #3970 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-12 08:03:49 +02:00
Min RK
c8a8892292 nbclassic extension name has been renamed
our patches to the jinja env need updating to find the new env
2022-07-11 20:37:29 -07:00
pre-commit-ci[bot]
273b25cb6f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v2.37.1](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.37.1)
2022-07-11 21:54:26 +00:00
Erik Sundell
71e06a4cd7 Merge pull request #3958 from minrk/autocomplete-annotation
add correct autocomplete fields for login form
2022-07-10 12:11:04 +02:00
Erik Sundell
827310aca6 Fix disabling of individual page template announcements 2022-07-10 12:05:48 +02:00
Min RK
b9c83cf7ab allow HubAuth to be async
Switches requests to tornado AsyncHTTPClient instead of requests

For backward-compatibility, use opt-in `sync=False` arg for all public methods that _may_ be async

When sync=True (default), async functions still used, but blocking via ThreadPool + asyncio run_until_complete
2022-07-09 16:45:41 -07:00
Min RK
8a44748324 Bump to 2.4.0.dev 2022-07-09 16:44:24 -07:00
Min RK
e4f469ef73 Merge pull request #3968 from minrk/test-nbclassic
pin nbclassic in dev requirements
2022-07-09 16:41:27 -07:00
Min RK
4cf4566fff pin nbclassic < 0.4
0.4 doesn't actually load at all
2022-07-09 10:05:15 -07:00
Min RK
55c866f340 note why we depend on nbclassic
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-07-08 12:15:41 -07:00
Min RK
fd550e223e add nbclassic to dev requirements
ensures `/tree/` handler is present
2022-07-08 10:32:13 -07:00
Min RK
225ace636a call client-allowed scopes JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES 2022-07-08 10:18:59 -07:00
Erik Sundell
ee4c8b835b Merge pull request #3964 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-05 10:56:02 +02:00
pre-commit-ci[bot]
f43ad0c176 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0)
2022-07-04 22:34:28 +00:00
Min RK
95de2618a3 Merge pull request #3960 from cqzlxl/cqzlxl-patch-1
Fix GET /api/proxy with pagination
2022-06-30 21:18:09 +02:00
Min RK
48c9f6ca50 Test proxy API endpoint with pagination 2022-06-30 12:09:20 -07:00
cqzlxl
2d56bb74eb FIX a bug
it's an obvious bug.
2022-06-27 22:42:33 +08:00
Min RK
42cc3cae8e add correct autocomplete fields for login form 2022-06-24 10:37:50 +02:00
Min RK
02da11e06e Merge pull request #3955 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-06-21 09:58:36 +02:00
pre-commit-ci[bot]
eb1061a910 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.6.2 → v2.7.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.2...v2.7.1)
2022-06-20 21:02:45 +00:00
Min RK
d852d9e37c Merge pull request #3953 from silenius/freebsd_pw
FreeBSD, missing -n for pw useradd
2022-06-16 11:33:06 +02:00
Julien Cigar
1392aee195 -n is required 2022-06-15 17:23:13 +02:00
Erik Sundell
63b7defe1a Merge pull request #3950 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-06-14 00:43:29 +02:00
pre-commit-ci[bot]
00803f039a [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.32.1 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.1...v2.34.0)
- [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0)
2022-06-13 22:29:26 +00:00
Erik Sundell
2b1c246c13 Merge pull request #3947 from jupyterhub/dependabot/github_actions/char0n/swagger-editor-validate-8829b79e438e100191c1e6ec1519daf0b66fed34
Bump char0n/swagger-editor-validate from 182d1a5d26ff5c2f4f452c43bd55e2c7d8064003 to 1.3.1
2022-06-13 13:48:53 +02:00
Erik Sundell
4f6dd69cb1 Merge pull request #3949 from jupyterhub/dependabot/github_actions/actions/setup-python-4
Bump actions/setup-python from 2 to 4
2022-06-13 13:48:43 +02:00
Erik Sundell
4fde1d2b65 Apply suggestions from code review 2022-06-13 13:48:21 +02:00
Erik Sundell
ccceebe257 Merge pull request #3948 from jupyterhub/dependabot/github_actions/actions/upload-artifact-3
Bump actions/upload-artifact from 2 to 3
2022-06-13 13:47:09 +02:00
Erik Sundell
499dac9ee2 ci: fix typo in test-docs workflow triggers 2022-06-13 13:46:09 +02:00
Erik Sundell
1d26e61f7e Apply suggestions from code review 2022-06-13 13:46:09 +02:00
dependabot[bot]
c40e20a3e3 Bump actions/setup-python from 2 to 4
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 4.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v2...v4)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:08 +00:00
dependabot[bot]
549b2b8e95 Bump actions/upload-artifact from 2 to 3
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:04 +00:00
Erik Sundell
15665c0363 Merge pull request #3944 from jupyterhub/dependabot/github_actions/actions/setup-node-3
Bump actions/setup-node from 1 to 3
2022-06-13 13:38:01 +02:00
dependabot[bot]
226f993e7d Bump char0n/swagger-editor-validate
Bumps [char0n/swagger-editor-validate](https://github.com/char0n/swagger-editor-validate) from 182d1a5d26ff5c2f4f452c43bd55e2c7d8064003 to 1.3.1. This release includes the previously tagged commit.
- [Release notes](https://github.com/char0n/swagger-editor-validate/releases)
- [Commits](182d1a5d26...8829b79e43)

---
updated-dependencies:
- dependency-name: char0n/swagger-editor-validate
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:38:00 +00:00
dependabot[bot]
9081265dab Bump actions/setup-node from 1 to 3
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 1 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:37:44 +00:00
Erik Sundell
de14f18be8 Merge pull request #3946 from consideRatio/pr/weekly-05
ci: run dependabot updates weekly monday 05:00 UTC+0 time
2022-06-13 13:37:39 +02:00
Erik Sundell
da276f0c6b Merge pull request #3945 from jupyterhub/dependabot/github_actions/actions/checkout-3
Bump actions/checkout from 2 to 3
2022-06-13 13:36:59 +02:00
Erik Sundell
5a3c98a849 Merge pull request #3943 from jupyterhub/dependabot/github_actions/docker/setup-qemu-action-2
Bump docker/setup-qemu-action from 1.0.2 to 2
2022-06-13 13:35:53 +02:00
Erik Sundell
51fa0af3fe Merge pull request #3942 from jupyterhub/dependabot/github_actions/docker/setup-buildx-action-2
Bump docker/setup-buildx-action from 1.1.2 to 2
2022-06-13 13:35:37 +02:00
Erik Sundell
fcdce01ae6 Merge pull request #3941 from jupyterhub/dependabot/github_actions/docker/build-push-action-3
Bump docker/build-push-action from 2.4.0 to 3
2022-06-13 13:35:19 +02:00
dependabot[bot]
9af9a7bff7 Bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:36 +00:00
dependabot[bot]
1eef021704 Bump docker/setup-qemu-action from 1.0.2 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1.0.2 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](25f0500ff2...8b122486ce)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:27 +00:00
dependabot[bot]
a308a0c9b4 Bump docker/setup-buildx-action from 1.1.2 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1.1.2 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](2a4b53665e...dc7b9719a9)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:23 +00:00
dependabot[bot]
726b8243eb Bump docker/build-push-action from 2.4.0 to 3
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2.4.0 to 3.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](e1b7f96249...e551b19e49)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-13 11:32:17 +00:00
Erik Sundell
88cea51561 Run updates weekly monday 05:00 UTC+0 time 2022-06-13 13:32:12 +02:00
Erik Sundell
ec0bcb1f1b Merge pull request #3940 from turrisxyz/Dependabot-GitHub-Actions
chore: add dependabot config for github actions
2022-06-13 13:31:32 +02:00
naveen
2df1808c4e chore: Included githubactions in the dependabot config
This should help with keeping the GitHub actions updated on new releases. This will also help with keeping it secure.

Dependabot helps in keeping the supply chain secure https://docs.github.com/en/code-security/dependabot

GitHub actions up to date https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot

https://github.com/ossf/scorecard/blob/main/docs/checks.md#dependency-update-tool
Signed-off-by: naveen <172697+naveensrinivasan@users.noreply.github.com>
2022-06-13 01:30:37 +00:00
Erik Sundell
c85e90a71b Merge pull request #3939 from turrisxyz/Pinned-Dependencies-GitHub
chore: Set permissions for GitHub actions
2022-06-12 10:19:14 +02:00
naveen
1013a49db2 chore: Set permissions for GitHub actions
Restrict the GitHub token permissions only to the required ones; this way, even if the attackers will succeed in compromising your workflow, they won’t be able to do much.

- Included permissions for the action. https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions

https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs

[Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)

Signed-off-by: naveen <172697+naveensrinivasan@users.noreply.github.com>
2022-06-12 00:30:04 +00:00
Erik Sundell
f6eec29aa2 Merge pull request #3937 from minrk/importlib
switch to importlib_metadata for entrypoints
2022-06-08 15:23:54 +02:00
Min RK
64b99d5587 switch to importlib_metadata for entrypoints
standalone entrypoints package is deprecated
now that similar functionality is in the stdlib

need importlib_metadata >= 3.6 backport on Python < 3.10
2022-06-08 15:14:50 +02:00
Erik Sundell
75b07fc0d6 Merge pull request #3936 from minrk/add-user-validate
admin: Hub is responsible for username validation
2022-06-08 14:55:52 +02:00
Erik Sundell
d64068da66 Merge pull request #3935 from minrk/spawn-page-url
admin: Fix spawn page link for default server
2022-06-08 14:51:26 +02:00
Min RK
62b38934e5 store scopes on oauth clients, too
rather than roles, matching tokens

because oauth clients are mostly involved with issuing tokens,
they don't have roles themselves (their owners do).

This deprecates the `oauth_roles` config on Spawners and Services, in favor of `oauth_allowed_scopes`.

The ambiguously named `oauth_scopes` is renamed to `oauth_access_scopes`.
2022-06-08 12:26:48 +02:00
Min RK
14d8e23135 trim user input forms 2022-06-08 12:09:11 +02:00
Min RK
0908a15848 Server is responsible for username validation
Don't reimplement in the client
2022-06-08 11:06:33 +02:00
Min RK
2e878fb5ca fix spawn page link 2022-06-08 10:48:04 +02:00
Min RK
62d24341ca fix static url in admin page 2022-06-08 10:47:14 +02:00
Yuvi Panda
f2085fdf0f Merge pull request #3931 from consideRatio/pr/add-changelog-to-main
Add changelog for 2.3.0 and 2.3.1
2022-06-06 19:53:56 +05:30
Erik Sundell
a19c211612 Add changelog for 2.3.1 2022-06-06 16:18:03 +02:00
Min RK
9bbcf594ea One more in the changelog 2022-06-06 16:17:55 +02:00
Min RK
da89155503 changelog for 2.3 2022-06-06 16:17:54 +02:00
Min RK
3b59c4861f Merge pull request #3904 from manics/named-servers-escape
Escape named server name
2022-06-03 17:09:58 +02:00
Min RK
6f5764fd3d Merge pull request #3921 from manics/pages-unreachable
pages.py: Remove unreachable code
2022-06-03 16:58:33 +02:00
Simon Li
3c059f3acf Need to escape URLs in spawn-pending too 2022-06-02 19:56:52 +01:00
Simon Li
3a022f1ae3 pages.py: Remove unreachable code 2022-06-02 19:13:25 +01:00
Min RK
049a59f2ed Merge pull request #3920 from jupyterhub/dependabot/npm_and_yarn/jsx/eventsource-1.1.1
Bump eventsource from 1.1.0 to 1.1.1 in /jsx
2022-06-02 09:51:36 +02:00
Min RK
ed9ea4e6cc Merge pull request #3914 from manics/setuppy-yarn-jsx
Build admin app in setup.py
2022-06-02 09:51:22 +02:00
dependabot[bot]
c415be2db3 Bump eventsource from 1.1.0 to 1.1.1 in /jsx
Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: eventsource
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-01 22:51:13 +00:00
Simon Li
2bc5061e22 Don't escape servername in json blobs 2022-06-01 22:21:00 +01:00
pre-commit-ci[bot]
cedf12baeb [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-06-01 20:28:55 +00:00
Simon Li
b403c41c15 Remove old comment, include description in servername error
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-06-01 21:28:24 +01:00
Simon Li
acd75d85c7 Move installed data file check to script 2022-06-01 20:44:40 +01:00
Simon Li
5e5dad9512 check sdist files in release workflow 2022-06-01 20:43:28 +01:00
Simon Li
95e343395d Ensure jsx is in sdist 2022-06-01 20:43:02 +01:00
Yuvi Panda
6a29e5193b Merge pull request #3919 from minrk/jupyter-server-templates
ensure custom template is loaded with jupyter-server notebook extension
2022-06-01 22:59:33 +05:30
Min RK
1cb7177597 ensure custom template is loaded with jupyter-server notebook extension
our patches to page.html didn't affect nbclassic,
which gets its own jinja environment

regression test included
2022-06-01 16:13:10 +02:00
Yuvi Panda
50e863ca52 Merge pull request #3910 from minrk/optimize-prefix-lookup
use equality to filter token prefixes
2022-06-01 19:05:45 +05:30
Yuvi Panda
8cdd7ca2d2 Merge pull request #3918 from minrk/default-url-priority
set default_url via config
2022-06-01 19:04:55 +05:30
Min RK
6fbf8411ec Merge pull request #3915 from manics/contrib-docs
Update Contributing documentation
2022-05-31 19:52:30 +02:00
Min RK
fa200fed98 set default_url via config
avoids accidental overrides of `@default('default_url')` in subclasses,
e.g. SingleUserLabApp
2022-05-31 17:05:58 +02:00
Simon Li
7d7d30bcae Don't build admin app on readthedocs 2022-05-29 19:23:57 +01:00
Simon Li
85a4bbc28e Update Contributing documentation
Adds yarn, moves most of CONTRIBUTING.md into https://jupyterhub.readthedocs.io/en/stable/contributing/index.html to reduce duplication
2022-05-29 19:11:57 +01:00
Simon Li
0b161627c2 yarn: allow jlpm to be used instead 2022-05-29 17:14:14 +01:00
Simon Li
36e7898ed4 Update CI so that setup.py can build admin app 2022-05-29 16:52:24 +01:00
Simon Li
3537722208 Include generated admin-react.js.LICENSE.txt 2022-05-29 16:52:24 +01:00
Simon Li
dfcaa29c8a Build react admin app in setup.py 2022-05-29 16:52:20 +01:00
Simon Li
92c6d69bc8 Remove share/jupyterhub/static/js/admin-react.js jsx/build 2022-05-29 16:12:29 +01:00
Simon Li
7b8a2ae57b Escape server-name in URLs returned by API 2022-05-27 23:06:55 +01:00
Simon Li
b444fe478c Ensure server-name is escaped in proxy add_route 2022-05-27 22:44:09 +01:00
Simon Li
50fb1a016c Move server-name / check to higher up, add test 2022-05-27 22:06:19 +01:00
Min RK
e229c63e11 use equality to filter token prefixes
otherwise, index isn't used

note: this means changing the token prefix size requires revoking all tokens,
where before only _increasing_ the token prefix size required doing that.
2022-05-25 15:54:34 +02:00
Erik Sundell
9649a57e34 Merge pull request #3908 from minrk/fail-fail-auth-state
allow auth_state_hook to halt spawn
2022-05-25 12:43:39 +02:00
Erik Sundell
ac85d63013 Merge pull request #3907 from minrk/bump-moment
bump moment.js 2.29.2
2022-05-25 12:39:34 +02:00
Min RK
4b2ba1f6c0 allow auth_state_hook to halt spawn
hooks prior to start should raise and stop the whole thing

only hooks during cleanup need to be passed over
2022-05-25 11:36:32 +02:00
Min RK
886d15b622 bump moment.js 2.29.2 2022-05-25 11:32:06 +02:00
Min RK
d517ce37e7 Merge pull request #3906 from fabianbaier/patch-1
Force add existing certificates
2022-05-25 11:23:58 +02:00
Min RK
85f0cec33e Merge pull request #3903 from manics/jupyter-troubleshoot
`jupyter troubleshooting` ➡️  `jupyter troubleshoot`
2022-05-25 11:18:35 +02:00
pre-commit-ci[bot]
5c37569b2a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-05-25 05:04:07 +00:00
Fabian Baier
956b96967e Update app.py to fix certipy.CertExistsError 2022-05-24 21:58:33 -07:00
Simon Li
f51faa25ed Ban / from server-name 2022-05-24 17:46:08 +01:00
Simon Li
aa8c85f404 Test explicitly escaped servername from client 2022-05-24 17:08:21 +01:00
Simon Li
98540f0f6d Need to escape server name in part of proxy 2022-05-24 16:56:15 +01:00
Simon Li
841c89769a Add test for special chars in named servers 2022-05-24 16:55:48 +01:00
Simon Li
84cb9761e8 Escape named servers when used in URL paths 2022-05-22 23:31:47 +01:00
Simon Li
178e340223 jupyter troubleshooting ➡️ jupyter troubleshoot 2022-05-22 15:18:42 +01:00
Min RK
b18a05c2c8 Merge pull request #3895 from yuvipanda/document-display
Document the 'display' attribute of services
2022-05-13 11:56:58 +02:00
Min RK
3466de1473 Merge pull request #3899 from manics/admin_access_deprecated
`admin_access` no longer works as it is overridden by RBAC scopes
2022-05-13 11:53:58 +02:00
Simon Li
4be4e41fa7 admin_access no longer works as it is overridden by RBAC scopes
The `admin_access` variable is still referenced elsewhere and I considered removing it, but I couldn't work out exactly how/if it's used so this is just a docs change for now.
2022-05-12 12:31:50 +01:00
YuviPanda
3264463366 Document the 'display' attribute of services
Ref https://github.com/2i2c-org/infrastructure/issues/1301
2022-05-11 19:04:58 +05:30
Simon Li
8252504dad Merge pull request #3885 from minrk/deprecate-db
Deprecate Authenticator.db, Spawner.db
2022-05-10 17:48:20 +01:00
Min RK
ac3ef1efc1 Deprecate Authenticator.db, Spawner.db
These objects should not access the shared db session;
add a warning pointing to Issue about their removal if it is accessed
2022-05-10 10:24:32 +02:00
Min RK
54cb259882 Merge pull request #3891 from bbrauns/main
remove apache NE flag as it prevents opening folders and renaming fil…
2022-05-10 10:22:01 +02:00
Min RK
04d0291fa0 Merge pull request #3889 from johnkpark/jp--remove-admin-table-head
admin: make user-info table selectable
2022-05-10 10:21:06 +02:00
Erik Sundell
c8d6700406 Merge pull request #3892 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-05-10 07:01:25 +02:00
pre-commit-ci[bot]
e61f2d74a8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.32.0 → v2.32.1](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.32.1)
2022-05-09 20:54:37 +00:00
Björn Braunschweig
a0b9a1fe86 remove apache NE flag as it prevents opening folders and renaming files with whitespace characters. apache returns Bad Request 2022-05-09 11:36:14 +02:00
John Park
27d2e95c43 add regeneratorRuntime back in 2022-05-06 14:03:05 -07:00
John Park
819e59292d yarn lint / place 2022-05-05 16:34:28 -07:00
John Park
f3ef16b948 remove admin-table-head class 2022-05-05 16:21:29 -07:00
Simon Li
5e1e44057d Merge pull request #3886 from minrk/cleanup-api-shutdown
Cleanup everything on API shutdown
2022-05-05 20:16:43 +01:00
Min RK
bf2e322c22 Cleanup everything on API shutdown
via app.stop()
2022-05-05 12:53:21 +02:00
Min RK
585b47051f Merge pull request #3882 from yuvipanda/mo-exceptions
Use log.exception when logging exceptions
2022-05-05 11:37:42 +02:00
Min RK
5ca96fa758 Merge pull request #3878 from minrk/admin-ui-scope
add 'admin-ui' scope for access to the admin ui
2022-05-03 11:20:23 +02:00
YuviPanda
aba6eb962f Use log.exception when logging exceptions
This provides the stack trace in the log file, incredibly
useful when debugging
2022-05-02 17:36:31 -07:00
Georgiana Elena
107dc02fd0 Merge pull request #3876 from minrk/disallow-next-check
don't confuse :// in next_url query params for a redirect hostname
2022-04-29 13:13:21 +03:00
Min RK
debac715bf add 'admin-ui' scope for access to the admin ui 2022-04-29 11:54:02 +02:00
Min RK
c6ed41e322 don't confuse :// in next_url query params for a redirect hostname 2022-04-28 13:35:37 +02:00
Min RK
ec2c90c73f Merge pull request #3874 from code-review-doctor/fix-probably-meant-fstring
Missing `f` prefix on f-strings fix
2022-04-26 15:15:55 +02:00
Min RK
6c2c5e5a90 Don't let spawner._log_name fail when running without user in tests 2022-04-26 14:27:39 +02:00
code-review-doctor
f0b2d8c4eb Fix issue probably-meant-fstring found at https://codereview.doctor 2022-04-24 17:30:49 +01:00
Min RK
a588a0bfa3 Merge pull request #3851 from minrk/service-filter
!service and !server filters
2022-04-21 12:32:31 +02:00
Simon Li
c07358a526 Merge pull request #3869 from jupyterhub/dependabot/npm_and_yarn/jsx/async-2.6.4
Bump async from 2.6.3 to 2.6.4 in /jsx
2022-04-15 21:06:19 +01:00
dependabot[bot]
9058fa42dd Bump async from 2.6.3 to 2.6.4 in /jsx
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: async
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-15 19:08:43 +00:00
Min RK
55d7ebe006 Merge pull request #3867 from consideRatio/pr/black-detail
ci: update black configuration
2022-04-15 21:08:07 +02:00
Erik Sundell
6edbfdad89 ci: update black configuration 2022-04-15 19:16:51 +02:00
Erik Sundell
715a4a25cf Merge pull request #3864 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-04-12 11:04:12 +01:00
pre-commit-ci[bot]
e15447c8b8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.31.1 → v2.32.0](https://github.com/asottile/pyupgrade/compare/v2.31.1...v2.32.0)
- [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0)
2022-04-11 21:25:48 +00:00
Min RK
ab8eec164c Merge pull request #3863 from NarekA/narek/fix-search-bar
[Bug Fix] Search bar disabled on admin dashboard
2022-04-11 11:15:05 +02:00
Narek Amirbekian
b1177cd2ce Improve user search tests 2022-04-08 12:23:27 -07:00
Narek Amirbekian
40d95dc142 Fix search bar in admin dashboard 2022-04-08 11:27:41 -07:00
Min RK
d78bd42cfc call owner_name user_name since it's only defined when the owner is a user 2022-04-08 20:17:12 +02:00
Min RK
b6210dc225 add !service and !server scope filters
allows oauth clients to issue scopes that only grant access to the issuing service

e.g. access:service!service or access:servers!server

especially useful with custom scopes
2022-04-08 20:10:23 +02:00
Erik Sundell
b05a89a3e0 Merge pull request #3862 from cmd-ntrf/readme-rest-api
Fix typo in [rest api] link in README.md
2022-04-08 17:21:31 +02:00
Félix-Antoine Fortin
13e99b904b Fix typo in [rest api] link 2022-04-08 09:56:18 -04:00
Simon Li
01a4b9c4b4 Merge pull request #3859 from minrk/get_env_modify
Do not store Spawner.ip/port on spawner.server during get_env
2022-04-07 22:03:14 +01:00
Min RK
d6df1be272 Merge pull request #3850 from minrk/cache-scopes
memoize some scope functions
2022-04-07 12:56:19 +02:00
Min RK
85ef5cf807 Do not store Spawner.ip/port on spawner.server
This was part of an attempt to get the url from self.server.bind_url that didn't end up getting used

shouldn't mutate db state when getting the environment
2022-04-07 10:56:49 +02:00
Min RK
ff020cb5a4 needs_db typo
Co-authored-by: Simon Li <orpheus+devel@gmail.com>
2022-04-07 09:42:25 +02:00
Min RK
5c54ac9aa1 Merge pull request #3853 from jwclark/patch-1
Fix xsrf_cookie_kwargs ValueError
2022-04-05 20:28:03 +02:00
Erik Sundell
e48662423a Merge pull request #3856 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-04-05 01:00:08 +02:00
pre-commit-ci[bot]
f124f06c2d [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0)
- [github.com/pre-commit/mirrors-prettier: v2.6.1 → v2.6.2](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.1...v2.6.2)
2022-04-04 20:23:56 +00:00
Joseph Clark
f2faf0ee43 Fix ValueError
Fixes ValueError: too many values to unpack (expected 2)
2022-04-01 15:44:28 -06:00
Min RK
ab2913008e more docs, comments, asserts about immutable scope functions 2022-04-01 11:54:32 +02:00
Min RK
eebc0f485d Apply suggestions from code review
Co-authored-by: Simon Li <orpheus+devel@gmail.com>
2022-04-01 11:36:06 +02:00
Min RK
bb6427ea9b Add FrozenDict for caching parsed_scopes dicts
Since we need them to be immutable
2022-04-01 11:36:05 +02:00
Min RK
29b73563dc cache common scope operations
we expand/parse the same scopes _a lot_.
We can save time with some caching.

Main change: cached functions must return immutable frozenset instead of mutable set,
to avoid mutating the result of subsequent returns.

Some functions can only be cached _sometimes_ (e.g. group lookups in db cannot be cached),
for which we have a DoNotCache(result) exception
2022-04-01 11:35:05 +02:00
Erik Sundell
aa0ce1c88a Merge pull request #3852 from minrk/isort
Use isort for import formatting
2022-04-01 11:07:28 +02:00
Min RK
7a9778249f run pre-commit with isort 2022-03-31 12:33:26 +02:00
Min RK
c41b732fbd switch to isort for import formatting
isort produces nicer imports without wasting a huge amount of space
2022-03-31 12:32:11 +02:00
Erik Sundell
d9b85a819e Merge pull request #3849 from huage1994/patch-1
The word `used` is duplicated in upgrade.md
2022-03-30 08:26:08 +02:00
nihua
6d00eb501a Update upgrade.md 2022-03-30 14:20:30 +08:00
Erik Sundell
318c95342d Merge pull request #3833 from minrk/token-scopes
Tokens have scopes instead of roles
2022-03-29 23:49:35 +02:00
Erik Sundell
cde0f12f07 Merge pull request #3848 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-28 22:41:22 +02:00
pre-commit-ci[bot]
6668fb39f9 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.6.0 → v2.6.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.6.0...v2.6.1)
2022-03-28 19:36:33 +00:00
Min RK
4691fae90a Merge pull request #3845 from jupyterhub/dependabot/npm_and_yarn/jsx/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /jsx
2022-03-28 09:03:51 +02:00
Min RK
0fccbc69ff Merge pull request #3834 from NarekA/narek/server-details-in-dashboard
Admin Dashboard - Collapsible Details View
2022-03-28 09:03:26 +02:00
dependabot[bot]
d699f794ac Bump minimist from 1.2.5 to 1.2.6 in /jsx
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-26 13:42:02 +00:00
Min RK
29a9ca18fe Merge pull request #3841 from minrk/asyncio-mode
adopt pytest-asyncio asyncio_mode='auto'
2022-03-26 14:41:29 +01:00
Erik Sundell
72ae21d6dc Merge pull request #3843 from minrk/doc-typos
Some typos in docs
2022-03-24 16:25:47 +01:00
Min RK
310d9621e5 limit server->read:users:name filter to read: scopes
it shouldn't be included in _access_ scopes, for instance
2022-03-24 16:13:57 +01:00
Min RK
0f4258d00c update more test expectations 2022-03-24 15:47:02 +01:00
Min RK
78b5aa150c avoid always-adding identify scope to everything
add it to token permissions _before_ intersecting with owner
2022-03-24 15:36:56 +01:00
Min RK
3cfb14b9e5 rerender rest-api 2022-03-24 15:16:21 +01:00
Min RK
7e22614a4e [squash me] token progress
tokens have scopes

    instead of roles, which allow tokens to change permissions over time

    This is mostly a low-level change,
    with little outward-facing effects.

    - on upgrade, evaluate all token role assignments to their current scopes,
      and store those scopes on the tokens
    - assigning roles to tokens still works, but scopes are evaluated and validated immediately,
      rather than lazily stored as roles
    - no longer need to check for role permission changes on startup, because token permissions aren't affected
    - move a few scope utilities from roles to scopes
    - oauth allows specifying scopes, not just roles.
      But these are still at the level specified in roles,
      not fully-resolved scopes.
    - more granular APIs for working with scopes and roles

    Still to do later:

    - expose scopes config for Spawner/service
    - compute 'full' intersection of requested scopes, rather than on the 'raw' scope list in roles
2022-03-24 15:05:50 +01:00
Min RK
66ecaf472a fix some outdated references to 'all' metascope
it is called 'inherit', but not all docs were updated
2022-03-24 14:06:05 +01:00
Min RK
3ba262f6f6 fix heading level in changelog
sphinx has started to error with this
2022-03-24 14:06:04 +01:00
Min RK
b935190da8 adopt pytest-asyncio asyncio_mode
removes need for our own implementation of the same behavior

but keep it around while we still support Python 3.6,
since the version (0.17) introducing asyncio_mode drops support for Python 3.6
2022-03-23 09:25:22 +01:00
Erik Sundell
7cd5c1c12b Merge pull request #3840 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-22 06:35:53 +01:00
pre-commit-ci[bot]
4708fce4f8 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.5.1 → v2.6.0](https://github.com/pre-commit/mirrors-prettier/compare/v2.5.1...v2.6.0)
2022-03-21 23:39:32 +00:00
Narek Amirbekian
93fda7c96b Change layout 2022-03-21 13:05:56 -07:00
Erik Sundell
912e0ad53f Merge pull request #3839 from yuvipanda/log-docs-1
Document version mismatch log message
2022-03-21 11:40:49 +01:00
YuviPanda
3e9ce8bc03 Document version mismatch log message 2022-03-19 14:10:24 -07:00
Min RK
a08aa3398c ensure literal_binds is set in order
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-03-18 15:25:46 +01:00
Min RK
3076845927 tokens have scopes
instead of roles, which allow tokens to change permissions over time

This is mostly a low-level change,
with little outward-facing effects.

- on upgrade, evaluate all token role assignments to their current scopes,
  and store those scopes on the tokens
- assigning roles to tokens still works, but scopes are evaluated and validated immediately,
  rather than lazily stored as roles
- no longer need to check for role permission changes on startup, because token permissions aren't affected
- move a few scope utilities from roles to scopes
- oauth allows specifying scopes, not just roles.
  But these are still at the level specified in roles,
  not fully-resolved scopes.
- more granular APIs for working with scopes and roles
2022-03-18 14:13:16 +01:00
Erik Sundell
cb25d29b0b Merge pull request #3837 from minrk/fix-import-error
ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable
2022-03-18 10:03:18 +01:00
Min RK
2e8d303ad8 ensure _import_error is set when JUPYTERHUB_SINGLEUSER_APP is unavailable 2022-03-18 09:24:22 +01:00
Erik Sundell
a754d56433 Merge pull request #3835 from minrk/rm-distutils
remove lingering reference to distutils
2022-03-17 13:28:43 +01:00
Min RK
775a16dc50 remove lingering reference to distutils 2022-03-17 12:16:44 +01:00
Narek Amirbekian
16824dcadb Use .toHaveClass instead of .contains 2022-03-16 16:42:17 -07:00
Narek Amirbekian
f949cda227 Add test for details view 2022-03-16 16:36:34 -07:00
Erik Sundell
454e356e4d Merge pull request #3713 from minrk/custom-scopes
allow user-defined custom scopes
2022-03-16 08:52:55 +01:00
Min RK
9a87b59e84 improve custom scope docstrings 2022-03-16 08:44:52 +01:00
Narek Amirbekian
93d82a9012 Fix tests 2022-03-15 17:09:26 -07:00
Narek Amirbekian
564458b106 Set defaults for name_filter 2022-03-15 15:13:04 -07:00
Narek Amirbekian
b38e9c45bf Improved layout 2022-03-15 13:40:44 -07:00
Narek Amirbekian
85d4c5bd7a Remove unused state object 2022-03-15 12:14:38 -07:00
Narek Amirbekian
6a9d27ceb4 Server details in server dashboard 2022-03-15 12:01:22 -07:00
Min RK
d2eaf90df2 authorize subsets of roles
- oauth clients can request a list of roles
- authorization will proceed with the _subset_ of those roles held by the user
- in the future, this subsetting will be refined to the scope level
2022-03-15 11:54:42 +01:00
Min RK
fa8cd90793 Merge pull request #3827 from NarekA/narek/admin-dashboard-search
[Admin Dash] Add search bar for user name
2022-03-15 11:35:57 +01:00
Narek Amirbekian
7dafae29fb Update compiled files 2022-03-15 02:40:27 -07:00
Narek Amirbekian
89a6c745b5 Add base_url to spawner 2022-03-15 02:33:17 -07:00
Erik Sundell
821d9e229d Merge pull request #3831 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-03-15 07:10:27 +01:00
Narek Amirbekian
db7619fa7a Fix server url 2022-03-14 21:02:18 -07:00
Narek Amirbekian
1ed9423530 Update compiled jsx 2022-03-14 18:06:10 -07:00
Narek Amirbekian
147a578f7a Fix index error on assertion 2022-03-14 18:03:56 -07:00
Narek Amirbekian
3a59a15164 Add front end tests for user search 2022-03-14 17:54:51 -07:00
pre-commit-ci[bot]
1b7aded7f9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 23:25:52 +00:00
pre-commit-ci[bot]
bc45d77365 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1)
- [github.com/asottile/reorder_python_imports: v2.7.1 → v3.0.1](https://github.com/asottile/reorder_python_imports/compare/v2.7.1...v3.0.1)
2022-03-14 23:24:13 +00:00
Narek Amirbekian
1b3b005ca4 Add test for name_filter 2022-03-14 13:33:05 -07:00
pre-commit-ci[bot]
e0be811b2c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-14 19:17:28 +00:00
Narek Amirbekian
3627251246 Merge branch 'main' into narek/admin-dashboard-search 2022-03-14 12:16:50 -07:00
Erik Sundell
8d056170d7 Bump to 2.3.0.dev 2022-03-14 12:32:56 +01:00
Erik Sundell
3590d16e30 Bump to 2.2.2 2022-03-14 12:32:25 +01:00
Erik Sundell
572d258cd2 Merge pull request #3828 from consideRatio/pr/changelog-2.2.2
Add changelog for 2.2.2
2022-03-14 12:30:27 +01:00
Min RK
11d0954551 Merge pull request #3826 from consideRatio/pr/add-dedicated-jsx-tests 2022-03-14 12:27:59 +01:00
Erik Sundell
650d47d5c1 Add changelog for 2.2.2 2022-03-14 12:15:54 +01:00
Erik Sundell
945fc824d8 Remove autoformat added new line for generated file 2022-03-12 01:12:29 +01:00
Erik Sundell
a8aa737b00 Don't autoformat generated admin-react.js 2022-03-12 01:12:29 +01:00
Erik Sundell
cd689a1fab ci: test jsx in a dedicated workflow along with src->dist check 2022-03-12 01:12:29 +01:00
Narek Amirbekian
b3f04e7c66 Add search bar for user name 2022-03-11 15:12:53 -08:00
Erik Sundell
fbcf857991 Add inline comments to .pre-commit-config.yaml 2022-03-12 00:00:27 +01:00
Erik Sundell
6c5e5452bc Merge pull request #3825 from NarekA/narek/fix-admin-table-sorting-2
Update admin-react.js
2022-03-11 18:33:33 +01:00
pre-commit-ci[bot]
2f5ba7ba30 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 17:15:21 +00:00
Narek Amirbekian
a045eefa64 React file not updated 2022-03-11 09:13:14 -08:00
Min RK
6ea4f2af0d Bump to 2.3.0.dev 2022-03-11 17:00:28 +01:00
Min RK
3d3ad2929c Bump to 2.2.1 2022-03-11 16:59:52 +01:00
Erik Sundell
00287ff5ba Merge pull request #3824 from minrk/221
changelog for 2.2.1
2022-03-11 16:17:11 +01:00
Min RK
805d063d1d changelog for 2.2.1 2022-03-11 15:52:41 +01:00
Min RK
e6bacf7109 Merge pull request #3822 from NarekA/narek/fix-admin-table-sorting
Fix admin dashboard table sorting
2022-03-11 15:48:45 +01:00
Min RK
33ccfa7963 handle null user.server
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-03-11 14:16:30 +01:00
Min RK
fdf23600c0 allow custom scopes
defined with

    c.JupyterHub.custom_scopes = {
        'custom:scope': {'description': "text shown on oauth confirm"}
    }

Allows injecting custom scopes to roles,
allowing extension of granular permissions to service-defined custom scopes.

Custom scopes:

- MUST start with `custom:`
- MUST only contain ascii lowercase, numbers, colon, hyphen, asterisk, underscore
- MUST define a `description`
- MAY also define `subscopes` list(s), each of which must also be explicitly defined

HubAuth can be used to retrieve and check for custom scopes to authorize requests.
2022-03-11 11:37:26 +01:00
Erik Sundell
593404f558 Merge pull request #3823 from minrk/clear-cookie-kwargs
Fix clearing cookie with custom xsrf cookie options
2022-03-11 09:53:43 +01:00
Min RK
e7bc282c80 clear_cookie only accepts path, domain cookie args 2022-03-11 09:24:31 +01:00
pre-commit-ci[bot]
b939b482a1 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-11 06:38:13 +00:00
Narek Amirbekian
8afc2c9ae9 Fix admin table sorting 2022-03-10 22:20:01 -08:00
Chris Holdgraf
d11eda14ed Merge pull request #3820 from yuvipanda/log-docs 2022-03-10 16:38:01 -08:00
Yuvi Panda
ab79251fe2 Reword for clarity
Co-authored-by: Chris Holdgraf <choldgraf@gmail.com>
2022-03-10 15:54:42 -08:00
Yuvi Panda
484dbf48de Merge pull request #3819 from minrk/raise-no-orm-spawner
allow Spawner.server to be mocked without underlying orm_spawner
2022-03-10 13:46:49 -08:00
YuviPanda
6eb526d08a Add a little more structure 2022-03-10 13:45:28 -08:00
YuviPanda
e0a17db5f1 Add some docs on common log messages
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
log messages, and what they mean.

I currently added just one log message, but we can add more
over time.

Ref https://github.com/2i2c-org/infrastructure/issues/1081
where this would've been useful troubleshooting
2022-03-10 12:45:09 -08:00
Min RK
45132b7244 allow Spawner.server to be mocked without underlying orm_spawner 2022-03-10 15:40:01 +01:00
Min RK
c23cddeb51 Bump to 2.2.0 2022-03-07 14:35:46 +01:00
Erik Sundell
672e19a22a Merge pull request #3815 from minrk/changelog-2.2
Changelog for 2.2
2022-03-07 14:32:56 +01:00
Min RK
4a6c9c3a01 Prepare changelog for 2.2 2022-03-07 14:27:31 +01:00
Erik Sundell
2b79bc44da Merge pull request #3802 from minrk/fresh-spawner
Replace failed spawners when starting new launch
2022-03-07 14:23:22 +01:00
Min RK
7861662e17 Replace failed spawners when starting new launch
Avoids leaving stale state when re-using a spawner that failed the last time it started

we keep failed spawners around to track their errors,
but we don't want to re-use them when it comes time to start a new launch.

adds User.get_spawner(server_name, replace_failed=True) to always get a non-failed Spawner
2022-03-07 14:03:48 +01:00
Simon Li
4a1842bf8a Merge pull request #3809 from minrk/page_config_hook
Add user token to JupyterLab PageConfig
2022-03-04 21:27:34 +00:00
Min RK
8f18303e50 fix some links revealed by myst
mostly pre-myst markdown links
2022-03-04 10:41:20 +01:00
Min RK
bcad6e287d Merge pull request #3812 from ktaletsk/patch-1
Update example to not reference an undefined scope
2022-03-04 10:03:53 +01:00
Min RK
9de1951952 Merge pull request #3813 from rzo1/apache-sec
Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers)
2022-03-04 10:03:01 +01:00
pre-commit-ci[bot]
99cb1f17f0 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-03-03 15:41:17 +00:00
Richard Zowalla
10d5157e95 Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) 2022-03-03 16:40:38 +01:00
Konstantin Taletskiy
2fc4f26832 Update example to not reference an undefined scope
Fixes #3811
2022-03-01 12:25:54 -08:00
Simon Li
f6230001bb Merge pull request #3810 from minrk/server-trait
Keep Spawner.server in sync with underlying orm_spawner.server
2022-03-01 17:40:33 +00:00
Min RK
960f7cbeb9 Keep Spawner.server in sync with underlying orm_spawner.server
Rather than one-time sets of ._server allowing it to become out-of-sync
with underlying orm_spawner.server
2022-03-01 15:59:16 +01:00
Erik Sundell
76f06a6b55 Merge pull request #3808 from manics/apache-x-forwarded-proto
Apache: set X-Forwarded-Proto header
2022-03-01 14:14:34 +01:00
Min RK
9c498aa5d4 Document HubOAuth.get_token for requests on behalf of users 2022-03-01 10:05:17 +01:00
Min RK
a0b60f9118 place JupyterHub token in JupyterLab PageConfig
restores token field useful for javascript-originating API requests,
removed in 1.5 / 2.0 for security reasons because it was the wrong token.

This places the _user's_ token in PageConfig,
so it should have the right permissions.

requires jupyterlab_server 2.9, has no effect on earlier versions.
2022-03-01 09:45:14 +01:00
Min RK
27cb56429b HubAuth.get_token returns oauth token stored in cookie
Useful for backend services that want to use the user's token.

Added `in_cookie` bool argument to exclude cookies (previous behavior),
since notebook servers do some things differently when auth is in query param or header vs cookies
2022-03-01 09:43:01 +01:00
Simon Li
b1ffd4b10b Apache: set X-Forwarded-Proto header 2022-02-28 21:46:53 +00:00
dependabot[bot]
a9ea064202 Merge pull request #3807 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.10 2022-02-28 09:56:10 +00:00
dependabot[bot]
687a41a467 Bump url-parse from 1.5.7 to 1.5.10 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-28 04:27:03 +00:00
Erik Sundell
5348451b2e Merge pull request #3803 from tmtabor/patch-1
idle-culler example config missing closing bracket
2022-02-22 23:09:15 +01:00
Thorin Tabor
55f0579dcc idle-culler example config missing closing bracket 2022-02-22 13:16:37 -08:00
Simon Li
a3ea0f0449 Merge pull request #3799 from jupyterhub/dependabot/npm_and_yarn/jsx/url-parse-1.5.7
Bump url-parse from 1.5.3 to 1.5.7 in /jsx
2022-02-19 08:11:52 +00:00
dependabot[bot]
78492a4a8e Bump url-parse from 1.5.3 to 1.5.7 in /jsx
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 07:35:42 +00:00
Min RK
f22203f50e Merge pull request #3793 from satra/patch-1
show insecure-login-warning for all authenticators
2022-02-15 11:24:09 +01:00
Simon Li
500b354a00 Merge pull request #3795 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.8
Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
2022-02-14 09:52:27 +00:00
dependabot[bot]
9d4093782f Bump follow-redirects from 1.14.7 to 1.14.8 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 08:52:00 +00:00
Min RK
43b3cebfff Merge pull request #3791 from rcthomas/async-options
Enable options_from_form(spawner, form_data) from configuration file
2022-02-14 09:45:04 +01:00
Min RK
63c381431d Merge pull request #3787 from minrk/stop_open_session_default
Stop opening PAM sessions by default
2022-02-14 09:43:12 +01:00
Min RK
bf41767b33 Merge pull request #3790 from NarekA/narek/admin-named-servers
Add Missing Features In Admin Console
2022-02-14 09:25:25 +01:00
Satrajit Ghosh
83d6e4e993 fix: insecure-login-warning for all authenticators 2022-02-11 22:19:39 -05:00
pre-commit-ci[bot]
d64a2ddd95 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-11 21:11:10 +00:00
Rollin Thomas
392176d873 Add tests for both forms of options_from_form 2022-02-11 12:55:09 -08:00
Narek Amirbekian
58420b3307 Merge remote-tracking branch 'origin' into narek/admin-named-servers 2022-02-11 10:52:21 -08:00
Narek Amirbekian
a5e3b66dee One edit user button per user 2022-02-11 10:50:58 -08:00
Rollin Thomas
a9fbe5c9f6 Enable options_from_form w/spawner from configuration 2022-02-11 10:06:38 -08:00
Erik Sundell
71bbbe4a67 Merge pull request #3792 from minrk/short-circuit
short-circuit token permission check if token and owner share role
2022-02-11 17:36:07 +01:00
Min RK
3843885382 short-circuit token permission check if token and owner share role
No need to compute intersection when we know it's a subset already
2022-02-11 15:20:14 +01:00
Narek Amirbekian
25ea559e0d Pull out button components 2022-02-09 15:21:12 -08:00
Narek Amirbekian
c18815de91 Fix failing tests 2022-02-09 14:04:38 -08:00
Narek Amirbekian
50d53667ce Add start server back 2022-02-09 13:15:27 -08:00
pre-commit-ci[bot]
68e2baf4aa [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 21:04:22 +00:00
Narek Amirbekian
6fc9d40e51 Merge remote-tracking branch 'origin/narek/admin-named-servers' into narek/admin-named-servers 2022-02-09 13:02:19 -08:00
Narek Amirbekian
0b25694b40 Add "spawn new" button 2022-02-09 12:59:34 -08:00
pre-commit-ci[bot]
bf750e488f [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-02-09 19:58:05 +00:00
Narek Amirbekian
359f9055fc Admin missing features 2022-02-09 11:43:06 -08:00
Min RK
b84dd5d735 Stop opening PAM sessions by default
We don't do it correctly, so don't try by default

It does work _sometimes_, but most of the time it does work, it's because it's a no-op.
Turning it off by default makes it more likely folks will see the caveat that it may not work.
2022-02-07 15:45:38 +01:00
Erik Sundell
3ed345f496 Merge pull request #3784 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-31 23:57:45 +01:00
pre-commit-ci[bot]
6633f8ef28 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-31 22:17:11 +00:00
pre-commit-ci[bot]
757053a9ec [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/reorder_python_imports: v2.6.0 → v2.7.1](https://github.com/asottile/reorder_python_imports/compare/v2.6.0...v2.7.1)
- [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0)
2022-01-31 22:16:31 +00:00
Erik Sundell
36cad38ddf Merge pull request #3781 from cqzlxl/cqzlxl-patch-1
Log proxy's public_url only when started by JupyterHub
2022-01-29 09:16:29 +01:00
pre-commit-ci[bot]
1e9a1cb621 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-29 05:59:52 +00:00
cqzlxl
9f051d3172 Update jupyterhub/app.py
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-01-29 13:59:20 +08:00
cqzlxl
53576c8f82 Update app.py
When we run the proxy separately,  defaults of `hub.bind_url` may be different from proxy's public url. Actually, the hub has no ways to know about which address the proxy is serving at if we do not configure its `bind_url` explicitly.
2022-01-27 21:05:05 +08:00
Min RK
bb5ec39b2f Merge pull request #3548 from C4IROcean/authenticator_user_group_management
Authenticator user group management
2022-01-25 14:36:41 +01:00
Min RK
4c54c6dcc8 Bump to 2.2.0.dev 2022-01-25 14:36:24 +01:00
Min RK
39da98f133 Bump to 2.1.1 2022-01-25 14:36:02 +01:00
Erik Sundell
29e69aa880 Merge pull request #3779 from minrk/changelog-211
changelog for 2.1.1
2022-01-25 12:18:37 +01:00
Min RK
0c315f31b7 specify nodejs, python versions in readthedocs
rather than use ancient system node

does v2 require the new .readthedocs.yaml filename?
Docs suggest it does.
2022-01-25 10:43:50 +01:00
Min RK
508842a68c changelog for 2.1.1 2022-01-25 09:37:58 +01:00
Min RK
4b31615a05 Merge pull request #3778 from minrk/missing-metrics
add missing read:metrics scope to admin role
2022-01-24 16:12:09 +01:00
Min RK
17b64280e8 add missing metrics scope to admin role
new scope defined, but not added to admin

In the future, the admin list should probably be derived automatically
2022-01-24 15:35:57 +01:00
Min RK
88be7a9967 test coverage for Authenticator.managed_groups
- tests
- docs
- ensure all group APIs are rejected when auth is in control
- use 'groups' field in return value of authenticate/refresh_user, instead of defining new method
- log group changes in sync_groups
2022-01-24 13:45:35 +01:00
Simon Li
4ca2344af7 Merge pull request #3777 from jupyterhub/dependabot/npm_and_yarn/jsx/nanoid-3.2.0
Bump nanoid from 3.1.23 to 3.2.0 in /jsx
2022-01-22 08:50:45 +00:00
dependabot[bot]
4c050cf165 Bump nanoid from 3.1.23 to 3.2.0 in /jsx
Bumps [nanoid](https://github.com/ai/nanoid) from 3.1.23 to 3.2.0.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.1.23...3.2.0)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-22 04:38:13 +00:00
Min RK
5e2ccb81fa Bump to 2.2.0.dev 2022-01-21 11:36:55 +01:00
Min RK
b8dc3befab Bump to 2.1.0 2022-01-21 11:35:49 +01:00
Erik Sundell
2f29848757 Merge pull request #3776 from minrk/cl21
Changelog for 2.1.0
2022-01-21 10:54:09 +01:00
Min RK
4f3d6cdd0c changelog for 2.1.0 2022-01-21 10:42:25 +01:00
Min RK
67733ef928 Merge pull request #3773 from IgorBerman/issue-3772-user_options-returns-empty-jupyterhub-restart
Using orm_spawner in server model user_options
2022-01-21 09:38:37 +01:00
Erik Sundell
e657754e7f Merge pull request #3775 from minrk/on_rtd_edit
DOCS: Add github metadata for edit button
2022-01-20 19:39:35 +01:00
Igor Berman
2d6087959c issue-3772: populating user_options from orm_spawner; adding test 2022-01-20 20:07:43 +02:00
Min RK
08a913707f define html_context needed for edit_page_button 2022-01-20 18:56:41 +01:00
Igor Berman
9c8a4f287a issue-3772: populating user_options from orm_spawner, cleanup 2022-01-20 18:04:35 +02:00
Igor Berman
64d6f0222c issue-3772: populating user_options from orm_spawner 2022-01-20 18:01:57 +02:00
Erik Sundell
538abdf084 Merge pull request #3763 from minrk/page-scopes
apply scope checks to some admin-or-self situations
2022-01-20 16:21:51 +01:00
Thomas Li Fredriksen
144abcb965 Added authenticator hook for synchronizing user groups
- Added hook function stub to authenticator base class
- Added new config option `manage_groups` to base `Authenticator` class
- Call authenticator hook from `refresh_auth`-function in `Base` handler class
- Added example
2022-01-20 13:30:03 +01:00
Min RK
6e5c307edb apply scope checks to some admin-or-self pages
Some non-api spawn and redirect checks still had `self or admin`,
when they should have checked directly for the appropriate permissions

This removes the long-deprecated redirect from `/user/other` -> `/user/self` _if_ the other server is not running.
The result is a more consistent behavior whether the requested server is running or not,
and whether the user has _access_ to the running server or not.
2022-01-20 13:27:43 +01:00
Igor Berman
67ebe0b0cf Update base.py 2022-01-19 21:45:45 +02:00
Min RK
dcf21d53fd Merge pull request #3765 from twalcari/patch-2
Improve documentation about spawner exception handling
2022-01-19 10:01:51 +01:00
Erik Sundell
f5bb0a2622 Merge pull request #3770 from minrk/metrics-scope
Add `read:metrics` scope for metrics endpoint
2022-01-18 17:51:50 +01:00
Min RK
704712cc81 Add read:metrics scope for metrics endpoint
and ensure token auth is accepted
2022-01-18 15:02:24 +01:00
Erik Sundell
f86d53a234 Merge pull request #3764 from minrk/progress-error-message
relay custom messages in exception.jupyterhub_message in progress API
2022-01-18 13:18:29 +01:00
Thijs Walcarius
5466224988 Improve documentation about spawner error messages 2022-01-18 09:18:01 +01:00
Min RK
f9fa21bfd7 relay custom messages in exception.jupyterhub_message in progress API
matches the message shown on the HTML spawn-failed page

For consistency, also support `jupyterhub_html_message` to populate the `html_message` field
2022-01-18 09:15:58 +01:00
Simon Li
e4855c30f5 Merge pull request #3768 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.7
Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
2022-01-15 13:56:47 +00:00
dependabot[bot]
f1c4fdd5a2 Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.13.0 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.13.0...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 08:58:31 +00:00
Min RK
e58cf06706 Merge pull request #3762 from DougTrajano/main
Add the capability to inform a connection to Alembic Migration Script
2022-01-12 14:02:09 +01:00
pre-commit-ci[bot]
91f4918cff [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-11 11:55:37 +00:00
Douglas Trajano
b15ccfa4ae Add connection parameter 2022-01-11 08:50:20 -03:00
Min RK
5102fde2f0 Bump to 2.1.0.dev 2022-01-10 13:54:49 +01:00
Min RK
f5dc005a70 Bump to 2.0.2 2022-01-10 13:54:24 +01:00
Min RK
5fd8f0f596 Merge pull request #3759 from minrk/cl-202
changelog for 2.0.2
2022-01-10 13:53:34 +01:00
Min RK
26ceafa8a3 changelog for 2.0.2 2022-01-10 13:30:14 +01:00
Min RK
2e2ed8a4ff Merge pull request #3760 from minrk/admin-th-macro
remove unused macro with missing references
2022-01-10 13:28:10 +01:00
Min RK
6cc734f884 Merge pull request #3750 from consideRatio/pr/ci-refactor-docs-workflows
ci: refactor to avoid triggering all tests on changes to docs
2022-01-10 13:27:57 +01:00
Erik Sundell
4f7f07d3b7 Fix missing docs requirements 2022-01-10 11:18:22 +01:00
Min RK
d436c97e3d remove unused macro with missing references
The th macro is unused and doesn't work
because it references `sort` template variable,
which has been removed
2022-01-10 11:09:34 +01:00
Erik Sundell
807c5b8ff9 Make the generate-scope-table script autoformat its output 2022-01-10 10:48:01 +01:00
Erik Sundell
8da06d1259 Fix git CLI flag ordering 2022-01-10 10:33:23 +01:00
Erik Sundell
1c1be8a24b Generate yaml formatted to match prettier better 2022-01-10 10:31:30 +01:00
Min RK
897606b00c Merge pull request #3754 from jupyterhub/doc-theme-config
DOCS: Update theme configuration
2022-01-10 09:34:51 +01:00
Simon Li
615af5eb33 Merge pull request #3757 from minrk/get-browser-proto
use outermost proxied entry when looking up browser protocol
2022-01-09 22:44:07 +00:00
Erik Sundell
85f94c12fc Merge pull request #3748 from jupyterhub/DOC-allowed-users
DOC: Add note about allowed_users not being set
2022-01-08 18:59:24 +01:00
Min RK
ccfee4d235 use outermost proxied entry when checking for browser protocol
wee care about what the browser sees, so trust the outermost entry instead of the innermost

This is not secure _in general_, in that these values can be spoofed by malicious proxies,
but for CORS and cookie purposes, we only care about what the browser sees,
however many hops there may be.

A malicious proxy in the chain here isn't a concern because what matters is the immediate
hop from the _browser_, not the immediate hop from the _server_.
2022-01-07 14:03:11 +01:00
Min RK
a2ba55756d Merge pull request #3746 from manics/more-cors-tests
Extra test_cors_check tests
2022-01-07 12:37:37 +01:00
pre-commit-ci[bot]
1b3e94db6c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-04 22:23:45 +00:00
Chris Holdgraf
614d9d89d0 DOCS: Update theme configuration 2022-01-04 14:22:45 -08:00
Chris Holdgraf
05a3f5aa9a Update docs/source/getting-started/authenticators-users-basics.md
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-01-04 13:32:39 -08:00
Erik Sundell
4f47153123 ci: cleanup comments for readability 2022-01-04 00:53:33 +01:00
Erik Sundell
a14d9ecaa1 ci: refactor to avoid triggering all tests on changes to docs 2022-01-04 00:53:33 +01:00
Erik Sundell
6815f30d36 Merge pull request #3749 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-03 22:33:13 +01:00
pre-commit-ci[bot]
13172e6856 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-03 21:06:46 +00:00
pre-commit-ci[bot]
ebc9fd7758 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.1 → v2.31.0](https://github.com/asottile/pyupgrade/compare/v2.29.1...v2.31.0)
2022-01-03 21:06:11 +00:00
Chris Holdgraf
0761a5db02 DOC: Add note about allowed_users not being set 2022-01-03 10:27:10 -08:00
Erik Sundell
46e7a231fe Merge pull request #3747 from minrk/https-typo
localhost URL is http, not https
2022-01-03 15:54:14 +01:00
Min RK
ffa5a20e2f localhost URL is not https 2022-01-03 15:41:54 +01:00
Simon Li
2088a57ffe Extra test_cors_check tests 2022-01-03 13:55:04 +00:00
Erik Sundell
345805781f Merge pull request #3740 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-12-27 22:53:25 +01:00
pre-commit-ci[bot]
9eb52ea788 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)
2021-12-27 21:10:45 +00:00
Min RK
fb1405ecd8 Bump to 2.1.0.dev 2021-12-22 14:16:34 +01:00
Min RK
3f01bf400b Bump to 2.0.1 2021-12-22 14:15:53 +01:00
Erik Sundell
c528751502 Merge pull request #3728 from minrk/changelog-2.0.1
Changelog for 2.0.1
2021-12-22 12:32:00 +01:00
Min RK
0018184150 Changelog for 2.0.1 2021-12-22 12:01:30 +01:00
Min RK
7903f76e11 Merge pull request #3723 from sgaist/use_login_url_from_authenticator
Use URL from authenticator on default login form
2021-12-22 10:50:26 +01:00
Samuel Gaist
d5551a2f32 Use URL from authenticator also for local authenticator
This patch is related to the implementation of the
MultiAuthenticator in jupyterhub/oauthenticator#459

The issue will be triggered when using more than one local provider
or mixing with oauth providers.

With multiple providers the template generates a set of buttons to
choose from to continue the login process.

For OAuth, the user will be sent to the provider login page and
the redirect at the end will continue nicely the process.

Now for the tricky part: using a local provider (e.g. PAM), the
user will be redirected to the "same page" thus the same template
will be rendered but this time to show the username/password dialog.

This will trip the workflow because of the action URL coming from
the settings and not from the authenticator. Therefore when the button
is clicked, the user will come back to the original multiple choice page
rather than continue the login.
2021-12-22 10:41:24 +01:00
Erik Sundell
ca564a5948 Merge pull request #3735 from minrk/admin-users-roles
initialize new admin users with default roles
2021-12-22 10:28:20 +01:00
Erik Sundell
0fcc559323 Merge pull request #3726 from minrk/service-whoami-update
update service-whoami example
2021-12-22 10:19:02 +01:00
Min RK
a746e8e7fb update service-whoami example
- update models with 2.0.0
- different scopes for oauth, api
  shows model depends on permissions
- update text with more details about scopes
- fix outdated reference to local-system credentials
2021-12-22 10:10:16 +01:00
Min RK
b2ce6023e1 initialize new admin users with default roles
it was possible for a user in `admin_users` to not get the `user` role
2021-12-22 10:00:08 +01:00
Erik Sundell
39b331df1b Merge pull request #3733 from manics/missing-f
Fix missing f-string modifier
2021-12-22 00:37:37 +01:00
Simon Li
a69140ae1b Fix missing f-string modifier 2021-12-21 23:26:45 +00:00
Erik Sundell
225ca9007a Merge pull request #3731 from minrk/allow-token-auth-user-url
accept token auth on `/hub/user/...`
2021-12-20 17:42:41 +01:00
Erik Sundell
11efebf1e2 Merge pull request #3722 from minrk/ensure-user-login
always assign default roles on login
2021-12-20 17:39:40 +01:00
Erik Sundell
3e5082f265 Merge pull request #3727 from minrk/grant-role-twice
clarify `role` argument in grant/strip_role
2021-12-20 17:38:27 +01:00
Min RK
36cb1df27e accept token auth on /hub/user/... which are probably requests to non-running servers
otherwise, requests get redirected to `/hub/login` instead of failing with 404/503
2021-12-20 13:37:47 +01:00
Min RK
fcad2d5695 clarify role argument in grant/strip_role
I got confused with a variable called `rolename` that was actually an orm.Role

casting types in a signature is confusing,
but now `role` input can be Role or name,
and in the body it will always be a Role that exists

Behavior is unchanged
2021-12-20 11:39:50 +01:00
Min RK
2ec722d3af Merge pull request #3708 from minrk/user-role-startup
Avoid clearing user role membership when defining custom user scopes
2021-12-20 10:48:03 +01:00
Min RK
390f50e246 Merge pull request #3705 from minrk/intersect-token-scopes
use intersect_scopes utility to check token permissions
2021-12-20 10:30:13 +01:00
Min RK
3276e4a58f Merge pull request #3720 from minrk/fix-initial-user-role
simplify default role assignment
2021-12-20 10:30:01 +01:00
Min RK
2a8428dbb0 always assign default roles on login
successful authentication of a user always grants 'user' role

rather than only on first user creation in db
2021-12-16 12:42:47 +01:00
Min RK
7febb3aa06 simplify default role assignment
- always assign 'user' role, not just when no other roles are assigned
- 'admin' role is in addition, not instead
2021-12-16 12:15:31 +01:00
Simon Li
92c6a23a13 Merge pull request #3716 from minrk/pre_spawn_start_msg
Fix error message about Authenticator.pre_spawn_start
2021-12-15 14:00:18 +00:00
Min RK
bb75081086 Fix error message about pre_spawn_start
This isn't the only or even main thing likely to raise here,
so don't blame it, which is confusing, especially in a message shown to users.

Log the full exception, and show a more opaque message to the user to avoid confusion
2021-12-15 12:44:14 +01:00
Min RK
915c244d02 Test loading user/admin role membership from config
Cover different combinations of:

- existing assignments in db
- additive allowed_users/admin_users config
- strict users membership assignment in load_roles
2021-12-15 12:40:54 +01:00
Min RK
b5e0f46796 rbac_upgrade detection only when users already exist in the db
Instead of just checking for absent roles, also check for present users

otherwise, this will run on all first launches post-2.0, which we don't want
2021-12-15 12:37:55 +01:00
Min RK
34e8e2d828 Avoid clearing user role membership when defining custom user role
If the user role was defined but did not specify a user membership list,
users granted access by the Authenticator would lose their status

Instead, do nothing on an undefined user membership list,
leaving any users with their existing default role assignment
2021-12-15 12:37:55 +01:00
Min RK
c2cbeda9e4 Merge pull request #3714 from team-monolith-product/main
Grant role after user creation during config load
2021-12-15 12:36:53 +01:00
이창환
92a33bd358 Use assign_default_role not grant_role 2021-12-15 20:27:18 +09:00
이창환
e19700348d Move grant role into _get_or_create_user 2021-12-15 19:05:16 +09:00
Simon Li
04ac02c09d Merge pull request #3717 from minrk/allowed-roles-type
fix Spawner.oauth_roles config
2021-12-14 15:46:07 +00:00
Min RK
2b61c16c06 fix Spawner.oauth_roles config
missing cast to orm.Role from config when populating oauth client

test included
2021-12-14 13:20:11 +01:00
Min RK
028722a5ac Merge pull request #3719 from minrk/dist-upgrade-apt
check for db clients before requesting install
2021-12-14 13:12:28 +01:00
Min RK
ca7e07de54 check for db clients before requesting install
workaround weird issue where mysql-client install fails because it's present with a weird pinning
2021-12-14 11:51:39 +01:00
Min RK
c523e74644 Merge pull request #3715 from naatebarber/pass-base-url
Pass Base Url
2021-12-14 10:43:40 +01:00
pre-commit-ci[bot]
dd932784ed [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-14 01:46:27 +00:00
Nathan Barber
4704217dc5 Fix bug with umwarranted error messages 2021-12-13 20:36:00 -05:00
Nathan Barber
3893fb6d2c Pass base_url 2021-12-13 19:55:23 -05:00
이창환
59b2b36a27 Grant role after user creation during config load 2021-12-13 21:32:25 +09:00
Min RK
f6eaaebdf4 use intersect_scopes utility to check token permissions
we didn't have this function when we started checking token scopes
2021-12-07 13:55:32 +01:00
Erik Sundell
bb20002aea Merge pull request #3704 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-12-06 22:18:08 +01:00
pre-commit-ci[bot]
d1995ba7eb [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0)
- [github.com/pre-commit/mirrors-prettier: v2.5.0 → v2.5.1](https://github.com/pre-commit/mirrors-prettier/compare/v2.5.0...v2.5.1)
2021-12-06 21:09:54 +00:00
Yuvi Panda
b06f4cda33 Merge pull request #3697 from naatebarber/react-error-handling
React Error Handling
2021-12-03 12:22:22 +05:30
Erik Sundell
9d7a235107 Merge pull request #3701 from minrk/extra-cors-check
cors: handle mismatched implicit/explicit ports in host header
2021-12-02 12:46:26 +01:00
Erik Sundell
18459bad11 Merge pull request #3698 from minrk/separate-jest
run jsx tests in their own job
2021-12-02 12:30:43 +01:00
Min RK
ced941a6aa cors: handle mismatched implicit/explicit ports in host header
http://host:80 should match http://host

cors tests are parametrized to make it easier to add more cases
2021-12-02 11:02:21 +01:00
Min RK
85e37e7f8c Merge pull request #3195 from kylewm/x-forwarded-host
add option to use a different Host header for referer checks
2021-12-02 10:03:33 +01:00
Min RK
53067de596 finalize forwarded_host_header tests 2021-12-02 09:37:02 +01:00
Kyle Mahan
9c13861eb8 add configuration value to use a different Host key for CORS checks 2021-12-02 09:18:38 +01:00
Min RK
b0ed9f5928 run jsx tests in their own job
no need to re-run them for each entry in our Python matrix
2021-12-02 08:57:45 +01:00
Min RK
ff0d15fa43 Bump to 2.1.0.dev 2021-12-02 08:53:50 +01:00
Nathan Barber
81bb05d0ef Merge branch 'jupyterhub:main' into react-error-handling 2021-12-01 10:27:40 -05:00
Min RK
95649a3ece Bump to 2.0.0 2021-12-01 14:58:11 +01:00
Erik Sundell
08288f5b0f Merge pull request #3696 from minrk/changelog-2.0
Changelog for 2.0
2021-12-01 14:56:30 +01:00
Min RK
01b1ce3995 Link to upgrading doc from the changelog 2021-12-01 14:36:07 +01:00
Min RK
cbe93810be remove redundant admin/upgrading ref target
confuses myst to have a ref: and doc: target with the same name
2021-12-01 14:36:06 +01:00
Min RK
75309d9dc4 Changelog for 2.0
ready to go!
2021-12-01 14:36:06 +01:00
pre-commit-ci[bot]
8594b3fa70 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-01 06:54:56 +00:00
Nathan Barber
1e956df4c7 Re-lint withAPI 2021-12-01 01:54:18 -05:00
Nathan Barber
8ba2bcdfd4 Merge branch 'react-error-handling' of github.com:naatebarber/jupyterhub into react-error-handling 2021-12-01 01:52:59 -05:00
Nathan Barber
999cc0a37c Clean and lint 2021-12-01 01:52:18 -05:00
pre-commit-ci[bot]
a6611e5999 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-12-01 06:45:29 +00:00
Nathan Barber
c0d5778d93 Merge branch 'jupyterhub:main' into react-error-handling 2021-12-01 01:35:14 -05:00
Nathan Barber
293fe4e838 Updated ServerDashboard to testing-library, added tests 2021-12-01 01:32:19 -05:00
Nathan Barber
dfee471e22 Updated Groups to testing-library 2021-12-01 00:20:16 -05:00
Nathan Barber
db7cdc4aa7 Updated GroupEdit to testing-library. Added tests 2021-12-01 00:03:56 -05:00
Nathan Barber
c048ad4aac Updated CreateGroup, EditUser to testing-library. Added tests 2021-11-30 23:23:52 -05:00
Nathan Barber
9e245379e8 Begin replacing enzyme with react-testing-library 2021-11-30 22:23:22 -05:00
Nathan Barber
496f414a2e Update structure for AddUser tests, add tests 2021-11-30 16:43:55 -05:00
Nathan Barber
df67a75893 Add UI error dialogues to api requests 2021-11-30 15:35:00 -05:00
Erik Sundell
249b4af59f Merge pull request #3695 from minrk/service-auth-doc
Service auth doc
2021-11-30 16:09:12 +01:00
Min RK
db3b2d8961 refine service auth docs
favor HubOAuth, as that should really be the default for most services

- Remove some outdated 'new in' text
- Remove docs for some deprecated features (hub_users, hub_groups)
- more detail on what's required
2021-11-30 10:48:53 +01:00
Min RK
7d44a0ffc8 add tornado to intersphinx 2021-11-30 10:45:38 +01:00
Min RK
202b2590e9 doc: remove redundant TOC from services doc
The same TOC is automatically generated on the sidebar, no need for a manual copy
2021-11-30 09:18:57 +01:00
Erik Sundell
c98ef547a8 Merge pull request #3693 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-29 22:16:35 +01:00
pre-commit-ci[bot]
8a866a9102 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/mirrors-prettier: v2.4.1 → v2.5.0](https://github.com/pre-commit/mirrors-prettier/compare/v2.4.1...v2.5.0)
2021-11-29 20:06:06 +00:00
Min RK
b186bdbce3 Bump to 2.0.0rc5 2021-11-26 09:07:15 +01:00
Min RK
36fe6c6f66 Merge pull request #3692 from minrk/clrc5
changelog for 2.0.0rc5
2021-11-26 09:06:21 +01:00
Min RK
8bf559db52 changelog for 2.0.0rc5 2021-11-26 09:05:21 +01:00
Simon Li
750085f627 Merge pull request #3690 from minrk/gha-singleuser
build jupyterhub/singleuser along with other images
2021-11-25 20:17:12 +00:00
Min RK
2dc2c99b4a Merge pull request #3640 from minrk/doc-api-only
add api-only doc
2021-11-25 20:26:25 +01:00
pre-commit-ci[bot]
e703555888 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2021-11-25 19:16:41 +00:00
Min RK
7e102f0511 Apply suggestions from code review
Co-authored-by: Carol Willing <carolcode@willingconsulting.com>
2021-11-25 20:16:10 +01:00
Min RK
facde96425 build jupyterhub/singleuser along with other images
got lost in the migration to GHA docker builds
2021-11-24 21:15:59 +01:00
Erik Sundell
608c746a59 Merge pull request #3689 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-22 22:26:25 +01:00
pre-commit-ci[bot]
a8c834410f [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.0 → v2.29.1](https://github.com/asottile/pyupgrade/compare/v2.29.0...v2.29.1)
- [github.com/psf/black: 21.10b0 → 21.11b1](https://github.com/psf/black/compare/21.10b0...21.11b1)
2021-11-22 20:51:45 +00:00
Min RK
bda14b487a Bump to 2.0.0rc4 2021-11-18 15:33:12 +01:00
Min RK
fd5cf8c360 Merge pull request #3687 from minrk/rc4-changelog
update 2.0 changelog
2021-11-18 15:32:27 +01:00
Min RK
03758e5b46 update 2.0 changelog
prep for rc4
2021-11-18 14:50:10 +01:00
Erik Sundell
e540d143bb Merge pull request #3685 from minrk/session-id-model
Add Session id to token/identify models
2021-11-18 13:39:34 +01:00
Erik Sundell
b2c5ad40c5 Merge pull request #3686 from minrk/login_with_token
Hub: only accept tokens in API requests
2021-11-18 13:27:41 +01:00
Min RK
edfdf672d8 Hub: only accept tokens in API requests
do not allow token-based access to pages

Tokens are only accepted via Authorization header, which doesn't make sense to pass to pages,
so disallow it explicitly to avoid surprises
2021-11-18 09:36:49 +01:00
Min RK
39f19aef49 add session_id to token model 2021-11-17 09:46:26 +01:00
Min RK
8813bb63d4 update to openapi 3.0
easier to implement oneOf schemas

document scopes, session_id in /api/user model
2021-11-17 09:44:38 +01:00
Yuvi Panda
7c18d6fe14 Merge pull request #3681 from minrk/log-app-versions
Log single-user app versions at startup
2021-11-16 00:11:32 +05:30
Erik Sundell
d1fe17d3cb Merge pull request #3682 from minrk/relpath
always use relative paths in data_files
2021-11-08 14:06:20 +01:00
Min RK
b8965c2017 always use relative paths in data_files
instead of absolute paths for sources

seems to have started failing on Windows
2021-11-08 13:29:26 +01:00
Min RK
733d7bc158 Log single-user app versions at startup
Reports jupyterlab, jupyter_server versions during startup
2021-11-08 09:21:32 +01:00
Min RK
88f31c29bb add api-only doc
Describe how to use JupyterHub as a "behind the scenes" implementation detail,
rather than a user-facing application.
2021-11-04 17:16:59 +01:00
Min RK
3caf3cfda8 Bump to 2.0.0rc3 2021-11-04 15:52:37 +01:00
Erik Sundell
d076c55cca Merge pull request #3679 from minrk/forward-1.5
Forward-port fixes from 1.5.0 security release
2021-11-04 15:30:04 +01:00
Min RK
3e185022c8 changelog for 1.5.0 2021-11-04 15:04:40 +01:00
Min RK
857ee2885f jupyterlab: don't use $JUPYTERHUB_API_TOKEN in PageConfig.token 2021-11-04 15:03:12 +01:00
Min RK
cd8dd56213 Revert "store tokens passed via url or header, not only url."
This reverts commit 53c3201c17.

Only tokens in URLs should be persisted in cookies.
Tokens in headers should not have any effect on cookies.
2021-11-04 15:03:12 +01:00
Erik Sundell
f06902aa8f Merge pull request #3675 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-11-02 01:56:07 +01:00
pre-commit-ci[bot]
bb109c6f75 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0)
2021-11-01 20:25:25 +00:00
Erik Sundell
e525ec7b5b Merge pull request #3674 from minrk/verify-login-role
verify that successful login assigns default role
2021-10-30 17:50:01 +02:00
Min RK
356b98e19f verify that successful login assigns default role
and that repeated login after revoked role doesn't reassign role
2021-10-30 14:30:33 +02:00
Erik Sundell
8c803e7a53 Merge pull request #3673 from minrk/main
more calculators
2021-10-30 14:21:17 +02:00
Min RK
2e21a6f4e0 more calculators 2021-10-30 14:07:04 +02:00
Min RK
cfd31b14e3 Bump to 2.0.0rc2 2021-10-30 13:36:54 +02:00
Erik Sundell
f03a620424 Merge pull request #3672 from minrk/prerelease
use v2 of jupyterhub/action-major-minor-tag-calculator
2021-10-30 13:29:43 +02:00
Min RK
440ad77ad5 use v2 of jupyterhub/action-major-minor-tag-calculator
needed for handling of prerelease tags
2021-10-30 12:42:29 +02:00
183 changed files with 12460 additions and 7355 deletions

15
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# dependabot.yml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
#
# Notes:
# - Status and logs from dependabot are provided at
# https://github.com/jupyterhub/jupyterhub/network/updates.
#
version: 2
updates:
# Maintain dependencies in our GitHub Workflows
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
time: "05:00"
timezone: "Etc/UTC"

View File

@@ -1,31 +1,49 @@
# Build releases and (on tags) publish to PyPI
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# Test build release artifacts (PyPI package, Docker images) and publish them on
# pushed git tags.
#
name: Release
# always build releases (to make sure wheel-building works)
# but only publish to PyPI on tags
on:
push:
branches:
- "!dependabot/**"
tags:
- "*"
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
build-release:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: "3.9"
- uses: actions/setup-node@v1
- uses: actions/setup-node@v3
with:
node-version: "14"
- name: install build package
- name: install build requirements
run: |
npm install -g yarn
pip install --upgrade pip
pip install build
pip freeze
@@ -35,28 +53,17 @@ jobs:
python -m build --sdist --wheel .
ls -l dist
- name: verify wheel
- name: verify sdist
run: |
cd dist
pip install ./*.whl
# verify data-files are installed where they are found
cat <<EOF | python
import os
from jupyterhub._data import DATA_FILES_PATH
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
for subpath in (
"templates/page.html",
"static/css/style.min.css",
"static/components/jquery/dist/jquery.js",
):
path = os.path.join(DATA_FILES_PATH, subpath)
assert os.path.exists(path), path
print("OK")
EOF
./ci/check_sdist.py dist/jupyterhub-*.tar.gz
- name: verify data-files are installed where they are found
run: |
pip install dist/*.whl
./ci/check_installed_data.py
# ref: https://github.com/actions/upload-artifact#readme
- uses: actions/upload-artifact@v2
- uses: actions/upload-artifact@v3
with:
name: jupyterhub-${{ github.sha }}
path: "dist/*"
@@ -91,17 +98,16 @@ jobs:
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
- uses: actions/checkout@v3
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 # associated tag: v1.0.2
- name: Set up Docker Buildx (for multi-arch builds)
uses: docker/setup-buildx-action@2a4b53665e15ce7d7049afb11ff1f70ff1610609 # associated tag: v1.1.2
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 # associated tag: v1.1.2
with:
# Allows pushing to registry on localhost:5000
driver-opts: network=host
@@ -120,6 +126,8 @@ jobs:
run: |
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# image: jupyterhub/jupyterhub
#
# https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker tags
@@ -129,7 +137,7 @@ jobs:
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
- name: Get list of jupyterhub tags
id: jupyterhubtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
@@ -137,7 +145,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -146,11 +154,11 @@ jobs:
# array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild
# image: jupyterhub/jupyterhub-onbuild
#
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
@@ -158,7 +166,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -167,11 +175,11 @@ jobs:
push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo
# image: jupyterhub/jupyterhub-demo
#
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v1
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
@@ -179,7 +187,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -190,3 +198,24 @@ jobs:
platforms: linux/amd64
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# image: jupyterhub/singleuser
#
- name: Get list of jupyterhub/singleuser tags
id: singleusertags
uses: jupyterhub/action-major-minor-tag-calculator@v2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
context: singleuser
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}

62
.github/workflows/test-docs.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# This workflow validates the REST API definition and runs the pytest tests in
# the docs/ folder. This workflow does not build the documentation. That is
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
#
name: Test docs
# The tests defined in docs/ are currently influenced by changes to _version.py
# and scopes.py.
on:
pull_request:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/test-docs.yml"
push:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/test-docs.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
validate-rest-api-definition:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- name: Validate REST API definition
uses: char0n/swagger-editor-validate@v1.3.1
with:
definition-file: docs/source/_static/rest-api.yml
test-docs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.9"
- name: Install requirements
run: |
pip install -r docs/requirements.txt pytest -e .
- name: pytest docs/
run: |
pytest docs/

52
.github/workflows/test-jsx.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test jsx (admin-react.js)
on:
pull_request:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
push:
paths:
- "jsx/**"
- ".github/workflows/test-jsx.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
permissions:
contents: read
jobs:
# The ./jsx folder contains React based source code files that are to compile
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
# tests also has tests that this job is meant to run with `yarn test`
# according to the documentation in jsx/README.md.
test-jsx-admin-react:
runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "14"
- name: Install yarn
run: |
npm install -g yarn
- name: yarn
run: |
cd jsx
yarn
- name: yarn test
run: |
cd jsx
yarn test

View File

@@ -1,14 +1,28 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test
# Trigger the workflow's on all PRs but only on pushed tags or commits to
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
# to trigger.
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
@@ -16,30 +30,14 @@ env:
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
permissions:
contents: read
jobs:
rest-api:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
- uses: actions/setup-python@v2
with:
python-version: "3.9"
# in addition to the doc requirements
# the docs *tests* require pre-commit and pytest
- run: |
pip install -r docs/requirements.txt pytest pre-commit -e .
- run: |
pytest docs/
# Run "pytest jupyterhub/tests" in various configurations
pytest:
runs-on: ubuntu-20.04
timeout-minutes: 10
timeout-minutes: 15
strategy:
# Keep running even if one variation of the job fail
@@ -58,9 +56,9 @@ jobs:
# Tests everything when JupyterHub works against a dedicated mysql or
# postgresql server.
#
# nbclassic:
# legacy_notebook:
# Tests everything when the user instances are started with
# notebook instead of jupyter_server.
# the legacy notebook server instead of jupyter_server.
#
# ssl:
# Tests everything using internal SSL connections instead of
@@ -74,20 +72,24 @@ jobs:
# GitHub UI when the workflow run, we avoid using true/false as
# values by instead duplicating the name to signal true.
include:
- python: "3.6"
- python: "3.7"
oldest_dependencies: oldest_dependencies
nbclassic: nbclassic
- python: "3.6"
subdomain: subdomain
- python: "3.7"
db: mysql
- python: "3.7"
ssl: ssl
legacy_notebook: legacy_notebook
- python: "3.8"
db: postgres
- python: "3.8"
nbclassic: nbclassic
legacy_notebook: legacy_notebook
- python: "3.9"
db: mysql
- python: "3.10"
db: postgres
- python: "3.10"
subdomain: subdomain
- python: "3.10"
ssl: ssl
# can't test 3.11.0-beta.4 until a greenlet release
# greenlet is a dependency of sqlalchemy on linux
# see https://github.com/gevent/gevent/issues/1867
# - python: "3.11.0-beta.4"
- python: "3.10"
main_dependencies: main_dependencies
steps:
@@ -115,26 +117,25 @@ jobs:
if [ "${{ matrix.jupyter_server }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
fi
- uses: actions/checkout@v2
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
- uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Node v14
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: "14"
- name: Install Node dependencies
- name: Install Javascript dependencies
run: |
npm install
npm install -g configurable-http-proxy
npm install -g yarn
npm install -g configurable-http-proxy yarn
npm list
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
# NOTE: actions/setup-python@v4 make use of a cache within the GitHub base
# environment and setup in a fraction of a second.
- name: Install Python ${{ matrix.python }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
python-version: "${{ matrix.python }}"
- name: Install Python dependencies
run: |
pip install --upgrade pip
@@ -151,9 +152,9 @@ jobs:
if [ "${{ matrix.main_dependencies }}" != "" ]; then
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
fi
if [ "${{ matrix.nbclassic }}" != "" ]; then
if [ "${{ matrix.legacy_notebook }}" != "" ]; then
pip uninstall jupyter_server --yes
pip install notebook
pip install 'notebook<7'
fi
if [ "${{ matrix.db }}" == "mysql" ]; then
pip install mysql-connector-python
@@ -189,14 +190,18 @@ jobs:
if: ${{ matrix.db }}
run: |
if [ "${{ matrix.db }}" == "mysql" ]; then
if [[ -z "$(which mysql)" ]]; then
sudo apt-get update
sudo apt-get install -y mysql-client
fi
DB=mysql bash ci/docker-db.sh
DB=mysql bash ci/init-db.sh
fi
if [ "${{ matrix.db }}" == "postgres" ]; then
if [[ -z "$(which psql)" ]]; then
sudo apt-get update
sudo apt-get install -y postgresql-client
fi
DB=postgres bash ci/docker-db.sh
DB=postgres bash ci/init-db.sh
fi
@@ -204,9 +209,6 @@ jobs:
- name: Run pytest
run: |
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
- name: Run yarn jest test
run: |
cd jsx && yarn && yarn test
- name: Submit codecov report
run: |
codecov
@@ -216,7 +218,7 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: build images
run: |

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@ docs/build
docs/source/_static/rest-api
docs/source/rbac/scope-table.md
.ipynb_checkpoints
jsx/build/
# ignore config file at the top-level of the repo
# but not sub-dirs
/jupyterhub_config.py
@@ -19,6 +20,7 @@ package-lock.json
share/jupyterhub/static/components
share/jupyterhub/static/css/style.min.css
share/jupyterhub/static/css/style.min.css.map
share/jupyterhub/static/js/admin-react.js*
*.egg-info
MANIFEST
.coverage

View File

@@ -1,30 +1,52 @@
# pre-commit is a tool to perform a predefined set of tasks manually and/or
# automatically before git commits are made.
#
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
#
# Common tasks
#
# - Run on all files: pre-commit run --all-files
# - Register git hooks: pre-commit install --install-hooks
#
repos:
# Autoformat: Python code, syntax patterns are modernized
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.0
rev: v2.37.3
hooks:
- id: pyupgrade
args:
- --py36-plus
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0
# Autoformat: Python code
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: reorder-python-imports
- id: isort
# Autoformat: Python code
- repo: https://github.com/psf/black
rev: 21.9b0
rev: 22.6.0
hooks:
- id: black
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.1
rev: v2.7.1
hooks:
- id: prettier
- repo: https://github.com/PyCQA/flake8
rev: "4.0.1"
hooks:
- id: flake8
# Autoformat and linting, misc. details
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.3.0
hooks:
- id: end-of-file-fixer
exclude: share/jupyterhub/static/js/admin-react.js
- id: requirements-txt-fixer
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: requirements-txt-fixer
# Linting: Python code (see the file .flake8)
- repo: https://github.com/PyCQA/flake8
rev: "5.0.2"
hooks:
- id: flake8

View File

@@ -4,10 +4,12 @@ sphinx:
configuration: docs/source/conf.py
build:
image: latest
os: ubuntu-20.04
tools:
nodejs: "16"
python: "3.9"
python:
version: 3.7
install:
- method: pip
path: .

View File

@@ -6,134 +6,9 @@ you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md)
for a friendly and welcoming collaborative environment.
## Setting up a development environment
Please see our documentation on
<!--
https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html
contains a lot of the same information. Should we merge the docs and
just have this page link to that one?
-->
- [Setting up a development install](https://jupyterhub.readthedocs.io/en/latest/contributing/setup.html)
- [Testing JupyterHub and linting code](https://jupyterhub.readthedocs.io/en/latest/contributing/tests.html)
JupyterHub requires Python >= 3.5 and nodejs.
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
1. clone the repo
```bash
git clone https://github.com/jupyterhub/jupyterhub
```
2. do a development install with pip
```bash
cd jupyterhub
python3 -m pip install --editable .
```
3. install the development requirements,
which include things like testing tools
```bash
python3 -m pip install -r dev-requirements.txt
```
4. install configurable-http-proxy with npm:
```bash
npm install -g configurable-http-proxy
```
5. set up pre-commit hooks for automatic code formatting, etc.
```bash
pre-commit install
```
You can also invoke the pre-commit hook manually at any time with
```bash
pre-commit run
```
## Contributing
JupyterHub has adopted automatic code formatting so you shouldn't
need to worry too much about your code style.
As long as your code is valid,
the pre-commit hook should take care of how it should look.
You can invoke the pre-commit hook by hand at any time with:
```bash
pre-commit run
```
which should run any autoformatting on your code
and tell you about any errors it couldn't fix automatically.
You may also install [black integration](https://github.com/psf/black#editor-integration)
into your text editor to format code automatically.
If you have already committed files before setting up the pre-commit
hook with `pre-commit install`, you can fix everything up using
`pre-commit run --all-files`. You need to make the fixing commit
yourself after that.
## Testing
It's a good idea to write tests to exercise any new features,
or that trigger any bugs that you have fixed to catch regressions.
You can run the tests with:
```bash
pytest -v
```
in the repo directory. If you want to just run certain tests,
check out the [pytest docs](https://pytest.readthedocs.io/en/latest/usage.html)
for how pytest can be called.
For instance, to test only spawner-related things in the REST API:
```bash
pytest -v -k spawn jupyterhub/tests/test_api.py
```
The tests live in `jupyterhub/tests` and are organized roughly into:
1. `test_api.py` tests the REST API
2. `test_pages.py` tests loading the HTML pages
and other collections of tests for different components.
When writing a new test, there should usually be a test of
similar functionality already written and related tests should
be added nearby.
The fixtures live in `jupyterhub/tests/conftest.py`. There are
fixtures that can be used for JupyterHub components, such as:
- `app`: an instance of JupyterHub with mocked parts
- `auth_state_enabled`: enables persisting auth_state (like authentication tokens)
- `db`: a sqlite in-memory DB session
- `io_loop`: a Tornado event loop
- `event_loop`: a new asyncio event loop
- `user`: creates a new temporary user
- `admin_user`: creates a new temporary admin user
- single user servers
- `cleanup_after`: allows cleanup of single user servers between tests
- mocked service
- `MockServiceSpawner`: a spawner that mocks services for testing with a short poll interval
- `mockservice`: mocked service with no external service url
- `mockservice_url`: mocked service with a url to test external services
And fixtures to add functionality or spawning behavior:
- `admin_access`: grants admin access
- `no_patience`: sets slow-spawning timeouts to zero
- `slow_spawn`: enables the SlowSpawner (a spawner that takes a few seconds to start)
- `never_spawn`: enables the NeverSpawner (a spawner that will never start)
- `bad_spawn`: enables the BadSpawner (a spawner that fails immediately)
- `slow_bad_spawn`: enables the SlowBadSpawner (a spawner that fails after a short delay)
To read more about fixtures check out the
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
for how to use the existing fixtures, and how to create new ones.
When in doubt, feel free to [ask](https://gitter.im/jupyterhub/jupyterhub).
If you need some help, feel free to ask on [Gitter](https://gitter.im/jupyterhub/jupyterhub) or [Discourse](https://discourse.jupyter.org/).

View File

@@ -21,7 +21,7 @@
# your jupyterhub_config.py will be added automatically
# from your docker directory.
ARG BASE_IMAGE=ubuntu:focal-20200729
ARG BASE_IMAGE=ubuntu:22.04
FROM $BASE_IMAGE AS builder
USER root
@@ -37,6 +37,7 @@ RUN apt-get update \
python3-pycurl \
nodejs \
npm \
yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -8,6 +8,7 @@ include *requirements.txt
include Dockerfile
graft onbuild
graft jsx
graft jupyterhub
graft scripts
graft share
@@ -18,6 +19,10 @@ graft ci
graft docs
prune docs/node_modules
# Intermediate javascript files
prune jsx/node_modules
prune jsx/build
# prune some large unused files from components
prune share/jupyterhub/static/components/bootstrap/dist/css
exclude share/jupyterhub/static/components/bootstrap/dist/fonts/*.svg

View File

@@ -59,7 +59,7 @@ JupyterHub also provides a
[REST API][]
for administration of the Hub and its users.
[rest api]: https://juptyerhub.readthedocs.io/en/latest/reference/rest-api.html
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
## Installation
@@ -117,8 +117,7 @@ To start the Hub server, run the command:
jupyterhub
Visit `https://localhost:8000` in your browser, and sign in with your unix
PAM credentials.
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
_Note_: To allow multiple users to sign in to the server, you will need to
run the `jupyterhub` command as a _privileged user_, such as root.

20
ci/check_installed_data.py Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
# Check that installed package contains everything we expect
import os
from jupyterhub._data import DATA_FILES_PATH
print("Checking jupyterhub._data")
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
for subpath in (
"templates/page.html",
"static/css/style.min.css",
"static/components/jquery/dist/jquery.js",
"static/js/admin-react.js",
):
path = os.path.join(DATA_FILES_PATH, subpath)
assert os.path.exists(path), path
print("OK")

28
ci/check_sdist.py Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python
# Check that sdist contains everything we expect
import sys
import tarfile
from tarfile import TarFile
expected_files = [
"docs/requirements.txt",
"jsx/package.json",
"package.json",
"README.md",
]
assert len(sys.argv) == 2, "Expected one file"
print(f"Checking {sys.argv[1]}")
tar = tarfile.open(name=sys.argv[1], mode="r:gz")
try:
# Remove leading jupyterhub-VERSION/
filelist = {f.partition('/')[2] for f in tar.getnames()}
finally:
tar.close()
for e in expected_files:
assert e in filelist, f"{e} not found"
print("OK")

View File

@@ -20,7 +20,7 @@ fi
# Configure a set of databases in the database server for upgrade tests
set -x
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130; do
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done

View File

@@ -9,9 +9,13 @@ cryptography
html5lib # needed for beautifulsoup
jupyterlab >=3
mock
# nbclassic provides the '/tree/' handler, which we use in tests
# it is a transitive dependency via jupyterlab,
# but depend on it directly
nbclassic
pre-commit
pytest>=3.3
pytest-asyncio
pytest-asyncio>=0.17
pytest-cov
requests-mock
tbump

View File

@@ -3,8 +3,10 @@
alabaster_jupyterhub
autodoc-traits
myst-parser
pre-commit
pydata-sphinx-theme
pytablewriter>=0.56
ruamel.yaml
sphinx>=1.7
sphinx-copybutton
sphinx-jsonschema

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
# Interpreting common log messages
When debugging errors and outages, looking at the logs emitted by
JupyterHub is very helpful. This document tries to document some common
log messages, and what they mean.
## Failing suspected API request to not-running server
### Example
Your logs might be littered with lines that might look slightly scary
```
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
```
### Most likely cause
This likely means is that the user's server has stopped running but they
still have a browser tab open. For example, you might have 3 tabs open, and shut
your server down via one. Or you closed your laptop, your server was
culled for inactivity, and then you reopen your laptop again! The
client side code (JupyterLab, Classic Notebook, etc) does not know
yet that the server is dead, and continues to make some API requests.
JupyterHub's architecture means that the proxy routes all requests that
don't go to a running user server to the hub process itself. The hub
process then explicitly returns a failure response, so the client knows
that the server is not running anymore. This is used by JupyterLab to
tell you your server is not running anymore, and offer you the option
to let you restart it.
Most commonly, you'll see this in reference to the `/api/metrics/v1`
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
### Actions you can take
This log message is benign, and there is usually no action for you to take.
## JupyterHub Singleuser Version mismatch
### Example
```
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
```
### Cause
JupyterHub requires the `jupyterhub` python package installed inside the image or
environment the user server starts in. This message indicates that the version of
the `jupyterhub` package installed inside the user image or environment is not
the same version as the JupyterHub server itself. This is not necessarily always a
problem - some version drift is mostly acceptable, and the only two known cases of
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
changelog before upgrading!**. The primary problems this _could_ cause are:
1. Infinite redirect loops after the user server starts
2. Missing expected environment variables in the user server once it starts
3. Failure for the started user server to authenticate with the JupyterHub server -
note that this is _not_ the same as _user authentication_ failing!
However, for the most part, unless you are seeing these specific issues, the log
message should be counted as a warning to get the `jupyterhub` package versions
aligned, rather than as an indicator of an existing problem.
### Actions you can take
Upgrade the version of the `jupyterhub` package in your user environment or image
so it matches the version of JupyterHub running your JupyterHub server! If you
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)

View File

@@ -1,5 +1,3 @@
.. _admin/upgrading:
====================
Upgrading JupyterHub
====================

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@ extensions = [
'myst_parser',
]
myst_heading_anchors = 2
myst_enable_extensions = [
'colon_fence',
'deflist',
@@ -47,7 +48,7 @@ version = '%i.%i' % jupyterhub.version_info[:2]
# The full version, including alpha/beta/rc tags.
release = jupyterhub.__version__
language = None
language = "en"
exclude_patterns = []
pygments_style = 'sphinx'
todo_include_todos = False
@@ -55,13 +56,15 @@ todo_include_todos = False
# Set the default role so we can use `foo` instead of ``foo``
default_role = 'literal'
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
from docutils import nodes
from sphinx.directives.other import SphinxDirective
from contextlib import redirect_stdout
from io import StringIO
from docutils import nodes
from sphinx.directives.other import SphinxDirective
# -- Config -------------------------------------------------------------
from jupyterhub.app import JupyterHub
# create a temp instance of JupyterHub just to get the output of the generate-config
# and help --all commands.
jupyterhub_app = JupyterHub()
@@ -130,6 +133,30 @@ html_static_path = ['_static']
htmlhelp_basename = 'JupyterHubdoc'
html_theme_options = {
"icon_links": [
{
"name": "GitHub",
"url": "https://github.com/jupyterhub/jupyterhub",
"icon": "fab fa-github-square",
},
{
"name": "Discourse",
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
"icon": "fab fa-discourse",
},
],
"use_edit_page_button": True,
"navbar_align": "left",
}
html_context = {
"github_user": "jupyterhub",
"github_repo": "jupyterhub",
"github_version": "main",
"doc_path": "docs",
}
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
@@ -205,7 +232,10 @@ epub_exclude_files = ['search.html']
# -- Intersphinx ----------------------------------------------------------
intersphinx_mapping = {'https://docs.python.org/3/': None}
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
}
# -- Read The Docs --------------------------------------------------------

View File

@@ -16,7 +16,7 @@ Install Python
--------------
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
requires you have at least version 3.5 installed locally. If you havent
requires you have at least version 3.6 installed locally. If you havent
installed Python before, the recommended way to install it is to use
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the Python 3 version,
and **not** the Python 2 version!
@@ -24,11 +24,10 @@ and **not** the Python 2 version!
Install nodejs
--------------
``configurable-http-proxy``, the default proxy implementation for
JupyterHub, is written in Javascript to run on `NodeJS
<https://nodejs.org/en/>`_. If you have not installed nodejs before, we
recommend installing it in the ``miniconda`` environment you set up for
Python. You can do so with ``conda install nodejs``.
`NodeJS 12+ <https://nodejs.org/en/>`_ is required for building some JavaScript components.
``configurable-http-proxy``, the default proxy implementation for JupyterHub, is written in Javascript.
If you have not installed nodejs before, we recommend installing it in the ``miniconda`` environment you set up for Python.
You can do so with ``conda install nodejs``.
Install git
-----------
@@ -46,7 +45,7 @@ their effects quickly. You need to do a developer install to make that
happen.
.. note:: This guide does not attempt to dictate *how* development
environements should be isolated since that is a personal preference and can
environments should be isolated since that is a personal preference and can
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
a more detailed discussion.
@@ -66,7 +65,7 @@ happen.
python -V
This should return a version number greater than or equal to 3.5.
This should return a version number greater than or equal to 3.6.
.. code:: bash
@@ -74,12 +73,11 @@ happen.
This should return a version number greater than or equal to 5.0.
3. Install ``configurable-http-proxy``. This is required to run
JupyterHub.
3. Install ``configurable-http-proxy`` (required to run and test the default JupyterHub configuration) and ``yarn`` (required to build some components):
.. code:: bash
npm install -g configurable-http-proxy
npm install -g configurable-http-proxy yarn
If you get an error that says ``Error: EACCES: permission denied``,
you might need to prefix the command with ``sudo``. If you do not
@@ -87,11 +85,17 @@ happen.
.. code:: bash
npm install configurable-http-proxy
npm install configurable-http-proxy yarn
export PATH=$PATH:$(pwd)/node_modules/.bin
The second line needs to be run every time you open a new terminal.
If you are using conda you can instead run:
.. code:: bash
conda install configurable-http-proxy yarn
4. Install the python packages required for JupyterHub development.
.. code:: bash
@@ -186,3 +190,4 @@ development updates, with:
python3 setup.py js # fetch updated client-side js
python3 setup.py css # recompile CSS from LESS sources
python3 setup.py jsx # build React admin app

View File

@@ -1,8 +1,8 @@
.. _contributing/tests:
==================
Testing JupyterHub
==================
===================================
Testing JupyterHub and linting code
===================================
Unit test help validate that JupyterHub works the way we think it does,
and continues to do so when changes occur. They also help communicate
@@ -57,6 +57,50 @@ Running the tests
pytest -v jupyterhub/tests/test_api.py::test_shutdown
See the `pytest usage documentation <https://pytest.readthedocs.io/en/latest/usage.html>`_ for more details.
Test organisation
=================
The tests live in ``jupyterhub/tests`` and are organized roughly into:
#. ``test_api.py`` tests the REST API
#. ``test_pages.py`` tests loading the HTML pages
and other collections of tests for different components.
When writing a new test, there should usually be a test of
similar functionality already written and related tests should
be added nearby.
The fixtures live in ``jupyterhub/tests/conftest.py``. There are
fixtures that can be used for JupyterHub components, such as:
- ``app``: an instance of JupyterHub with mocked parts
- ``auth_state_enabled``: enables persisting auth_state (like authentication tokens)
- ``db``: a sqlite in-memory DB session
- ``io_loop```: a Tornado event loop
- ``event_loop``: a new asyncio event loop
- ``user``: creates a new temporary user
- ``admin_user``: creates a new temporary admin user
- single user servers
- ``cleanup_after``: allows cleanup of single user servers between tests
- mocked service
- ``MockServiceSpawner``: a spawner that mocks services for testing with a short poll interval
- ``mockservice```: mocked service with no external service url
- ``mockservice_url``: mocked service with a url to test external services
And fixtures to add functionality or spawning behavior:
- ``admin_access``: grants admin access
- ``no_patience```: sets slow-spawning timeouts to zero
- ``slow_spawn``: enables the SlowSpawner (a spawner that takes a few seconds to start)
- ``never_spawn``: enables the NeverSpawner (a spawner that will never start)
- ``bad_spawn``: enables the BadSpawner (a spawner that fails immediately)
- ``slow_bad_spawn``: enables the SlowBadSpawner (a spawner that fails after a short delay)
See the `pytest fixtures documentation <https://pytest.readthedocs.io/en/latest/fixture.html>`_
for how to use the existing fixtures, and how to create new ones.
Troubleshooting Test Failures
=============================
@@ -66,3 +110,27 @@ All the tests are failing
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
can launch ``jupyterhub`` from the terminal.
Code formatting and linting
===========================
JupyterHub has adopted automatic code formatting and linting.
As long as your code is valid, the pre-commit hook should take care of how it should look.
You can invoke the pre-commit hook by hand at any time with:
.. code:: bash
pre-commit run
which should run any autoformatting on your code and tell you about any errors it couldn't fix automatically.
You may also install `black integration <https://github.com/psf/black#editor-integration>`_
into your text editor to format code automatically.
If you have already committed files before running pre-commit you can fix everything using:
.. code:: bash
pre-commit run --all-files
And committing the changes.

View File

@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
Users in the `allowed_users` set are added to the Hub database when the Hub is
started.
```{warning}
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
## Configure admins (`admin_users`)
```{note}

View File

@@ -183,12 +183,6 @@ itself, ``jupyterhub_config.py``, as a binary string:
c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING')
.. important::
If the cookie secret value changes for the Hub, all single-user notebook
servers must also be restarted.
.. _cookies:
Cookies used by JupyterHub authentication

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
troubleshooting
admin/upgrading
admin/log-messages
changelog

View File

@@ -1,16 +1,33 @@
"""
This script updates two files with the RBAC scope descriptions found in
`scopes.py`.
The files are:
1. scope-table.md
This file is git ignored and referenced by the documentation.
2. rest-api.yml
This file is JupyterHub's REST API schema. Both a version and the RBAC
scopes descriptions are updated in it.
"""
import os
from collections import defaultdict
from pathlib import Path
from subprocess import run
from pytablewriter import MarkdownTableWriter
from ruamel.yaml import YAML
import jupyterhub
from jupyterhub import __version__
from jupyterhub.scopes import scope_definitions
HERE = os.path.abspath(os.path.dirname(__file__))
DOCS = Path(HERE).parent.parent.absolute()
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
class ScopeTableGenerator:
@@ -82,8 +99,9 @@ class ScopeTableGenerator:
return table_rows
def write_table(self):
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
filename = f"{HERE}/scope-table.md"
"""Generates the RBAC scopes reference documentation as a markdown table
and writes it to the .gitignored `scope-table.md`."""
filename = SCOPE_TABLE_MD
table_name = ""
headers = ["Scope", "Grants permission to:"]
values = self._parse_scopes()
@@ -99,26 +117,39 @@ class ScopeTableGenerator:
)
def write_api(self):
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
"""Loads `rest-api.yml` and writes it back with a dynamically set
JupyterHub version field and list of RBAC scopes descriptions from
`scopes.py`."""
filename = REST_API_YAML
yaml = YAML(typ='rt')
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
yaml.indent(mapping=2, offset=2, sequence=4)
scope_dict = {}
with open(filename) as f:
content = yaml.load(f.read())
content["info"]["version"] = jupyterhub.__version__
content["info"]["version"] = __version__
for scope in self.scopes:
description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '')
if doc_description:
description = doc_description
scope_dict[scope] = description
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
content['components']['securitySchemes']['oauth2']['flows'][
'authorizationCode'
]['scopes'] = scope_dict
with open(filename, 'w') as f:
yaml.dump(content, f)
run(
['pre-commit', 'run', 'prettier', '--files', filename],
cwd=HERE,
check=False,
)
def main():
table_generator = ScopeTableGenerator()

View File

@@ -1,3 +1,5 @@
(RBAC)=
# JupyterHub RBAC
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.

View File

@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
```{admonition} **Default roles**
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
- `server` role allows for posting activity of "itself" only.
**These roles cannot be deleted.**
@@ -27,7 +27,6 @@ Roles can be assigned to the following entities:
- Users
- Services
- Groups
- Tokens
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
@@ -41,7 +40,7 @@ Services do not have a default role. Services without roles have no access to th
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
**Tokens** \
A tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
A tokens permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific scopes are requested for a new token, the token is assigned the scopes of the `token` role.
(define-role-target)=

View File

@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
1. default user scope `self`, and
2. default token scope `all`.
2. default token scope `inherit`.
(default-user-scope-target)=
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
### Default token scope
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
(horizontal-filtering-target)=
@@ -72,13 +72,31 @@ Requested resources are filtered based on the filter of the corresponding scope.
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
### `!user` filter
(self-referencing-filters)=
### Self-referencing filters
There are some 'shortcut' filters,
which can be applied to all scopes,
that filter based on the entities associated with the request.
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
The filter can be applied to any scope.
:::{versionadded} 3.0
`!service` and `!server` filters.
:::
In addition to `!user`, _tokens_ may have filters `!service`
or `!server`, which expand similarly to `!service=servicename`
and `!server=servername`.
This only applies to tokens issued via the OAuth flow.
In these cases, the name is the _issuing_ entity (a service or single-user server),
so that access can be restricted to the issuing service,
e.g. `access:servers!server` would grant access only to the server that requested the token.
These filters can be applied to any scope.
(vertical-filtering-target)=
@@ -114,11 +132,170 @@ There are four exceptions to the general {ref}`scope conventions <scope-conventi
```
:::{versionadded} 3.0
The `admin-ui` scope is added to explicitly grant access to the admin page,
rather than combining `admin:users` and `admin:servers` permissions.
This means a deployment can enable the admin page with only a subset of functionality enabled.
Note that this means actions to take _via_ the admin UI
and access _to_ the admin UI are separated.
For example, it generally doesn't make sense to grant
`admin-ui` without at least `list:users` for at least some subset of users.
For example:
```python
c.JupyterHub.load_roles = [
{
"name": "instructor-data8",
"scopes": [
# access to the admin page
"admin-ui",
# list users in the class group
"list:users!group=students-data8",
# start/stop servers for users in the class
"admin:servers!group=students-data8",
# access servers for users in the class
"access:servers!group=students-data8",
],
"group": ["instructors-data8"],
}
]
```
will grant instructors in the data8 course permission to:
1. view the admin UI
2. see students in the class (but not all users)
3. start/stop/access servers for users in the class
4. but _not_ permission to administer the users themselves (e.g. change their permissions, etc.)
:::
```{Caution}
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
```
(custom-scopes)=
### Custom scopes
:::{versionadded} 3.0
:::
JupyterHub 3.0 introduces support for custom scopes.
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
Custom scope names must start with `custom:`
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (`-_:*`).
The part after `custom:` must start with a letter or number.
Scopes may not end with a hyphen or colon.
The only strict requirement is that a custom scope definition must have a `description`.
It _may_ also have `subscopes` if you are defining multiple scopes that have a natural hierarchy,
For example:
```python
c.JupyterHub.custom_scopes = {
"custom:myservice:read": {
"description": "read-only access to myservice",
},
"custom:myservice:write": {
"description": "write access to myservice",
# write permission implies read permission
"subscopes": [
"custom:myservice:read",
],
},
}
c.JupyterHub.load_roles = [
# graders have read-only access to the service
{
"name": "service-user",
"groups": ["graders"],
"scopes": [
"custom:myservice:read",
"access:service!service=myservice",
],
},
# instructors have read and write access to the service
{
"name": "service-admin",
"groups": ["instructors"],
"scopes": [
"custom:myservice:write",
"access:service!service=myservice",
],
},
]
```
In the above configuration, two scopes are defined:
- `custom:myservice:read` grants read-only access to the service, and
- `custom:myservice:write` grants write access to the service
- write access _implies_ read access via the `subscope`
These custom scopes are assigned to two groups via `roles`:
- users in the group `graders` are granted read access to the service
- users in the group `instructors` are
- both are granted _access_ to the service via `access:service!service=myservice`
When the service completes OAuth, it will retrieve the user model from `/hub/api/user`.
This model includes a `scopes` field which is a list of authorized scope for the request,
which can be used.
```python
def require_scope(scope):
"""decorator to require a scope to perform an action"""
def wrapper(func):
@functools.wraps(func)
def wrapped_func(request):
user = fetch_hub_api_user(request.token)
if scope not in user["scopes"]:
raise HTTP403(f"Requires scope {scope}")
else:
return func()
return wrapper
@require_scope("custom:myservice:read")
async def read_something(request):
...
@require_scope("custom:myservice:write")
async def write_something(request):
...
```
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
against the `.hub_scopes` attribute of each Handler
(the default is populated from `$JUPYTERHUB_OAUTH_ACCESS_SCOPES` and usually `access:services!service=myservice`).
:::{versionchanged} 3.0
The JUPYTERHUB_OAUTH_SCOPES environment variable is deprecated and renamed to JUPYTERHUB_OAUTH_ACCESS_SCOPES,
to avoid ambiguity with JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES
:::
```python
from tornado import web
from jupyterhub.services.auth import HubOAuthenticated
class MyHandler(HubOAuthenticated, BaseHandler):
hub_scopes = ["custom:myservice:read"]
@web.authenticated
def get(self):
...
```
Existing scope filters (`!user=`, etc.) may be applied to custom scopes.
Custom scope _filters_ are NOT supported.
### Scopes and APIs
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).

View File

@@ -7,11 +7,11 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
```{admonition} **Scope variable nomenclature**
:class: tip
- _scopes_ \
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`.
List of scopes that may contain abbreviations (used in role definitions). E.g., `["users:activity!user", "self"]`.
- _expanded scopes_ \
Set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
Set of fully expanded scopes without abbreviations (i.e., resolved metascopes, filters, and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
- _parsed scopes_ \
Dictionary JSON like format of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
Dictionary represenation of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
- _intersection_ \
Set of expanded scopes as intersection of 2 expanded scope sets.
- _identify scopes_ \
@@ -22,27 +22,47 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
## Resolving roles and scopes
**Resolving roles** refers to determining which roles a user, service, token, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
**Resolving roles** refers to determining which roles a user, service, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific roles or making an API request. The following sections provide more details.
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific scopes or making an API request. The following sections provide more details.
(requesting-api-token-target)=
### Requesting API token with specific roles
### Requesting API token with specific scopes
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific existing roles. To date, it is only possible to add roles to a token through the _POST /users/:name/tokens_ API where the roles can be specified in the token parameters body (see [](../reference/rest-api.rst)).
:::{versionchanged} 3.0
API tokens have _scopes_ instead of roles,
so that their permissions cannot be updated.
RBAC adds several steps into the token issue flow.
You may still request roles for a token,
but those roles will be evaluated to the corresponding _scopes_ immediately.
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token).
Prior to 3.0, tokens stored _roles_,
which meant their scopes were resolved on each request.
:::
If the token is requested with any roles, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed. The API request is resolved without additional errors using the scopes _intersection_, but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
RBAC is involved in several stages of the OAuth token flow.
Resolving a token's roles (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
When requesting a token via the tokens API (`/users/:name/tokens`), or the token page (`/hub/token`),
if no scopes are requested, the token is issued with the permissions stored on the default `token` role
(providing the requester is allowed to create the token).
OAuth tokens are also requested via OAuth flow
If the token is requested with any scopes, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
If, due to modifications of permissions of the token or token owner,
at API request time a token has any scopes that its owner does not,
those scopes are removed.
The API request is resolved without additional errors using the scope _intersection_;
the Hub logs a warning in this case (see {ref}`Figure 2 <api-request-chart>`).
Resolving a token's scope (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's own scopes into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy.
If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested scopes; if not, JupyterHub will raise an error.
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
@@ -55,9 +75,9 @@ Figure 1. Resolving roles and scopes during API token request
### Making an API request
With the RBAC framework each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
With the RBAC framework, each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
When an API request is performed, the requesting API token's roles are again resolved (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
When an API request is performed, the requesting API token's scopes are again intersected with its owner's (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
The passed scopes are compared to the scopes required to access the API as follows:

View File

@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
### With RBAC
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.

View File

@@ -0,0 +1,128 @@
(api-only)=
# Deploying JupyterHub in "API only mode"
As a service for deploying and managing Jupyter servers for users, JupyterHub
exposes this functionality _primarily_ via a [REST API](rest).
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
The basic web UI enables users to click a button to quickly start and stop their servers,
and it lets admins perform some basic user and server management tasks.
The REST API has always provided additional functionality beyond what is available in the basic web UI.
Similarly, we avoid implementing UI functionality that is also not available via the API.
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
In other words, no UI pages should rely on information not available via the REST API.
Previously, some admin UI functionality could only be achieved via admin pages,
such as paginated requests.
## Limited UI customization via templates
The JupyterHub UI is customizable via extensible HTML [templates](templates),
but this has some limited scope to what can be customized.
Adding some content and messages to existing pages is well supported,
but changing the page flow and what pages are available are beyond the scope of what is customizable.
## Rich UI customization with REST API based apps
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
for other Jupyter-based applications that might want to present a different user experience.
If you want a fully customized user experience,
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
to build your own web application to serve your users,
relying on the Hub only as an API for managing users and servers.
One example of such an application is [BinderHub][], which powers https://mybinder.org,
and motivates many of these changes.
BinderHub is distinct from a traditional JupyterHub deployment
because it uses temporary users created for each launch.
Instead of presenting a login page,
users are presented with a form to specify what environment they would like to launch:
![Binder launch form](../images/binderhub-form.png)
When a launch is requested:
1. an image is built, if necessary
2. a temporary user is created,
3. a server is launched for that user, and
4. when running, users are redirected to an already running server with an auth token in the URL
5. after the session is over, the user is deleted
This means that a lot of JupyterHub's UI flow doesn't make sense:
- there is no way for users to login
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
We'll use BinderHub here as an example of various configuration options.
[binderhub]: https://binderhub.readthedocs.io
## Disabling Hub UI
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
There are three values that are most logical for `hub_routespec`:
- `/` - this is the default, and used in most deployments.
It is also the only option prior to JupyterHub 1.4.
- `/hub/` - this serves only Hub pages, both UI and API
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
aside from the OAuth confirmation page, if used.
If you choose a hub routespec other than `/`,
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
when the requested server is not running.
JupyterHub's handling of this request shows this page,
telling you that the server is not running,
with a button to launch it again:
![screenshot of hub page for server not running](../images/server-not-running.png)
If you set `hub_routespec` to something other than `/`,
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
If you don't, you will see a default 404 page from the proxy:
![screenshot of CHP default 404](../images/chp-404.png)
For mybinder.org, the default "start my server" page doesn't make sense,
because when a server is gone, there is no restart action.
Instead, we provide hints about how to get back to a link to start a _new_ server:
![screenshot of mybinder.org 404](../images/binder-404.png)
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
that runs nginx and only serves this static HTML error page.
This is set with
```python
c.Proxy.extra_routes = {
"/": "http://custom-404-entpoint/",
}
```
You may want to use an alternate behavior, such as redirecting to a landing page,
or taking some other action based on the requested page.
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
then all the Hub pages will be available,
and only this default-page-404 issue will come up.
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
then only the Hub _API_ will be available,
and all UI will be up to you.
mybinder.org takes this last option,
because none of the Hub UI pages really make sense.
Binder users don't have any reason to know or care that JupyterHub happens
to be an implementation detail of how their environment is managed.
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
:::{versionadded} 1.4
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
:::

View File

@@ -1,6 +1,6 @@
# Authenticators
The [Authenticator][] is the mechanism for authorizing users to use the
The {class}`.Authenticator` is the mechanism for authorizing users to use the
Hub and single user notebook servers.
## The default PAM Authenticator
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
Because the username is passed from the Authenticator to the Spawner,
a custom Authenticator and Spawner are often used together.
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
auth-related startup (e.g. opening PAM sessions) and cleanup
(e.g. closing PAM sessions).
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
This may mean defining environment variables, placing certificate in the user's home directory, etc.
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
to Spawner environment:
```python
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
```
(authenticator-groups)=
## Authenticator-managed group membership
:::{versionadded} 2.2
:::
Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub.
This is now possible with `Authenticator.managed_groups`.
You can set the config:
```python
c.Authenticator.manage_groups = True
```
to enable this behavior.
The default is False for Authenticators that ship with JupyterHub,
but may be True for custom Authenticators.
Check your Authenticator's documentation for manage_groups support.
If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups`
which is a list of group names the user should be a member of:
- Membership will be added for any group in the list
- Membership in any groups not in the list will be revoked
- Any groups not already present in the database will be created
- If `None` is returned, no changes are made to the user's group membership
If authenticator-managed groups are enabled,
all group-management via the API is disabled.
## pre_spawn_start and post_spawn_stop hooks
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
[post_spawn_stop(user, spawner)][] to add pass additional state information
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
between the authenticator and a spawner. These hooks are typically used auth-related
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
PAM session.
@@ -259,10 +291,7 @@ PAM session.
Beginning with version 0.8, JupyterHub is an OAuth provider.
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
[oauth]: https://en.wikipedia.org/wiki/OAuth
[github oauth]: https://developer.github.com/v3/oauth/
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop

View File

@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
First, we will need to enable the apache modules that we are going to need:
```bash
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
```
Our Apache configuration is equivalent to the nginx configuration above:
@@ -188,13 +188,24 @@ Listen 443
ServerName HUB.DOMAIN.TLD
# enable HTTP/2, if available
Protocols h2 http/1.1
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
Header always set Strict-Transport-Security "max-age=63072000"
# configure SSL
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
SSLProtocol All -SSLv2 -SSLv3
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
SSLSessionTickets off
# Use RewriteEngine to handle websocket connection upgrades
RewriteEngine On
@@ -208,6 +219,7 @@ Listen 443
# proxy to JupyterHub
ProxyPass http://127.0.0.1:8000/
ProxyPassReverse http://127.0.0.1:8000/
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
</Location>
</VirtualHost>
```
@@ -219,8 +231,8 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
httpd.conf amendments:
```bash
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE,P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [P,L]
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [P,L]
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/

View File

@@ -21,6 +21,7 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
monitoring
database
templates
api-only
../events/index
config-user-env
config-examples

View File

@@ -5,7 +5,7 @@ Below is an interactive view of JupyterHub's OpenAPI specification.
<!-- client-rendered openapi UI copied from FastAPI -->
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.1/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<!-- render the ui here -->

View File

@@ -1,3 +1,5 @@
(rest-api)=
# Using JupyterHub's REST API
This section will give you information on:
@@ -111,7 +113,6 @@ c.JupyterHub.load_roles = [
"scopes": [
# specify the permissions the token should have
"admin:users",
"admin:services",
],
"services": [
# assign the service the above permissions

View File

@@ -1,17 +1,5 @@
# Services
With version 0.7, JupyterHub adds support for **Services**.
This section provides the following information about Services:
- [Definition of a Service](#definition-of-a-service)
- [Properties of a Service](#properties-of-a-service)
- [Hub-Managed Services](#hub-managed-services)
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
- [Externally-Managed Services](#externally-managed-services)
- [Writing your own Services](#writing-your-own-services)
- [Hub Authentication and Services](#hub-authentication-and-services)
## Definition of a Service
When working with JupyterHub, a **Service** is defined as a process that interacts
@@ -47,6 +35,8 @@ A Service may have the following properties:
the service will be added to the proxy at `/services/:name`
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
an API token to perform API requests to the Hub
- `display: bool (default - True)` - When set to true, display a link to the
service's URL under the 'Services' dropdown in user's hub home page.
If a service is also to be managed by the Hub, it has a few extra options:
@@ -95,6 +85,7 @@ c.JupyterHub.load_roles = [
# 'admin:users' # needed if culling idle users as well
]
}
]
c.JupyterHub.services = [
{
@@ -115,6 +106,8 @@ parameters, which describe the environment needed to start the Service process:
The Hub will pass the following environment variables to launch the Service:
(service-env)=
```bash
JUPYTERHUB_SERVICE_NAME: The name of the service
JUPYTERHUB_API_TOKEN: API token assigned to the service
@@ -123,7 +116,10 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
Only for proxied web services.
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service
(deprecated in 3.0, use JUPYTERHUB_OAUTH_ACCESS_SCOPES).
JUPYTERHUB_OAUTH_ACCESS_SCOPES: JSON-serialized list of scopes to use for allowing access to the service (new in 3.0).
JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES: JSON-serialized list of scopes that can be requested by the oauth client on behalf of users (new in 3.0).
```
For the previous 'cull idle' Service example, these environment variables
@@ -196,25 +192,45 @@ extra slash you might get unexpected behavior. For example if your service has a
## Hub Authentication and Services
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
mechanism to govern access to your service. When a user logs into JupyterHub,
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
cookie to authenticate requests.
JupyterHub provides some utilities for using the Hub's authentication
mechanism to govern access to your service.
JupyterHub ships with a reference implementation of Hub authentication that
Requests to all JupyterHub services are made with OAuth tokens.
These can either be requests with a token in the `Authorization` header,
or url parameter `?token=...`,
or browser requests which must complete the OAuth authorization code flow,
which results in a token that should be persisted for future requests
(persistence is up to the service,
but an encrypted cookie confined to the service path is appropriate,
and provided by default).
:::{versionchanged} 2.0
The shared `jupyterhub-services` cookie is removed.
OAuth must be used to authenticate browser requests with services.
:::
JupyterHub includes a reference implementation of Hub authentication that
can be used by services. You may go beyond this reference implementation and
create custom hub-authenticating clients and services. We describe the process
below.
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
which implements the requests to the Hub.
The reference, or base, implementation is the {class}`.HubAuth` class,
which implements the API requests to the Hub that resolve a token to a User model.
There are two levels of authentication with the Hub:
- {class}`.HubAuth` - the most basic authentication,
for services that should only accept API requests authorized with a token.
- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub.
This should be used for any service that serves pages that should be visited with a browser.
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
or via the `JUPYTERHUB_API_TOKEN` environment variable.
Most of the logic for authentication implementation is found in the
[`HubAuth.user_for_token`][hubauth.user_for_token]
methods, which makes a request of the Hub, and returns:
{meth}`.HubAuth.user_for_token` methods,
which makes a request of the Hub, and returns:
- None, if no user could be identified, or
- a dict of the following form:
@@ -235,6 +251,19 @@ action.
HubAuth also caches the Hub's response for a number of seconds,
configurable by the `cookie_cache_max_age` setting (default: five minutes).
If your service would like to make further requests _on behalf of users_,
it should use the token issued by this OAuth process.
If you are using tornado,
you can access the token authenticating the current request with {meth}`.HubAuth.get_token`.
:::{versionchanged} 2.2
{meth}`.HubAuth.get_token` adds support for retrieving
tokens stored in tornado cookies after completion of OAuth.
Previously, it only retrieved tokens from URL parameters or the Authorization header.
Passing `get_token(handler, in_cookie=False)` preserves this behavior.
:::
### Flask Example
For example, you have a Flask service that returns information about a user.
@@ -250,18 +279,17 @@ for more details.
### Authenticating tornado services with JupyterHub
Since most Jupyter services are written with tornado,
we include a mixin class, [`HubAuthenticated`][hubauthenticated],
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
for quickly authenticating your own tornado services with JupyterHub.
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
method to identify the user. Mixing in `HubAuthenticated` defines
`get_current_user` to use HubAuth. If you want to configure the HubAuth
instance beyond the default, you'll want to define an `initialize` method,
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
method to identify the user. Mixing in {class}`.HubAuthenticated` defines
{meth}`~.HubAuthenticated.get_current_user` to use HubAuth. If you want to configure the HubAuth
instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
such as:
```python
class MyHandler(HubAuthenticated, web.RequestHandler):
hub_users = {'inara', 'mal'}
class MyHandler(HubOAuthenticated, web.RequestHandler):
def initialize(self, hub_auth):
self.hub_auth = hub_auth
@@ -271,14 +299,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
...
```
The HubAuth will automatically load the desired configuration from the Service
environment variables.
The HubAuth class will automatically load the desired configuration from the Service
[environment variables](service-env).
If you want to limit user access, you can specify allowed users through either the
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
username and user group list, respectively. If a user matches neither the user
list nor the group list, they will not be allowed access. If both are left
undefined, then any user will be allowed.
:::{versionchanged} 2.0
Access scopes are used to govern access to services.
Prior to 2.0,
sets of users and groups could be used to grant access
by defining `.hub_groups` or `.hub_users` on the authenticated handler.
These are ignored if the 2.0 `.hub_scopes` is defined.
:::
:::{seealso}
{meth}`.HubAuth.check_scopes`
:::
### Implementing your own Authentication with JupyterHub
@@ -328,7 +363,7 @@ and taking note of the following process:
```python
{
"name": "inara",
# groups may be omitted, depending on permissions
# groups may be omitted, depending on permissions
"groups": ["serenity", "guild"],
# scopes is new in JupyterHub 2.0
"scopes": [
@@ -344,7 +379,7 @@ The `scopes` field can be used to manage access.
Note: a user will have access to a service to complete oauth access to the service for the first time.
Individual permissions may be revoked at any later point without revoking the token,
in which case the `scopes` field in this model should be checked on each access.
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_ACCESS_SCOPES`.
An example of using an Externally-Managed Service and authentication is
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
@@ -354,9 +389,6 @@ section on securing the notebook viewer.
[requests]: http://docs.python-requests.org/en/master/
[services_auth]: ../api/services.auth.html
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
[fastapi]: https://fastapi.tiangolo.com

View File

@@ -108,6 +108,16 @@ class MySpawner(Spawner):
return url
```
#### Exception handling
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
Alternatively `.jupyterhub_message` is rendered as unformatted text.
If both attributes are not present, the Exception will be shown to the user as unformatted text.
### Spawner.poll
`Spawner.poll` should check if the spawner is still running.
@@ -298,6 +308,9 @@ The process environment is returned by `Spawner.get_env`, which specifies the fo
This is also the OAuth client secret.
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
- JUPYTERHUB_OAUTH_ACCESS_SCOPES - the scopes required to access the server (called JUPYTERHUB_OAUTH_SCOPES prior to 3.0)
- JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES - the scopes the service is allowed to request.
If no scopes are requested explicitly, these scopes will be requested.
Optional environment variables, depending on configuration:

View File

@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
Then restart JupyterHub.
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
See also {ref}`ssl-encryption`.
### Install JupyterHub without a network connection
@@ -371,7 +371,7 @@ a JupyterHub deployment. The commands are:
- System and deployment information
```bash
jupyter troubleshooting
jupyter troubleshoot
```
- Kernel information

View File

@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
root = here.parent
def test_rest_api_version():
def test_rest_api_version_is_updated():
"""Checks that the version in JupyterHub's REST API definition file
(rest-api.yml) is matching the JupyterHub version."""
version_py = root.joinpath("jupyterhub", "_version.py")
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
ns = {}
@@ -25,18 +27,17 @@ def test_rest_api_version():
assert jupyterhub_version == rest_api_version
def test_restapi_scopes():
def test_rest_api_rbac_scope_descriptions_are_updated():
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
definition file (rest-api.yml) as can be updated by generate-scope-table.py
matches what is committed."""
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
run(
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
cwd=here,
check=False,
)
run(
[
"git",
"diff",
"--no-pager",
"diff",
"--color=always",
"--exit-code",
str(here.joinpath("source", "_static", "rest-api.yml")),
],

View File

@@ -0,0 +1,31 @@
"""sample jupyterhub config file for testing
configures jupyterhub with dummyauthenticator and simplespawner
to enable testing without administrative privileges.
"""
c = get_config() # noqa
c.Application.log_level = 'DEBUG'
import os
from oauthenticator.azuread import AzureAdOAuthenticator
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")
c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET")
c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL")
c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID")
c.AzureAdOAuthenticator.username_claim = "email"
c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL")
c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL")
c.Authenticator.manage_groups = True
c.Authenticator.refresh_pre_spawn = True
# Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password"
from jupyterhub.spawner import SimpleLocalProcessSpawner
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner

View File

@@ -0,0 +1,2 @@
oauthenticator
pyjwt

View File

@@ -0,0 +1,132 @@
import os
from functools import wraps
from html import escape
from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
from jupyterhub.utils import url_path_join
SCOPE_PREFIX = "custom:grades"
READ_SCOPE = f"{SCOPE_PREFIX}:read"
WRITE_SCOPE = f"{SCOPE_PREFIX}:write"
def require_scope(scopes):
"""Decorator to require scopes
For use if multiple methods on one Handler
may want different scopes,
so class-level .hub_scopes is insufficient
(e.g. read for GET, write for POST).
"""
if isinstance(scopes, str):
scopes = [scopes]
def wrap(method):
"""The actual decorator"""
@wraps(method)
@authenticated
def wrapped(self, *args, **kwargs):
self.hub_scopes = scopes
return method(self, *args, **kwargs)
return wrapped
return wrap
class MyGradesHandler(HubOAuthenticated, RequestHandler):
# no hub_scopes, anyone with access to this service
# will be able to visit this URL
@authenticated
def get(self):
self.write("<h1>My grade</h1>")
name = self.current_user["name"]
grades = self.settings["grades"]
self.write(f"<p>My name is: {escape(name)}</p>")
if name in grades:
self.write(f"<p>My grade is: {escape(str(grades[name]))}</p>")
else:
self.write("<p>No grade entered</p>")
if READ_SCOPE in self.current_user["scopes"]:
self.write('<a href="grades/">enter grades</a>')
class GradesHandler(HubOAuthenticated, RequestHandler):
# default scope for this Handler: read-only
hub_scopes = [READ_SCOPE]
def _render(self):
grades = self.settings["grades"]
self.write("<h1>All grades</h1>")
self.write("<table>")
self.write("<tr><th>Student</th><th>Grade</th></tr>")
for student, grade in grades.items():
qstudent = escape(student)
qgrade = escape(str(grade))
self.write(
f"""
<tr>
<td class="student">{qstudent}</td>
<td class="grade">{qgrade}</td>
</tr>
"""
)
if WRITE_SCOPE in self.current_user["scopes"]:
self.write("Enter grade:")
self.write(
"""
<form action=. method=POST>
<input name=student placeholder=student></input>
<input kind=number name=grade placeholder=grade></input>
<input type="submit" value="Submit">
"""
)
@require_scope([READ_SCOPE])
async def get(self):
self._render()
# POST requires WRITE_SCOPE instead of READ_SCOPE
@require_scope([WRITE_SCOPE])
async def post(self):
name = self.get_argument("student")
grade = self.get_argument("grade")
self.settings["grades"][name] = grade
self._render()
def main():
base_url = os.environ['JUPYTERHUB_SERVICE_PREFIX']
app = Application(
[
(base_url, MyGradesHandler),
(url_path_join(base_url, 'grades/'), GradesHandler),
(
url_path_join(base_url, 'oauth_callback'),
HubOAuthCallbackHandler,
),
],
cookie_secret=os.urandom(32),
grades={"student": 53},
)
http_server = HTTPServer(app)
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
http_server.listen(url.port, url.hostname)
try:
IOLoop.current().start()
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,52 @@
import sys
c = get_config() # noqa
c.JupyterHub.services = [
{
'name': 'grades',
'url': 'http://127.0.0.1:10101',
'command': [sys.executable, './grades.py'],
'oauth_client_allowed_scopes': [
'custom:grades:write',
'custom:grades:read',
],
},
]
c.JupyterHub.custom_scopes = {
"custom:grades:read": {
"description": "read-access to all grades",
},
"custom:grades:write": {
"description": "Enter new grades",
"subscopes": ["custom:grades:read"],
},
}
c.JupyterHub.load_roles = [
{
"name": "user",
# grant all users access to services
"scopes": ["access:services", "self"],
},
{
"name": "grader",
# grant graders access to write grades
"scopes": ["custom:grades:write"],
"users": ["grader"],
},
{
"name": "instructor",
# grant instructors access to read, but not write grades
"scopes": ["custom:grades:read"],
"users": ["instructor"],
},
]
c.JupyterHub.allowed_users = {"instructor", "grader", "student"}
# dummy spawner and authenticator for testing, don't actually use these!
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
c.JupyterHub.log_level = 10

View File

@@ -5,13 +5,10 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl
"""
import json
import os
from urllib.parse import urlencode
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from tornado import log
from tornado import web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpclient import HTTPRequest
from tornado import log, web
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import url_concat
from tornado.ioloop import IOLoop

View File

@@ -2,6 +2,8 @@
# 1. start/stop servers, and
# 2. access the server API
c = get_config() # noqa
c.JupyterHub.load_roles = [
{
"name": "launcher",

View File

@@ -16,7 +16,6 @@ import time
import requests
log = logging.getLogger(__name__)

View File

@@ -3,9 +3,7 @@ import datetime
import json
import os
from tornado import escape
from tornado import ioloop
from tornado import web
from tornado import escape, ioloop, web
from jupyterhub.services.auth import HubAuthenticated

View File

@@ -1,8 +1,5 @@
from datetime import datetime
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Any, Dict, List, Optional
from pydantic import BaseModel

View File

@@ -1,9 +1,7 @@
import json
import os
from fastapi import HTTPException
from fastapi import Security
from fastapi import status
from fastapi import HTTPException, Security, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.security.api_key import APIKeyQuery

View File

@@ -1,14 +1,9 @@
import os
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Form
from fastapi import Request
from fastapi import APIRouter, Depends, Form, Request
from .client import get_client
from .models import AuthorizationError
from .models import HubApiError
from .models import User
from .models import AuthorizationError, HubApiError, User
from .security import get_current_user
# APIRouter prefix cannot end in /

View File

@@ -7,16 +7,10 @@ import os
import secrets
from functools import wraps
from flask import Flask
from flask import make_response
from flask import redirect
from flask import request
from flask import Response
from flask import session
from flask import Flask, Response, make_response, redirect, request, session
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)

View File

@@ -8,59 +8,72 @@ There is an implementation each of api-token-based `HubAuthenticated` and OAuth-
1. Launch JupyterHub and the `whoami` services with
jupyterhub --ip=127.0.0.1
jupyterhub
2. Visit http://127.0.0.1:8000/services/whoami-oauth
After logging in with your local-system credentials, you should see a JSON dump of your user info:
After logging in with any username and password, you should see a JSON dump of your user info:
```json
{
"admin": false,
"last_activity": "2016-05-27T14:05:18.016372",
"groups": [],
"kind": "user",
"name": "queequeg",
"pending": null,
"server": "/user/queequeg"
"scopes": ["access:services!service=whoami-oauth"],
"session_id": "5a2164273a7346728873bcc2e3c26415"
}
```
What is contained in the model will depend on the permissions
requested in the `oauth_client_allowed_scopes` configuration of the service `whoami-oauth` service.
The default is the minimum required for identification and access to the service,
which will provide the username and current scopes.
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page (`/hub/token`),
and making a direct request:
```bash
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
token="d584cbc5bba2430fb153aadb305029b4"
curl -H "Authorization: token $token" http://127.0.0.1:8000/services/whoami-api/ | jq .
```
```json
{
"admin": false,
"created": "2021-05-21T09:47:41.299400Z",
"created": "2021-12-20T09:49:37.258427Z",
"groups": [],
"kind": "user",
"last_activity": "2021-05-21T09:49:08.290745Z",
"name": "test",
"last_activity": "2021-12-20T10:07:31.298056Z",
"name": "queequeg",
"pending": null,
"roles": [
"user"
],
"roles": ["user"],
"scopes": [
"access:servers!user=queequeg",
"access:services",
"access:servers!user=test",
"read:users!user=test",
"read:users:activity!user=test",
"read:users:groups!user=test",
"read:users:name!user=test",
"read:servers!user=test",
"read:tokens!user=test",
"users!user=test",
"users:activity!user=test",
"users:groups!user=test",
"users:name!user=test",
"servers!user=test",
"tokens!user=test"
"delete:servers!user=queequeg",
"read:servers!user=queequeg",
"read:tokens!user=queequeg",
"read:users!user=queequeg",
"read:users:activity!user=queequeg",
"read:users:groups!user=queequeg",
"read:users:name!user=queequeg",
"servers!user=queequeg",
"tokens!user=queequeg",
"users:activity!user=queequeg"
],
"server": null
"server": null,
"servers": {},
"session_id": null
}
```
The above is a more complete user model than the `whoami-oauth` example, because
the token was issued with the default `token` role,
which has the `inherit` metascope,
meaning the token has access to everything the tokens owner has access to.
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,

View File

@@ -10,7 +10,15 @@ c.JupyterHub.services = [
'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10102',
'command': [sys.executable, './whoami-oauth.py'],
'oauth_roles': ['user'],
# the default oauth roles is minimal,
# only requesting access to the service,
# and identification by name,
# nothing more.
# Specifying 'oauth_client_allowed_scopes' as a list of scopes
# allows requesting more information about users,
# or the ability to take actions on users' behalf, as required.
# the 'inherit' scope means the full permissions of the owner
# 'oauth_client_allowed_scopes': ['inherit'],
},
]

View File

@@ -10,12 +10,9 @@ from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application
from tornado.web import authenticated
from tornado.web import RequestHandler
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler
from jupyterhub.services.auth import HubOAuthenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
from jupyterhub.utils import url_path_join

View File

@@ -10,9 +10,7 @@ from urllib.parse import urlparse
from tornado.httpserver import HTTPServer
from tornado.ioloop import IOLoop
from tornado.web import Application
from tornado.web import authenticated
from tornado.web import RequestHandler
from tornado.web import Application, RequestHandler, authenticated
from jupyterhub.services.auth import HubAuthenticated

View File

@@ -1,47 +0,0 @@
/*
object-assign
(c) Sindre Sorhus
@license MIT
*/
/*!
Copyright (c) 2017 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/** @license React v0.20.1
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.1
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

View File

@@ -1,6 +0,0 @@
<!DOCTYPE html>
<head></head>
<body>
<div id="admin-react-hook"></div>
<script src="admin-react.js"></script>
</body>

View File

@@ -8,7 +8,7 @@
"scripts": {
"build": "yarn && webpack",
"hot": "webpack && webpack-dev-server",
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
"place": "cp build/admin-react.js* ../share/jupyterhub/static/js/",
"test": "jest --verbose",
"snap": "jest --updateSnapshot",
"lint": "eslint --ext .jsx --ext .js src/",
@@ -28,40 +28,48 @@
}
},
"dependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"babel-loader": "^8.2.1",
"bootstrap": "^4.5.3",
"css-loader": "^5.0.1",
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"history": "^5.0.0",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-bootstrap": "^2.1.1",
"react-dom": "^17.0.1",
"react-icons": "^4.1.0",
"react-multi-select-component": "^3.0.7",
"react-object-table-viewer": "^1.0.7",
"react-redux": "^7.2.2",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"recompose": "^0.30.0",
"recompose": "npm:react-recompose@^0.31.2",
"redux": "^4.0.5",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^3.3.4",
"webpack-dev-server": "^3.11.0"
"regenerator-runtime": "^0.13.9"
},
"devDependencies": {
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"@testing-library/jest-dom": "^5.15.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@webpack-cli/serve": "^1.7.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
"babel-jest": "^26.6.3",
"babel-loader": "^8.2.1",
"css-loader": "^5.0.1",
"enzyme": "^3.11.0",
"eslint": "^7.18.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-unused-imports": "^1.1.1",
"file-loader": "^6.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1"
"prettier": "^2.2.1",
"sinon": "^13.0.1",
"style-loader": "^2.0.0",
"webpack": "^5.6.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3"
}
}

View File

@@ -22,15 +22,16 @@ const store = createStore(reducers, initialState);
const App = () => {
useEffect(() => {
let { limit, user_page, groups_page } = initialState;
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
let api = withAPI()().props;
api
.updateUsers(user_page * limit, limit)
.then((data) =>
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
)
.catch((err) => console.log(err));
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
api
.updateGroups(groups_page * limit, limit)
.then((data) =>
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
)

View File

@@ -1,6 +1,7 @@
export const initialState = {
user_data: undefined,
user_page: 0,
name_filter: "",
groups_data: undefined,
groups_page: 0,
limit: window.api_page_limit,
@@ -13,6 +14,7 @@ export const reducers = (state = initialState, action) => {
return Object.assign({}, state, {
user_page: action.value.page,
user_data: action.value.data,
name_filter: action.value.name_filter || "",
});
// Updates the client group model data and stores the page

View File

@@ -25,11 +25,20 @@ const AddUser = (props) => {
return (
<>
<div className="container">
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
@@ -49,18 +58,23 @@ const AddUser = (props) => {
id="add-user-textarea"
rows="3"
placeholder="usernames separated by line"
data-testid="user-textarea"
onBlur={(e) => {
let split_users = e.target.value.split("\n");
let split_users = e.target.value
.split("\n")
.map((u) => u.trim())
.filter((u) => u.length > 0);
setUsers(split_users);
}}
></textarea>
<br></br>
<input
className="form-check-input"
data-testid="check"
type="checkbox"
value=""
id="admin-check"
onChange={(e) => setAdmin(e.target.checked)}
checked={admin}
onChange={() => setAdmin(!admin)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
@@ -74,32 +88,25 @@ const AddUser = (props) => {
<span> </span>
<button
id="submit"
data-testid="submit"
className="btn btn-primary"
onClick={() => {
let filtered_users = users.filter(
(e) =>
e.length > 2 &&
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
);
if (filtered_users.length < users.length) {
setUsers(filtered_users);
failRegexEvent();
}
addUsers(filtered_users, admin)
addUsers(users, admin)
.then((data) =>
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
.catch(() =>
setErrorAlert(`Failed to update users.`)
)
: setErrorAlert(
`[${data.status}] Failed to create user. ${
`Failed to create user. ${
data.status == 409 ? "User already exists." : ""
}`
)
)
.catch((err) => console.log(err));
.catch(() => setErrorAlert(`Failed to create user.`));
}}
>
Add Users

View File

@@ -1,12 +1,15 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import AddUser from "./AddUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import AddUser from "./AddUser";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,64 +17,123 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("AddUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var addUserJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var addUserJsx = (spy, spy2, spy3) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<AddUser
addUsers={spy}
failRegexEvent={spy2 || spy}
updateUsers={spy3 || spy2 || spy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(addUserJsx(mockAsync()));
expect(component.find(".container").length).toBe(1);
});
it("Removes users when they fail Regex", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
textarea = component.find("textarea").first();
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
});
it("Correctly submits admin", () => {
let callbackSpy = mockAsync(),
component = mount(addUserJsx(callbackSpy)),
input = component.find("input").first();
input.simulate("change", { target: { checked: true } });
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalledWith([], true);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
await act(async () => {
render(addUserJsx());
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Removes users when they fail Regex", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
});
test("Correctly submits admin", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let textarea = screen.getByTestId("user-textarea");
let submit = screen.getByTestId("submit");
let check = screen.getByTestId("check");
userEvent.click(check);
fireEvent.blur(textarea, { target: { value: "foo" } });
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
});
test("Shows a UI error dialogue when user creation fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(addUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to create user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(addUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText(
"Failed to create user. User already exists."
);
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

@@ -24,11 +24,20 @@ const CreateGroup = (props) => {
return (
<>
<div className="container">
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
@@ -44,12 +53,13 @@ const CreateGroup = (props) => {
<div className="input-group">
<input
className="group-name-input"
data-testid="group-input"
type="text"
id="group-name"
value={groupName}
placeholder="group name..."
onChange={(e) => {
setGroupName(e.target.value);
setGroupName(e.target.value.trim());
}}
></input>
</div>
@@ -61,6 +71,7 @@ const CreateGroup = (props) => {
<span> </span>
<button
id="submit"
data-testid="submit"
className="btn btn-primary"
onClick={() => {
createGroup(groupName)
@@ -69,16 +80,18 @@ const CreateGroup = (props) => {
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"))
.catch((err) => console.log(err))
.catch(() =>
setErrorAlert(`Could not update groups list.`)
)
: setErrorAlert(
`[${data.status}] Failed to create group. ${
`Failed to create group. ${
data.status == 409
? "Group already exists."
: ""
}`
);
})
.catch((err) => console.log(err));
.catch(() => setErrorAlert(`Failed to create group.`));
}}
>
Create

View File

@@ -1,13 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import CreateGroup from "./CreateGroup";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
Enzyme.configure({ adapter: new Adapter() });
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
import CreateGroup from "./CreateGroup";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -15,52 +16,100 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("CreateGroup Component: ", () => {
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var mockAsync = (result) =>
jest.fn().mockImplementation(() => Promise.resolve(result));
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var createGroupJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Renders", () => {
let component = mount(createGroupJsx());
expect(component.find(".container").length).toBe(1);
});
it("Calls createGroup on submit", () => {
let callbackSpy = mockAsync({ status: 200 }),
component = mount(createGroupJsx(callbackSpy)),
input = component.find("input").first(),
submit = component.find("#submit").first();
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(component.find(".alert.alert-danger").length).toBe(0);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
await act(async () => {
render(createGroupJsx());
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Calls createGroup on submit", async () => {
let callbackSpy = mockAsync({ status: 200 });
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let input = screen.getByTestId("group-input");
let submit = screen.getByTestId("submit");
userEvent.type(input, "groupname");
await act(async () => fireEvent.click(submit));
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
});
test("Shows a UI error dialogue when group creation fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to create group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(createGroupJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText(
"Failed to create group. Group already exists."
);
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

@@ -19,14 +19,7 @@ const EditUser = (props) => {
});
};
var {
editUser,
deleteUser,
failRegexEvent,
noChangeEvent,
updateUsers,
history,
} = props;
var { editUser, deleteUser, noChangeEvent, updateUsers, history } = props;
if (props.location.state == undefined) {
props.history.push("/");
@@ -40,11 +33,20 @@ const EditUser = (props) => {
return (
<>
<div className="container">
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">{errorAlert}</div>
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
@@ -61,6 +63,7 @@ const EditUser = (props) => {
<div className="form-group">
<textarea
className="form-control"
data-testid="edit-username-input"
id="exampleFormControlTextarea1"
rows="3"
placeholder="updated username"
@@ -81,20 +84,26 @@ const EditUser = (props) => {
<br></br>
<button
id="delete-user"
data-testid="delete-user"
className="btn btn-danger btn-sm"
onClick={() => {
onClick={(e) => {
e.preventDefault();
deleteUser(username)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
.catch(() =>
setErrorAlert(
`Could not update users list.`
)
)
: setErrorAlert(`Failed to edit user.`);
})
.catch((err) => console.log(err));
.catch(() => {
setErrorAlert(`Failed to edit user.`);
});
}}
>
Delete user
@@ -109,8 +118,10 @@ const EditUser = (props) => {
<span> </span>
<button
id="submit"
data-testid="submit"
className="btn btn-primary"
onClick={() => {
onClick={(e) => {
e.preventDefault();
if (updatedUsername == "" && admin == has_admin) {
noChangeEvent();
return;
@@ -129,17 +140,20 @@ const EditUser = (props) => {
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
.catch(() =>
setErrorAlert(
`Could not update users list.`
)
)
: setErrorAlert(`Failed to edit user.`);
})
.catch((err) => {
console.log(err);
.catch(() => {
setErrorAlert(`Failed to edit user.`);
});
} else {
setUpdatedUsername("");
failRegexEvent();
setErrorAlert(
`Failed to edit user. Make sure the username does not contain special characters.`
);
}
} else {
editUser(username, username, admin)
@@ -148,13 +162,13 @@ const EditUser = (props) => {
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
: setErrorAlert(
`[${data.status}] Failed to edit user.`
);
.catch(() =>
setErrorAlert(`Could not update users list.`)
)
: setErrorAlert(`Failed to edit user.`);
})
.catch((err) => {
console.log(err);
.catch(() => {
setErrorAlert(`Failed to edit user.`);
});
}
}}

View File

@@ -1,12 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import EditUser from "./EditUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import EditUser from "./EditUser";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,67 +16,124 @@ jest.mock("react-redux", () => ({
useSelector: jest.fn(),
}));
describe("EditUser Component: ", () => {
var mockAsync = () =>
jest
.fn()
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
var mockSync = () => jest.fn();
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data));
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={
empty ? {} : { state: { username: "foo", has_admin: false } }
}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var mockAppState = () => ({
limit: 3,
var editUserJsx = (callbackSpy, empty) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<EditUser
location={empty ? {} : { state: { username: "foo", has_admin: false } }}
deleteUser={callbackSpy}
editUser={callbackSpy}
updateUsers={callbackSpy}
history={{ push: () => {} }}
failRegexEvent={callbackSpy}
noChangeEvent={callbackSpy}
/>
</HashRouter>
</Provider>
);
var mockAppState = () => ({
limit: 3,
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
beforeEach(() => {
useDispatch.mockImplementation(() => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
it("Calls the delete user function when the button is pressed", () => {
let callbackSpy = mockAsync(),
component = mount(editUserJsx(callbackSpy)),
deleteUser = component.find("#delete-user");
deleteUser.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Submits the edits when the button is pressed", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy)),
submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Doesn't render when no data is provided", () => {
let callbackSpy = mockSync(),
component = mount(editUserJsx(callbackSpy, true));
expect(component.find(".container").length).toBe(0);
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useDispatch.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Calls the delete user function when the button is pressed", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let deleteUser = screen.getByTestId("delete-user");
await act(async () => {
fireEvent.click(deleteUser);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Submits the edits when the button is pressed", async () => {
let callbackSpy = mockAsync({ key: "value", status: 200 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when user edit fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let usernameInput = screen.getByTestId("edit-username-input");
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when user edit returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 409 });
await act(async () => {
render(editUserJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let usernameInput = screen.getByTestId("edit-username-input");
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit user.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

@@ -7,6 +7,7 @@ import GroupSelect from "../GroupSelect/GroupSelect";
const GroupEdit = (props) => {
var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false),
[errorAlert, setErrorAlert] = useState(null),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
@@ -41,7 +42,25 @@ const GroupEdit = (props) => {
if (!group_data) return <div></div>;
return (
<div className="container">
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3>
@@ -65,6 +84,7 @@ const GroupEdit = (props) => {
<span> </span>
<button
id="submit"
data-testid="submit"
className="btn btn-primary"
onClick={() => {
// check for changes
@@ -89,29 +109,43 @@ const GroupEdit = (props) => {
);
Promise.all(promiseQueue)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
.then((data) => {
// ensure status of all requests are < 300
let allPassed =
data.map((e) => e.status).filter((e) => e >= 300).length ==
0;
allPassed
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"))
: setErrorAlert(`Failed to edit group.`);
})
.catch((err) => console.log(err));
.catch(() => {
console.log("outer");
setErrorAlert(`Failed to edit group.`);
});
}}
>
Apply
</button>
<button
id="delete-group"
data-testid="delete-group"
className="btn btn-danger"
style={{ float: "right" }}
onClick={() => {
var groupName = group_data.name;
deleteGroup(groupName)
.then(() => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
// TODO add error if res not ok
.then((data) => {
data.status < 300
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"))
: setErrorAlert(`Failed to delete group.`);
})
.catch((err) => console.log(err));
.catch(() => setErrorAlert(`Failed to delete group.`));
}}
>
Delete Group

View File

@@ -1,100 +1,228 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import GroupEdit from "./GroupEdit";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
import { act } from "react-dom/test-utils";
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import GroupEdit from "./GroupEdit";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("GroupEdit Component: ", () => {
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data));
var okPacket = new Promise((resolve) => resolve(true));
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
var okPacket = new Promise((resolve) => resolve(true));
var mockAppState = () => ({
limit: 3,
});
var groupEditJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<GroupEdit
location={{
state: {
group_data: { users: ["foo"], name: "group" },
callback: () => {},
},
}}
addToGroup={callbackSpy}
removeFromGroup={callbackSpy}
deleteGroup={callbackSpy}
history={{ push: () => callbackSpy }}
updateGroups={callbackSpy}
validateUser={jest.fn().mockImplementation(() => okPacket)}
/>
</HashRouter>
</Provider>
);
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
var mockAppState = () => ({
limit: 3,
});
afterEach(() => {
useSelector.mockClear();
});
it("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
input = component.find("#username-input"),
validateUser = component.find("#validate-user"),
submit = component.find("#submit");
input.simulate("change", { target: { value: "bar" } });
validateUser.simulate("click");
await act(() => okPacket);
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
it("Removes a user recently added from input from the selectables list", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
unsubmittedUser = component.find(".item.selected").last();
unsubmittedUser.simulate("click");
expect(component.find(".item").length).toBe(1);
});
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
groupUser = component.find(".item.selected").first();
groupUser.simulate("click");
expect(component.find(".item.unselected").length).toBe(1);
expect(component.find(".item").length).toBe(1);
// test deleteUser call
let submit = component.find("#submit");
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
it("Calls deleteGroup on button click", () => {
let callbackSpy = mockAsync(),
component = mount(groupEditJsx(callbackSpy)),
deleteGroup = component.find("#delete-group").first();
deleteGroup.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Adds user from input to user selectables on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let input = screen.getByTestId("username-input");
let validateUser = screen.getByTestId("validate-user");
let submit = screen.getByTestId("submit");
userEvent.type(input, "bar");
fireEvent.click(validateUser);
await act(async () => okPacket);
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
});
test("Removes a user recently added from input from the selectables list", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let selectedUser = screen.getByText("foo");
fireEvent.click(selectedUser);
let unselectedUser = screen.getByText("foo");
expect(unselectedUser.className).toBe("item unselected");
});
test("Grays out a user, already in the group, when unselected and calls deleteUser on submit", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let submit = screen.getByTestId("submit");
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let unselectedUser = screen.getByText("foo");
expect(unselectedUser.className).toBe("item unselected");
// test deleteUser call
await act(async () => {
fireEvent.click(submit);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
});
test("Calls deleteGroup on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
});
test("Shows a UI error dialogue when group edit fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group edit returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 403 });
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let groupUser = screen.getByText("foo");
fireEvent.click(groupUser);
let submit = screen.getByTestId("submit");
await act(async () => {
fireEvent.click(submit);
});
let errorDialog = screen.getByText("Failed to edit group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group delete fails", async () => {
let callbackSpy = mockAsyncRejection();
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
let errorDialog = screen.getByText("Failed to delete group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});
test("Shows a UI error dialogue when group delete returns an improper status code", async () => {
let callbackSpy = mockAsync({ status: 403 });
await act(async () => {
render(groupEditJsx(callbackSpy));
});
let deleteGroup = screen.getByTestId("delete-group");
await act(async () => {
fireEvent.click(deleteGroup);
});
let errorDialog = screen.getByText("Failed to delete group.");
expect(errorDialog).toBeVisible();
expect(callbackSpy).toHaveBeenCalled();
});

View File

@@ -24,6 +24,7 @@ const GroupSelect = (props) => {
<div className="input-group">
<input
id="username-input"
data-testid="username-input"
type="text"
className="form-control"
placeholder="Add by username"
@@ -35,6 +36,7 @@ const GroupSelect = (props) => {
<span className="input-group-btn">
<button
id="validate-user"
data-testid="validate-user"
className="btn btn-default"
type="button"
onClick={() => {

View File

@@ -19,7 +19,7 @@ const Groups = (props) => {
var { updateGroups, history } = props;
if (!groups_data || !user_data) {
return <div></div>;
return <div data-testid="no-show"></div>;
}
const dispatchPageChange = (data, page) => {
@@ -39,7 +39,7 @@ const Groups = (props) => {
}
return (
<div className="container">
<div className="container" data-testid="container">
<div className="row">
<div className="col-md-12 col-lg-10 col-lg-offset-1">
<div className="panel panel-default">

View File

@@ -1,12 +1,14 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import Groups from "./Groups";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import { render, screen } from "@testing-library/react";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import Groups from "./Groups";
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
@@ -14,52 +16,75 @@ jest.mock("react-redux", () => ({
useDispatch: jest.fn(),
}));
describe("Groups Component: ", () => {
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var mockAsync = () =>
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var groupsJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
</HashRouter>
</Provider>
);
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
groups_data: JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
),
limit: 10,
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders groups_data prop into links", () => {
let callbackSpy = mockAsync(),
component = mount(groupsJsx(callbackSpy)),
links = component.find("li");
expect(links.length).toBe(2);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(groupsJsx());
expect(component.html()).toBe("<div></div>");
useDispatch.mockImplementation(() => {
return () => {};
});
});
afterEach(() => {
useSelector.mockClear();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Renders groups_data prop into links", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
let testgroup = screen.getByText("testgroup");
let testgroup2 = screen.getByText("testgroup2");
expect(testgroup).toBeVisible();
expect(testgroup2).toBeVisible();
});
test("Renders nothing if required data is not available", async () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let callbackSpy = mockAsync();
await act(async () => {
render(groupsJsx(callbackSpy));
});
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
});

View File

@@ -1,8 +1,19 @@
import React, { useState } from "react";
import regeneratorRuntime from "regenerator-runtime";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import {
Button,
Col,
Row,
FormControl,
Card,
CardGroup,
Collapse,
} from "react-bootstrap";
import ReactObjectTableViewer from "react-object-table-viewer";
import { Link } from "react-router-dom";
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
@@ -10,7 +21,16 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ url }) => (
<a href={url || ""}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
</a>
);
const ServerDashboard = (props) => {
let base_url = window.base_url || "/";
// sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
@@ -27,15 +47,19 @@ const ServerDashboard = (props) => {
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
const [collapseStates, setCollapseStates] = useState({});
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
name_filter = useSelector((state) => state.name_filter),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var slice = [page * limit, limit, name_filter];
const dispatch = useDispatch();
@@ -49,35 +73,312 @@ const ServerDashboard = (props) => {
history,
} = props;
var dispatchPageUpdate = (data, page) => {
var dispatchPageUpdate = (data, page, name_filter) => {
dispatch({
type: "USER_PAGE",
value: {
data: data,
page: page,
name_filter: name_filter,
},
});
};
if (!user_data) {
return <div></div>;
return <div data-testid="no-show"></div>;
}
if (page != user_page) {
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
updateUsers(...slice).then((data) =>
dispatchPageUpdate(data, page, name_filter)
);
}
var debounce = require("lodash.debounce");
const handleSearch = debounce(async (event) => {
// setNameFilter(event.target.value);
updateUsers(page * limit, limit, event.target.value).then((data) =>
dispatchPageUpdate(data, page, name_filter)
);
}, 300);
if (sortMethod != null) {
user_data = sortMethod(user_data);
}
const StopServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-danger btn-xs stop-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
stopServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page, name_filter);
})
.catch(() => {
setIsDisabled(false);
setErrorAlert(`Failed to update users list.`);
});
} else {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
});
}}
>
Stop Server
</button>
);
};
const StartServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-success btn-xs start-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
startServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page, name_filter);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
setIsDisabled(false);
});
} else {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
});
}}
>
Start Server
</button>
);
};
const EditUserCell = ({ user }) => {
return (
<td>
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: user.name,
has_admin: user.admin,
},
})
}
>
Edit User
</button>
</td>
);
};
const ServerRowTable = ({ data }) => {
const sortedData = Object.keys(data)
.sort()
.reduce(function (result, key) {
let value = data[key];
switch (key) {
case "last_activity":
case "created":
case "started":
// format timestamps
value = value ? timeSince(value) : value;
break;
}
if (Array.isArray(value)) {
// cast arrays (e.g. roles, groups) to string
value = value.sort().join(", ");
}
result[key] = value;
return result;
}, {});
return (
<ReactObjectTableViewer
className="table-striped table-bordered"
style={{
padding: "3px 6px",
margin: "auto",
}}
keyStyle={{
padding: "4px",
}}
valueStyle={{
padding: "4px",
}}
data={sortedData}
/>
);
};
const serverRow = (user, server) => {
const { servers, ...userNoServers } = user;
const serverNameDash = server.name ? `-${server.name}` : "";
const userServerName = user.name + serverNameDash;
const open = collapseStates[userServerName] || false;
return [
<tr key={`${userServerName}-row`} className="user-row">
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="caret"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
<td data-testid="user-row-server">
<p className="text-secondary">{server.name}</p>
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-activity">
{server.started ? (
// Stop Single-user server
<>
<StopServerButton serverName={server.name} userName={user.name} />
<AccessServerButton url={server.url} />
</>
) : (
// Start Single-user server
<>
<StartServerButton
serverName={server.name}
userName={user.name}
style={{ marginRight: 20 }}
/>
<a
href={`${base_url}spawn/${user.name}${
server.name ? "/" + server.name : ""
}`}
>
<button
className="btn btn-secondary btn-xs"
style={{ marginRight: 20 }}
>
Spawn Page
</button>
</a>
</>
)}
</td>
<EditUserCell user={user} />
</tr>,
<tr>
<td
colSpan={6}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={userNoServers} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>,
];
};
let servers = user_data.flatMap((user) => {
let userServers = Object.values({
"": user.server || {},
...(user.servers || {}),
});
return userServers.map((server) => [user, server]);
});
return (
<div className="container">
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</div>
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
</div>
</div>
) : (
<></>
)}
<div className="server-dashboard-container">
<table className="table table-striped table-bordered table-hover">
<Row>
<Col md={4}>
<FormControl
type="text"
name="user_search"
placeholder="Search users"
aria-label="user-search"
defaultValue={name_filter}
onChange={handleSearch}
/>
</Col>
<Col md="auto" style={{ float: "right", margin: 15 }}>
<Link to="/groups">{"> Manage Groups"}</Link>
</Col>
</Row>
<table className="table table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
@@ -85,6 +386,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
testid="user-sort"
/>
</th>
<th id="admin-header">
@@ -92,6 +394,15 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: adminAsc, desc: adminDesc }}
callback={(method) => setSortMethod(() => method)}
testid="admin-sort"
/>
</th>
<th id="server-header">
Server{" "}
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
testid="server-sort"
/>
</th>
<th id="last-activity-header">
@@ -99,6 +410,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }}
callback={(method) => setSortMethod(() => method)}
testid="last-activity-sort"
/>
</th>
<th id="running-status-header">
@@ -106,6 +418,7 @@ const ServerDashboard = (props) => {
<SortHandler
sorts={{ asc: runningAsc, desc: runningDesc }}
callback={(method) => setSortMethod(() => method)}
testid="running-status-sort"
/>
</th>
<th id="actions-header">Actions</th>
@@ -125,17 +438,33 @@ const ServerDashboard = (props) => {
<Button
variant="primary"
className="start-all"
data-testid="start-all"
onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to start ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already running?`
);
}
return res;
})
.catch((err) => console.log(err));
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page, name_filter);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res;
})
.catch(() => setErrorAlert(`Failed to start servers.`));
}}
>
Start All
@@ -145,17 +474,33 @@ const ServerDashboard = (props) => {
<Button
variant="danger"
className="stop-all"
data-testid="stop-all"
onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to stop ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already stopped?`
);
}
return res;
})
.catch((err) => console.log(err));
.then((res) => {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page, name_filter);
})
.catch(() =>
setErrorAlert(`Failed to update users list.`)
);
return res;
})
.catch(() => setErrorAlert(`Failed to stop servers.`));
}}
>
Stop All
@@ -172,70 +517,7 @@ const ServerDashboard = (props) => {
</Button>
</td>
</tr>
{user_data.map((e, i) => (
<tr key={i + "row"} className="user-row">
<td>{e.name}</td>
<td>{e.admin ? "admin" : ""}</td>
<td>
{e.last_activity ? timeSince(e.last_activity) : "Never"}
</td>
<td>
{e.server != null ? (
// Stop Single-user server
<button
className="btn btn-danger btn-xs stop-button"
onClick={() =>
stopServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Stop Server
</button>
) : (
// Start Single-user server
<button
className="btn btn-primary btn-xs start-button"
onClick={() =>
startServer(e.name)
.then((res) => {
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
.catch((err) => console.log(err))
}
>
Start Server
</button>
)}
</td>
<td>
{/* Edit User */}
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: e.name,
has_admin: e.admin,
},
})
}
>
edit user
</button>
</td>
</tr>
))}
{servers.flatMap(([user, server]) => serverRow(user, server))}
</tbody>
</table>
<PaginationFooter
@@ -269,13 +551,14 @@ ServerDashboard.propTypes = {
};
const SortHandler = (props) => {
var { sorts, callback } = props;
var { sorts, callback, testid } = props;
var [direction, setDirection] = useState(undefined);
return (
<div
className="sort-icon"
data-testid={testid}
onClick={() => {
if (!direction) {
callback(sorts.desc);
@@ -303,6 +586,7 @@ const SortHandler = (props) => {
SortHandler.propTypes = {
sorts: PropTypes.object,
callback: PropTypes.func,
testid: PropTypes.string,
};
export default ServerDashboard;

View File

@@ -1,161 +1,535 @@
import React from "react";
import Enzyme, { mount } from "enzyme";
import ServerDashboard from "./ServerDashboard";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import "@testing-library/jest-dom";
import { act } from "react-dom/test-utils";
import userEvent from "@testing-library/user-event";
import { render, screen, fireEvent } from "@testing-library/react";
import { HashRouter, Switch } from "react-router-dom";
import { Provider, useSelector } from "react-redux";
import { createStore } from "redux";
// eslint-disable-next-line
import regeneratorRuntime from "regenerator-runtime";
Enzyme.configure({ adapter: new Adapter() });
import ServerDashboard from "./ServerDashboard";
import * as sinon from "sinon";
let clock;
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useSelector: jest.fn(),
}));
describe("ServerDashboard Component: ", () => {
var serverDashboardJsx = (callbackSpy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={callbackSpy}
shutdownHub={callbackSpy}
startServer={callbackSpy}
stopServer={callbackSpy}
startAll={callbackSpy}
stopAll={callbackSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
var serverDashboardJsx = (spy) => (
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
var mockAsync = () =>
jest
.fn()
.mockImplementation(() =>
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
);
var mockAsync = (data) =>
jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
var mockAsyncRejection = () =>
jest.fn().mockImplementation(() => Promise.reject());
beforeEach(() => {
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
var mockAppState = () => ({
user_data: JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
),
});
afterEach(() => {
useSelector.mockClear();
});
it("Renders users from props.user_data into table", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
expect(userRows.length).toBe(2);
});
it("Renders correctly the status of a single-user server", () => {
let component = mount(serverDashboardJsx(mockAsync())),
userRows = component.find(".user-row");
// Renders .stop-button when server is started
// Should be 1 since user foo is started
expect(userRows.at(0).find(".stop-button").length).toBe(1);
// Renders .start-button when server is stopped
// Should be 1 since user bar is stopped
expect(userRows.at(1).find(".start-button").length).toBe(1);
});
it("Invokes the startServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
startBtn = component.find(".start-button");
startBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the stopServer event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
stopBtn = component.find(".stop-button");
stopBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Invokes the shutdownHub event on button click", () => {
let callbackSpy = mockAsync(),
component = mount(serverDashboardJsx(callbackSpy)),
shutdownBtn = component.find("#shutdown-button").first();
shutdownBtn.simulate("click");
expect(callbackSpy).toHaveBeenCalled();
});
it("Sorts according to username", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").first();
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("bar")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("foo")).toBe(true);
});
it("Sorts according to admin", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(1);
handler.simulate("click");
let first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
expect(first.html().includes("admin")).toBe(false);
});
it("Sorts according to last activity", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(2);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo used most recently
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar used least recently
expect(first.html().includes("bar")).toBe(true);
});
it("Sorts according to server status (running/not running)", () => {
let component = mount(serverDashboardJsx(mockAsync())).find(
"ServerDashboard"
),
handler = component.find("SortHandler").at(3);
handler.simulate("click");
let first = component.find(".user-row").first();
// foo running
expect(first.html().includes("foo")).toBe(true);
handler.simulate("click");
first = component.find(".user-row").first();
// invert sort - bar not running
expect(first.html().includes("bar")).toBe(true);
});
it("Renders nothing if required data is not available", () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let component = mount(serverDashboardJsx(jest.fn()));
expect(component.html()).toBe("<div></div>");
beforeEach(() => {
clock = sinon.useFakeTimers();
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});
afterEach(() => {
useSelector.mockClear();
clock.restore();
});
test("Renders", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
expect(screen.getByTestId("container")).toBeVisible();
});
test("Renders users from props.user_data into table", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let foo = screen.getByTestId("user-name-div-foo");
let bar = screen.getByTestId("user-name-div-bar");
expect(foo).toBeVisible();
expect(bar).toBeVisible();
});
test("Renders correctly the status of a single-user server", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let start = screen.getByText("Start Server");
let stop = screen.getByText("Stop Server");
expect(start).toBeVisible();
expect(stop).toBeVisible();
});
test("Renders spawn page link", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let link = screen.getByText("Spawn Page").closest("a");
let url = new URL(link.href);
expect(url.pathname).toEqual("/spawn/bar");
});
test("Invokes the startServer event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Invokes the stopServer event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Invokes the shutdownHub event on button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let shutdown = screen.getByText("Shutdown Hub");
await act(async () => {
fireEvent.click(shutdown);
});
expect(callbackSpy).toHaveBeenCalled();
});
test("Sorts according to username", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("user-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("bar");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("foo");
});
test("Sorts according to admin", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("admin-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-admin")[0];
expect(first.textContent).toBe("admin");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-admin")[0];
expect(first.textContent).toBe("");
});
test("Sorts according to last activity", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("last-activity-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("bar");
});
test("Sorts according to server status (running/not running)", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let handler = screen.getByTestId("running-status-sort");
fireEvent.click(handler);
let first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("foo");
fireEvent.click(handler);
first = screen.getAllByTestId("user-row-name")[0];
expect(first.textContent).toContain("bar");
});
test("Shows server details with button click", async () => {
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let button = screen.getByTestId("foo-collapse-button");
let collapse = screen.getByTestId("foo-collapse");
let collapseBar = screen.getByTestId("bar-collapse");
// expect().toBeVisible does not work here with collapse.
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse");
expect(collapse).not.toHaveClass("show");
expect(collapseBar).not.toHaveClass("show");
await act(async () => {
fireEvent.click(button);
});
clock.tick(400);
expect(collapse).toHaveClass("collapse show");
expect(collapseBar).not.toHaveClass("show");
});
test("Renders nothing if required data is not available", async () => {
useSelector.mockImplementation((callback) => {
return callback({});
});
let callbackSpy = mockAsync();
await act(async () => {
render(serverDashboardJsx(callbackSpy));
});
let noShow = screen.getByTestId("no-show");
expect(noShow).toBeVisible();
});
test("Shows a UI error dialogue when start all servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection;
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={rejectSpy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let startAll = screen.getByTestId("start-all");
await act(async () => {
fireEvent.click(startAll);
});
let errorDialog = screen.getByText("Failed to start servers.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop all servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection;
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={rejectSpy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stopAll = screen.getByTestId("stop-all");
await act(async () => {
fireEvent.click(stopAll);
});
let errorDialog = screen.getByText("Failed to stop servers.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when start user server fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection();
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={rejectSpy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
let errorDialog = screen.getByText("Failed to start server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when start user server returns an improper status code", async () => {
let spy = mockAsync();
let rejectSpy = mockAsync({ status: 403 });
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={rejectSpy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let start = screen.getByText("Start Server");
await act(async () => {
fireEvent.click(start);
});
let errorDialog = screen.getByText("Failed to start server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop user servers fails", async () => {
let spy = mockAsync();
let rejectSpy = mockAsyncRejection();
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={rejectSpy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
let errorDialog = screen.getByText("Failed to stop server.");
expect(errorDialog).toBeVisible();
});
test("Shows a UI error dialogue when stop user server returns an improper status code", async () => {
let spy = mockAsync();
let rejectSpy = mockAsync({ status: 403 });
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={spy}
shutdownHub={spy}
startServer={spy}
stopServer={rejectSpy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let stop = screen.getByText("Stop Server");
await act(async () => {
fireEvent.click(stop);
});
let errorDialog = screen.getByText("Failed to stop server.");
expect(errorDialog).toBeVisible();
});
test("Search for user calls updateUsers with name filter", async () => {
let spy = mockAsync();
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
return Promise.resolve([]);
});
await act(async () => {
render(
<Provider store={createStore(() => {}, {})}>
<HashRouter>
<Switch>
<ServerDashboard
updateUsers={mockUpdateUsers}
shutdownHub={spy}
startServer={spy}
stopServer={spy}
startAll={spy}
stopAll={spy}
/>
</Switch>
</HashRouter>
</Provider>
);
});
let search = screen.getByLabelText("user-search");
userEvent.type(search, "a");
expect(search.value).toEqual("a");
clock.tick(400);
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
userEvent.type(search, "b");
expect(search.value).toEqual("ab");
clock.tick(400);
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
});

View File

@@ -1,5 +1,7 @@
export const jhapiRequest = (endpoint, method, data) => {
return fetch("/hub/api" + endpoint, {
let base_url = window.base_url || "/",
api_url = `${base_url}hub/api`;
return fetch(api_url + endpoint, {
method: method,
json: true,
headers: {

View File

@@ -2,17 +2,22 @@ import { withProps } from "recompose";
import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({
updateUsers: (offset, limit) =>
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
data.json()
),
updateUsers: (offset, limit, name_filter) =>
jhapiRequest(
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
name_filter || ""
}`,
"GET"
).then((data) => data.json()),
updateGroups: (offset, limit) =>
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
(data) => data.json()
),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
startServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
stopServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) =>
@@ -36,13 +41,14 @@ const withAPI = withProps(() => ({
jhapiRequest("/users/" + username, "GET")
.then((data) => data.status)
.then((data) => (data > 200 ? false : true)),
failRegexEvent: () =>
alert(
"Cannot change username - either contains special characters or is too short."
),
noChangeEvent: () => {
returns;
// Temporarily Unused
failRegexEvent: () => {
return null;
},
noChangeEvent: () => {
return null;
},
//
refreshGroupsData: () =>
jhapiRequest("/groups", "GET").then((data) => data.json()),
refreshUserData: () =>

View File

@@ -1,6 +1,5 @@
const webpack = require("webpack");
const path = require("path");
const express = require("express");
module.exports = {
entry: path.resolve(__dirname, "src", "App.jsx"),
@@ -34,16 +33,19 @@ module.exports = {
},
plugins: [new webpack.HotModuleReplacementPlugin()],
devServer: {
contentBase: path.resolve(__dirname, "build"),
static: {
directory: path.resolve(__dirname, "build"),
},
port: 9000,
before: (app, server) => {
onBeforeSetupMiddleware: (devServer) => {
const app = devServer.app;
var user_data = JSON.parse(
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
);
var group_data = JSON.parse(
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
);
app.use(express.json());
// get user_data
app.get("/hub/api/users", (req, res) => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1 @@
from ._version import __version__
from ._version import version_info
from ._version import __version__, version_info

View File

@@ -4,7 +4,7 @@
def get_data_files():
"""Walk up until we find share/jupyterhub"""
import sys
from os.path import join, abspath, dirname, exists, split
from os.path import abspath, dirname, exists, join, split
path = abspath(dirname(__file__))
starting_points = [path]

154
jupyterhub/_memoize.py Normal file
View File

@@ -0,0 +1,154 @@
"""Utilities for memoization
Note: a memoized function should always return an _immutable_
result to avoid later modifications polluting cached results.
"""
from collections import OrderedDict
from functools import wraps
class DoNotCache:
"""Wrapper to return a result without caching it.
In a function decorated with `@lru_cache_key`:
return DoNotCache(result)
is equivalent to:
return result # but don't cache it!
"""
def __init__(self, result):
self.result = result
class LRUCache:
"""A simple Least-Recently-Used (LRU) cache with a max size"""
def __init__(self, maxsize=1024):
self._cache = OrderedDict()
self.maxsize = maxsize
def __contains__(self, key):
return key in self._cache
def get(self, key, default=None):
"""Get an item from the cache"""
if key in self._cache:
# cache hit, bump to front of the queue for LRU
result = self._cache[key]
self._cache.move_to_end(key)
return result
return default
def set(self, key, value):
"""Store an entry in the cache
Purges oldest entry if cache is full
"""
self._cache[key] = value
# cache is full, purge oldest entry
if len(self._cache) > self.maxsize:
self._cache.popitem(last=False)
__getitem__ = get
__setitem__ = set
def lru_cache_key(key_func, maxsize=1024):
"""Like functools.lru_cache, but takes a custom key function,
as seen in sorted(key=func).
Useful for non-hashable arguments which have a known hashable equivalent (e.g. sets, lists),
or mutable objects where only immutable fields might be used
(e.g. User, where only username affects output).
For safety: Cached results should always be immutable,
such as using `frozenset` instead of mutable `set`.
Example:
@lru_cache_key(lambda user: user.name)
def func_user(user):
# output only varies by name
Args:
key (callable):
Should have the same signature as the decorated function.
Returns a hashable key to use in the cache
maxsize (int):
The maximum size of the cache.
"""
def cache_func(func):
cache = LRUCache(maxsize=maxsize)
# the actual decorated function:
@wraps(func)
def cached(*args, **kwargs):
cache_key = key_func(*args, **kwargs)
if cache_key in cache:
# cache hit
return cache[cache_key]
else:
# cache miss, call function and cache result
result = func(*args, **kwargs)
if isinstance(result, DoNotCache):
# DoNotCache prevents caching
result = result.result
else:
cache[cache_key] = result
return result
return cached
return cache_func
class FrozenDict(dict):
"""A frozen dictionary subclass
Immutable and hashable, so it can be used as a cache key
Values will be frozen with `.freeze(value)`
and must be hashable after freezing.
Not rigorous, but enough for our purposes.
"""
_hash = None
def __init__(self, d):
dict_set = dict.__setitem__
for key, value in d.items():
dict.__setitem__(self, key, self._freeze(value))
def _freeze(self, item):
"""Make values of a dict hashable
- list, set -> frozenset
- dict -> recursive _FrozenDict
- anything else: assumed hashable
"""
if isinstance(item, FrozenDict):
return item
elif isinstance(item, list):
return tuple(self._freeze(e) for e in item)
elif isinstance(item, set):
return frozenset(item)
elif isinstance(item, dict):
return FrozenDict(item)
else:
# any other type is assumed hashable
return item
def __setitem__(self, key):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def update(self, other):
raise RuntimeError("Cannot modify frozen {type(self).__name__}")
def __hash__(self):
"""Cache hash because we are immutable"""
if self._hash is None:
self._hash = hash(tuple((key, value) for key, value in self.items()))
return self._hash

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump`
version_info = (2, 0, 0, "rc1", "")
version_info = (3, 0, 0, "b1", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -3,17 +3,16 @@ import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if 'jupyterhub' in sys.modules:
from traitlets.config import MultipleInstanceError
from jupyterhub.app import JupyterHub
app = None
@@ -42,6 +41,16 @@ target_metadata = orm.Base.metadata
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
# pass these to context.configure(**config_opts)
common_config_opts = dict(
# target_metadata for autogenerate
target_metadata=target_metadata,
# transaction per migration to ensure
# each migration is 'complete' before running the next one
# (e.g. dropped tables)
transaction_per_migration=True,
)
def run_migrations_offline():
"""Run migrations in 'offline' mode.
@@ -55,8 +64,16 @@ def run_migrations_offline():
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
connectable = config.attributes.get('connection', None)
config_opts = {}
config_opts.update(common_config_opts)
config_opts["literal_binds"] = True
if connectable is None:
config_opts["url"] = config.get_main_option("sqlalchemy.url")
else:
config_opts["connection"] = connectable
context.configure(**config_opts)
with context.begin_transaction():
context.run_migrations()
@@ -69,14 +86,22 @@ def run_migrations_online():
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
connectable = config.attributes.get('connection', None)
config_opts = {}
config_opts.update(common_config_opts)
if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
**common_config_opts,
)
with context.begin_transaction():
context.run_migrations()

View File

@@ -11,8 +11,8 @@ down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -15,8 +15,8 @@ import logging
logger = logging.getLogger('alembic')
from alembic import op
import sqlalchemy as sa
from alembic import op
tables = ('oauth_access_tokens', 'oauth_codes')

View File

@@ -22,8 +22,9 @@ import logging
logger = logging.getLogger('alembic')
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict

View File

@@ -11,8 +11,9 @@ down_revision = '896818069c98'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict

View File

@@ -11,11 +11,11 @@ down_revision = '1cebaf56856c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
import logging
import sqlalchemy as sa
from alembic import op
logger = logging.getLogger('alembic')

View File

@@ -0,0 +1,115 @@
"""api_token_scopes
Revision ID: 651f5419b74d
Revises: 833da8570507
Create Date: 2022-02-28 12:42:55.149046
"""
# revision identifiers, used by Alembic.
revision = '651f5419b74d'
down_revision = '833da8570507'
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy.orm import relationship
from sqlalchemy.orm.session import Session
from jupyterhub import orm, roles, scopes
def upgrade():
c = op.get_bind()
tables = sa.inspect(c.engine).get_table_names()
# oauth codes are short lived, no need to upgrade them
if 'oauth_code_role_map' in tables:
op.drop_table('oauth_code_role_map')
if 'oauth_codes' in tables:
op.add_column('oauth_codes', sa.Column('scopes', orm.JSONList(), nullable=True))
if 'api_tokens' in tables:
# may not be present,
# e.g. upgrade from 1.x, token table dropped
# in which case no migration to do
# define new scopes column on API tokens
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
if 'api_token_role_map' in tables:
# redefine the to-be-removed api_token->role relationship
# so we can run a query on it for the migration
token_role_map = Table(
"api_token_role_map",
orm.Base.metadata,
Column(
'api_token_id',
ForeignKey('api_tokens.id', ondelete='CASCADE'),
primary_key=True,
),
Column(
'role_id',
ForeignKey('roles.id', ondelete='CASCADE'),
primary_key=True,
),
extend_existing=True,
)
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
# tokens have roles, evaluate to scopes
db = Session(bind=c)
for token in db.query(orm.APIToken):
token.scopes = list(roles.roles_to_scopes(token.roles))
db.commit()
# drop token-role relationship
op.drop_table('api_token_role_map')
if 'oauth_clients' in tables:
# define new scopes column on API tokens
op.add_column(
'oauth_clients', sa.Column('allowed_scopes', orm.JSONList(), nullable=True)
)
if 'oauth_client_role_map' in tables:
# redefine the to-be-removed api_token->role relationship
# so we can run a query on it for the migration
client_role_map = Table(
"oauth_client_role_map",
orm.Base.metadata,
Column(
'oauth_client_id',
ForeignKey('oauth_clients.id', ondelete='CASCADE'),
primary_key=True,
),
Column(
'role_id',
ForeignKey('roles.id', ondelete='CASCADE'),
primary_key=True,
),
extend_existing=True,
)
orm.OAuthClient.allowed_roles = relationship(
'Role', secondary='oauth_client_role_map'
)
# oauth clients have allowed_roles, evaluate to allowed_scopes
db = Session(bind=c)
for oauth_client in db.query(orm.OAuthClient):
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
allowed_scopes.update(scopes.access_scopes(oauth_client))
oauth_client.allowed_scopes = sorted(allowed_scopes)
db.commit()
# drop token-role relationship
op.drop_table('oauth_client_role_map')
def downgrade():
# cannot map permissions from scopes back to roles
# drop whole api token table (revokes all tokens), which will be recreated on hub start
op.drop_table('api_tokens')
op.drop_table('oauth_clients')
op.drop_table('oauth_codes')

View File

@@ -12,12 +12,11 @@ down_revision = '4dc2d5a8c53c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
from jupyterhub import orm
naming_convention = orm.meta.naming_convention

View File

@@ -11,8 +11,8 @@ down_revision = 'd68c98b66cd4'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from alembic import op
def upgrade():

View File

@@ -12,11 +12,11 @@ branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from datetime import datetime
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column('users', sa.Column('created', sa.DateTime, nullable=True))

Some files were not shown because too many files have changed in this diff Show More