Compare commits

...

35 Commits

Author SHA1 Message Date
Min RK
24506888ea Bump to 3.0.0 2022-09-09 08:32:58 +02:00
Min RK
457ad3ec85 Merge pull request #4029 from consideRatio/pr/update-changelog-for-3.0.0
Update changelog for 3.0.0
2022-09-09 08:16:24 +02:00
Min RK
75e8274a7b date for 3.0.0 2022-09-09 08:16:07 +02:00
Erik Sundell
2a3ceff29f Update changelog for 3.0.0 2022-09-07 09:58:17 +02:00
Erik Sundell
5e29605341 Merge pull request #4018 from minrk/reset-offset
reset offset to 0 on name filter change
2022-09-05 18:46:08 +02:00
Min RK
34b6bc3a3f call reducers in some tests
allows testing reducer functionality

workaround bug preventing mocked useSelector from behaving realistically
2022-09-05 15:09:11 +02:00
Erik Sundell
7a48da1916 Merge pull request #4022 from possiblyMikeB/token-template-correction
Use correct expiration labels in drop-down menu on token page.
2022-08-24 20:01:20 +02:00
possiblyMikeB
5eaf59dd72 correct token expiration time labels 2022-08-23 18:29:15 -04:00
Simon Li
73a33ed5fc Merge pull request #4019 from minrk/sync-groups-repeat
avoid database error on repeated group name in sync_groups
2022-08-19 16:54:06 +01:00
Min RK
0b9ae96a96 avoid database error on repeated group name in sync_groups 2022-08-19 10:53:21 +02:00
Min RK
2c9653bc0d reset offset to 0 on name filter change
move offset to redux state, rather than independent,
since it can come from two places (user_page and pagination footer). Keeps things in sync.

Adds reducers for setting offset, name filter explicitly.
2022-08-19 10:25:17 +02:00
Erik Sundell
71e86f3064 Merge pull request #4016 from minrk/edituser-validate
admin: avoid redundant client-side username validation in edit-user
2022-08-16 14:13:49 +02:00
Min RK
8a1110f2c0 admin: avoid redundant client-side username validation
username validation is the server-side's responsibility
2022-08-16 13:48:36 +02:00
Min RK
bb52351a6e Merge pull request #4013 from minrk/test-311
Test 3.11
2022-08-10 11:36:14 +02:00
Min RK
87c745d3bf mock greenlet needs to raise ImportError 2022-08-10 11:02:53 +02:00
Min RK
374c6c848b it's actually greenlet 2022-08-10 10:45:57 +02:00
Min RK
af31ee8c94 condition brackets
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-08-10 10:28:30 +02:00
Min RK
26a9883b93 add mock-gevent to allow install on Python 3.11
gevent is not actually required, but sqlalchemy lists it as a dependency (on linux only)
2022-08-10 10:08:08 +02:00
Min RK
bda3e0c931 test on Python 3.11.0-rc.1 2022-08-10 10:06:37 +02:00
Min RK
f3d17eb77e Merge pull request #4012 from minrk/doc-oauth-no-confirm
document oauth_no_confirm in services
2022-08-10 09:30:48 +02:00
Erik Sundell
5f92cfcc0e Merge pull request #4011 from minrk/trim-form-input
restore trimming of username input
2022-08-10 09:05:34 +02:00
Min RK
b55eaae51f document oauth_no_confirm in services 2022-08-10 08:57:35 +02:00
Min RK
c9e6d6afa3 restore trimming of username input
continue to not trim password or custom fields

trailing/leading space is explicitly forbidden in validate_username
2022-08-10 08:45:50 +02:00
Erik Sundell
2f1d340c42 Merge pull request #4006 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-08-09 08:09:29 +02:00
pre-commit-ci[bot]
2ba99656c1 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/PyCQA/flake8: 5.0.2 → 5.0.4](https://github.com/PyCQA/flake8/compare/5.0.2...5.0.4)
2022-08-08 22:58:55 +00:00
Erik Sundell
635f63c1cd Merge pull request #4005 from jupyterhub/dependabot/github_actions/docker/build-push-action-3.1.1
Bump docker/build-push-action from 3.1.0 to 3.1.1
2022-08-08 09:50:23 +02:00
dependabot[bot]
b9b49ff306 Bump docker/build-push-action from 3.1.0 to 3.1.1
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](1cb9d22b93...c84f382811)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-08-08 05:29:25 +00:00
Min RK
5640a1506e Merge pull request #4002 from naatebarber/admin-with-pagination-api
Integrate Pagination API into Admin JSX
2022-08-05 12:07:12 +02:00
pre-commit-ci[bot]
4767cfa4e9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-08-04 14:18:27 +00:00
Nathan Barber
309d687c26 Update jsx/src/components/Groups/Groups.jsx
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-08-04 10:17:43 -04:00
Nathan Barber
df25c09962 Update jsx/src/components/PaginationFooter/PaginationFooter.jsx
Co-authored-by: Min RK <benjaminrk@gmail.com>
2022-08-04 10:17:35 -04:00
Nathan Barber
09d0909878 Update unit tests to spec 2022-08-03 12:50:29 -04:00
Nathan Barber
72db4624e0 Move user/group queries from app to component uE's 2022-08-03 12:28:05 -04:00
Nathan Barber
e9eca22e3b add useEffect, new pagination style 2022-08-03 12:18:28 -04:00
Nathan Barber
33d4f382d5 Use data.items to display users 2022-08-03 10:59:38 -04:00
26 changed files with 415 additions and 179 deletions

View File

@@ -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) }}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
__version__ = "22.0.0.dev0"
raise ImportError("Don't actually have greenlet")

View 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"

View File

@@ -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

View File

@@ -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

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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
) {
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)
editUser(
username,
updatedUsername != "" ? updatedUsername : username,
admin
)
.then((data) => {
data.status < 300
? updateUsers(0, limit)

View File

@@ -122,7 +122,6 @@ const GroupEdit = (props) => {
: setErrorAlert(`Failed to edit group.`);
})
.catch(() => {
console.log("outer");
setErrorAlert(`Failed to edit group.`);
});
}}

View File

@@ -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({

View File

@@ -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);
});

View File

@@ -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">

View File

@@ -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>

View File

@@ -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, "");
});

View File

@@ -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,
});

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 = (3, 0, 0, "b1", "")
version_info = (3, 0, 0, "", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -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))

View File

@@ -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)

View File

@@ -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(

View File

@@ -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")]

View File

@@ -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):

View File

@@ -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

View File

@@ -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 %}