Implement session deleting via the session UI
Fixes #17
Diff
chartered-db/src/schema.rs | 1 +
chartered-db/src/users.rs | 17 +++++++++++++++++
migrations/2021-11-08-005634_update_sessions_add_uuid/down.sql | 2 ++
migrations/2021-11-08-005634_update_sessions_add_uuid/up.sql | 5 +++++
chartered-frontend/src/pages/sessions/ListSessions.tsx | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
chartered-web/src/endpoints/web_api/sessions/delete.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/sessions/list.rs | 2 ++
chartered-web/src/endpoints/web_api/sessions/mod.rs | 3 ++-
8 files changed, 193 insertions(+), 10 deletions(-)
@@ -75,6 +75,7 @@
expires_at -> Nullable<Timestamp>,
user_agent -> Nullable<Text>,
ip -> Nullable<Text>,
uuid -> Binary,
}
}
@@ -327,6 +327,7 @@
pub expires_at: Option<chrono::NaiveDateTime>,
pub user_agent: Option<String>,
pub ip: Option<String>,
pub uuid: SqlUuid,
}
impl UserSession {
@@ -339,7 +340,7 @@
given_ip: Option<String>,
) -> Result<Self> {
use crate::schema::user_sessions::dsl::{
expires_at, ip, session_key, user_agent, user_id, user_sessions, user_ssh_key_id,
expires_at, ip, session_key, user_agent, user_id, user_sessions, user_ssh_key_id, uuid,
};
tokio::task::spawn_blocking(move || {
@@ -359,6 +360,7 @@
expires_at.eq(given_expires_at),
user_agent.eq(given_user_agent),
ip.eq(given_ip),
uuid.eq(SqlUuid::random()),
))
.execute(&conn)?;
@@ -397,6 +399,19 @@
let res = diesel::delete(user_sessions::table)
.filter(user_sessions::id.eq(self.id))
.execute(&conn)?;
Ok(res > 0)
})
.await?
}
pub async fn delete_by_uuid(conn: ConnectionPool, uuid: uuid::Uuid) -> Result<bool> {
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
let res = diesel::delete(user_sessions::table)
.filter(user_sessions::uuid.eq(SqlUuid(uuid)))
.execute(&conn)?;
Ok(res > 0)
@@ -1,0 +1,2 @@
DROP INDEX unique_user_sessions_uuid;
ALTER TABLE user_sessions DROP COLUMN uuid;
@@ -1,0 +1,5 @@
DELETE FROM user_sessions;
ALTER TABLE user_sessions ADD COLUMN uuid BINARY(128) NOT NULL;
CREATE UNIQUE INDEX unique_user_sessions_uuid ON user_sessions(uuid);
@@ -1,17 +1,24 @@
import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
import { Link, Navigate } from "react-router-dom";
import { RoundedPicture, useAuthenticatedRequest } from "../../util";
import {
authenticatedEndpoint,
RoundedPicture,
useAuthenticatedRequest,
} from "../../util";
import ErrorPage from "../ErrorPage";
import { LoadingSpinner } from "../Loading";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { Button, Modal, OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
import { XOctagonFill } from "react-bootstrap-icons";
import { useState } from "react";
interface Response {
sessions: ResponseSession[];
}
interface ResponseSession {
uuid: string;
expires_at: string | null;
user_agent: string | null;
ip: string | null;
@@ -20,27 +27,78 @@
export default function ListSessions() {
const auth = useAuth();
const [deleting, setDeleting] = useState<null | string>(null);
const [error, setError] = useState("");
const [reloadSessions, setReloadSessions] = useState(0);
if (!auth) {
return <Navigate to="/login" />;
}
const { response: list, error } = useAuthenticatedRequest<Response>({
auth,
endpoint: "sessions",
});
const { response: list, error: loadError } =
useAuthenticatedRequest<Response>(
{
auth,
endpoint: "sessions",
},
[reloadSessions]
);
if (error) {
return <ErrorPage message={error} />;
if (loadError) {
return <ErrorPage message={loadError} />;
}
const deleteSession = async () => {
setError("");
if (!deleting) {
return;
}
try {
let res = await fetch(authenticatedEndpoint(auth, "sessions"), {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ uuid: deleting }),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setReloadSessions(reloadSessions + 1);
} catch (e: any) {
setError(e.message);
} finally {
setDeleting(null);
}
};
return (
<div>
<Nav />
<div className="container mt-4 pb-4">
<h1>Active Sessions</h1>
<div
className="alert alert-danger alert-dismissible"
role="alert"
style={{ display: error ? "block" : "none" }}
>
{error}
<button
type="button"
className="btn-close"
aria-label="Close"
onClick={() => setError("")}
/>
</div>
<div className="card border-0 shadow-sm text-black p-2">
{!list ? (
<LoadingSpinner />
@@ -58,6 +116,7 @@
<th scope="col">User Agent</th>
<th scope="col">SSH Key Fingerprint</th>
<th scope="col">Expires</th>
<th scope="col"></th>
</tr>
</thead>
@@ -87,6 +146,15 @@
) : (
"n/a"
)}
</td>
<td className="fit">
<button
type="button"
className="btn text-danger p-0"
onClick={() => setDeleting(v.uuid)}
>
<XOctagonFill />
</button>
</td>
</tr>
))}
@@ -97,6 +165,42 @@
)}
</div>
</div>
<DeleteModal
show={deleting !== null}
onCancel={() => setDeleting(null)}
onConfirm={() => deleteSession()}
/>
</div>
);
}
function DeleteModal(props: {
show: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Modal
show={props.show}
onHide={props.onCancel}
size="lg"
aria-labelledby="delete-modal-title"
centered
>
<Modal.Header closeButton>
<Modal.Title id="delete-modal-title">
Are you sure you wish to delete this session?
</Modal.Title>
</Modal.Header>
<Modal.Footer>
<Button onClick={props.onCancel} variant="primary">
Close
</Button>
<Button onClick={props.onConfirm} variant="danger">
Delete
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,0 +1,53 @@
use axum::{extract, Json};
use chartered_db::{users::UserSession, ConnectionPool};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub async fn handle_delete(
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Json(req): extract::Json<Request>,
) -> Result<Json<Response>, Error> {
if UserSession::delete_by_uuid(db.clone(), req.uuid).await? {
Ok(Json(Response { success: true }))
} else {
Err(Error::UnknownSession)
}
}
#[derive(Deserialize)]
pub struct Request {
uuid: chartered_db::uuid::Uuid,
}
#[derive(Serialize)]
pub struct Response {
success: bool,
}
#[derive(Serialize)]
pub struct ResponseSession {
uuid: chartered_db::uuid::Uuid,
expires_at: Option<chrono::NaiveDateTime>,
user_agent: Option<String>,
ip: Option<String>,
ssh_key_fingerprint: Option<String>,
}
#[derive(Error, Debug)]
pub enum Error {
#[error("{0}")]
Database(#[from] chartered_db::Error),
#[error("Unknown session")]
UnknownSession,
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
match self {
Self::Database(e) => e.status_code(),
Self::UnknownSession => axum::http::StatusCode::BAD_REQUEST,
}
}
}
define_error_response!(Error);
@@ -15,6 +15,7 @@
sessions: sessions
.into_iter()
.map(|(session, ssh_key)| ResponseSession {
uuid: session.uuid.0,
expires_at: session.expires_at,
user_agent: session.user_agent,
ip: session.ip,
@@ -32,6 +33,7 @@
#[derive(Serialize)]
pub struct ResponseSession {
uuid: chartered_db::uuid::Uuid,
expires_at: Option<chrono::NaiveDateTime>,
user_agent: Option<String>,
ip: Option<String>,
@@ -1,7 +1,8 @@
mod delete;
mod list;
use axum::{routing::get, Router};
pub fn routes() -> Router {
Router::new().route("/", get(list::handle_get))
Router::new().route("/", get(list::handle_get).delete(delete::handle_delete))
}