Allow the user to look at active sessions for their account
Implements the majority of the remaining work required for #17
Diff
chartered-db/src/users.rs | 22 ++++++++++++++++++++++
chartered-frontend/src/dark.sass | 3 +++
chartered-frontend/src/index.tsx | 4 ++++
chartered-frontend/src/pages/ErrorPage.tsx | 2 +-
chartered-frontend/src/sections/Nav.tsx | 4 ++++
chartered-web/src/middleware/logging.rs | 4 ++--
chartered-frontend/src/pages/sessions/ListSessions.tsx | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/mod.rs | 2 ++
chartered-web/src/endpoints/web_api/sessions/list.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/sessions/mod.rs | 7 +++++++
10 files changed, 202 insertions(+), 3 deletions(-)
@@ -369,6 +369,28 @@
.await?
}
pub async fn list(
conn: ConnectionPool,
given_user_id: i32,
) -> Result<Vec<(Self, Option<UserSshKey>)>> {
use crate::schema::user_sessions::dsl::{expires_at, user_id};
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
Ok(crate::schema::user_sessions::table
.filter(
expires_at
.is_null()
.or(expires_at.gt(chrono::Utc::now().naive_utc())),
)
.filter(user_id.eq(given_user_id))
.left_join(user_ssh_keys::table)
.load(&conn)?)
})
.await?
}
pub async fn delete(self: Arc<Self>, conn: ConnectionPool) -> Result<bool> {
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
@@ -46,6 +46,9 @@
table td
color: $body-color !important
table th
color: $primary !important
a.btn
color: $link-color
@@ -27,6 +27,7 @@
import Search from "./pages/Search";
import { backgroundFix } from "./overscrollColourFixer";
import Register from "./pages/Register";
import ListSessions from "./pages/sessions/ListSessions";
if (
window.matchMedia &&
@@ -72,6 +73,9 @@
<Route path="/organisations" element={<Private element={<Navigate to="/organisations/list" />} />} />
<Route path="/organisations/list" element={<Private element={<ListOrganisations />} />} />
<Route path="/organisations/create" element={<Private element={<CreateOrganisation />} />} />
<Route path="/sessions" element={<Private element={<Navigate to="/sessions/list" />} />} />
<Route path="/sessions/list" element={<Private element={<ListSessions />} />} />
</Routes>
</Router>
</ProvideAuth>
@@ -1,6 +1,6 @@
export default function ErrorPage({ message }: { message: string }) {
return (
<div className="bg-primary min-vh-100 d-flex justify-content-center align-items-center">
<div className="min-vh-100 d-flex justify-content-center align-items-center">
<div className="alert alert-danger" role="alert">
{message}
</div>
@@ -99,6 +99,10 @@
Your profile
</Dropdown.Item>
<Dropdown.Item as={Link} to="/sessions">
Sessions
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item
@@ -38,7 +38,7 @@
self.0.poll_ready(cx)
}
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let clone = self.0.clone();
@@ -49,7 +49,7 @@
Box::pin(async move {
let start = std::time::Instant::now();
let user_agent = req.headers_mut().remove(axum::http::header::USER_AGENT);
let user_agent = req.headers().get(axum::http::header::USER_AGENT).cloned();
let method = req.method().clone();
let uri = replace_sensitive_path(req.uri().path());
@@ -1,0 +1,102 @@
import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
import { Link, Navigate } from "react-router-dom";
import { RoundedPicture, useAuthenticatedRequest } from "../../util";
import ErrorPage from "../ErrorPage";
import { LoadingSpinner } from "../Loading";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
interface Response {
sessions: ResponseSession[];
}
interface ResponseSession {
expires_at: string | null;
user_agent: string | null;
ip: string | null;
ssh_key_fingerprint: string | null;
}
export default function ListSessions() {
const auth = useAuth();
if (!auth) {
return <Navigate to="/login" />;
}
const { response: list, error } = useAuthenticatedRequest<Response>({
auth,
endpoint: "sessions",
});
if (error) {
return <ErrorPage message={error} />;
}
return (
<div>
<Nav />
<div className="container mt-4 pb-4">
<h1>Active Sessions</h1>
<div className="card border-0 shadow-sm text-black p-2">
{!list ? (
<LoadingSpinner />
) : (
<>
{list.sessions.length === 0 ? (
<div className="card-body">
You don't belong to any organisations yet.
</div>
) : (
<table className="table table-borderless mb-0">
<thead>
<tr>
<th scope="col">IP Address</th>
<th scope="col">User Agent</th>
<th scope="col">SSH Key Fingerprint</th>
<th scope="col">Expires</th>
</tr>
</thead>
<tbody>
{list.sessions.map((v, i) => (
<tr key={i}>
<td>
<strong>{v.ip?.split(":")[0]}</strong>
</td>
<td>{v.user_agent}</td>
<td>{v.ssh_key_fingerprint || "n/a"}</td>
<td>
{v.expires_at ? (
<OverlayTrigger
overlay={
<Tooltip id={`sessions-${i}-created-at`}>
{new Date(v.expires_at).toLocaleString()}
</Tooltip>
}
>
<span className="text-decoration-underline-dotted">
<HumanTime
time={new Date(v.expires_at).getTime()}
/>
</span>
</OverlayTrigger>
) : (
"n/a"
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
mod auth;
mod crates;
mod organisations;
mod sessions;
mod ssh_key;
mod users;
@@ -15,6 +16,7 @@
.nest("/crates", crates::routes())
.nest("/users", users::routes())
.nest("/auth", auth::authenticated_routes())
.nest("/sessions", sessions::routes())
.route(
"/ssh-key",
get(ssh_key::handle_get).put(ssh_key::handle_put),
@@ -1,0 +1,55 @@
use axum::{extract, Json};
use chartered_db::users::UserSession;
use chartered_db::{users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;
pub async fn handle_get(
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
let sessions = UserSession::list(db.clone(), user.id).await?;
Ok(Json(Response {
sessions: sessions
.into_iter()
.map(|(session, ssh_key)| ResponseSession {
expires_at: session.expires_at,
user_agent: session.user_agent,
ip: session.ip,
ssh_key_fingerprint: ssh_key
.map(|v| v.fingerprint().unwrap_or_else(|_| "INVALID".to_string())),
})
.collect(),
}))
}
#[derive(Serialize)]
pub struct Response {
sessions: Vec<ResponseSession>,
}
#[derive(Serialize)]
pub struct ResponseSession {
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),
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
match self {
Self::Database(e) => e.status_code(),
}
}
}
define_error_response!(Error);
@@ -1,0 +1,7 @@
mod list;
use axum::{routing::get, Router};
pub fn routes() -> Router {
Router::new().route("/", get(list::handle_get))
}