Merge branch 'jupyterhub:main' into group_property_feature

This commit is contained in:
Vlad Vifor
2022-02-16 11:35:57 +01:00
committed by GitHub
10 changed files with 256 additions and 93 deletions

View File

@@ -10,6 +10,14 @@ import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince"; import { timeSince } from "../../util/timeSince";
import PaginationFooter from "../PaginationFooter/PaginationFooter"; import PaginationFooter from "../PaginationFooter/PaginationFooter";
const AccessServerButton = ({ userName, serverName }) => (
<a href={`/user/${userName}/${serverName || ""}`}>
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
Access Server
</button>
</a>
);
const ServerDashboard = (props) => { const ServerDashboard = (props) => {
// sort methods // sort methods
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)), var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
@@ -29,6 +37,7 @@ const ServerDashboard = (props) => {
var [errorAlert, setErrorAlert] = useState(null); var [errorAlert, setErrorAlert] = useState(null);
var [sortMethod, setSortMethod] = useState(null); var [sortMethod, setSortMethod] = useState(null);
var [disabledButtons, setDisabledButtons] = useState({});
var user_data = useSelector((state) => state.user_data), var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page), user_page = useSelector((state) => state.user_page),
@@ -72,6 +81,101 @@ const ServerDashboard = (props) => {
user_data = sortMethod(user_data); user_data = sortMethod(user_data);
} }
const StopServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-danger btn-xs stop-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
stopServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() => {
setIsDisabled(false);
setErrorAlert(`Failed to update users list.`);
});
} else {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to stop server.`);
setIsDisabled(false);
});
}}
>
Stop Server
</button>
);
};
const StartServerButton = ({ serverName, userName }) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<button
className="btn btn-success btn-xs start-button"
disabled={isDisabled}
onClick={() => {
setIsDisabled(true);
startServer(userName, serverName)
.then((res) => {
if (res.status < 300) {
updateUsers(...slice)
.then((data) => {
dispatchPageUpdate(data, page);
})
.catch(() => {
setErrorAlert(`Failed to update users list.`);
setIsDisabled(false);
});
} else {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to start server.`);
setIsDisabled(false);
});
}}
>
Start Server
</button>
);
};
const EditUserCell = ({ user, numServers, serverName }) => {
if (serverName) return null;
return (
<td rowspan={numServers}>
<button
className="btn btn-primary btn-xs"
style={{ marginRight: 20 }}
onClick={() =>
history.push({
pathname: "/edit-user",
state: {
username: user.name,
has_admin: user.admin,
},
})
}
>
Edit User
</button>
</td>
);
};
return ( return (
<div className="container" data-testid="container"> <div className="container" data-testid="container">
{errorAlert != null ? ( {errorAlert != null ? (
@@ -115,6 +219,14 @@ const ServerDashboard = (props) => {
testid="admin-sort" testid="admin-sort"
/> />
</th> </th>
<th id="server-header">
Server{" "}
<SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }}
callback={(method) => setSortMethod(() => method)}
testid="server-sort"
/>
</th>
<th id="last-activity-header"> <th id="last-activity-header">
Last Activity{" "} Last Activity{" "}
<SortHandler <SortHandler
@@ -227,88 +339,88 @@ const ServerDashboard = (props) => {
</Button> </Button>
</td> </td>
</tr> </tr>
{user_data.map((e, i) => ( {user_data.flatMap((e, i) => {
<tr key={i + "row"} className="user-row"> let userServers = Object.values({
<td data-testid="user-row-name">{e.name}</td> "": e.server,
<td data-testid="user-row-admin">{e.admin ? "admin" : ""}</td> ...(e.servers || {}),
<td data-testid="user-row-last-activity"> });
{e.last_activity ? timeSince(e.last_activity) : "Never"} return userServers.map((server) => {
</td> server = { name: "", ...server };
<td data-testid="user-row-server-activity"> return (
{e.server != null ? ( <tr key={i + "row"} className="user-row">
// Stop Single-user server {!server.name && (
<button <td
className="btn btn-danger btn-xs stop-button" data-testid="user-row-name"
onClick={() => rowspan={userServers.length}
stopServer(e.name) >
.then((res) => { {e.name}
if (res.status < 300) { </td>
updateUsers(...slice) )}
.then((data) => { {!server.name && (
dispatchPageUpdate(data, page); <td
}) data-testid="user-row-admin"
.catch(() => rowspan={userServers.length}
setErrorAlert(`Failed to update users list.`) >
); {e.admin ? "admin" : ""}
} else { </td>
setErrorAlert(`Failed to stop server.`); )}
}
return res; <td data-testid="user-row-server">
}) {server.name ? (
.catch(() => setErrorAlert(`Failed to stop server.`)) <p class="text-secondary">{server.name}</p>
} ) : (
> <p style={{ color: "lightgrey" }}>[MAIN]</p>
Stop Server )}
</button> </td>
) : ( <td data-testid="user-row-last-activity">
// Start Single-user server {server.last_activity
<button ? timeSince(server.last_activity)
className="btn btn-primary btn-xs start-button" : "Never"}
onClick={() => </td>
startServer(e.name) <td data-testid="user-row-server-activity">
.then((res) => { {server.started ? (
if (res.status < 300) { // Stop Single-user server
updateUsers(...slice) <>
.then((data) => { <StopServerButton
dispatchPageUpdate(data, page); serverName={server.name}
}) userName={e.name}
.catch(() => />
setErrorAlert(`Failed to update users list.`) <AccessServerButton
); serverName={server.name}
} else { userName={e.name}
setErrorAlert(`Failed to start server.`); />
} </>
return res; ) : (
}) // Start Single-user server
.catch(() => { <>
setErrorAlert(`Failed to start server.`); <StartServerButton
}) serverName={server.name}
} userName={e.name}
> />
Start Server <a
</button> href={`/spawn/${e.name}${
)} server.name && "/" + server.name
</td> }`}
<td> >
{/* Edit User */} <button
<button className="btn btn-secondary btn-xs"
className="btn btn-primary btn-xs" style={{ marginRight: 20 }}
style={{ marginRight: 20 }} >
onClick={() => Spawn Page
history.push({ </button>
pathname: "/edit-user", </a>
state: { </>
username: e.name, )}
has_admin: e.admin, </td>
}, <EditUserCell
}) user={e}
} numServers={userServers.length}
> serverName={server.name}
edit user />
</button> </tr>
</td> );
</tr> });
))} })}
</tbody> </tbody>
</table> </table>
<PaginationFooter <PaginationFooter

View File

@@ -11,8 +11,10 @@ const withAPI = withProps(() => ({
(data) => data.json() (data) => data.json()
), ),
shutdownHub: () => jhapiRequest("/shutdown", "POST"), shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"), startServer: (name, serverName = "") =>
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"), jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
stopServer: (name, serverName = "") =>
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
startAll: (names) => startAll: (names) =>
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")), names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
stopAll: (names) => stopAll: (names) =>

View File

@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
follow-redirects@^1.0.0: follow-redirects@^1.0.0:
version "1.14.7" version "1.14.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
for-in@^1.0.2: for-in@^1.0.2:
version "1.0.2" version "1.0.2"

View File

@@ -975,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
).tag(config=True) ).tag(config=True)
open_sessions = Bool( open_sessions = Bool(
True, False,
help=""" help="""
Whether to open a new PAM session when spawners are started. Whether to open a new PAM session when spawners are started.
This may trigger things like mounting shared filsystems, This may trigger things like mounting shared filesystems,
loading credentials, etc. depending on system configuration, loading credentials, etc. depending on system configuration.
but it does not always work.
The lifecycle of PAM sessions is not correct,
so many PAM session configurations will not work.
If any errors are encountered when opening/closing PAM sessions, If any errors are encountered when opening/closing PAM sessions,
this is automatically set to False. this is automatically set to False.
.. versionchanged:: 2.2
Due to longstanding problems in the session lifecycle,
this is now disabled by default.
You may opt-in to opening sessions by setting this to True.
""", """,
).tag(config=True) ).tag(config=True)

View File

@@ -255,7 +255,7 @@ class SpawnHandler(BaseHandler):
self.log.debug( self.log.debug(
"Triggering spawn with supplied form options for %s", spawner._log_name "Triggering spawn with supplied form options for %s", spawner._log_name
) )
options = await maybe_future(spawner.options_from_form(form_options)) options = await maybe_future(spawner.run_options_from_form(form_options))
pending_url = self._get_pending_url(user, server_name) pending_url = self._get_pending_url(user, server_name)
return await self._wrap_spawn_single_user( return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options user, server_name, spawner, pending_url, options

View File

@@ -403,6 +403,10 @@ def _token_allowed_role(db, token, role):
if owner is None: if owner is None:
raise ValueError(f"Owner not found for {token}") raise ValueError(f"Owner not found for {token}")
if role in owner.roles:
# shortcut: token is assigned an exact role the owner has
return True
expanded_scopes = _get_subscopes(role, owner=owner) expanded_scopes = _get_subscopes(role, owner=owner)
implicit_permissions = {'inherit', 'read:inherit'} implicit_permissions = {'inherit', 'read:inherit'}

View File

@@ -11,6 +11,7 @@ import shutil
import signal import signal
import sys import sys
import warnings import warnings
from inspect import signature
from subprocess import Popen from subprocess import Popen
from tempfile import mkdtemp from tempfile import mkdtemp
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -424,6 +425,13 @@ class Spawner(LoggingConfigurable):
def _default_options_from_form(self, form_data): def _default_options_from_form(self, form_data):
return form_data return form_data
def run_options_from_form(self, form_data):
sig = signature(self.options_from_form)
if 'spawner' in sig.parameters:
return self.options_from_form(form_data, spawner=self)
else:
return self.options_from_form(form_data)
def options_from_query(self, query_data): def options_from_query(self, query_data):
"""Interpret query arguments passed to /spawn """Interpret query arguments passed to /spawn

View File

@@ -459,3 +459,27 @@ async def test_spawner_oauth_roles_bad(app, user):
# raises ValueError if we try to assign a role that doesn't exist # raises ValueError if we try to assign a role that doesn't exist
with pytest.raises(ValueError): with pytest.raises(ValueError):
await spawner.user.spawn() await spawner.user.spawn()
async def test_spawner_options_from_form(db):
def options_from_form(form_data):
return form_data
spawner = new_spawner(db, options_from_form=options_from_form)
form_data = {"key": ["value"]}
result = spawner.run_options_from_form(form_data)
for key, value in form_data.items():
assert key in result
assert result[key] == value
async def test_spawner_options_from_form_with_spawner(db):
def options_from_form(form_data, spawner):
return form_data
spawner = new_spawner(db, options_from_form=options_from_form)
form_data = {"key": ["value"]}
result = spawner.run_options_from_form(form_data)
for key, value in form_data.items():
assert key in result
assert result[key] == value

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,11 @@
{{ custom_html | safe }} {{ custom_html | safe }}
{% elif login_service %} {% elif login_service %}
<div class="service-login"> <div class="service-login">
<p id='insecure-login-warning' class='hidden'>
Warning: JupyterHub seems to be served over an unsecured HTTP connection.
We strongly recommend enabling HTTPS for JupyterHub.
</p>
<a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'> <a role="button" class='btn btn-jupyter btn-lg' href='{{authenticator_login_url}}'>
Sign in with {{login_service}} Sign in with {{login_service}}
</a> </a>