mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
24506888ea | ||
![]() |
457ad3ec85 | ||
![]() |
75e8274a7b | ||
![]() |
2a3ceff29f | ||
![]() |
5e29605341 | ||
![]() |
34b6bc3a3f | ||
![]() |
7a48da1916 | ||
![]() |
5eaf59dd72 | ||
![]() |
73a33ed5fc | ||
![]() |
0b9ae96a96 | ||
![]() |
2c9653bc0d | ||
![]() |
71e86f3064 | ||
![]() |
8a1110f2c0 | ||
![]() |
bb52351a6e | ||
![]() |
87c745d3bf | ||
![]() |
374c6c848b | ||
![]() |
af31ee8c94 | ||
![]() |
26a9883b93 | ||
![]() |
bda3e0c931 | ||
![]() |
f3d17eb77e | ||
![]() |
5f92cfcc0e | ||
![]() |
b55eaae51f | ||
![]() |
c9e6d6afa3 | ||
![]() |
2f1d340c42 | ||
![]() |
2ba99656c1 | ||
![]() |
635f63c1cd | ||
![]() |
b9b49ff306 | ||
![]() |
5640a1506e | ||
![]() |
4767cfa4e9 | ||
![]() |
309d687c26 | ||
![]() |
df25c09962 | ||
![]() |
09d0909878 | ||
![]() |
72db4624e0 | ||
![]() |
e9eca22e3b | ||
![]() |
33d4f382d5 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -145,7 +145,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub
|
||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-onbuild
|
||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub-demo
|
||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||
with:
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
branchRegex: ^\w[\w-.]*$
|
||||
|
||||
- name: Build and push jupyterhub/singleuser
|
||||
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||
uses: docker/build-push-action@c84f38281176d4c9cdb1626ffafcd6b3911b5d94
|
||||
with:
|
||||
build-args: |
|
||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||
|
17
.github/workflows/test.yml
vendored
17
.github/workflows/test.yml
vendored
@@ -71,6 +71,8 @@ jobs:
|
||||
# NOTE: Since only the value of these parameters are presented in the
|
||||
# GitHub UI when the workflow run, we avoid using true/false as
|
||||
# values by instead duplicating the name to signal true.
|
||||
# Python versions available at:
|
||||
# https://github.com/actions/python-versions/blob/HEAD/versions-manifest.json
|
||||
include:
|
||||
- python: "3.7"
|
||||
oldest_dependencies: oldest_dependencies
|
||||
@@ -85,10 +87,7 @@ jobs:
|
||||
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.11.0-rc.1"
|
||||
- python: "3.10"
|
||||
main_dependencies: main_dependencies
|
||||
|
||||
@@ -136,9 +135,19 @@ jobs:
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "${{ matrix.python }}"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
|
||||
if [[ "${{ matrix.python }}" == "3.11"* ]]; then
|
||||
# greenlet is not actually required,
|
||||
# but is an install dependency of sqlalchemy.
|
||||
# It does not yet install on 3.11
|
||||
# see: see https://github.com/gevent/gevent/issues/1867
|
||||
pip install ./ci/mock-greenlet
|
||||
fi
|
||||
|
||||
pip install --upgrade . -r dev-requirements.txt
|
||||
|
||||
if [ "${{ matrix.oldest_dependencies }}" != "" ]; then
|
||||
|
@@ -47,6 +47,6 @@ repos:
|
||||
|
||||
# Linting: Python code (see the file .flake8)
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "5.0.2"
|
||||
rev: "5.0.4"
|
||||
hooks:
|
||||
- id: flake8
|
||||
|
3
ci/mock-greenlet/greenlet.py
Normal file
3
ci/mock-greenlet/greenlet.py
Normal file
@@ -0,0 +1,3 @@
|
||||
__version__ = "22.0.0.dev0"
|
||||
|
||||
raise ImportError("Don't actually have greenlet")
|
13
ci/mock-greenlet/pyproject.toml
Normal file
13
ci/mock-greenlet/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "greenlet"
|
||||
description = 'Mock greenlet to allow install on 3.11'
|
||||
requires-python = ">=3.7"
|
||||
dynamic = ["version"]
|
||||
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "greenlet.py"
|
@@ -6,7 +6,7 @@ info:
|
||||
description: The REST API for JupyterHub
|
||||
license:
|
||||
name: BSD-3-Clause
|
||||
version: 3.0.0b1
|
||||
version: 3.0.0
|
||||
servers:
|
||||
- url: /hub/api
|
||||
security:
|
||||
|
File diff suppressed because one or more lines are too long
@@ -38,6 +38,15 @@ A Service may have the following properties:
|
||||
- `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.
|
||||
|
||||
- `oauth_no_confirm: bool (default - False)` - When set to true,
|
||||
skip the OAuth confirmation page when users access this service.
|
||||
|
||||
By default, when users authenticate with a service using JupyterHub,
|
||||
they are prompted to confirm that they want to grant that service
|
||||
access to their credentials.
|
||||
Skipping the confirmation page is useful for admin-managed services that are considered part of the Hub
|
||||
and shouldn't need extra prompts for login.
|
||||
|
||||
If a service is also to be managed by the Hub, it has a few extra options:
|
||||
|
||||
- `command: (str/Popen list)` - Command for JupyterHub to spawn the service. - Only use this if the service should be a subprocess. - If command is not specified, the Service is assumed to be managed
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { createStore } from "redux";
|
||||
import { compose } from "recompose";
|
||||
import { initialState, reducers } from "./Store";
|
||||
import { jhapiRequest } from "./util/jhapiUtil";
|
||||
import withAPI from "./util/withAPI";
|
||||
import { HashRouter, Switch, Route } from "react-router-dom";
|
||||
|
||||
@@ -20,24 +19,6 @@ import "./style/root.css";
|
||||
const store = createStore(reducers, initialState);
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
let { limit, user_page, groups_page } = initialState;
|
||||
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));
|
||||
|
||||
api
|
||||
.updateGroups(groups_page * limit, limit)
|
||||
.then((data) =>
|
||||
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
||||
)
|
||||
.catch((err) => console.log(err));
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="resets">
|
||||
<Provider store={store}>
|
||||
|
@@ -1,23 +1,48 @@
|
||||
export const initialState = {
|
||||
user_data: undefined,
|
||||
user_page: 0,
|
||||
user_page: { offset: 0, limit: window.api_page_limit || 100 },
|
||||
name_filter: "",
|
||||
groups_data: undefined,
|
||||
groups_page: 0,
|
||||
limit: window.api_page_limit,
|
||||
groups_page: { offset: 0, limit: window.api_page_limit || 100 },
|
||||
limit: window.api_page_limit || 100,
|
||||
};
|
||||
|
||||
export const reducers = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
// Updates the client user model data and stores the page
|
||||
case "USER_OFFSET":
|
||||
return Object.assign({}, state, {
|
||||
user_page: Object.assign({}, state.user_page, {
|
||||
offset: action.value.offset,
|
||||
}),
|
||||
});
|
||||
|
||||
case "USER_NAME_FILTER":
|
||||
// set offset to 0 if name filter changed,
|
||||
// otherwise leave it alone
|
||||
const newOffset =
|
||||
action.value.name_filter !== state.name_filter ? 0 : state.name_filter;
|
||||
return Object.assign({}, state, {
|
||||
user_page: Object.assign({}, state.user_page, {
|
||||
offset: newOffset,
|
||||
}),
|
||||
name_filter: action.value.name_filter,
|
||||
});
|
||||
|
||||
case "USER_PAGE":
|
||||
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
|
||||
// Updates the client group user model data and stores the page
|
||||
case "GROUPS_OFFSET":
|
||||
return Object.assign({}, state, {
|
||||
groups_page: Object.assign({}, state.groups_page, {
|
||||
offset: action.value.offset,
|
||||
}),
|
||||
});
|
||||
|
||||
case "GROUPS_PAGE":
|
||||
return Object.assign({}, state, {
|
||||
groups_page: action.value.page,
|
||||
|
@@ -125,38 +125,12 @@ const EditUser = (props) => {
|
||||
if (updatedUsername == "" && admin == has_admin) {
|
||||
noChangeEvent();
|
||||
return;
|
||||
} else if (updatedUsername != "") {
|
||||
if (
|
||||
updatedUsername.length > 2 &&
|
||||
/[!@#$%^&*(),.?":{}|<>]/g.test(updatedUsername) == false
|
||||
) {
|
||||
} else {
|
||||
editUser(
|
||||
username,
|
||||
updatedUsername != "" ? updatedUsername : username,
|
||||
admin
|
||||
)
|
||||
.then((data) => {
|
||||
data.status < 300
|
||||
? updateUsers(0, limit)
|
||||
.then((data) => dispatchPageChange(data, 0))
|
||||
.then(() => history.push("/"))
|
||||
.catch(() =>
|
||||
setErrorAlert(
|
||||
`Could not update users list.`
|
||||
)
|
||||
)
|
||||
: setErrorAlert(`Failed to edit user.`);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to edit user.`);
|
||||
});
|
||||
} else {
|
||||
setErrorAlert(
|
||||
`Failed to edit user. Make sure the username does not contain special characters.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
editUser(username, username, admin)
|
||||
.then((data) => {
|
||||
data.status < 300
|
||||
? updateUsers(0, limit)
|
||||
|
@@ -122,7 +122,6 @@ const GroupEdit = (props) => {
|
||||
: setErrorAlert(`Failed to edit group.`);
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("outer");
|
||||
setErrorAlert(`Failed to edit group.`);
|
||||
});
|
||||
}}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
@@ -6,23 +6,26 @@ import { Link } from "react-router-dom";
|
||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||
|
||||
const Groups = (props) => {
|
||||
var user_data = useSelector((state) => state.user_data),
|
||||
groups_data = useSelector((state) => state.groups_data),
|
||||
var groups_data = useSelector((state) => state.groups_data),
|
||||
groups_page = useSelector((state) => state.groups_page),
|
||||
limit = useSelector((state) => state.limit),
|
||||
dispatch = useDispatch(),
|
||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
||||
dispatch = useDispatch();
|
||||
|
||||
page = isNaN(page) ? 0 : page;
|
||||
var slice = [page * limit, limit];
|
||||
var offset = groups_page ? groups_page.offset : 0;
|
||||
|
||||
const setOffset = (offset) => {
|
||||
dispatch({
|
||||
type: "GROUPS_OFFSET",
|
||||
value: {
|
||||
offset: offset,
|
||||
},
|
||||
});
|
||||
};
|
||||
var limit = groups_page ? groups_page.limit : window.api_page_limit;
|
||||
var total = groups_page ? groups_page.total : undefined;
|
||||
|
||||
var { updateGroups, history } = props;
|
||||
|
||||
if (!groups_data || !user_data) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
const dispatchPageChange = (data, page) => {
|
||||
const dispatchPageUpdate = (data, page) => {
|
||||
dispatch({
|
||||
type: "GROUPS_PAGE",
|
||||
value: {
|
||||
@@ -32,10 +35,14 @@ const Groups = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (groups_page != page) {
|
||||
updateGroups(...slice).then((data) => {
|
||||
dispatchPageChange(data, page);
|
||||
});
|
||||
useEffect(() => {
|
||||
updateGroups(offset, limit).then((data) =>
|
||||
dispatchPageUpdate(data.items, data._pagination)
|
||||
);
|
||||
}, [offset, limit]);
|
||||
|
||||
if (!groups_data || !groups_page) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -59,7 +66,6 @@ const Groups = (props) => {
|
||||
pathname: "/group-edit",
|
||||
state: {
|
||||
group_data: e,
|
||||
user_data: user_data,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -74,11 +80,12 @@ const Groups = (props) => {
|
||||
)}
|
||||
</ul>
|
||||
<PaginationFooter
|
||||
endpoint="/groups"
|
||||
page={page}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
numOffset={slice[0]}
|
||||
numElements={groups_data.length}
|
||||
visible={groups_data.length}
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset >= limit ? offset - limit : 0)}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-footer">
|
||||
@@ -102,8 +109,6 @@ const Groups = (props) => {
|
||||
};
|
||||
|
||||
Groups.propTypes = {
|
||||
user_data: PropTypes.array,
|
||||
groups_data: PropTypes.array,
|
||||
updateUsers: PropTypes.func,
|
||||
updateGroups: PropTypes.func,
|
||||
history: PropTypes.shape({
|
||||
|
@@ -1,53 +1,72 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
import { initialState, reducers } from "../../Store";
|
||||
import Groups from "./Groups";
|
||||
|
||||
jest.mock("react-redux", () => ({
|
||||
...jest.requireActual("react-redux"),
|
||||
useSelector: jest.fn(),
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
var mockAsync = () =>
|
||||
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||
|
||||
var groupsJsx = (callbackSpy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||
<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"]}]'
|
||||
),
|
||||
limit: 10,
|
||||
var mockReducers = jest.fn((state, action) => {
|
||||
if (action.type === "GROUPS_PAGE" && !action.value.data) {
|
||||
// no-op from mock, don't update state
|
||||
return state;
|
||||
}
|
||||
state = reducers(state, action);
|
||||
// mocked useSelector seems to cause a problem
|
||||
// this should get the right state back?
|
||||
// not sure
|
||||
// useSelector.mockImplementation((callback) => callback(state);
|
||||
return state;
|
||||
});
|
||||
|
||||
var mockAppState = () =>
|
||||
Object.assign({}, initialState, {
|
||||
groups_data: [
|
||||
{ kind: "group", name: "testgroup", users: [] },
|
||||
{ kind: "group", name: "testgroup2", users: ["foo", "bar"] },
|
||||
],
|
||||
groups_page: {
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
total: 4,
|
||||
next: {
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useSelector.mockImplementation((callback) => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
useDispatch.mockImplementation(() => {
|
||||
return () => {};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
mockReducers.mockClear();
|
||||
});
|
||||
|
||||
test("Renders", async () => {
|
||||
@@ -88,3 +107,30 @@ test("Renders nothing if required data is not available", async () => {
|
||||
let noShow = screen.getByTestId("no-show");
|
||||
expect(noShow).toBeVisible();
|
||||
});
|
||||
|
||||
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(groupsJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(callbackSpy).toBeCalledWith(0, 2);
|
||||
|
||||
var lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
expect(lastState.groups_page.offset).toEqual(0);
|
||||
expect(lastState.groups_page.limit).toEqual(2);
|
||||
|
||||
let next = screen.getByTestId("paginate-next");
|
||||
fireEvent.click(next);
|
||||
|
||||
lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
expect(lastState.groups_page.offset).toEqual(2);
|
||||
expect(lastState.groups_page.limit).toEqual(2);
|
||||
|
||||
// FIXME: mocked useSelector, state seem to prevent updateGroups from being called
|
||||
// making the test environment not representative
|
||||
// expect(callbackSpy).toHaveBeenCalledWith(2, 2);
|
||||
});
|
||||
|
@@ -1,33 +1,40 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "./pagination-footer.css";
|
||||
|
||||
const PaginationFooter = (props) => {
|
||||
let { endpoint, page, limit, numOffset, numElements } = props;
|
||||
let { offset, limit, visible, total, next, prev } = props;
|
||||
return (
|
||||
<div className="pagination-footer">
|
||||
<p>
|
||||
Displaying {numOffset}-{numOffset + numElements}
|
||||
Displaying {offset}-{offset + visible}
|
||||
<br></br>
|
||||
<br></br>
|
||||
{page >= 1 ? (
|
||||
{offset >= 1 ? (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
<Link to={`${endpoint}?page=${page - 1}`}>
|
||||
<span className="active-pagination">Previous</span>
|
||||
</Link>
|
||||
<span
|
||||
className="active-pagination"
|
||||
data-testid="paginate-prev"
|
||||
onClick={prev}
|
||||
>
|
||||
Previous
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
<span className="inactive-pagination">Previous</span>
|
||||
</button>
|
||||
)}
|
||||
{numElements >= limit ? (
|
||||
{offset + visible < total ? (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
<Link to={`${endpoint}?page=${page + 1}`}>
|
||||
<span className="active-pagination">Next</span>
|
||||
</Link>
|
||||
<span
|
||||
className="active-pagination"
|
||||
data-testid="paginate-next"
|
||||
onClick={next}
|
||||
>
|
||||
Next
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { debounce } from "lodash";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import {
|
||||
@@ -50,16 +50,15 @@ const ServerDashboard = (props) => {
|
||||
var [errorAlert, setErrorAlert] = useState(null);
|
||||
var [sortMethod, setSortMethod] = useState(null);
|
||||
var [disabledButtons, setDisabledButtons] = useState({});
|
||||
const [collapseStates, setCollapseStates] = useState({});
|
||||
var [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"));
|
||||
name_filter = useSelector((state) => state.name_filter);
|
||||
|
||||
page = isNaN(page) ? 0 : page;
|
||||
var slice = [page * limit, limit, name_filter];
|
||||
var offset = user_page ? user_page.offset : 0;
|
||||
var limit = user_page ? user_page.limit : window.api_page_limit;
|
||||
var total = user_page ? user_page.total : undefined;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -73,33 +72,48 @@ const ServerDashboard = (props) => {
|
||||
history,
|
||||
} = props;
|
||||
|
||||
var dispatchPageUpdate = (data, page, name_filter) => {
|
||||
const dispatchPageUpdate = (data, page) => {
|
||||
dispatch({
|
||||
type: "USER_PAGE",
|
||||
value: {
|
||||
data: data,
|
||||
page: page,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setOffset = (newOffset) => {
|
||||
dispatch({
|
||||
type: "USER_OFFSET",
|
||||
value: {
|
||||
offset: newOffset,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setNameFilter = (name_filter) => {
|
||||
dispatch({
|
||||
type: "USER_NAME_FILTER",
|
||||
value: {
|
||||
name_filter: name_filter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!user_data) {
|
||||
useEffect(() => {
|
||||
updateUsers(offset, limit, name_filter)
|
||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
||||
.catch((err) => setErrorAlert("Failed to update user list."));
|
||||
}, [offset, limit, name_filter]);
|
||||
|
||||
if (!user_data || !user_page) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
if (page != user_page) {
|
||||
updateUsers(...slice).then((data) =>
|
||||
dispatchPageUpdate(data, page, name_filter)
|
||||
);
|
||||
}
|
||||
var slice = [offset, limit, 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)
|
||||
);
|
||||
setNameFilter(event.target.value);
|
||||
}, 300);
|
||||
|
||||
if (sortMethod != null) {
|
||||
@@ -119,7 +133,11 @@ const ServerDashboard = (props) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsDisabled(false);
|
||||
@@ -155,7 +173,11 @@ const ServerDashboard = (props) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorAlert(`Failed to update users list.`);
|
||||
@@ -457,7 +479,11 @@ const ServerDashboard = (props) => {
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
@@ -493,7 +519,11 @@ const ServerDashboard = (props) => {
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data, page, name_filter);
|
||||
dispatchPageUpdate(
|
||||
data.items,
|
||||
data._pagination,
|
||||
name_filter
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setErrorAlert(`Failed to update users list.`)
|
||||
@@ -521,11 +551,12 @@ const ServerDashboard = (props) => {
|
||||
</tbody>
|
||||
</table>
|
||||
<PaginationFooter
|
||||
endpoint="/"
|
||||
page={page}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
numOffset={slice[0]}
|
||||
numElements={user_data.length}
|
||||
visible={user_data.length}
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset - limit)}
|
||||
/>
|
||||
<br></br>
|
||||
</div>
|
||||
|
@@ -10,6 +10,7 @@ import { createStore } from "redux";
|
||||
import regeneratorRuntime from "regenerator-runtime";
|
||||
|
||||
import ServerDashboard from "./ServerDashboard";
|
||||
import { initialState, reducers } from "../../Store";
|
||||
import * as sinon from "sinon";
|
||||
|
||||
let clock;
|
||||
@@ -20,7 +21,7 @@ jest.mock("react-redux", () => ({
|
||||
}));
|
||||
|
||||
var serverDashboardJsx = (spy) => (
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
@@ -42,10 +43,67 @@ var mockAsync = (data) =>
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
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 mockAppState = () =>
|
||||
Object.assign({}, initialState, {
|
||||
user_data: [
|
||||
{
|
||||
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: {},
|
||||
},
|
||||
],
|
||||
user_page: {
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
total: 4,
|
||||
next: {
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var mockReducers = jest.fn((state, action) => {
|
||||
if (action.type === "USER_PAGE" && !action.value.data) {
|
||||
// no-op from mock, don't update state
|
||||
return state;
|
||||
}
|
||||
state = reducers(state, action);
|
||||
// mocked useSelector seems to cause a problem
|
||||
// this should get the right state back?
|
||||
// not sure
|
||||
// useSelector.mockImplementation((callback) => callback(state);
|
||||
return state;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -57,6 +115,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useSelector.mockClear();
|
||||
mockReducers.mockClear();
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
@@ -498,11 +557,22 @@ test("Shows a UI error dialogue when stop user server returns an improper status
|
||||
test("Search for user calls updateUsers with name filter", async () => {
|
||||
let spy = mockAsync();
|
||||
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||
return Promise.resolve([]);
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
_pagination: {
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
total: offset + limit * 2,
|
||||
next: {
|
||||
offset: offset + limit,
|
||||
limit: limit,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
render(
|
||||
<Provider store={createStore(() => {}, {})}>
|
||||
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<ServerDashboard
|
||||
@@ -521,15 +591,56 @@ test("Search for user calls updateUsers with name filter", async () => {
|
||||
|
||||
let search = screen.getByLabelText("user-search");
|
||||
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
|
||||
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);
|
||||
|
||||
expect(mockReducers.mock.calls).toHaveLength(3);
|
||||
var lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
expect(lastState.name_filter).toEqual("a");
|
||||
// TODO: this should
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
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);
|
||||
expect(mockReducers.mock.calls).toHaveLength(4);
|
||||
lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
expect(lastState.name_filter).toEqual("ab");
|
||||
expect(lastState.user_page.offset).toEqual(0);
|
||||
});
|
||||
|
||||
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||
let callbackSpy = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx(callbackSpy));
|
||||
});
|
||||
|
||||
expect(callbackSpy).toBeCalledWith(0, 2, "");
|
||||
|
||||
expect(mockReducers.mock.results).toHaveLength(2);
|
||||
lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
console.log(lastState);
|
||||
expect(lastState.user_page.offset).toEqual(0);
|
||||
expect(lastState.user_page.limit).toEqual(2);
|
||||
|
||||
let next = screen.getByTestId("paginate-next");
|
||||
fireEvent.click(next);
|
||||
clock.tick(400);
|
||||
|
||||
expect(mockReducers.mock.results).toHaveLength(3);
|
||||
var lastState =
|
||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||
expect(lastState.user_page.offset).toEqual(2);
|
||||
expect(lastState.user_page.limit).toEqual(2);
|
||||
|
||||
// FIXME: should call updateUsers, does in reality.
|
||||
// tests don't reflect reality due to mocked state/useSelector
|
||||
// unclear how to fix this.
|
||||
// expect(callbackSpy.mock.calls).toHaveLength(2);
|
||||
// expect(callbackSpy).toHaveBeenCalledWith(2, 2, "");
|
||||
});
|
||||
|
@@ -6,6 +6,7 @@ export const jhapiRequest = (endpoint, method, data) => {
|
||||
json: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/jupyterhub-pagination+json",
|
||||
},
|
||||
body: data ? JSON.stringify(data) : null,
|
||||
});
|
||||
|
@@ -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 = (3, 0, 0, "b1", "")
|
||||
version_info = (3, 0, 0, "", "")
|
||||
|
||||
# pep 440 version: no dot before beta/rc, but before .dev
|
||||
# 0.1.0rc1
|
||||
|
@@ -256,6 +256,9 @@ class Authenticator(LoggingConfigurable):
|
||||
if not username:
|
||||
# empty usernames are not allowed
|
||||
return False
|
||||
if username != username.strip():
|
||||
# starting/ending with space is not allowed
|
||||
return False
|
||||
if not self.username_regex:
|
||||
return True
|
||||
return bool(self.username_regex.match(username))
|
||||
|
@@ -145,7 +145,9 @@ class LoginHandler(BaseHandler):
|
||||
# parse the arguments dict
|
||||
data = {}
|
||||
for arg in self.request.arguments:
|
||||
data[arg] = self.get_argument(arg, strip=False)
|
||||
# strip username, but not other fields like passwords,
|
||||
# which should be allowed to start or end with space
|
||||
data[arg] = self.get_argument(arg, strip=arg == "username")
|
||||
|
||||
auth_timer = self.statsd.timer('login.authenticate').start()
|
||||
user = await self.login_user(data)
|
||||
|
@@ -740,9 +740,17 @@ async def test_login_fail(app):
|
||||
assert not r.cookies
|
||||
|
||||
|
||||
async def test_login_strip(app):
|
||||
"""Test that login form doesn't strip whitespace from passwords"""
|
||||
form_data = {'username': 'spiff', 'password': ' space man '}
|
||||
@pytest.mark.parametrize(
|
||||
"form_user, auth_user, form_password",
|
||||
[
|
||||
("spiff", "spiff", " space man "),
|
||||
(" spiff ", "spiff", " space man "),
|
||||
],
|
||||
)
|
||||
async def test_login_strip(app, form_user, auth_user, form_password):
|
||||
"""Test that login form strips space form usernames, but not passwords"""
|
||||
form_data = {"username": form_user, "password": form_password}
|
||||
expected_auth = {"username": auth_user, "password": form_password}
|
||||
base_url = public_url(app)
|
||||
called_with = []
|
||||
|
||||
@@ -754,7 +762,7 @@ async def test_login_strip(app):
|
||||
base_url + 'hub/login', data=form_data, allow_redirects=False
|
||||
)
|
||||
|
||||
assert called_with == [form_data]
|
||||
assert called_with == [expected_auth]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@@ -29,12 +29,12 @@ async def test_userdict_get(db, attr):
|
||||
["isin1", "isin2"],
|
||||
["isin1"],
|
||||
["notin", "isin1"],
|
||||
["new-group", "isin1"],
|
||||
["new-group", "new-group", "isin1"],
|
||||
[],
|
||||
],
|
||||
)
|
||||
def test_sync_groups(app, user, group_names):
|
||||
expected = sorted(group_names)
|
||||
expected = sorted(set(group_names))
|
||||
db = app.db
|
||||
db.add(orm.Group(name="notin"))
|
||||
in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")]
|
||||
|
@@ -310,19 +310,19 @@ class User:
|
||||
return
|
||||
|
||||
# log group changes
|
||||
new_groups = set(group_names).difference(current_groups)
|
||||
added_groups = new_groups.difference(current_groups)
|
||||
removed_groups = current_groups.difference(group_names)
|
||||
if new_groups:
|
||||
self.log.info(f"Adding user {self.name} to group(s): {new_groups}")
|
||||
if added_groups:
|
||||
self.log.info(f"Adding user {self.name} to group(s): {added_groups}")
|
||||
if removed_groups:
|
||||
self.log.info(f"Removing user {self.name} from group(s): {removed_groups}")
|
||||
|
||||
if group_names:
|
||||
groups = (
|
||||
self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all()
|
||||
self.db.query(orm.Group).filter(orm.Group.name.in_(new_groups)).all()
|
||||
)
|
||||
existing_groups = {g.name for g in groups}
|
||||
for group_name in group_names:
|
||||
for group_name in added_groups:
|
||||
if group_name not in existing_groups:
|
||||
# create groups that don't exist yet
|
||||
self.log.info(
|
||||
@@ -331,9 +331,9 @@ class User:
|
||||
group = orm.Group(name=group_name)
|
||||
self.db.add(group)
|
||||
groups.append(group)
|
||||
self.groups = groups
|
||||
self.orm_user.groups = groups
|
||||
else:
|
||||
self.groups = []
|
||||
self.orm_user.groups = []
|
||||
self.db.commit()
|
||||
|
||||
async def save_auth_state(self, auth_state):
|
||||
|
@@ -17,7 +17,7 @@ target_version = [
|
||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||
|
||||
[tool.tbump.version]
|
||||
current = "3.0.0b1"
|
||||
current = "3.0.0"
|
||||
|
||||
# Example of a semver regexp.
|
||||
# Make sure this matches current_version before
|
||||
|
@@ -24,9 +24,10 @@
|
||||
{% block expiration_options %}
|
||||
<select id="token-expiration-seconds"
|
||||
class="form-control">
|
||||
<option value="3600">1 Day</option>
|
||||
<option value="86400">1 Week</option>
|
||||
<option value="604800">1 Month</option>
|
||||
<!-- unit used for each value is `seconds` -->
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400">1 Day</option>
|
||||
<option value="604800">1 Week</option>
|
||||
<option value="" selected="selected">Never</option>
|
||||
</select>
|
||||
{% endblock expiration_options %}
|
||||
|
Reference in New Issue
Block a user