Merge pull request #4774 from minrk/bs5

update bootstrap to v5
This commit is contained in:
Min RK
2024-04-18 12:10:55 +02:00
committed by GitHub
43 changed files with 1065 additions and 930 deletions

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@ jupyterhub_cookie_secret
jupyterhub.sqlite jupyterhub.sqlite
jupyterhub.sqlite* jupyterhub.sqlite*
share/jupyterhub/static/components share/jupyterhub/static/components
share/jupyterhub/static/css/style.css
share/jupyterhub/static/css/style.css.map
share/jupyterhub/static/css/style.min.css share/jupyterhub/static/css/style.min.css
share/jupyterhub/static/css/style.min.css.map share/jupyterhub/static/css/style.min.css.map
share/jupyterhub/static/js/admin-react.js* share/jupyterhub/static/js/admin-react.js*

View File

@@ -67,10 +67,10 @@ a more detailed discussion.
This should return a version number greater than or equal to 5.0. This should return a version number greater than or equal to 5.0.
3. Install `configurable-http-proxy` (required to run and test the default JupyterHub configuration) and `yarn` (required to build some components): 3. Install `configurable-http-proxy` (required to run and test the default JupyterHub configuration):
```bash ```bash
npm install -g configurable-http-proxy yarn npm install -g configurable-http-proxy
``` ```
If you get an error that says `Error: EACCES: permission denied`, you might need to prefix the command with `sudo`. If you get an error that says `Error: EACCES: permission denied`, you might need to prefix the command with `sudo`.
@@ -78,7 +78,7 @@ a more detailed discussion.
If you do not have access to sudo, you may instead run the following commands: If you do not have access to sudo, you may instead run the following commands:
```bash ```bash
npm install configurable-http-proxy yarn npm install configurable-http-proxy
export PATH=$PATH:$(pwd)/node_modules/.bin export PATH=$PATH:$(pwd)/node_modules/.bin
``` ```
@@ -87,7 +87,7 @@ a more detailed discussion.
If you are using conda you can instead run: If you are using conda you can instead run:
```bash ```bash
conda install configurable-http-proxy yarn conda install configurable-http-proxy
``` ```
4. Install an editable version of JupyterHub and its requirements for 4. Install an editable version of JupyterHub and its requirements for
@@ -123,6 +123,14 @@ configuration:
jupyterhub -f testing/jupyterhub_config.py jupyterhub -f testing/jupyterhub_config.py
``` ```
The test configuration enables a few things to make testing easier:
- use 'dummy' authentication and 'simple' spawner
- named servers are enabled
- listen only on localhost
- 'admin' is an admin user, if you want to test the admin page
- disable caching of static files
The default JupyterHub [authenticator](PAMAuthenticator) The default JupyterHub [authenticator](PAMAuthenticator)
& [spawner](LocalProcessSpawner) & [spawner](LocalProcessSpawner)
require your system to have user accounts for each user you want to log in to require your system to have user accounts for each user you want to log in to
@@ -139,6 +147,29 @@ SimpleLocalProcessSpawner. If you are working on just authenticator-related
parts, use only SimpleLocalProcessSpawner. Similarly, if you are working on parts, use only SimpleLocalProcessSpawner. Similarly, if you are working on
just spawner-related parts, use only DummyAuthenticator. just spawner-related parts, use only DummyAuthenticator.
## Building frontend components
The testing configuration file also disables caching of static files,
which allows you to edit and rebuild these files without restarting JupyterHub.
If you are working on the admin react page, which is in the `jsx` directory, you can run:
```bash
cd jsx
npm install
npm run build:watch
```
to continuously rebuild the admin page, requiring only a refresh of the page.
If you are working on the frontend SCSS files, you can run the same `build:watch` command
in the _top level_ directory of the repo:
```bash
npm install
npm run build:watch
```
## Troubleshooting ## Troubleshooting
This section lists common ways setting up your development environment may This section lists common ways setting up your development environment may

1
jsx/.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules node_modules
build/admin-react.js build/admin-react.js
.yarn

View File

@@ -1,7 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Button, Col } from "react-bootstrap";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import ErrorAlert from "../../util/error";
const AddUser = (props) => { const AddUser = (props) => {
const [users, setUsers] = useState([]), const [users, setUsers] = useState([]),
@@ -27,31 +29,14 @@ const AddUser = (props) => {
return ( return (
<> <>
<div className="container" data-testid="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( <ErrorAlert errorAlert={errorAlert} setErrorAlert={setErrorAlert} />
<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="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <Col md={{ span: 10, offset: 1 }} lg={{ span: 8, offset: 2 }}>
<div className="panel panel-default"> <div className="card">
<div className="panel-heading"> <div className="card-header">
<h4>Add Users</h4> <h4>Add Users</h4>
</div> </div>
<div className="panel-body"> <div className="card-body">
<form> <form>
<div className="form-group"> <div className="form-group">
<textarea <textarea
@@ -82,15 +67,17 @@ const AddUser = (props) => {
</div> </div>
</form> </form>
</div> </div>
<div className="panel-footer"> <div className="card-footer">
<button id="return" className="btn btn-light"> <Link to="/">
<Link to="/">Back</Link> <Button variant="light" id="return">
</button> Back
</Button>
</Link>
<span> </span> <span> </span>
<button <Button
id="submit" id="submit"
data-testid="submit" data-testid="submit"
className="btn btn-primary" variant="primary"
onClick={() => { onClick={() => {
addUsers(users, admin) addUsers(users, admin)
.then((data) => .then((data) =>
@@ -111,10 +98,10 @@ const AddUser = (props) => {
}} }}
> >
Add Users Add Users
</button> </Button>
</div> </div>
</div> </div>
</div> </Col>
</div> </div>
</div> </div>
</> </>

View File

@@ -1,7 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Button, Card } from "react-bootstrap";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { MainContainer } from "../../util/layout";
const CreateGroup = (props) => { const CreateGroup = (props) => {
const [groupName, setGroupName] = useState(""), const [groupName, setGroupName] = useState(""),
@@ -24,85 +26,61 @@ const CreateGroup = (props) => {
const { createGroup, updateGroups } = props; const { createGroup, updateGroups } = props;
return ( return (
<> <MainContainer errorAlert={errorAlert} setErrorAlert={setErrorAlert}>
<div className="container" data-testid="container"> <Card>
{errorAlert != null ? ( <Card.Header>
<div className="row"> <h4>Create Group</h4>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> </Card.Header>
<div className="alert alert-danger"> <Card.Body>
{errorAlert} <div className="input-group">
<button <input
type="button" className="group-name-input"
className="close" data-testid="group-input"
onClick={() => setErrorAlert(null)} type="text"
> id="group-name"
<span>&times;</span> value={groupName}
</button> placeholder="group name..."
</div> onChange={(e) => {
</div> setGroupName(e.target.value.trim());
}}
></input>
</div> </div>
) : ( </Card.Body>
<></> <Card.Footer>
)} <Link to="/groups">
<div className="row"> <Button variant="light" id="return">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> Back
<div className="panel panel-default"> </Button>
<div className="panel-heading"> </Link>
<h4>Create Group</h4> <span> </span>
</div> <Button
<div className="panel-body"> id="submit"
<div className="input-group"> data-testid="submit"
<input variant="primary"
className="group-name-input" onClick={() => {
data-testid="group-input" createGroup(groupName)
type="text" .then((data) => {
id="group-name" return data.status < 300
value={groupName} ? updateGroups(0, limit)
placeholder="group name..." .then((data) => dispatchPageUpdate(data, 0))
onChange={(e) => { .then(() => navigate("/groups"))
setGroupName(e.target.value.trim()); .catch(() =>
}} setErrorAlert(`Could not update groups list.`),
></input> )
</div> : setErrorAlert(
</div> `Failed to create group. ${
<div className="panel-footer"> data.status == 409 ? "Group already exists." : ""
<button id="return" className="btn btn-light"> }`,
<Link to="/">Back</Link> );
</button> })
<span> </span> .catch(() => setErrorAlert(`Failed to create group.`));
<button }}
id="submit" >
data-testid="submit" Create
className="btn btn-primary" </Button>
onClick={() => { </Card.Footer>
createGroup(groupName) </Card>
.then((data) => { </MainContainer>
return data.status < 300
? updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => navigate("/groups"))
.catch(() =>
setErrorAlert(`Could not update groups list.`),
)
: setErrorAlert(
`Failed to create group. ${
data.status == 409
? "Group already exists."
: ""
}`,
);
})
.catch(() => setErrorAlert(`Failed to create group.`));
}}
>
Create
</button>
</div>
</div>
</div>
</div>
</div>
</>
); );
}; };

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import "./table-select.css"; import "./table-select.css";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
const DynamicTable = (props) => { const DynamicTable = (props) => {
var [message, setMessage] = useState(""), var [message, setMessage] = useState(""),
@@ -94,8 +95,8 @@ const DynamicTable = (props) => {
/> />
</td> </td>
<td> <td>
<button <Button
className="form-control btn btn-default" variant="danger"
onClick={() => { onClick={() => {
propvalues.splice(i, 1); propvalues.splice(i, 1);
propkeys.splice(i, 1); propkeys.splice(i, 1);
@@ -110,7 +111,7 @@ const DynamicTable = (props) => {
}} }}
> >
Delete Delete
</button> </Button>
</td> </td>
</tr> </tr>
); );
@@ -150,15 +151,14 @@ const DynamicTable = (props) => {
/> />
</td> </td>
<td> <td>
<button <Button
id="add-item" id="add-item"
data-testid="add-item" data-testid="add-item"
className="form-control btn btn-default" className="text-nowrap"
type="button"
onClick={() => handleAddItem()} onClick={() => handleAddItem()}
> >
Add Item Add Item
</button> </Button>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,14 +1,12 @@
@import url(../../style/root.css); @import url(../../style/root.css);
.properties-table { .properties-table {
width: 95%;
position: relative; position: relative;
padding: 5px; padding: 5px;
overflow-x: scroll; overflow-x: scroll;
} }
.properties-table-keyvalues { .properties-table-keyvalues {
width: 95%;
position: relative; position: relative;
padding: 5px; padding: 5px;
overflow-x: scroll; overflow-x: scroll;

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, Card } from "react-bootstrap";
import { MainContainer } from "../../util/layout";
const EditUser = (props) => { const EditUser = (props) => {
const limit = useSelector((state) => state.limit), const limit = useSelector((state) => state.limit),
@@ -39,129 +41,103 @@ const EditUser = (props) => {
[admin, setAdmin] = useState(has_admin); [admin, setAdmin] = useState(has_admin);
return ( return (
<> <MainContainer errorAlert={errorAlert} setErrorAlert={setErrorAlert}>
<div className="container" data-testid="container"> <Card>
{errorAlert != null ? ( <Card.Header>
<div className="row"> <h1>Editing user {username}</h1>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> </Card.Header>
<div className="alert alert-danger"> <Card.Body>
{errorAlert} <form>
<button <div className="form-group">
type="button" <textarea
className="close" className="form-control"
onClick={() => setErrorAlert(null)} data-testid="edit-username-input"
> id="exampleFormControlTextarea1"
<span>&times;</span> rows="3"
</button> placeholder="updated username"
</div> onBlur={(e) => {
setUpdatedUsername(e.target.value);
}}
></textarea>
<br></br>
<input
className="form-check-input"
checked={admin}
type="checkbox"
id="admin-check"
onChange={() => setAdmin(!admin)}
/>
<span> </span>
<label className="form-check-label">Admin</label>
</div> </div>
</div> </form>
) : ( </Card.Body>
<></> <Card.Footer>
)} <Link to="/">
<div className="row"> <Button variant="light">Back</Button>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> </Link>
<div className="panel panel-default"> <span> </span>
<div className="panel-heading"> <Button
<h4>Editing user {username}</h4> id="submit"
</div> data-testid="submit"
<div className="panel-body"> variant="primary"
<form> onClick={(e) => {
<div className="form-group"> e.preventDefault();
<textarea if (updatedUsername == "" && admin == has_admin) {
className="form-control" noChangeEvent();
data-testid="edit-username-input" return;
id="exampleFormControlTextarea1" } else {
rows="3" editUser(
placeholder="updated username" username,
onBlur={(e) => { updatedUsername != "" ? updatedUsername : username,
setUpdatedUsername(e.target.value); admin,
}} )
></textarea> .then((data) => {
<br></br> data.status < 300
<input ? updateUsers(0, limit)
className="form-check-input" .then((data) => dispatchPageChange(data, 0))
checked={admin} .then(() => navigate("/"))
type="checkbox" .catch(() =>
id="admin-check" setErrorAlert(`Could not update users list.`),
onChange={() => setAdmin(!admin)} )
/> : setErrorAlert(`Failed to edit user.`);
<span> </span> })
<label className="form-check-label">Admin</label> .catch(() => {
<br></br> setErrorAlert(`Failed to edit user.`);
<button });
id="delete-user" }
data-testid="delete-user" }}
className="btn btn-danger btn-sm" >
onClick={(e) => { Apply
e.preventDefault(); </Button>
deleteUser(username) <Button
.then((data) => { id="delete-user"
data.status < 300 data-testid="delete-user"
? updateUsers(0, limit) variant="danger"
.then((data) => dispatchPageChange(data, 0)) className="float-end"
.then(() => navigate("/")) onClick={(e) => {
.catch(() => e.preventDefault();
setErrorAlert( deleteUser(username)
`Could not update users list.`, .then((data) => {
), data.status < 300
) ? updateUsers(0, limit)
: setErrorAlert(`Failed to edit user.`); .then((data) => dispatchPageChange(data, 0))
}) .then(() => navigate("/"))
.catch(() => { .catch(() =>
setErrorAlert(`Failed to edit user.`); setErrorAlert(`Could not update users list.`),
}); )
}} : setErrorAlert(`Failed to edit user.`);
> })
Delete user .catch(() => {
</button> setErrorAlert(`Failed to edit user.`);
</div> });
</form> }}
</div> >
<div className="panel-footer"> Delete user
<button className="btn btn-light"> </Button>
<Link to="/">Back</Link> </Card.Footer>
</button> </Card>
<span> </span> </MainContainer>
<button
id="submit"
data-testid="submit"
className="btn btn-primary"
onClick={(e) => {
e.preventDefault();
if (updatedUsername == "" && admin == has_admin) {
noChangeEvent();
return;
} else {
editUser(
username,
updatedUsername != "" ? updatedUsername : username,
admin,
)
.then((data) => {
data.status < 300
? updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => navigate("/"))
.catch(() =>
setErrorAlert(`Could not update users list.`),
)
: setErrorAlert(`Failed to edit user.`);
})
.catch(() => {
setErrorAlert(`Failed to edit user.`);
});
}
}}
>
Apply
</button>
</div>
</div>
</div>
</div>
</div>
</>
); );
}; };

View File

@@ -2,8 +2,10 @@ import React, { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { Link, useNavigate, useLocation } from "react-router-dom"; import { Link, useNavigate, useLocation } from "react-router-dom";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button, Card } from "react-bootstrap";
import GroupSelect from "../GroupSelect/GroupSelect"; import GroupSelect from "../GroupSelect/GroupSelect";
import DynamicTable from "../DynamicTable/DynamicTable"; import DynamicTable from "../DynamicTable/DynamicTable";
import { MainContainer } from "../../util/layout";
const GroupEdit = (props) => { const GroupEdit = (props) => {
const [selected, setSelected] = useState([]), const [selected, setSelected] = useState([]),
@@ -34,8 +36,6 @@ const GroupEdit = (props) => {
validateUser, validateUser,
} = props; } = props;
console.log("group edit", location, location.state);
useEffect(() => { useEffect(() => {
if (!location.state) { if (!location.state) {
navigate("/groups"); navigate("/groups");
@@ -49,47 +49,26 @@ const GroupEdit = (props) => {
const [propvalues, setPropValues] = useState([]); const [propvalues, setPropValues] = useState([]);
return ( return (
<div className="container" data-testid="container"> <MainContainer errorAlert={errorAlert} setErrorAlert={setErrorAlert}>
{errorAlert != null ? ( <h1>Editing Group {group_data.name}</h1>
<div className="row"> <Card>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <Card.Header>
<div className="alert alert-danger"> <h2>Manage group members</h2>
{errorAlert} </Card.Header>
<button <Card.Body>
type="button" <GroupSelect
className="close" users={group_data.users}
onClick={() => setErrorAlert(null)} validateUser={validateUser}
> onChange={(selection) => {
<span>&times;</span> setSelected(selection);
</button> setChanged(true);
</div> }}
</div> />
</div> </Card.Body>
) : ( <Card.Header>
<></> <h2>Manage group properties</h2>
)} </Card.Header>
<div className="row"> <Card.Body>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<h3>Editing Group {group_data.name}</h3>
<br></br>
<div className="alert alert-info">Manage group members</div>
</div>
</div>
<GroupSelect
users={group_data.users}
validateUser={validateUser}
onChange={(selection) => {
setSelected(selection);
setChanged(true);
}}
/>
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-info">Manage group properties</div>
</div>
</div>
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<DynamicTable <DynamicTable
current_propobject={group_data.properties} current_propobject={group_data.properties}
setProp={setProp} setProp={setProp}
@@ -98,19 +77,21 @@ const GroupEdit = (props) => {
//Add keys //Add keys
/> />
</div> <div>
</div> <span id="error"></span>
</div>
<div className="row"> </Card.Body>
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2"> <Card.Footer>
<button id="return" className="btn btn-light"> <Link to="/groups">
<Link to="/groups">Back</Link> <Button variant="light" id="return">
</button> Back
</Button>
</Link>
<span> </span> <span> </span>
<button <Button
id="submit" id="submit"
data-testid="submit" data-testid="submit"
className="btn btn-primary" variant="primary"
onClick={() => { onClick={() => {
// check for changes // check for changes
let new_users = selected.filter( let new_users = selected.filter(
@@ -158,15 +139,12 @@ const GroupEdit = (props) => {
}} }}
> >
Apply Apply
</button> </Button>
<div> <Button
<span id="error"></span>
</div>
<button
id="delete-group" id="delete-group"
data-testid="delete-group" data-testid="delete-group"
className="btn btn-danger" variant="danger"
style={{ float: "right" }} className="float-end"
onClick={() => { onClick={() => {
var groupName = group_data.name; var groupName = group_data.name;
deleteGroup(groupName) deleteGroup(groupName)
@@ -182,12 +160,13 @@ const GroupEdit = (props) => {
}} }}
> >
Delete Group Delete Group
</button> </Button>
<br></br> <div>
<br></br> <span id="error"></span>
</div> </div>
</div> </Card.Footer>
</div> </Card>
</MainContainer>
); );
}; };

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button } from "react-bootstrap";
import "./group-select.css"; import "./group-select.css";
const GroupSelect = (props) => { const GroupSelect = (props) => {
@@ -12,92 +13,80 @@ const GroupSelect = (props) => {
if (!users) return null; if (!users) return null;
return ( return (
<div className="row"> <>
{error != null ? ( {error != null ? (
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left"> <div className="alert alert-danger">{error}</div>
<div className="alert alert-danger">{error}</div>
</div>
) : ( ) : (
<></> <></>
)} )}
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left"> <div className="input-group">
<div className="input-group"> <input
<input id="username-input"
id="username-input" data-testid="username-input"
data-testid="username-input" type="text"
type="text" className="form-control"
className="form-control" placeholder="Add by username"
placeholder="Add by username" value={username}
value={username} onChange={(e) => {
onChange={(e) => { setUsername(e.target.value);
setUsername(e.target.value); }}
}} />
/> <Button
<span className="input-group-btn"> id="validate-user"
<button data-testid="validate-user"
id="validate-user" onClick={() => {
data-testid="validate-user" validateUser(username).then((exists) => {
className="btn btn-default" if (exists && !selected.includes(username)) {
type="button" let updated_selection = selected.concat([username]);
onChange(updated_selection, users);
setUsername("");
setSelected(updated_selection);
if (error != null) setError(null);
} else if (!exists) {
setError(`"${username}" is not a valid JupyterHub user.`);
}
});
}}
>
Add user
</Button>
</div>
<div className="users-container">
<hr></hr>
<div>
{selected.map((e, i) => (
<div
key={"selected" + i}
className="item selected"
onClick={() => { onClick={() => {
validateUser(username).then((exists) => { let updated_selection = selected
if (exists && !selected.includes(username)) { .slice(0, i)
let updated_selection = selected.concat([username]); .concat(selected.slice(i + 1));
onChange(updated_selection, users); onChange(updated_selection, users);
setUsername(""); setSelected(updated_selection);
setSelected(updated_selection);
if (error != null) setError(null);
} else if (!exists) {
setError(`"${username}" is not a valid JupyterHub user.`);
}
});
}} }}
> >
Add user {e}
</button> </div>
</span> ))}
</div> {users.map((e, i) =>
</div> selected.includes(e) ? undefined : (
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2 text-left">
<div className="users-container">
<hr></hr>
<div>
{selected.map((e, i) => (
<div <div
key={"selected" + i} key={"unselected" + i}
className="item selected" className="item unselected"
onClick={() => { onClick={() => {
let updated_selection = selected let updated_selection = selected.concat([e]);
.slice(0, i)
.concat(selected.slice(i + 1));
onChange(updated_selection, users); onChange(updated_selection, users);
setSelected(updated_selection); setSelected(updated_selection);
}} }}
> >
{e} {e}
</div> </div>
))} ),
{users.map((e, i) => )}
selected.includes(e) ? undefined : (
<div
key={"unselected" + i}
className="item unselected"
onClick={() => {
let updated_selection = selected.concat([e]);
onChange(updated_selection, users);
setSelected(updated_selection);
}}
>
{e}
</div>
),
)}
</div>
</div> </div>
<br></br>
<br></br>
</div> </div>
</div> </>
); );
}; };

View File

@@ -2,9 +2,11 @@ import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button, Card } from "react-bootstrap";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { usePaginationParams } from "../../util/paginationParams"; import { usePaginationParams } from "../../util/paginationParams";
import PaginationFooter from "../PaginationFooter/PaginationFooter"; import PaginationFooter from "../PaginationFooter/PaginationFooter";
import { MainContainer } from "../../util/layout";
const Groups = (props) => { const Groups = (props) => {
const groups_data = useSelector((state) => state.groups_data); const groups_data = useSelector((state) => state.groups_data);
@@ -41,59 +43,53 @@ const Groups = (props) => {
} }
return ( return (
<div className="container" data-testid="container"> <MainContainer>
<div className="row"> <Card>
<div className="col-md-12 col-lg-10 col-lg-offset-1"> <Card.Header>
<div className="panel panel-default"> <h4>Groups</h4>
<div className="panel-heading"> </Card.Header>
<h4>Groups</h4> <Card.Body>
</div> <ul className="list-group">
<div className="panel-body"> {groups_data.length > 0 ? (
<ul className="list-group"> groups_data.map((e, i) => (
{groups_data.length > 0 ? ( <li className="list-group-item" key={"group-item" + i}>
groups_data.map((e, i) => ( <span className="badge rounded-pill bg-success mx-2">
<li className="list-group-item" key={"group-item" + i}> {e.users.length + " users"}
<span className="badge badge-pill badge-success"> </span>
{e.users.length + " users"} <Link to="/group-edit" state={{ group_data: e }}>
</span> {e.name}
<Link to="/group-edit" state={{ group_data: e }}> </Link>
{e.name} </li>
</Link> ))
</li> ) : (
)) <div>
) : ( <h4>no groups created...</h4>
<div> </div>
<h4>no groups created...</h4> )}
</div> </ul>
)} <PaginationFooter
</ul> offset={offset}
<PaginationFooter limit={limit}
offset={offset} visible={groups_data.length}
limit={limit} total={total}
visible={groups_data.length} next={() => setOffset(offset + limit)}
total={total} prev={() => setOffset(offset - limit)}
next={() => setOffset(offset + limit)} handleLimit={handleLimit}
prev={() => setOffset(offset - limit)} />
handleLimit={handleLimit} </Card.Body>
/> <Card.Footer>
</div> <Link to="/">
<div className="panel-footer"> <Button variant="light" id="return">
<button className="btn btn-light adjacent-span-spacing"> Back
<Link to="/">Back</Link> </Button>
</button> </Link>
<button <span> </span>
className="btn btn-primary adjacent-span-spacing" <Link to="/create-group">
onClick={() => { <Button variant="primary">New Group</Button>
navigate("/create-group"); </Link>
}} </Card.Footer>
> </Card>
New Group </MainContainer>
</button>
</div>
</div>
</div>
</div>
</div>
); );
}; };

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { FormControl } from "react-bootstrap"; import { Button, FormControl } from "react-bootstrap";
import "./pagination-footer.css"; import "./pagination-footer.css";
@@ -13,7 +13,7 @@ const PaginationFooter = (props) => {
{total ? `of ${total}` : ""} {total ? `of ${total}` : ""}
<br /> <br />
{offset >= 1 ? ( {offset >= 1 ? (
<button className="btn btn-sm btn-light spaced"> <Button variant="light" size="sm">
<span <span
className="active-pagination" className="active-pagination"
data-testid="paginate-prev" data-testid="paginate-prev"
@@ -21,14 +21,14 @@ const PaginationFooter = (props) => {
> >
Previous Previous
</span> </span>
</button> </Button>
) : ( ) : (
<button className="btn btn-sm btn-light spaced"> <Button variant="light" size="sm">
<span className="inactive-pagination">Previous</span> <span className="inactive-pagination">Previous</span>
</button> </Button>
)} )}
{offset + visible < total ? ( {offset + visible < total ? (
<button className="btn btn-sm btn-light spaced"> <Button variant="light" size="sm">
<span <span
className="active-pagination" className="active-pagination"
data-testid="paginate-next" data-testid="paginate-next"
@@ -36,11 +36,11 @@ const PaginationFooter = (props) => {
> >
Next Next
</span> </span>
</button> </Button>
) : ( ) : (
<button className="btn btn-sm btn-light spaced"> <Button variant="light" size="sm">
<span className="inactive-pagination">Next</span> <span className="inactive-pagination">Next</span>
</button> </Button>
)} )}
<label> <label>
Items per page: Items per page:

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState, Fragment } from "react";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { debounce } from "lodash"; import { debounce } from "lodash";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import ErrorAlert from "../../util/error";
import { import {
Button, Button,
@@ -151,11 +152,20 @@ const ServerDashboard = (props) => {
setNameFilter(event.target.value); setNameFilter(event.target.value);
}, 300); }, 300);
const ServerButton = ({ server, user, action, name, extraClass }) => { const ServerButton = ({
server,
user,
action,
name,
variant,
extraClass,
}) => {
var [isDisabled, setIsDisabled] = useState(false); var [isDisabled, setIsDisabled] = useState(false);
return ( return (
<button <Button
className={`btn btn-xs ${extraClass}`} size="xs"
variant={variant}
className={extraClass}
disabled={isDisabled || server.pending} disabled={isDisabled || server.pending}
onClick={() => { onClick={() => {
setIsDisabled(true); setIsDisabled(true);
@@ -183,7 +193,7 @@ const ServerDashboard = (props) => {
}} }}
> >
{name} {name}
</button> </Button>
); );
}; };
@@ -196,7 +206,8 @@ const ServerDashboard = (props) => {
user, user,
action: stopServer, action: stopServer,
name: "Stop Server", name: "Stop Server",
extraClass: "btn-danger stop-button", variant: "danger",
extraClass: "stop-button",
}); });
}; };
const DeleteServerButton = ({ server, user }) => { const DeleteServerButton = ({ server, user }) => {
@@ -212,7 +223,8 @@ const ServerDashboard = (props) => {
user, user,
action: deleteServer, action: deleteServer,
name: "Delete Server", name: "Delete Server",
extraClass: "btn-danger stop-button", variant: "danger",
extraClass: "stop-button",
}); });
}; };
@@ -225,7 +237,8 @@ const ServerDashboard = (props) => {
user, user,
action: startServer, action: startServer,
name: server.pending ? "Server is pending" : "Start Server", name: server.pending ? "Server is pending" : "Start Server",
extraClass: "btn-success start-button", variant: "success",
extraClass: "start-button",
}); });
}; };
@@ -239,7 +252,9 @@ const ServerDashboard = (props) => {
server.name ? "/" + server.name : "" server.name ? "/" + server.name : ""
}`} }`}
> >
<button className="btn btn-light btn-xs">Spawn Page</button> <Button variant="light" size="xs">
Spawn Page
</Button>
</a> </a>
); );
}; };
@@ -250,15 +265,18 @@ const ServerDashboard = (props) => {
} }
return ( return (
<a href={server.url || ""}> <a href={server.url || ""}>
<button className="btn btn-primary btn-xs">Access Server</button> <Button variant="primary" size="xs">
Access Server
</Button>
</a> </a>
); );
}; };
const EditUserButton = ({ user }) => { const EditUserButton = ({ user }) => {
return ( return (
<button <Button
className="btn btn-light btn-xs" size="xs"
variant="light"
onClick={() => onClick={() =>
navigate("/edit-user", { navigate("/edit-user", {
state: { state: {
@@ -269,7 +287,7 @@ const ServerDashboard = (props) => {
} }
> >
Edit User Edit User
</button> </Button>
); );
}; };
@@ -300,7 +318,7 @@ const ServerDashboard = (props) => {
}, {}); }, {});
return ( return (
<ReactObjectTableViewer <ReactObjectTableViewer
className="table-striped table-bordered" className="table table-striped table-bordered"
style={{ style={{
padding: "3px 6px", padding: "3px 6px",
margin: "auto", margin: "auto",
@@ -342,7 +360,7 @@ const ServerDashboard = (props) => {
variant={open ? "secondary" : "primary"} variant={open ? "secondary" : "primary"}
size="sm" size="sm"
> >
<span className="caret"></span> <span className="fa fa-caret-down"></span>
</Button>{" "} </Button>{" "}
</span> </span>
<span data-testid={`user-name-div-${userServerName}`}> <span data-testid={`user-name-div-${userServerName}`}>
@@ -402,26 +420,9 @@ const ServerDashboard = (props) => {
return ( return (
<div className="container" data-testid="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( <ErrorAlert errorAlert={errorAlert} setErrorAlert={setErrorAlert} />
<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"> <div className="server-dashboard-container">
<Row> <Row className="rows-cols-lg-auto g-3 mb-3 align-items-center">
<Col md={4}> <Col md={4}>
<FormControl <FormControl
type="text" type="text"
@@ -432,27 +433,32 @@ const ServerDashboard = (props) => {
onChange={handleSearch} onChange={handleSearch}
/> />
</Col> </Col>
<Col md={3}> <Col md={4}>
{/* div.checkbox required for BS3 CSS */} <Form.Check
<div className="checkbox"> inline
<label title="check to only show running servers, otherwise show all"> title="check to only show running servers, otherwise show all"
<Form.Check >
inline <Form.Check.Input
type="checkbox" type="checkbox"
name="active_servers" name="active_servers"
id="active-servers-filter" id="active-servers-filter"
checked={state_filter == "active"} checked={state_filter == "active"}
onChange={(event) => { onChange={(event) => {
setStateFilter(event.target.checked ? "active" : null); setStateFilter(event.target.checked ? "active" : null);
}} }}
/> />
<Form.Check.Label for="active-servers-filter">
{"only active servers"} {"only active servers"}
</label> </Form.Check.Label>
</div> </Form.Check>
</Col> </Col>
<Col md="auto" style={{ float: "right", margin: 15 }}> <Col md={{ span: 3, offset: 1 }}>
<Link to="/groups">{"> Manage Groups"}</Link> <Link to="/groups">
<Button variant="light" className="form-control">
{"Manage Groups"}
</Button>
</Link>
</Col> </Col>
</Row> </Row>
<table className="table table-bordered table-hover"> <table className="table table-bordered table-hover">
@@ -565,7 +571,7 @@ const ServerDashboard = (props) => {
Stop All Stop All
</Button> </Button>
{/* spacing between start/stop and Shutdown */} {/* spacing between start/stop and Shutdown */}
<span style={{ marginLeft: "56px" }}> </span> <span style={{ marginLeft: "30px" }}> </span>
{/* Shutdown Jupyterhub */} {/* Shutdown Jupyterhub */}
<Button <Button
variant="danger" variant="danger"

View File

@@ -7,13 +7,6 @@
margin-left: auto; margin-left: auto;
} }
.btn-light {
/* backport bs5 btn-light colors */
background-color: #f9fafb;
border-color: #f9fafb;
color: #000;
}
.server-dashboard-container .btn-light { .server-dashboard-container .btn-light {
border: 1px solid #ddd; border: 1px solid #ddd;
} }
@@ -80,7 +73,7 @@ goals:
min-width: 180px; min-width: 180px;
} }
.admin-table-head #actions-header { .admin-table-head #actions-header {
width: 350px; width: 410px;
} }
/* vertical stack server buttons on small windows */ /* vertical stack server buttons on small windows */

32
jsx/src/util/error.jsx Normal file
View File

@@ -0,0 +1,32 @@
import React from "react";
import { Button, Alert, Col, Row } from "react-bootstrap";
import PropTypes from "prop-types";
const ErrorAlert = (props) => {
const { errorAlert, setErrorAlert } = props;
if (!errorAlert) {
return <></>;
}
return (
<Row>
<Col md={{ span: 10, offset: 1 }} lg={{ span: 8, offset: 2 }}>
<Alert variant="danger">
{errorAlert}
<Button
variant="close"
className="float-end"
aria-label="Close"
onClick={() => setErrorAlert(null)}
></Button>
</Alert>
</Col>
</Row>
);
};
ErrorAlert.propTypes = {
errorAlert: PropTypes.string,
setErrorAlert: PropTypes.func,
};
export default ErrorAlert;

38
jsx/src/util/layout.jsx Normal file
View File

@@ -0,0 +1,38 @@
import React from "react";
import { withProps } from "recompose";
import { Col, Row, Container } from "react-bootstrap";
import PropTypes from "prop-types";
import ErrorAlert from "./error";
export const MainCol = (props) => {
// main column layout
// sets default width, span
return withProps({
md: { span: 10, offset: 1 },
lg: { span: 8, offset: 2 },
...props,
})(Col)();
};
export const MainContainer = (props) => {
// default container for an admin page
// adds errorAlert and sets main column width
props = props || {};
return (
<Container data-testid="container">
<ErrorAlert
errorAlert={props.errorAlert}
setErrorAlert={props.setErrorAlert}
/>
<Row>
<MainCol>{props.children}</MainCol>
</Row>
</Container>
);
};
MainContainer.propTypes = {
errorAlert: PropTypes.string,
setErrorAlert: PropTypes.func,
children: PropTypes.array,
};

View File

@@ -15,7 +15,9 @@ class CacheControlStaticFilesHandler(StaticFileHandler):
return None return None
def set_extra_headers(self, path): def set_extra_headers(self, path):
if "v" not in self.request.arguments: if "v" not in self.request.arguments or self.settings.get(
"no_cache_static", False
):
self.add_header("Cache-Control", "no-cache") self.add_header("Cache-Control", "no-cache")

View File

@@ -329,6 +329,64 @@ async def open_home_page(app, browser, user):
await expect(browser).to_have_url(re.compile(".*/hub/home")) await expect(browser).to_have_url(re.compile(".*/hub/home"))
async def test_home_nav_collapse(app, browser, user_special_chars):
user = user_special_chars.user
await open_home_page(app, browser, user)
nav = browser.locator(".navbar")
navbar_collapse = nav.locator(".navbar-collapse")
logo = nav.locator("#jupyterhub-logo")
home = nav.get_by_text("Home")
logout_name = nav.get_by_text(user.name)
logout_btn = nav.get_by_text("Logout")
toggler = nav.locator(".navbar-toggler")
await expect(nav).to_be_visible()
await browser.set_viewport_size({"width": 640, "height": 480})
# links visible, nav items visible, collapse not visible
await expect(logo).to_be_visible()
await expect(home).to_be_visible()
await expect(logout_name).to_be_visible()
await expect(logout_btn).to_be_visible()
await expect(toggler).not_to_be_visible()
# below small breakpoint (576px)
await browser.set_viewport_size({"width": 500, "height": 480})
# logo visible, links and logout not visible, toggler visible
await expect(logo).to_be_visible()
await expect(home).not_to_be_visible()
await expect(logout_name).not_to_be_visible()
await expect(logout_btn).not_to_be_visible()
await expect(toggler).to_be_visible()
# click toggler, links should be visible
await toggler.click()
# wait for expand to finish
# expand animates through `collapse -> collapsing -> collapse show`
await expect(navbar_collapse).to_have_class(re.compile(r"\bshow\b"))
await expect(home).to_be_visible()
await expect(logout_name).to_be_visible()
await expect(logout_btn).to_be_visible()
await expect(toggler).to_be_visible()
# wait for expand animation
# click toggler again, links should hide
# need to wait for expand to complete
await toggler.click()
await expect(navbar_collapse).not_to_have_class(re.compile(r"\bshow\b"))
await expect(home).not_to_be_visible()
await expect(logout_name).not_to_be_visible()
await expect(logout_btn).not_to_be_visible()
await expect(toggler).to_be_visible()
# resize, should re-show
await browser.set_viewport_size({"width": 640, "height": 480})
await expect(logo).to_be_visible()
await expect(home).to_be_visible()
await expect(logout_name).to_be_visible()
await expect(logout_btn).to_be_visible()
await expect(toggler).not_to_be_visible()
async def test_start_button_server_not_started(app, browser, user_special_chars): async def test_start_button_server_not_started(app, browser, user_special_chars):
"""verify that when server is not started one button is available, """verify that when server is not started one button is available,
after starting 2 buttons are available""" after starting 2 buttons are available"""
@@ -413,7 +471,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
"""verify elements of the request token form""" """verify elements of the request token form"""
await open_token_page(app, browser, user_special_chars.user) await open_token_page(app, browser, user_special_chars.user)
request_btn = browser.locator('//div[@class="text-center"]').get_by_role("button") request_btn = browser.locator('//button[@type="submit"]')
expected_btn_name = 'Request new API token' expected_btn_name = 'Request new API token'
# check if the request token button is enabled # check if the request token button is enabled
# check the buttons name # check the buttons name
@@ -455,7 +513,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
expected_panel_token_heading = "Your new API Token" expected_panel_token_heading = "Your new API Token"
token_area = browser.locator('#token-area') token_area = browser.locator('#token-area')
await expect(token_area).to_be_visible() await expect(token_area).to_be_visible()
token_area_heading = token_area.locator('//div[@class="panel-heading"]') token_area_heading = token_area.locator('div.card-header')
await expect(token_area_heading).to_have_text(expected_panel_token_heading) await expect(token_area_heading).to_have_text(expected_panel_token_heading)
token_result = browser.locator('#token-result') token_result = browser.locator('#token-result')
await expect(token_result).not_to_be_empty() await expect(token_result).not_to_be_empty()
@@ -463,7 +521,7 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
# verify that "Your new API Token" panel is hidden after refresh the page # verify that "Your new API Token" panel is hidden after refresh the page
await browser.reload(wait_until="load") await browser.reload(wait_until="load")
await expect(token_area).to_be_hidden() await expect(token_area).to_be_hidden()
api_token_table_area = browser.locator('//div[@class="row"]').nth(2) api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible() await expect(api_token_table_area.get_by_role("table")).to_be_visible()
expected_table_name = "API Tokens" expected_table_name = "API Tokens"
await expect(api_token_table_area.get_by_role("heading")).to_have_text( await expect(api_token_table_area.get_by_role("heading")).to_have_text(
@@ -516,7 +574,7 @@ async def test_request_token_expiration(
# reload the page # reload the page
await browser.reload(wait_until="load") await browser.reload(wait_until="load")
# API Tokens table: verify that elements are displayed # API Tokens table: verify that elements are displayed
api_token_table_area = browser.locator("div#api-tokens-section").nth(0) api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible() await expect(api_token_table_area.get_by_role("table")).to_be_visible()
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1) await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
@@ -619,12 +677,14 @@ async def test_request_token_permissions(
error_message = await error_dialog.locator(".modal-body").inner_text() error_message = await error_dialog.locator(".modal-body").inner_text()
assert "API request failed (400)" in error_message assert "API request failed (400)" in error_message
assert expected_error in error_message assert expected_error in error_message
await error_dialog.locator("button[aria-label='Close']").click()
await expect(error_dialog).not_to_be_visible()
return return
await browser.reload(wait_until="load") await browser.reload(wait_until="load")
# API Tokens table: verify that elements are displayed # API Tokens table: verify that elements are displayed
api_token_table_area = browser.locator("div#api-tokens-section").nth(0) api_token_table_area = browser.locator("div#api-tokens-section")
await expect(api_token_table_area.get_by_role("table")).to_be_visible() await expect(api_token_table_area.get_by_role("table")).to_be_visible()
await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1) await expect(api_token_table_area.locator("tr.token-row")).to_have_count(1)
@@ -670,9 +730,7 @@ async def test_revoke_token(app, browser, token_type, user_special_chars):
await browser.wait_for_load_state("load") await browser.wait_for_load_state("load")
await expect(browser).to_have_url(re.compile(".*/hub/token")) await expect(browser).to_have_url(re.compile(".*/hub/token"))
if token_type == "both" or token_type == "request_by_user": if token_type == "both" or token_type == "request_by_user":
request_btn = browser.locator('//div[@class="text-center"]').get_by_role( request_btn = browser.locator('//button[@type="submit"]')
"button"
)
await request_btn.click() await request_btn.click()
# wait for token response to show up on the page # wait for token response to show up on the page
await browser.wait_for_load_state("load") await browser.wait_for_load_state("load")
@@ -879,9 +937,9 @@ async def test_oauth_page(
# login user # login user
await login(browser, user.name, password=str(user.name)) await login(browser, user.name, password=str(user.name))
auth_btn = browser.locator('//input[@type="submit"]') auth_btn = browser.locator('//button[@type="submit"]')
await expect(auth_btn).to_be_enabled() await expect(auth_btn).to_be_enabled()
text_permission = browser.get_by_role("paragraph") text_permission = browser.get_by_role("paragraph").nth(1)
await expect(text_permission).to_contain_text(f"JupyterHub service {service.name}") await expect(text_permission).to_contain_text(f"JupyterHub service {service.name}")
await expect(text_permission).to_contain_text(f"oauth URL: {expected_redirect_url}") await expect(text_permission).to_contain_text(f"oauth URL: {expected_redirect_url}")
@@ -1348,7 +1406,7 @@ async def test_singleuser_xsrf(
# visit target user, sets credentials for second server # visit target user, sets credentials for second server
await browser.goto(public_url(app, target_user)) await browser.goto(public_url(app, target_user))
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize")) await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
auth_button = browser.locator('//input[@type="submit"]') auth_button = browser.locator('//button[@type="submit"]')
await expect(auth_button).to_be_enabled() await expect(auth_button).to_be_enabled()
await auth_button.click() await auth_button.click()
await expect(browser).to_have_url(re.compile(rf".*/user/{target_user.name}/.*")) await expect(browser).to_have_url(re.compile(rf".*/user/{target_user.name}/.*"))

View File

@@ -48,16 +48,16 @@ async def test_share_code_flow_full(app, browser, full_spawn, create_user_with_s
# back to accept-share page # back to accept-share page
await expect(browser).to_have_url(re.compile(r".*/accept-share")) await expect(browser).to_have_url(re.compile(r".*/accept-share"))
header_text = await browser.locator("//h2").first.text_content() header_text = await browser.locator("p.lead").first.text_content()
assert f"access {user.name}'s server" in header_text assert f"access {user.name}'s server" in header_text
assert f"You ({share_user.name})" in header_text assert f"You ({share_user.name})" in header_text
# TODO verify form # TODO verify form
submit = browser.locator('//input[@type="submit"]') submit = browser.locator('//button[@type="submit"]')
await submit.click() await submit.click()
# redirects to server, which triggers oauth approval # redirects to server, which triggers oauth approval
await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize")) await expect(browser).to_have_url(re.compile(r".*/oauth2/authorize"))
submit = browser.locator('//input[@type="submit"]') submit = browser.locator('//button[@type="submit"]')
await submit.click() await submit.click()
# finally, we are at the server! # finally, we are at the server!

View File

@@ -1328,7 +1328,7 @@ async def test_services_nav_links(
r = await get_page("home", app, cookies=cookies) r = await get_page("home", app, cookies=cookies)
assert r.status_code == 200 assert r.status_code == 200
page = BeautifulSoup(r.text) page = BeautifulSoup(r.text)
nav = page.find("ul", class_="nav") nav = page.find("ul", class_="navbar-nav")
# find service links # find service links
nav_urls = [a["href"] for a in nav.find_all("a")] nav_urls = [a["href"] for a in nav.find_all("a")]
if present: if present:

403
package-lock.json generated
View File

@@ -10,209 +10,205 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"bootstrap": "^3.4.1", "@fortawesome/fontawesome-free": "^6.1.1",
"font-awesome": "^4.7.0", "bootstrap": "^5.3.0",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"requirejs": "^2.3.6" "requirejs": "^2.3.6"
}, },
"devDependencies": { "devDependencies": {
"less": "^3.9.0", "sass": "^1.74.1"
"less-plugin-clean-css": "^1.5.1",
"prettier": "^1.16.4"
} }
}, },
"node_modules/amdefine": { "node_modules/@fortawesome/fontawesome-free": {
"version": "1.0.1", "version": "6.5.2",
"resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
"integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
"dev": true, "hasInstallScript": true,
"engines": {
"node": ">=0.4.2"
}
},
"node_modules/bootstrap": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz",
"integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/clean-css": { "node_modules/@popperjs/core": {
"version": "3.4.28", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-3.4.28.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-aTWyttSdI2mYi07kWqHi24NUU9YlELFKGOAgFzZjDN1064DMAOy2FBuoyGmkKRlXkbpXd0EVHmiVkbKhKoirTw==", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"dev": true, "peer": true,
"dependencies": { "funding": {
"commander": "2.8.x", "type": "opencollective",
"source-map": "0.4.x" "url": "https://opencollective.com/popperjs"
},
"bin": {
"cleancss": "bin/cleancss"
},
"engines": {
"node": ">=0.10.0"
} }
}, },
"node_modules/clean-css/node_modules/source-map": { "node_modules/anymatch": {
"version": "0.4.4", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"amdefine": ">=0.0.4" "normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}, },
"engines": { "engines": {
"node": ">=0.8.0" "node": ">= 8"
} }
}, },
"node_modules/commander": { "node_modules/binary-extensions": {
"version": "2.8.1", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true, "dev": true,
"dependencies": {
"graceful-readlink": ">= 1.0.0"
},
"engines": { "engines": {
"node": ">= 0.6.x" "node": ">=8"
}
},
"node_modules/copy-anything": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
"integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
"dev": true,
"dependencies": {
"is-what": "^3.14.1"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/errno": { "node_modules/bootstrap": {
"version": "0.1.8", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true, "dev": true,
"optional": true,
"dependencies": { "dependencies": {
"prr": "~1.0.1" "fill-range": "^7.0.1"
}, },
"bin": {
"errno": "cli.js"
}
},
"node_modules/font-awesome": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
"integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==",
"engines": { "engines": {
"node": ">=0.10.3" "node": ">=8"
} }
}, },
"node_modules/graceful-fs": { "node_modules/chokidar": {
"version": "4.2.11", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true, "dev": true,
"optional": true "dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
}, },
"node_modules/graceful-readlink": { "node_modules/fill-range": {
"version": "1.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immutable": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
"dev": true "dev": true
}, },
"node_modules/image-size": { "node_modules/is-binary-path": {
"version": "0.5.5", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true, "dev": true,
"optional": true, "dependencies": {
"bin": { "binary-extensions": "^2.0.0"
"image-size": "bin/image-size.js" },
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
}, },
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-what": { "node_modules/is-number": {
"version": "3.14.1", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true "dev": true,
"engines": {
"node": ">=0.12.0"
}
}, },
"node_modules/jquery": { "node_modules/jquery": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
"integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==" "integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ=="
}, },
"node_modules/less": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/less/-/less-3.13.1.tgz",
"integrity": "sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==",
"dev": true,
"dependencies": {
"copy-anything": "^2.0.1",
"tslib": "^1.10.0"
},
"bin": {
"lessc": "bin/lessc"
},
"engines": {
"node": ">=6"
},
"optionalDependencies": {
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"native-request": "^1.0.5",
"source-map": "~0.6.0"
}
},
"node_modules/less-plugin-clean-css": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/less-plugin-clean-css/-/less-plugin-clean-css-1.5.1.tgz",
"integrity": "sha512-Pc68AFHAEJO3aAoRvnUTW5iAiAv6y+TQsWLTTwVNqjiDno6xCvxz1AtfQl7Y0MZSpHPalFajM1EU4RB5UVINpw==",
"dev": true,
"dependencies": {
"clean-css": "^3.0.1"
},
"engines": {
"node": ">=0.4.2"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"optional": true,
"dependencies": {
"pify": "^4.0.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"optional": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/moment": { "node_modules/moment": {
"version": "2.29.4", "version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@@ -221,42 +217,39 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/native-request": { "node_modules/normalize-path": {
"version": "1.1.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/native-request/-/native-request-1.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true, "dev": true,
"optional": true
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"optional": true,
"engines": { "engines": {
"node": ">=6" "node": ">=0.10.0"
} }
}, },
"node_modules/prettier": { "node_modules/picomatch": {
"version": "1.19.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"bin": { "engines": {
"prettier": "bin-prettier.js" "node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
}, },
"engines": { "engines": {
"node": ">=4" "node": ">=8.10.0"
} }
}, },
"node_modules/prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
"dev": true,
"optional": true
},
"node_modules/requirejs": { "node_modules/requirejs": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz", "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
@@ -269,31 +262,43 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/semver": { "node_modules/sass": {
"version": "5.7.2", "version": "1.74.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==",
"dev": true, "dev": true,
"optional": true, "dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": { "bin": {
"semver": "bin/semver" "sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
} }
}, },
"node_modules/source-map": { "node_modules/source-map-js": {
"version": "0.6.1", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true, "dev": true,
"optional": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tslib": { "node_modules/to-regex-range": {
"version": "1.14.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true "dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
} }
} }
} }

View File

@@ -10,17 +10,15 @@
}, },
"scripts": { "scripts": {
"postinstall": "python3 ./bower-lite", "postinstall": "python3 ./bower-lite",
"fmt": "prettier --write --trailing-comma es5 share/jupyterhub/static/js/*", "css": "sass --style compressed -I share/jupyterhub/static/components --source-map share/jupyterhub/static/scss/style.scss:share/jupyterhub/static/css/style.min.css",
"lessc": "lessc" "build:watch": "npm run css -- --watch"
}, },
"devDependencies": { "devDependencies": {
"less": "^3.9.0", "sass": "^1.74.1"
"less-plugin-clean-css": "^1.5.1",
"prettier": "^1.16.4"
}, },
"dependencies": { "dependencies": {
"bootstrap": "^3.4.1", "@fortawesome/fontawesome-free": "^6.1.1",
"font-awesome": "^4.7.0", "bootstrap": "^5.3.0",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"requirejs": "^2.3.6" "requirejs": "^2.3.6"

View File

@@ -113,27 +113,34 @@ class NPM(BaseCommand):
class CSS(BaseCommand): class CSS(BaseCommand):
description = "compile CSS from LESS" description = "compile CSS"
def should_run(self): def should_run(self):
"""Does less need to run?""" """Does CSS need to run?"""
# from IPython.html.tasks.py
css_targets = [pjoin(static, 'css', 'style.min.css')] css_targets = [pjoin(static, 'css', 'style.min.css')]
css_maps = [t + '.map' for t in css_targets] css_maps = [t + '.map' for t in css_targets]
targets = css_targets + css_maps targets = css_targets + css_maps
if not all(os.path.exists(t) for t in targets): earliest_target_mtime = float('inf')
# some generated files don't exist earliest_target_name = ''
return True for t in targets:
earliest_target = sorted(mtime(t) for t in targets)[0] if not os.path.exists(t):
print(f"Need to build css target: {t}")
return True
target_mtime = mtime(t)
if target_mtime < earliest_target_mtime:
earliest_target_name = t
earliest_target_mtime = target_mtime
# check if any .less files are newer than the generated targets # check if any .scss files are newer than the generated targets
for dirpath, dirnames, filenames in os.walk(static): for dirpath, dirnames, filenames in os.walk(static):
for f in filenames: for f in filenames:
if f.endswith('.less'): if f.endswith('.scss'):
path = pjoin(static, dirpath, f) path = pjoin(static, dirpath, f)
timestamp = mtime(path) timestamp = mtime(path)
if timestamp > earliest_target: if timestamp > earliest_target_mtime:
print(
f"mtime for {path} > {earliest_target_name}, needs update"
)
return True return True
return False return False
@@ -144,33 +151,18 @@ class CSS(BaseCommand):
return return
self.run_command('js') self.run_command('js')
print("Building css with less") print("Building css")
style_less = pjoin(static, 'less', 'style.less') args = ['npm', 'run', 'css']
style_css = pjoin(static, 'css', 'style.min.css')
sourcemap = style_css + '.map'
args = [
'npm',
'run',
'lessc',
'--',
'--clean-css',
f'--source-map-basepath={static}',
f'--source-map={sourcemap}',
'--source-map-rootpath=../',
style_less,
style_css,
]
try: try:
check_call(args, cwd=here, shell=shell) check_call(args, cwd=here, shell=shell)
except OSError as e: except OSError as e:
print("Failed to run lessc: %s" % e, file=sys.stderr) print("Failed to build css: %s" % e, file=sys.stderr)
print("You can install js dependencies with `npm install`", file=sys.stderr) print("You can install js dependencies with `npm install`", file=sys.stderr)
raise raise
# update data-files in case this created new files # update data-files in case this created new files
self.distribution.data_files = get_data_files() self.distribution.data_files = get_data_files()
assert not self.should_run(), 'CSS.run failed' assert not self.should_run(), 'CSS.run did not produce up-to-date output'
class JSX(BaseCommand): class JSX(BaseCommand):

View File

@@ -118,7 +118,8 @@ define(["jquery"], function ($) {
var msg = log_ajax_error(jqXHR, status, error); var msg = log_ajax_error(jqXHR, status, error);
var dialog = $("#error-dialog"); var dialog = $("#error-dialog");
dialog.find(".ajax-error").text(msg); dialog.find(".ajax-error").text(msg);
dialog.modal(); var modal = new bootstrap.Modal(dialog[0]);
modal.show();
}; };
var utils = { var utils = {

View File

@@ -1,27 +0,0 @@
/*!
*
* Twitter Bootstrap
*
*/
@import "../components/bootstrap/less/bootstrap.less";
@import "../components/bootstrap/less/responsive-utilities.less";
/*!
*
* Font Awesome
*
*/
@import "../components/font-awesome/less/font-awesome.less";
@fa-font-path: "../components/font-awesome/fonts";
/*!
*
* Jupyter
*
*/
@import "./variables.less";
@import "./page.less";
@import "./admin.less";
@import "./error.less";
@import "./login.less";

View File

@@ -1,27 +0,0 @@
@border-radius-small: 2px;
@border-radius-base: 2px;
@border-radius-large: 3px;
@navbar-height: 40px;
@grid-float-breakpoint: @screen-xs-min;
@navbar-default-color: #222;
@navbar-default-link-color: @navbar-default-color;
// darken background on hover, no change to text
@navbar-default-link-hover-color: @navbar-default-color;
@navbar-default-link-hover-bg: darken(@navbar-default-bg, 10%);
@jupyter-orange: #f37524;
@jupyter-red: #e34f21;
// color blind-friendly alternative to red/green
// from 5-class RdYlBu via colorbrewer.org
// eliminate distinction between 'primary' and 'success'
@brand-primary: #2c7bb6;
@brand-success: @brand-primary;
@brand-danger: #d7191c;
@text-muted: #222;
.btn-jupyter {
.button-variant(#fff; @jupyter-orange; @jupyter-red);
}

View File

@@ -0,0 +1,20 @@
/* CSS variables
note: SCSS variable overrides must be loaded _before_ bootstrap (variables.scss)
while CSS variable overrides must be loaded _after_ bootstrap (cssvariables.scss)
*/
.navbar {
/* higher contrast nav links by default */
--bs-navbar-color: rgba(black, 0.95);
}
.navbar-nav {
/* no color change on nav links
darken background on hover, no change to text
background part is in page.scss
*/
--bs-nav-link-color: var(--bs-navbar-color);
--bs-nav-link-hover-color: var(--bs-nav-link-color);
--bs-nav-link-active-color: var(--bs-nav-link-color);
}

View File

@@ -6,7 +6,6 @@ div.error {
div.ajax-error { div.ajax-error {
padding: 1em; padding: 1em;
text-align: center; text-align: center;
.alert-danger();
} }
div.error > h1 { div.error > h1 {

View File

@@ -3,21 +3,19 @@
height: 80vh; height: 80vh;
& #insecure-login-warning { & #insecure-login-warning {
.bg-warning(); background-color: $warning-bg-subtle;
padding: 10px; padding: 10px;
} }
.service-login { .service-login {
text-align: center; text-align: center;
display: table-cell;
vertical-align: middle; vertical-align: middle;
margin: auto auto 20% auto; margin: auto;
} }
form { form {
display: table-cell;
vertical-align: middle; vertical-align: middle;
margin: auto auto 20% auto; margin: auto;
width: 350px; width: 350px;
} }
@@ -30,9 +28,9 @@
.auth-form-header { .auth-form-header {
padding: 10px 20px; padding: 10px 20px;
color: #fff; color: #fff;
background: @jupyter-orange; background: $jupyter-orange;
border-radius: @border-radius-large @border-radius-large 0 0;
font-size: large; font-size: large;
border-radius: $border-radius-large $border-radius-large 0 0;
} }
.auth-form-header > h1 { .auth-form-header > h1 {
@@ -45,6 +43,6 @@
padding: 20px; padding: 20px;
border: thin silver solid; border: thin silver solid;
border-top: none; border-top: none;
border-radius: 0 0 @border-radius-large @border-radius-large; border-radius: 0 0 $border-radius-large $border-radius-large;
} }
} }

View File

@@ -1,15 +1,25 @@
@import "../components/bootstrap/less/variables.less"; $logo-height: 28px;
$grid-float-breakpoint: map-get($grid-breakpoints, "sm");
@logo-height: 28px;
#jupyterhub-logo { #jupyterhub-logo {
@media (max-width: @grid-float-breakpoint) { @media (max-width: $grid-float-breakpoint) {
// same length as the navbar-toggle element, displayed on responsive mode // same length as the navbar-toggle element, displayed on responsive mode
margin-left: 15px; margin-left: 15px;
} }
.jpy-logo { .jpy-logo {
height: @logo-height; height: $logo-height;
margin-top: (@navbar-height - @logo-height) / 2; margin-top: calc($navbar-brand-height - $logo-height) / 2;
}
}
.navbar-nav {
.nav-link {
&:hover,
&:focus {
// no color change
color: var(--#{$prefix}navbar-color);
background-color: darken($body-tertiary-bg, 10%);
}
} }
} }
@@ -18,7 +28,7 @@
span { span {
// same as .nav > li > a from bootstrap, but applied to the span[id="login_widget"] // same as .nav > li > a from bootstrap, but applied to the span[id="login_widget"]
// or any other span that matches .nav > li > span, but only in responsive mode // or any other span that matches .nav > li > span, but only in responsive mode
@media (max-width: @grid-float-breakpoint) { @media (max-width: $grid-float-breakpoint) {
position: relative; position: relative;
display: block; display: block;
padding: 10px 15px; padding: 10px 15px;
@@ -68,7 +78,16 @@
.form-control:focus { .form-control:focus {
box-shadow: box-shadow:
inset 0 1px 1px rgba(0, 0, 0, 0.075), inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px @jupyter-orange; 0 0 8px $jupyter-orange;
border-color: @jupyter-orange; border-color: $jupyter-orange;
outline-color: @jupyter-orange; outline-color: $jupyter-orange;
}
.btn-jupyter {
@include button-variant(
$background: $jupyter-orange,
$border: $jupyter-red,
$color: #fff,
$hover-color: #fff
);
} }

View File

@@ -0,0 +1,71 @@
/*!
*
* Bootstrap
*
*/
// 1. Include functions first (so you can manipulate colors, SVGs, calc, etc)
@import "../components/bootstrap/scss/functions"; // Required
// 2. Include any default variable overrides here
@import "./variables.scss";
@import "../components/bootstrap/scss/bootstrap"; // Full bootstrap (maybe wasteful?)
// // 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets)
// @import "../components/bootstrap/scss/variables"; // Required
// @import "../components/bootstrap/scss/variables-dark"; // Required
//
//
// // 4. Include any default map overrides here
//
// // 5. Include remainder of required parts
// @import "../components/bootstrap/scss/maps"; // Required
// @import "../components/bootstrap/scss/mixins"; // Required
// @import "../components/bootstrap/scss/root"; // Required
//
// // 6. Optionally include any other parts as needed
// @import "../components/bootstrap/scss/utilities";
// @import "../components/bootstrap/scss/reboot";
// @import "../components/bootstrap/scss/type";
// @import "../components/bootstrap/scss/images";
// @import "../components/bootstrap/scss/navbar";
// @import "../components/bootstrap/scss/alert";
// @import "../components/bootstrap/scss/buttons";
// @import "../components/bootstrap/scss/containers";
// @import "../components/bootstrap/scss/grid";
// @import "../components/bootstrap/scss/modal";
// 7. Optionally include utilities API last to generate classes based on the Sass map in `_utilities.scss`
// @import "../components/bootstrap/scss/utilities/api";
// CSS variables must be loaded _after_ bootstrap to override
@import "./cssvariables";
// redefine .btn-xs, removed in bootstrap 4
.btn-xs {
// $padding-y, $padding-x, $font-size, $border-radius
@include button-size(1px, 5px, 14px, 3px);
}
/*!
*
* Font Awesome
*
*/
$fa-font-path: "../components/@fortawesome/fontawesome-free/webfonts";
@import "../components/@fortawesome/fontawesome-free/scss/fontawesome";
// You can include all the other styles the same as before
@import "../components/@fortawesome/fontawesome-free/scss/regular.scss";
@import "../components/@fortawesome/fontawesome-free/scss/solid.scss";
/*!
*
* Jupyter
*
*/
@import "./page.scss";
@import "./admin.scss";
@import "./error.scss";
@import "./login.scss";

View File

@@ -0,0 +1,11 @@
$border-radius-large: 5px;
$jupyter-orange: #f37524;
$jupyter-red: #e34f21;
// accessible alternative to red/green
// from 5-class RdYlBu via colorbrewer.org
// eliminate distinction between 'primary' and 'success'
$primary: #2c7bb6;
$success: $primary;
$danger: #d7191c;

View File

@@ -3,13 +3,14 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<div class="container col-md-6 col-md-offset-3"> <div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<h1 class="text-center">Accept sharing invitation</h1> <h1 class="text-center">Accept sharing invitation</h1>
<p class="lead">
<h2>
You ({{ user.name }}) have been invited to access {{ owner.name }}'s server You ({{ user.name }}) have been invited to access {{ owner.name }}'s server
{%- if spawner.name %} ({{ spawner.name }}){%- endif %} at <a href="{{ spawner_url | safe }}">{{ spawner_url }}</a>. {%- if spawner.name %} ({{ spawner.name }}){%- endif %} at <a href="{{ spawner_url | safe }}">{{ spawner_url }}</a>
</h2> </p>
{% if not spawner_ready %} {% if not spawner_ready %}
<p class="alert alert-danger"> <p class="alert alert-danger">
@@ -19,18 +20,18 @@
</p> </p>
{% endif %} {% endif %}
<p> <form method="POST" action="">
<div class="card">
<div class="card-header">
By accepting the invitation, you will be granted the following permissions, By accepting the invitation, you will be granted the following permissions,
restricted to this particular server: restricted to this particular server:
</p> </div>
<div class="card-body">
<div>
<form method="POST" action="">
{# these are the 'real' inputs to the form -#} {# these are the 'real' inputs to the form -#}
<input type="hidden" name="_xsrf" value="{{ xsrf }}" /> <input type="hidden" name="_xsrf" value="{{ xsrf }}" />
<input type="hidden" name="code" value="{{ code }}" /> <input type="hidden" name="code" value="{{ code }}" />
{% for scope_info in scope_descriptions -%} {% for scope_info in scope_descriptions -%}
<div class="checkbox input-group"> <div class="form-check input-group">
<label> <label>
<span> <span>
{{ scope_info['description'] }} {{ scope_info['description'] }}
@@ -40,12 +41,15 @@
</label> </label>
</div> </div>
{% endfor -%} {% endfor -%}
<p> </div>
<div class="card-footer">
<button type="submit" class="form-control btn btn-jupyter" >Accept invitation</button>
<p class="small">
After accepting the invitation, you will be redirected to <a href="{{ next_url | safe }}">{{ next_url }}</a>. After accepting the invitation, you will be redirected to <a href="{{ next_url | safe }}">{{ next_url }}</a>.
</p> </p>
<input type="submit" value="Accept invitation" class="form-control btn-jupyter" /> </div>
</form>
</div> </div>
</div> </form>
</div></div></div>
{% endblock %} {% endblock %}

View File

@@ -11,9 +11,7 @@
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
<div class="container-fluid navbar-default small version_footer"> <div class="py-2 px-4 bg-body-tertiary small version_footer">
<div class="navbar-text">
JupyterHub {{ server_version }} JupyterHub {{ server_version }}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -44,10 +44,10 @@
<tbody> <tbody>
<tr class="home-server-row add-server-row"> <tr class="home-server-row add-server-row">
<td colspan="4"> <td colspan="4">
<input class="new-server-name" placeholder="Name your server"> <input class="new-server-name" aria-label="server name" placeholder="name-your-server">
<a role="button" class="new-server-btn" class="add-server btn btn-xs btn-primary"> <button role="button" type="button" class="new-server-btn btn btn-xs btn-primary">
Add New Server Add New Server
</a> </button>
</td> </td>
</tr> </tr>
{% for spawner in named_spawners %} {% for spawner in named_spawners %}
@@ -76,7 +76,7 @@
> >
start start
</a> </a>
<a role="button" class="delete-server btn btn-xs btn-danger{% if spawner.active %} hidden{% endif %}" id="delete-{{ spawner.name }}">delete</a> <button role="button" class="delete-server btn btn-xs btn-danger{% if spawner.active %} hidden{% endif %}" id="delete-{{ spawner.name }}">delete</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -29,7 +29,7 @@
<div class="auth-form-header"> <div class="auth-form-header">
<h1>Sign in</h1> <h1>Sign in</h1>
</div> </div>
<div class='auth-form-body'> <div class='auth-form-body m-auto'>
<p id='insecure-login-warning' class='hidden'> <p id='insecure-login-warning' class='hidden'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection. Warning: JupyterHub seems to be served over an unsecured HTTP connection.

View File

@@ -4,13 +4,13 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<div class="container col-md-6 col-md-offset-3"> <div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<h1 class="text-center">Authorize access</h1> <h1 class="text-center">Authorize access</h1>
<p class="lead">
<h2>
An application is requesting authorization to access data associated with your JupyterHub account An application is requesting authorization to access data associated with your JupyterHub account
</h2> </p>
<p> <p>
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }}) {{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
would like permission to identify you. would like permission to identify you.
@@ -19,10 +19,14 @@
your behalf. your behalf.
{% endif %} {% endif %}
</p> </p>
<h3>This will grant the application permission to:</h3> <form method="POST" action="">
<div> <div class="card">
<form method="POST" action=""> <div class="card-header">
<p class="h5">This will grant the application permission to:
</p>
</div>
<div class="card-body">
<input type="hidden" name="_xsrf" value="{{ xsrf }}"/> <input type="hidden" name="_xsrf" value="{{ xsrf }}"/>
{# these are the 'real' inputs to the form -#} {# these are the 'real' inputs to the form -#}
{% for scope in allowed_scopes %} {% for scope in allowed_scopes %}
@@ -40,13 +44,17 @@
{% if scope_info['filter'] %} {% if scope_info['filter'] %}
Applies to {{ scope_info['filter'] }}. Applies to {{ scope_info['filter'] }}.
{% endif %} {% endif %}
</span> </span>
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
<input type="submit" value="Authorize" class="form-control btn-jupyter"/> </div>
</form> <div class="card-footer">
</div> <button type="submit" class="form-control btn btn-jupyter mt-2">Authorize</button>
</div>
</div>
</form>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,15 +5,14 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button> <h2 class="modal-title" id="{{key}}-label">{{title}}</h1>
<h1 class="modal-title" id="{{key}}-label">{{title}}</h1> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ caller() }} {{ caller() }}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> <button type="button" class="btn {{btn_class}}" data-bs-dismiss="modal" data-dismiss="modal">{{btn_label}}</button>
<button type="button" class="btn {{btn_class}}" data-dismiss="modal" data-dismiss="modal">{{btn_label}}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -38,9 +37,9 @@
<link rel="icon" href="{{ static_url("favicon.ico") }}" type="image/x-icon"> <link rel="icon" href="{{ static_url("favicon.ico") }}" type="image/x-icon">
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{static_url("components/bootstrap/dist/js/bootstrap.bundle.min.js") }}" type="text/javascript" charset="utf-8"></script>
<script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script> <script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script>
<script src="{{static_url("components/jquery/dist/jquery.min.js") }}" type="text/javascript" charset="utf-8"></script> <script src="{{static_url("components/jquery/dist/jquery.min.js") }}" type="text/javascript" charset="utf-8"></script>
<script src="{{static_url("components/bootstrap/dist/js/bootstrap.min.js") }}" type="text/javascript" charset="utf-8"></script>
{% endblock %} {% endblock %}
<script> <script>
require.config({ require.config({
@@ -51,15 +50,8 @@
paths: { paths: {
components: '../components', components: '../components',
jquery: '../components/jquery/dist/jquery.min', jquery: '../components/jquery/dist/jquery.min',
bootstrap: '../components/bootstrap/dist/js/bootstrap.min',
moment: "../components/moment/moment", moment: "../components/moment/moment",
}, },
shim: {
bootstrap: {
deps: ["jquery"],
exports: "bootstrap"
},
}
}); });
</script> </script>
@@ -104,36 +96,31 @@
</noscript> </noscript>
{% block nav_bar %} {% block nav_bar %}
<nav class="navbar navbar-default"> <nav class="navbar navbar-expand-sm bg-body-tertiary mb-4">
<div class="container-fluid"> <div class="container-fluid">
<div class="navbar-header">
{% block logo %} {% block logo %}
<span id="jupyterhub-logo" class="pull-left"> <span id="jupyterhub-logo" class="navbar-brand">
<a href="{{logo_url or base_url}}"><img src='{{base_url}}logo' alt='JupyterHub logo' class='jpy-logo' title='Home'/></a> <a href="{{logo_url or base_url}}"><img src='{{base_url}}logo' alt='JupyterHub logo' class='jpy-logo' title='Home'/></a>
</span> </span>
{% endblock %} {% endblock %}
{% if user %} {% if user %}
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#thenavbar" aria-expanded="false"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#thenavbar" aria-controls="thenavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="sr-only">Toggle navigation</span> <span class="navbar-toggler-icon"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button> </button>
{% endif %} {% endif %}
</div>
<div class="collapse navbar-collapse" id="thenavbar"> <div class="collapse navbar-collapse" id="thenavbar">
{% if user %} {% if user %}
<ul class="nav navbar-nav"> <ul class="navbar-nav me-auto mb-0">
{% block nav_bar_left_items %} {% block nav_bar_left_items %}
<li><a href="{{base_url}}home">Home</a></li> <li class="nav-item"><a class="nav-link" href="{{base_url}}home">Home</a></li>
<li><a href="{{base_url}}token">Token</a></li> <li class="nav-item"><a class="nav-link" href="{{base_url}}token">Token</a></li>
{% if 'admin-ui' in parsed_scopes %} {% if 'admin-ui' in parsed_scopes %}
<li><a href="{{base_url}}admin">Admin</a></li> <li class="nav-item"><a class="nav-link" href="{{base_url}}admin">Admin</a></li>
{% endif %} {% endif %}
{% if services %} {% if services %}
<li class="dropdown"> <li class="nav-item dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Services<span class="caret"></span></a> <a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">Services</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% for service in services %} {% for service in services %}
{% block service scoped %} {% block service scoped %}
@@ -146,16 +133,16 @@
{% endblock %} {% endblock %}
</ul> </ul>
{% endif %} {% endif %}
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav me-2">
{% block nav_bar_right_items %} {% block nav_bar_right_items %}
<li> <li class="nav-item">
{% block login_widget %} {% block login_widget %}
<span id="login_widget"> <span id="login_widget">
{% if user %} {% if user %}
<p class="navbar-text">{{user.name}}</p> <span class="navbar-text">{{user.name}}</span>
<a id="logout" role="button" class="navbar-btn btn-sm btn btn-default" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a> <a id="logout" role="button" class="btn btn-sm btn-outline-dark" href="{{logout_url}}"> <i aria-hidden="true" class="fa fa-sign-out"></i> Logout</a>
{% else %} {% else %}
<a id="login" role="button" class="btn-sm btn navbar-btn btn-default" href="{{login_url}}">Login</a> <a id="login" role="button" class="btn btn-sm btn-outline-dark" href="{{login_url}}">Login</a>
{% endif %} {% endif %}
</span> </span>
{% endblock %} {% endblock %}
@@ -187,7 +174,7 @@
{% endblock %} {% endblock %}
{% call modal('Error', btn_label='OK') %} {% call modal('Error', btn_label='OK') %}
<div class="ajax-error"> <div class="ajax-error alert-danger">
The error The error
</div> </div>
{% endcall %} {% endcall %}

View File

@@ -11,12 +11,13 @@
<h1>Server Options</h1> <h1>Server Options</h1>
</div> </div>
{% endblock %} {% endblock %}
<div class="row col-sm-offset-2 col-sm-8"> <div class="row justify-content-center">
<div class="col-md-8">
{% if for_user and user.name != for_user.name -%} {% if for_user and user.name != for_user.name -%}
<p>Spawning server for {{ for_user.name }}</p> <p>Spawning server for {{ for_user.name }}</p>
{% endif -%} {% endif -%}
{% if error_message -%} {% if error_message -%}
<p class="spawn-error-msg text-danger"> <p class="spawn-error-msg alert alert-danger">
Error: {{error_message}} Error: {{error_message}}
</p> </p>
{% endif %} {% endif %}
@@ -24,7 +25,7 @@
{{spawner_options_form | safe}} {{spawner_options_form | safe}}
<br> <br>
<div class="feedback-container"> <div class="feedback-container">
<input type="submit" value="Start" class="btn btn-jupyter form-control"> <button type="submit" class="btn btn-jupyter form-control">Start</button>
<div class="feedback-widget hidden"> <div class="feedback-widget hidden">
<i class="fa fa-spinner"></i> <i class="fa fa-spinner"></i>
</div> </div>

View File

@@ -17,8 +17,8 @@
<p id="progress-message"></p> <p id="progress-message"></p>
</div> </div>
</div> </div>
<div class="row"> <div class="row justify-content-center">
<div class="col-md-8 col-md-offset-2"> <div class="col-md-8">
<details id="progress-details"> <details id="progress-details">
<summary>Event log</summary> <summary>Event log</summary>
<div id="progress-log"></div> <div id="progress-log"></div>

View File

@@ -4,22 +4,22 @@
<div class="container"> <div class="container">
<h1 class="sr-only">Manage JupyterHub Tokens</h1> <h1 class="sr-only">Manage JupyterHub Tokens</h1>
<div class="row"> <div class="row justify-content-center">
<form id="request-token-form" class="col-md-offset-3 col-md-6"> <form id="request-token-form" class="col-lg-6">
<div class="form-group"> <div class="form-group">
<label for="token-note">Note</label> <label for="token-note" class="form-label">Note</label>
<input <input
id="token-note" id="token-note"
class="form-control" class="form-control"
placeholder="note to identify your new token"> placeholder="note to identify your new token">
<small id="note-note" class="form-text text-muted"> <small id="note-note" class="form-text">
This note will help you keep track of what your tokens are for. This note will help you keep track of what your tokens are for.
</small> </small>
<br/> <br/>
<label for="token-expiration-seconds">Token expires in</label> <label for="token-expiration-seconds" class="form-label">Token expires in</label>
{% block expiration_options %} {% block expiration_options %}
<select id="token-expiration-seconds" <select id="token-expiration-seconds"
class="form-control"> class="form-select">
<!-- unit used for each value is `seconds` --> <!-- unit used for each value is `seconds` -->
<option value="3600">1 Hour</option> <option value="3600">1 Hour</option>
<option value="86400">1 Day</option> <option value="86400">1 Day</option>
@@ -27,19 +27,19 @@
<option value="" selected="selected">Never</option> <option value="" selected="selected">Never</option>
</select> </select>
{% endblock expiration_options %} {% endblock expiration_options %}
<small id="note-expires-at" class="form-text text-muted"> <small id="note-expires-at" class="form-text">
You can configure when your token will expire. You can configure when your token will expire.
</small> </small>
<br/> <br/>
<label for="token-scopes">Permissions</label> <label for="token-scopes" class="form-label">Permissions</label>
<input id="token-scopes" class="form-control" placeholder="list of scopes for the token to have, separated by space"> <input id="token-scopes" class="form-control" placeholder="list of scopes for the token to have, separated by space">
<small id="note-token-scopes" class="form-text text-muted"> <small id="note-token-scopes" class="form-text">
You can limit the permissions of the token so it can only do what you want it to. You can limit the permissions of the token so it can only do what you want it to.
If none are specified, the token will have permission to do everything you can do. If none are specified, the token will have permission to do everything you can do.
See the <a href="https://jupyterhub.readthedocs.io/en/stable/rbac/scopes.html#available-scopes">JupyterHub documentation for a list of available scopes</a>. See the <a href="https://jupyterhub.readthedocs.io/en/stable/rbac/scopes.html#available-scopes">JupyterHub documentation for a list of available scopes</a>.
</small> </small>
</div> </div>
<div class="text-center"> <div class="text-center m-4">
<button type="submit" class="btn btn-lg btn-jupyter"> <button type="submit" class="btn btn-lg btn-jupyter">
Request new API token Request new API token
</button> </button>
@@ -47,17 +47,17 @@
</form> </form>
</div> </div>
<div class="row"> <div class="row justify-content-center">
<div id="token-area" class="col-md-6 col-md-offset-3" style="display: none;"> <div id="token-area" class="col-lg-6" style="display: none;">
<div class="panel panel-default"> <div class="card">
<div class="panel-heading"> <div class="card-header">
Your new API Token Your new API Token
</div> </div>
<div class="panel-body"> <div class="card-body">
<p class="lead text-center"> <p class="card-title text-center">
<span id="token-result"></span> <span id="token-result"></span>
</p> </p>
<p> <p class="card-text">
Copy this token. You won't be able to see it again, Copy this token. You won't be able to see it again,
but you can always come back here to get a new one. but you can always come back here to get a new one.
</p> </p>
@@ -68,6 +68,7 @@
{% if api_tokens %} {% if api_tokens %}
<div class="row" id="api-tokens-section"> <div class="row" id="api-tokens-section">
<div class="col">
<h2>API Tokens</h2> <h2>API Tokens</h2>
<p> <p>
These are tokens with access to the JupyterHub API. These are tokens with access to the JupyterHub API.
@@ -86,10 +87,10 @@
</thead> </thead>
<tbody> <tbody>
{% for token in api_tokens %} {% for token in api_tokens %}
<tr class="token-row" data-token-id="{{token.api_id}}"> <tr class="token-row container" data-token-id="{{token.api_id}}">
{% block token_row scoped %} {% block token_row scoped %}
<td class="note-col col-sm-4">{{token.note}}</td> <td class="note-col col">{{token.note}}</td>
<td class="scope-col col-sm-1"> <td class="scope-col col">
<details> <details>
<summary>scopes</summary> <summary>scopes</summary>
{% for scope in token.scopes %} {% for scope in token.scopes %}
@@ -97,28 +98,28 @@
{% endfor %} {% endfor %}
</details> </details>
</td> </td>
<td class="time-col col-sm-3"> <td class="time-col col">
{%- if token.last_activity -%} {%- if token.last_activity -%}
{{ token.last_activity.isoformat() + 'Z' }} {{ token.last_activity.isoformat() + 'Z' }}
{%- else -%} {%- else -%}
Never Never
{%- endif -%} {%- endif -%}
</td> </td>
<td class="time-col col-sm-3"> <td class="time-col col">
{%- if token.created -%} {%- if token.created -%}
{{ token.created.isoformat() + 'Z' }} {{ token.created.isoformat() + 'Z' }}
{%- else -%} {%- else -%}
N/A N/A
{%- endif -%} {%- endif -%}
</td> </td>
<td class="time-col col-sm-3"> <td class="time-col col">
{%- if token.expires_at -%} {%- if token.expires_at -%}
{{ token.expires_at.isoformat() + 'Z' }} {{ token.expires_at.isoformat() + 'Z' }}
{%- else -%} {%- else -%}
Never Never
{%- endif -%} {%- endif -%}
</td> </td>
<td class="col-sm-1 text-center"> <td class="col text-center">
<button class="revoke-token-btn btn btn-xs btn-danger">revoke</button> <button class="revoke-token-btn btn btn-xs btn-danger">revoke</button>
</td> </td>
{% endblock token_row %} {% endblock token_row %}
@@ -127,6 +128,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{% endif %} {% endif %}
{% if oauth_clients %} {% if oauth_clients %}

View File

@@ -6,16 +6,25 @@ to enable testing without administrative privileges.
c = get_config() # noqa c = get_config() # noqa
from jupyterhub.auth import DummyAuthenticator c.JupyterHub.authenticator_class = "dummy"
c.JupyterHub.authenticator_class = DummyAuthenticator
# Optionally set a global password that all users must use # Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password" # c.DummyAuthenticator.password = "your_password"
from jupyterhub.spawner import SimpleLocalProcessSpawner c.JupyterHub.spawner_class = "simple"
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
# only listen on localhost for testing # only listen on localhost for testing
c.JupyterHub.bind_url = 'http://127.0.0.1:8000' c.JupyterHub.bind_url = 'http://127.0.0.1:8000'
# don't cache static files
c.JupyterHub.tornado_settings = {
"no_cache_static": True,
"slow_spawn_timeout": 0,
}
c.JupyterHub.allow_named_servers = True
c.JupyterHub.default_url = "/hub/home"
# make sure admin UI is available and any user can login
c.Authenticator.admin_users = {"admin"}
c.Authenticator.allow_all = True