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

View File

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

View File

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

View File

@@ -975,16 +975,24 @@ class PAMAuthenticator(LocalAuthenticator):
).tag(config=True)
open_sessions = Bool(
True,
False,
help="""
Whether to open a new PAM session when spawners are started.
This may trigger things like mounting shared filsystems,
loading credentials, etc. depending on system configuration,
but it does not always work.
This may trigger things like mounting shared filesystems,
loading credentials, etc. depending on system configuration.
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,
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)

View File

@@ -255,7 +255,7 @@ class SpawnHandler(BaseHandler):
self.log.debug(
"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)
return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options

View File

@@ -403,6 +403,10 @@ def _token_allowed_role(db, token, role):
if owner is None:
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)
implicit_permissions = {'inherit', 'read:inherit'}

View File

@@ -11,6 +11,7 @@ import shutil
import signal
import sys
import warnings
from inspect import signature
from subprocess import Popen
from tempfile import mkdtemp
from urllib.parse import urlparse
@@ -424,6 +425,13 @@ class Spawner(LoggingConfigurable):
def _default_options_from_form(self, 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):
"""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
with pytest.raises(ValueError):
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 }}
{% elif login_service %}
<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}}'>
Sign in with {{login_service}}
</a>