🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-11-05 1:30:32.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-11-05 1:30:32.0 +00:00:00
commit
7a00a389fa8b984c948b44e5771f36633693becc [patch]
tree
d73ef194fc754123bd8f25670e75e050f5ab4b60
parent
d35012210d260b473f7eacd034e491013deb34d4
download
7a00a389fa8b984c948b44e5771f36633693becc.tar.gz

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(-)

diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index e0866e8..63530e7 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -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()?;
diff --git a/chartered-frontend/src/dark.sass b/chartered-frontend/src/dark.sass
index 7cd524e..f0669bf 100644
--- a/chartered-frontend/src/dark.sass
+++ a/chartered-frontend/src/dark.sass
@@ -46,6 +46,9 @@
  table td
    color: $body-color !important

  table th
    color: $primary !important

  a.btn
    color: $link-color

diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index ecb7b11..9cf29b5 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -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>
diff --git a/chartered-frontend/src/pages/ErrorPage.tsx b/chartered-frontend/src/pages/ErrorPage.tsx
index 12d7aeb..a08b5b4 100644
--- a/chartered-frontend/src/pages/ErrorPage.tsx
+++ a/chartered-frontend/src/pages/ErrorPage.tsx
@@ -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>
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index eda5ed5..340d380 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -99,6 +99,10 @@
                    Your profile
                  </Dropdown.Item>

                  <Dropdown.Item as={Link} to="/sessions">
                    Sessions
                  </Dropdown.Item>

                  <Dropdown.Divider />

                  <Dropdown.Item
diff --git a/chartered-web/src/middleware/logging.rs b/chartered-web/src/middleware/logging.rs
index 7ada30d..3425a25 100644
--- a/chartered-web/src/middleware/logging.rs
+++ a/chartered-web/src/middleware/logging.rs
@@ -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 {
        // best practice is to clone the inner service like this
        // see https://github.com/tower-rs/tower/issues/547 for details
        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());

diff --git a/chartered-frontend/src/pages/sessions/ListSessions.tsx b/chartered-frontend/src/pages/sessions/ListSessions.tsx
new file mode 100644
index 0000000..2124747 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/sessions/ListSessions.tsx
@@ -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>
  );
}
diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs
index c3ea305..08450e8 100644
--- a/chartered-web/src/endpoints/web_api/mod.rs
+++ a/chartered-web/src/endpoints/web_api/mod.rs
@@ -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),
diff --git a/chartered-web/src/endpoints/web_api/sessions/list.rs b/chartered-web/src/endpoints/web_api/sessions/list.rs
new file mode 100644
index 0000000..ec482da 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/sessions/list.rs
@@ -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);
diff --git a/chartered-web/src/endpoints/web_api/sessions/mod.rs b/chartered-web/src/endpoints/web_api/sessions/mod.rs
new file mode 100644
index 0000000..35ebad4 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/sessions/mod.rs
@@ -1,0 +1,7 @@
mod list;

use axum::{routing::get, Router};

pub fn routes() -> Router {
    Router::new().route("/", get(list::handle_get))
}