🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-11-08 1:18:56.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-11-08 1:18:56.0 +00:00:00
commit
0437bc9760b616fd198678f5b53932eba09f631f [patch]
tree
e7ca200d51097ba35394af29bb6c92fca8cfef37
parent
37f6aa90a473710e57ef65c4e8f03ad445b3c03e
download
0437bc9760b616fd198678f5b53932eba09f631f.tar.gz

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

diff --git a/chartered-db/src/schema.rs b/chartered-db/src/schema.rs
index b519ab2..9f6da57 100644
--- a/chartered-db/src/schema.rs
+++ a/chartered-db/src/schema.rs
@@ -75,6 +75,7 @@
        expires_at -> Nullable<Timestamp>,
        user_agent -> Nullable<Text>,
        ip -> Nullable<Text>,
        uuid -> Binary,
    }
}

diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index a8a0710..1798c12 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -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)
diff --git a/migrations/2021-11-08-005634_update_sessions_add_uuid/down.sql b/migrations/2021-11-08-005634_update_sessions_add_uuid/down.sql
new file mode 100644
index 0000000..99c116b 100644
--- /dev/null
+++ a/migrations/2021-11-08-005634_update_sessions_add_uuid/down.sql
@@ -1,0 +1,2 @@
DROP INDEX unique_user_sessions_uuid;
ALTER TABLE user_sessions DROP COLUMN uuid;
diff --git a/migrations/2021-11-08-005634_update_sessions_add_uuid/up.sql b/migrations/2021-11-08-005634_update_sessions_add_uuid/up.sql
new file mode 100644
index 0000000..dc7d397 100644
--- /dev/null
+++ a/migrations/2021-11-08-005634_update_sessions_add_uuid/up.sql
@@ -1,0 +1,5 @@
-- drop all sessions before creating NOT NULL column
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);
diff --git a/chartered-frontend/src/pages/sessions/ListSessions.tsx b/chartered-frontend/src/pages/sessions/ListSessions.tsx
index 2124747..c8b4908 100644
--- a/chartered-frontend/src/pages/sessions/ListSessions.tsx
+++ a/chartered-frontend/src/pages/sessions/ListSessions.tsx
@@ -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>
  );
}
diff --git a/chartered-web/src/endpoints/web_api/sessions/delete.rs b/chartered-web/src/endpoints/web_api/sessions/delete.rs
new file mode 100644
index 0000000..7f38782 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/sessions/delete.rs
@@ -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);
diff --git a/chartered-web/src/endpoints/web_api/sessions/list.rs b/chartered-web/src/endpoints/web_api/sessions/list.rs
index ec482da..4d01136 100644
--- a/chartered-web/src/endpoints/web_api/sessions/list.rs
+++ a/chartered-web/src/endpoints/web_api/sessions/list.rs
@@ -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>,
diff --git a/chartered-web/src/endpoints/web_api/sessions/mod.rs b/chartered-web/src/endpoints/web_api/sessions/mod.rs
index 35ebad4..b234f9c 100644
--- a/chartered-web/src/endpoints/web_api/sessions/mod.rs
+++ a/chartered-web/src/endpoints/web_api/sessions/mod.rs
@@ -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))
}