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*
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.map
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.
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
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`.
@@ -78,7 +78,7 @@ a more detailed discussion.
If you do not have access to sudo, you may instead run the following commands:
```bash
npm install configurable-http-proxy yarn
npm install configurable-http-proxy
export PATH=$PATH:$(pwd)/node_modules/.bin
```
@@ -87,7 +87,7 @@ a more detailed discussion.
If you are using conda you can instead run:
```bash
conda install configurable-http-proxy yarn
conda install configurable-http-proxy
```
4. Install an editable version of JupyterHub and its requirements for
@@ -123,6 +123,14 @@ configuration:
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)
& [spawner](LocalProcessSpawner)
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
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
This section lists common ways setting up your development environment may

1
jsx/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, Card } from "react-bootstrap";
import { MainContainer } from "../../util/layout";
const EditUser = (props) => {
const limit = useSelector((state) => state.limit),
@@ -39,129 +41,103 @@ const EditUser = (props) => {
[admin, setAdmin] = useState(has_admin);
return (
<>
<div className="container" data-testid="container">
{errorAlert != null ? (
<div className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="alert alert-danger">
{errorAlert}
<button
type="button"
className="close"
onClick={() => setErrorAlert(null)}
>
<span>&times;</span>
</button>
</div>
<MainContainer errorAlert={errorAlert} setErrorAlert={setErrorAlert}>
<Card>
<Card.Header>
<h1>Editing user {username}</h1>
</Card.Header>
<Card.Body>
<form>
<div className="form-group">
<textarea
className="form-control"
data-testid="edit-username-input"
id="exampleFormControlTextarea1"
rows="3"
placeholder="updated username"
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 className="row">
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
<div className="panel panel-default">
<div className="panel-heading">
<h4>Editing user {username}</h4>
</div>
<div className="panel-body">
<form>
<div className="form-group">
<textarea
className="form-control"
data-testid="edit-username-input"
id="exampleFormControlTextarea1"
rows="3"
placeholder="updated username"
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>
<br></br>
<button
id="delete-user"
data-testid="delete-user"
className="btn btn-danger btn-sm"
onClick={(e) => {
e.preventDefault();
deleteUser(username)
.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.`);
});
}}
>
Delete user
</button>
</div>
</form>
</div>
<div className="panel-footer">
<button className="btn btn-light">
<Link to="/">Back</Link>
</button>
<span> </span>
<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>
</>
</form>
</Card.Body>
<Card.Footer>
<Link to="/">
<Button variant="light">Back</Button>
</Link>
<span> </span>
<Button
id="submit"
data-testid="submit"
variant="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>
<Button
id="delete-user"
data-testid="delete-user"
variant="danger"
className="float-end"
onClick={(e) => {
e.preventDefault();
deleteUser(username)
.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.`);
});
}}
>
Delete user
</Button>
</Card.Footer>
</Card>
</MainContainer>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,13 +7,6 @@
margin-left: auto;
}
.btn-light {
/* backport bs5 btn-light colors */
background-color: #f9fafb;
border-color: #f9fafb;
color: #000;
}
.server-dashboard-container .btn-light {
border: 1px solid #ddd;
}
@@ -80,7 +73,7 @@ goals:
min-width: 180px;
}
.admin-table-head #actions-header {
width: 350px;
width: 410px;
}
/* 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
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")

View File

@@ -329,6 +329,64 @@ async def open_home_page(app, browser, user):
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):
"""verify that when server is not started one button is 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"""
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'
# check if the request token button is enabled
# 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"
token_area = browser.locator('#token-area')
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)
token_result = browser.locator('#token-result')
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
await browser.reload(wait_until="load")
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()
expected_table_name = "API Tokens"
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
await browser.reload(wait_until="load")
# 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.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()
assert "API request failed (400)" 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
await browser.reload(wait_until="load")
# 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.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 expect(browser).to_have_url(re.compile(".*/hub/token"))
if token_type == "both" or token_type == "request_by_user":
request_btn = browser.locator('//div[@class="text-center"]').get_by_role(
"button"
)
request_btn = browser.locator('//button[@type="submit"]')
await request_btn.click()
# wait for token response to show up on the page
await browser.wait_for_load_state("load")
@@ -879,9 +937,9 @@ async def test_oauth_page(
# login user
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()
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"oauth URL: {expected_redirect_url}")
@@ -1348,7 +1406,7 @@ async def test_singleuser_xsrf(
# visit target user, sets credentials for second server
await browser.goto(public_url(app, target_user))
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 auth_button.click()
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
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"You ({share_user.name})" in header_text
# TODO verify form
submit = browser.locator('//input[@type="submit"]')
submit = browser.locator('//button[@type="submit"]')
await submit.click()
# redirects to server, which triggers oauth approval
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()
# 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)
assert r.status_code == 200
page = BeautifulSoup(r.text)
nav = page.find("ul", class_="nav")
nav = page.find("ul", class_="navbar-nav")
# find service links
nav_urls = [a["href"] for a in nav.find_all("a")]
if present:

403
package-lock.json generated
View File

@@ -10,209 +10,205 @@
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"bootstrap": "^3.4.1",
"font-awesome": "^4.7.0",
"@fortawesome/fontawesome-free": "^6.1.1",
"bootstrap": "^5.3.0",
"jquery": "^3.5.1",
"moment": "^2.29.4",
"requirejs": "^2.3.6"
},
"devDependencies": {
"less": "^3.9.0",
"less-plugin-clean-css": "^1.5.1",
"prettier": "^1.16.4"
"sass": "^1.74.1"
}
},
"node_modules/amdefine": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz",
"integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==",
"dev": 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==",
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
"integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/clean-css": {
"version": "3.4.28",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-3.4.28.tgz",
"integrity": "sha512-aTWyttSdI2mYi07kWqHi24NUU9YlELFKGOAgFzZjDN1064DMAOy2FBuoyGmkKRlXkbpXd0EVHmiVkbKhKoirTw==",
"dev": true,
"dependencies": {
"commander": "2.8.x",
"source-map": "0.4.x"
},
"bin": {
"cleancss": "bin/cleancss"
},
"engines": {
"node": ">=0.10.0"
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/clean-css/node_modules/source-map": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
"integrity": "sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==",
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"amdefine": ">=0.0.4"
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">=0.8.0"
"node": ">= 8"
}
},
"node_modules/commander": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz",
"integrity": "sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==",
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"dependencies": {
"graceful-readlink": ">= 1.0.0"
},
"engines": {
"node": ">= 0.6.x"
}
},
"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"
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
"node_modules/bootstrap": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
"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,
"optional": true,
"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": {
"node": ">=0.10.3"
"node": ">=8"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz",
"integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==",
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"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
},
"node_modules/image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"optional": true,
"bin": {
"image-size": "bin/image-size.js"
"dependencies": {
"binary-extensions": "^2.0.0"
},
"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": {
"node": ">=0.10.0"
}
},
"node_modules/is-what": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
"integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
"dev": true
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jquery": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
"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": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
@@ -221,42 +217,39 @@
"node": "*"
}
},
"node_modules/native-request": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/native-request/-/native-request-1.1.0.tgz",
"integrity": "sha512-uZ5rQaeRn15XmpgE0xoPL8YWqcX90VtCFglYwAgkvKM5e8fog+vePLAhHxuuv/gRkrQxIeh5U3q9sMNUrENqWw==",
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"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": {
"node": ">=6"
"node": ">=0.10.0"
}
},
"node_modules/prettier": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
"engines": {
"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": {
"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": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.6.tgz",
@@ -269,31 +262,43 @@
"node": ">=0.4.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"node_modules/sass": {
"version": "1.74.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz",
"integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==",
"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": {
"semver": "bin/semver"
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

View File

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

View File

@@ -113,27 +113,34 @@ class NPM(BaseCommand):
class CSS(BaseCommand):
description = "compile CSS from LESS"
description = "compile CSS"
def should_run(self):
"""Does less need to run?"""
# from IPython.html.tasks.py
"""Does CSS need to run?"""
css_targets = [pjoin(static, 'css', 'style.min.css')]
css_maps = [t + '.map' for t in css_targets]
targets = css_targets + css_maps
if not all(os.path.exists(t) for t in targets):
# some generated files don't exist
return True
earliest_target = sorted(mtime(t) for t in targets)[0]
earliest_target_mtime = float('inf')
earliest_target_name = ''
for t in targets:
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 f in filenames:
if f.endswith('.less'):
if f.endswith('.scss'):
path = pjoin(static, dirpath, f)
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 False
@@ -144,33 +151,18 @@ class CSS(BaseCommand):
return
self.run_command('js')
print("Building css with less")
print("Building css")
style_less = pjoin(static, 'less', 'style.less')
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,
]
args = ['npm', 'run', 'css']
try:
check_call(args, cwd=here, shell=shell)
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)
raise
# update data-files in case this created new 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):

View File

@@ -118,7 +118,8 @@ define(["jquery"], function ($) {
var msg = log_ajax_error(jqXHR, status, error);
var dialog = $("#error-dialog");
dialog.find(".ajax-error").text(msg);
dialog.modal();
var modal = new bootstrap.Modal(dialog[0]);
modal.show();
};
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 {
padding: 1em;
text-align: center;
.alert-danger();
}
div.error > h1 {

View File

@@ -3,21 +3,19 @@
height: 80vh;
& #insecure-login-warning {
.bg-warning();
background-color: $warning-bg-subtle;
padding: 10px;
}
.service-login {
text-align: center;
display: table-cell;
vertical-align: middle;
margin: auto auto 20% auto;
margin: auto;
}
form {
display: table-cell;
vertical-align: middle;
margin: auto auto 20% auto;
margin: auto;
width: 350px;
}
@@ -30,9 +28,9 @@
.auth-form-header {
padding: 10px 20px;
color: #fff;
background: @jupyter-orange;
border-radius: @border-radius-large @border-radius-large 0 0;
background: $jupyter-orange;
font-size: large;
border-radius: $border-radius-large $border-radius-large 0 0;
}
.auth-form-header > h1 {
@@ -45,6 +43,6 @@
padding: 20px;
border: thin silver solid;
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;
$logo-height: 28px;
$grid-float-breakpoint: map-get($grid-breakpoints, "sm");
#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
margin-left: 15px;
}
.jpy-logo {
height: @logo-height;
margin-top: (@navbar-height - @logo-height) / 2;
height: $logo-height;
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 {
// 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
@media (max-width: @grid-float-breakpoint) {
@media (max-width: $grid-float-breakpoint) {
position: relative;
display: block;
padding: 10px 15px;
@@ -68,7 +78,16 @@
.form-control:focus {
box-shadow:
inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px @jupyter-orange;
border-color: @jupyter-orange;
outline-color: @jupyter-orange;
0 0 8px $jupyter-orange;
border-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 %}
{% 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>
<h2>
<p class="lead">
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>.
</h2>
{%- if spawner.name %} ({{ spawner.name }}){%- endif %} at <a href="{{ spawner_url | safe }}">{{ spawner_url }}</a>
</p>
{% if not spawner_ready %}
<p class="alert alert-danger">
@@ -19,18 +20,18 @@
</p>
{% endif %}
<p>
<form method="POST" action="">
<div class="card">
<div class="card-header">
By accepting the invitation, you will be granted the following permissions,
restricted to this particular server:
</p>
<div>
<form method="POST" action="">
</div>
<div class="card-body">
{# these are the 'real' inputs to the form -#}
<input type="hidden" name="_xsrf" value="{{ xsrf }}" />
<input type="hidden" name="code" value="{{ code }}" />
{% for scope_info in scope_descriptions -%}
<div class="checkbox input-group">
<div class="form-check input-group">
<label>
<span>
{{ scope_info['description'] }}
@@ -40,12 +41,15 @@
</label>
</div>
{% 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>.
</p>
<input type="submit" value="Accept invitation" class="form-control btn-jupyter" />
</form>
</div>
</div>
</div>
</form>
</div></div></div>
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,13 @@
{% endblock %}
{% 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>
<h2>
<p class="lead">
An application is requesting authorization to access data associated with your JupyterHub account
</h2>
</p>
<p>
{{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }})
would like permission to identify you.
@@ -19,10 +19,14 @@
your behalf.
{% endif %}
</p>
<h3>This will grant the application permission to:</h3>
<div>
<form method="POST" action="">
<form method="POST" action="">
<div class="card">
<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 }}"/>
{# these are the 'real' inputs to the form -#}
{% for scope in allowed_scopes %}
@@ -40,13 +44,17 @@
{% if scope_info['filter'] %}
Applies to {{ scope_info['filter'] }}.
{% endif %}
</span>
</span>
</label>
</div>
{% endfor %}
<input type="submit" value="Authorize" class="form-control btn-jupyter"/>
</form>
</div>
</div>
<div class="card-footer">
<button type="submit" class="form-control btn btn-jupyter mt-2">Authorize</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -5,15 +5,14 @@
<div class="modal-dialog">
<div class="modal-content">
<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>
<h1 class="modal-title" id="{{key}}-label">{{title}}</h1>
<h2 class="modal-title" id="{{key}}-label">{{title}}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ caller() }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn {{btn_class}}" data-dismiss="modal" data-dismiss="modal">{{btn_label}}</button>
<button type="button" class="btn {{btn_class}}" data-bs-dismiss="modal" data-dismiss="modal">{{btn_label}}</button>
</div>
</div>
</div>
@@ -38,9 +37,9 @@
<link rel="icon" href="{{ static_url("favicon.ico") }}" type="image/x-icon">
{% endblock %}
{% 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/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 %}
<script>
require.config({
@@ -51,15 +50,8 @@
paths: {
components: '../components',
jquery: '../components/jquery/dist/jquery.min',
bootstrap: '../components/bootstrap/dist/js/bootstrap.min',
moment: "../components/moment/moment",
},
shim: {
bootstrap: {
deps: ["jquery"],
exports: "bootstrap"
},
}
});
</script>
@@ -104,36 +96,31 @@
</noscript>
{% 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="navbar-header">
{% 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>
</span>
{% endblock %}
{% if user %}
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#thenavbar" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<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="navbar-toggler-icon"></span>
</button>
{% endif %}
</div>
<div class="collapse navbar-collapse" id="thenavbar">
{% if user %}
<ul class="nav navbar-nav">
<ul class="navbar-nav me-auto mb-0">
{% block nav_bar_left_items %}
<li><a 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}}home">Home</a></li>
<li class="nav-item"><a class="nav-link" href="{{base_url}}token">Token</a></li>
{% 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 %}
{% if services %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Services<span class="caret"></span></a>
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-expanded="false">Services</a>
<ul class="dropdown-menu">
{% for service in services %}
{% block service scoped %}
@@ -146,16 +133,16 @@
{% endblock %}
</ul>
{% endif %}
<ul class="nav navbar-nav navbar-right">
<ul class="nav navbar-nav me-2">
{% block nav_bar_right_items %}
<li>
<li class="nav-item">
{% block login_widget %}
<span id="login_widget">
{% if user %}
<p class="navbar-text">{{user.name}}</p>
<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>
<span class="navbar-text">{{user.name}}</span>
<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 %}
<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 %}
</span>
{% endblock %}
@@ -187,7 +174,7 @@
{% endblock %}
{% call modal('Error', btn_label='OK') %}
<div class="ajax-error">
<div class="ajax-error alert-danger">
The error
</div>
{% endcall %}

View File

@@ -11,12 +11,13 @@
<h1>Server Options</h1>
</div>
{% 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 -%}
<p>Spawning server for {{ for_user.name }}</p>
{% endif -%}
{% if error_message -%}
<p class="spawn-error-msg text-danger">
<p class="spawn-error-msg alert alert-danger">
Error: {{error_message}}
</p>
{% endif %}
@@ -24,7 +25,7 @@
{{spawner_options_form | safe}}
<br>
<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">
<i class="fa fa-spinner"></i>
</div>

View File

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

View File

@@ -4,22 +4,22 @@
<div class="container">
<h1 class="sr-only">Manage JupyterHub Tokens</h1>
<div class="row">
<form id="request-token-form" class="col-md-offset-3 col-md-6">
<div class="row justify-content-center">
<form id="request-token-form" class="col-lg-6">
<div class="form-group">
<label for="token-note">Note</label>
<label for="token-note" class="form-label">Note</label>
<input
id="token-note"
class="form-control"
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.
</small>
<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 %}
<select id="token-expiration-seconds"
class="form-control">
class="form-select">
<!-- unit used for each value is `seconds` -->
<option value="3600">1 Hour</option>
<option value="86400">1 Day</option>
@@ -27,19 +27,19 @@
<option value="" selected="selected">Never</option>
</select>
{% 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.
</small>
<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">
<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.
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>.
</small>
</div>
<div class="text-center">
<div class="text-center m-4">
<button type="submit" class="btn btn-lg btn-jupyter">
Request new API token
</button>
@@ -47,17 +47,17 @@
</form>
</div>
<div class="row">
<div id="token-area" class="col-md-6 col-md-offset-3" style="display: none;">
<div class="panel panel-default">
<div class="panel-heading">
<div class="row justify-content-center">
<div id="token-area" class="col-lg-6" style="display: none;">
<div class="card">
<div class="card-header">
Your new API Token
</div>
<div class="panel-body">
<p class="lead text-center">
<div class="card-body">
<p class="card-title text-center">
<span id="token-result"></span>
</p>
<p>
<p class="card-text">
Copy this token. You won't be able to see it again,
but you can always come back here to get a new one.
</p>
@@ -68,6 +68,7 @@
{% if api_tokens %}
<div class="row" id="api-tokens-section">
<div class="col">
<h2>API Tokens</h2>
<p>
These are tokens with access to the JupyterHub API.
@@ -86,10 +87,10 @@
</thead>
<tbody>
{% 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 %}
<td class="note-col col-sm-4">{{token.note}}</td>
<td class="scope-col col-sm-1">
<td class="note-col col">{{token.note}}</td>
<td class="scope-col col">
<details>
<summary>scopes</summary>
{% for scope in token.scopes %}
@@ -97,28 +98,28 @@
{% endfor %}
</details>
</td>
<td class="time-col col-sm-3">
<td class="time-col col">
{%- if token.last_activity -%}
{{ token.last_activity.isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</td>
<td class="time-col col-sm-3">
<td class="time-col col">
{%- if token.created -%}
{{ token.created.isoformat() + 'Z' }}
{%- else -%}
N/A
{%- endif -%}
</td>
<td class="time-col col-sm-3">
<td class="time-col col">
{%- if token.expires_at -%}
{{ token.expires_at.isoformat() + 'Z' }}
{%- else -%}
Never
{%- endif -%}
</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>
</td>
{% endblock token_row %}
@@ -127,6 +128,7 @@
</tbody>
</table>
</div>
</div>
{% endif %}
{% if oauth_clients %}

View File

@@ -6,16 +6,25 @@ to enable testing without administrative privileges.
c = get_config() # noqa
from jupyterhub.auth import DummyAuthenticator
c.JupyterHub.authenticator_class = DummyAuthenticator
c.JupyterHub.authenticator_class = "dummy"
# Optionally set a global password that all users must use
# c.DummyAuthenticator.password = "your_password"
from jupyterhub.spawner import SimpleLocalProcessSpawner
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
c.JupyterHub.spawner_class = "simple"
# only listen on localhost for testing
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