🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-18 3:21:14.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-18 3:21:43.0 +01:00:00
commit
4715d960fc5a7ecb815d637679486c9b88b8fd91 [patch]
tree
d6d086a0e0ac665f41ea269f47e29ddcb605fe85
parent
e0ebff4f613956266c1bb974f4833576cea96f5c
download
4715d960fc5a7ecb815d637679486c9b88b8fd91.tar.gz

Run prettier over chartered-frontend



Diff

 chartered-frontend/.prettierignore                    |   2 ++
 chartered-frontend/.prettierrc.json                   |   1 +
 chartered-frontend/index.html                         |  12 ++++++------
 chartered-frontend/package-lock.json                  |  19 +++++++++++++++++++
 chartered-frontend/package.json                       |   4 +++-
 chartered-frontend/src/index.tsx                      | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 chartered-frontend/src/overscrollColourFixer.ts       |  29 +++++++++++++++++------------
 chartered-frontend/src/useAuth.tsx                    | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
 chartered-frontend/src/util.tsx                       |  56 +++++++++++++++++++++++++++++++-------------------------
 chartered-frontend/src/pages/Dashboard.tsx            | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 chartered-frontend/src/pages/ErrorPage.tsx            |  16 ++++++++--------
 chartered-frontend/src/pages/Loading.tsx              |  14 ++++++++------
 chartered-frontend/src/pages/Login.tsx                | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 chartered-frontend/src/sections/Nav.tsx               |  87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
 chartered-frontend/src/pages/crate/CrateView.tsx      | 349 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 chartered-frontend/src/pages/crate/Members.tsx        | 769 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx  | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx | 303 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 18 files changed, 1410 insertions(+), 1015 deletions(-)

diff --git a/chartered-frontend/.prettierignore b/chartered-frontend/.prettierignore
new file mode 100644
index 0000000..ba70f38 100644
--- /dev/null
+++ a/chartered-frontend/.prettierignore
@@ -1,0 +1,2 @@
.cache
dist
diff --git a/chartered-frontend/.prettierrc.json b/chartered-frontend/.prettierrc.json
new file mode 100644
index 0000000..0967ef4 100644
--- /dev/null
+++ a/chartered-frontend/.prettierrc.json
@@ -1,0 +1,1 @@
{}
diff --git a/chartered-frontend/index.html b/chartered-frontend/index.html
index 99fbba8..8c2e44d 100644
--- a/chartered-frontend/index.html
+++ a/chartered-frontend/index.html
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>chartered</title>
</head>
  </head>

<body class="min-vh-100">
  <body class="min-vh-100">
    <div id="root"></div>
    <script src="./src/index.tsx"></script>
</body>
  </body>
</html>
diff --git a/chartered-frontend/package-lock.json b/chartered-frontend/package-lock.json
index 50d1efa..4a3e4f3 100644
--- a/chartered-frontend/package-lock.json
+++ a/chartered-frontend/package-lock.json
@@ -28,6 +28,7 @@
        "@types/react": "^17.0.20",
        "@types/react-dom": "^17.0.9",
        "parcel-bundler": "^1.12.5",
        "prettier": "2.4.1",
        "sass": "^1.39.2",
        "serve": "^12.0.1",
        "typescript": "^4.4.2"
@@ -8651,6 +8652,18 @@
        "node": ">= 0.8.0"
      }

    },

    "node_modules/prettier": {
      "version": "2.4.1",
      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
      "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
      "dev": true,
      "bin": {
        "prettier": "bin-prettier.js"
      },

      "engines": {
        "node": ">=10.13.0"
      }

    },

    "node_modules/prismjs": {
      "version": "1.24.1",
      "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.24.1.tgz",
@@ -18439,6 +18452,12 @@
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
      "dev": true
    },

    "prettier": {
      "version": "2.4.1",
      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
      "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
      "dev": true
    },

    "prismjs": {
diff --git a/chartered-frontend/package.json b/chartered-frontend/package.json
index e2e4938..8f4910c 100644
--- a/chartered-frontend/package.json
+++ a/chartered-frontend/package.json
@@ -6,7 +6,8 @@
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "parcel index.html --open",
    "build": "parcel build index.html"
    "build": "parcel build index.html",
    "fmt": "prettier --write ."
  },

  "keywords": [],
  "author": "",
@@ -16,6 +17,7 @@
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "parcel-bundler": "^1.12.5",
    "prettier": "2.4.1",
    "sass": "^1.39.2",
    "serve": "^12.0.1",
    "typescript": "^4.4.2"
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index c1d1caf..69dd4a4 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -1,16 +1,16 @@
import "./index.sass";

import './overscrollColourFixer.ts';
import "./overscrollColourFixer.ts";

import React = require("react");
import ReactDOM = require("react-dom");
import {
    BrowserRouter as Router,
    Switch,
    Route,
    Redirect,
    useLocation
  } from "react-router-dom";
  BrowserRouter as Router,
  Switch,
  Route,
  Redirect,
  useLocation,
} from "react-router-dom";

import { ProvideAuth, useAuth } from "./useAuth";

@@ -21,69 +21,119 @@
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";

function App() {
    return (
        <ProvideAuth>
            <Router>
                <Switch>
                    <PublicRoute exact unauthedOnly path="/login" component={() => <Login />} />

                    <PrivateRoute exact path="/" component={() => <Redirect to="/dashboard" />} />
                    <PrivateRoute exact path="/dashboard" component={() => <Dashboard />} />
                    <PrivateRoute exact path="/crates/:crate" component={() => <CrateView />} />
                    <PrivateRoute exact path="/ssh-keys/list" component={() => <ListSshKeys />} />
                    <PrivateRoute exact path="/ssh-keys/add" component={() => <AddSshKeys />} />
                </Switch>
            </Router>
        </ProvideAuth>
    );
  return (
    <ProvideAuth>
      <Router>
        <Switch>
          <PublicRoute
            exact
            unauthedOnly
            path="/login"
            component={() => <Login />}
          />

          <PrivateRoute
            exact
            path="/"
            component={() => <Redirect to="/dashboard" />}
          />
          <PrivateRoute
            exact
            path="/dashboard"
            component={() => <Dashboard />}
          />
          <PrivateRoute
            exact
            path="/crates/:crate"
            component={() => <CrateView />}
          />
          <PrivateRoute
            exact
            path="/ssh-keys/list"
            component={() => <ListSshKeys />}
          />
          <PrivateRoute
            exact
            path="/ssh-keys/add"
            component={() => <AddSshKeys />}
          />
        </Switch>
      </Router>
    </ProvideAuth>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

function PublicRoute({
    component: Component,
    unauthedOnly,
    ...rest
  component: Component,
  unauthedOnly,
  ...rest
}: {
    component: ({ match, location }: { match: any, location: ReturnType<typeof useLocation> }) => JSX.Element;
    unauthedOnly: boolean;
  component: ({
    match,
    location,
  }: {
    match: any;
    location: ReturnType<typeof useLocation>;
  }) => JSX.Element;
  unauthedOnly: boolean;
} & { [r: string]: any }) {
    const auth = useAuth();

    return (
        <Route
            {...rest}
            render={(props) => {
                // TODO: check if valid key
                if (!unauthedOnly || !auth || !auth.authKey || auth.expires < new Date()) {
                    return <Component {...props} />;
                } else {
                    return <Redirect to={{ pathname: "/dashboard", state: { from: props.location } }} />;
                }
            }}
        ></Route>
    )
  const auth = useAuth();

  return (
    <Route
      {...rest}
      render={(props) => {
        // TODO: check if valid key
        if (
          !unauthedOnly ||
          !auth ||
          !auth.authKey ||
          auth.expires < new Date()
        ) {
          return <Component {...props} />;
        } else {
          return (
            <Redirect
              to={{ pathname: "/dashboard", state: { from: props.location } }}
            />
          );
        }
      }}
    ></Route>
  );
}

function PrivateRoute({
    component: Component,
    ...rest
  component: Component,
  ...rest
}: {
    component: ({ match, location }: { match: any, location: ReturnType<typeof useLocation> }) => JSX.Element;
  component: ({
    match,
    location,
  }: {
    match: any;
    location: ReturnType<typeof useLocation>;
  }) => JSX.Element;
} & { [r: string]: any }) {
    const auth = useAuth();

    return (
        <Route
            {...rest}
            render={(props) => {
                // TODO: check if valid key
                if (auth && auth?.authKey && auth.expires > new Date()) {
                    return <Component {...props} />;
                } else {
                    return <Redirect to={{ pathname: "/login", state: { from: props.location } }} />;
                }
            }}
        ></Route>
    )
}
  const auth = useAuth();

  return (
    <Route
      {...rest}
      render={(props) => {
        // TODO: check if valid key
        if (auth && auth?.authKey && auth.expires > new Date()) {
          return <Component {...props} />;
        } else {
          return (
            <Redirect
              to={{ pathname: "/login", state: { from: props.location } }}
            />
          );
        }
      }}
    ></Route>
  );
}
diff --git a/chartered-frontend/src/overscrollColourFixer.ts b/chartered-frontend/src/overscrollColourFixer.ts
index 5945818..2a23da9 100644
--- a/chartered-frontend/src/overscrollColourFixer.ts
+++ a/chartered-frontend/src/overscrollColourFixer.ts
@@ -1,19 +1,24 @@
// A quick little utility to fix the overscroll colour at the bottom
// of the page vs the top of the page. We don't have a footer so we
// just want to carry on the body background, whereas the header is
// white so we want to use that at the top of the page.

window.addEventListener('load', () => {
    let ticking;
window.addEventListener("load", () => {
  let ticking;

    window.addEventListener('scroll', function (event) {
        if (!ticking) {
            ticking = true;
  window.addEventListener(
    "scroll",
    function (event) {
      if (!ticking) {
        ticking = true;

            window.requestAnimationFrame(() => {
                document.documentElement.style.backgroundColor = (window.scrollY > 70) ? 'var(--bs-primary)' : '#fff';
                ticking = false;
            });
        }
    }, false);
});
        window.requestAnimationFrame(() => {
          document.documentElement.style.backgroundColor =
            window.scrollY > 70 ? "var(--bs-primary)" : "#fff";
          ticking = false;
        });
      }
    },
    false
  );
});
diff --git a/chartered-frontend/src/useAuth.tsx b/chartered-frontend/src/useAuth.tsx
index 85810fa..bbee601 100644
--- a/chartered-frontend/src/useAuth.tsx
+++ a/chartered-frontend/src/useAuth.tsx
@@ -1,72 +1,75 @@
import React = require('react');
import React = require("react");
import { useState, useEffect, useContext, createContext } from "react";
import { unauthenticatedEndpoint } from "./util";

export interface AuthContext {
    authKey?: string,
    expires?: Date,
    login: (username: string, password: string) => Promise<void>,
    logout: () => Promise<void>,
  authKey?: string;
  expires?: Date;
  login: (username: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
}

const authContext = createContext<AuthContext | null>(null);

export function ProvideAuth({ children }: { children: any }) {
    const auth = useProvideAuth();
    return <authContext.Provider value={auth}>{children}</authContext.Provider>;
};
  const auth = useProvideAuth();
  return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

export const useAuth = (): AuthContext | null => {
    return useContext(authContext);
  return useContext(authContext);
};

function useProvideAuth(): AuthContext {
    const [authKey, setAuthKey] = useState(() => getAuthStorage().authKey);
    const [expires, setExpires] = useState(() => getAuthStorage().expires);

    useEffect(() => {
        localStorage.setItem("charteredAuthentication", JSON.stringify({ authKey, expires }));
    }, [authKey, expires]);

    const login = async (username: string, password: string) => {
        let res = await fetch(unauthenticatedEndpoint('login'), {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'User-Agent': window.navigator.userAgent,
            },
            body: JSON.stringify({ username, password }),
        });
        let json = await res.json();

        if (json.error) {
            throw new Error(json.error);
        }

        setExpires(new Date(json.expires));
        setAuthKey(json.key);
    };

    const logout = async () => {
        // todo call the service so we can purge the key from the db
        localStorage.removeItem("charteredAuthentication");
        setExpires(null);
        setAuthKey(null);
  const [authKey, setAuthKey] = useState(() => getAuthStorage().authKey);
  const [expires, setExpires] = useState(() => getAuthStorage().expires);

  useEffect(() => {
    localStorage.setItem(
      "charteredAuthentication",
      JSON.stringify({ authKey, expires })
    );
  }, [authKey, expires]);

  const login = async (username: string, password: string) => {
    let res = await fetch(unauthenticatedEndpoint("login"), {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "User-Agent": window.navigator.userAgent,
      },
      body: JSON.stringify({ username, password }),
    });
    let json = await res.json();

    if (json.error) {
      throw new Error(json.error);
    }

    return {
        authKey,
        expires,
        login,
        logout,
    }
    setExpires(new Date(json.expires));
    setAuthKey(json.key);
  };

  const logout = async () => {
    // todo call the service so we can purge the key from the db
    localStorage.removeItem("charteredAuthentication");
    setExpires(null);
    setAuthKey(null);
  };

  return {
    authKey,
    expires,
    login,
    logout,
  };
}

function getAuthStorage() {
    const saved = localStorage.getItem("charteredAuthentication");
    const initial = JSON.parse(saved);
    return {
        authKey: initial?.authKey || null,
        expires: initial?.expires ? new Date(initial.expires) : null,
    };
  const saved = localStorage.getItem("charteredAuthentication");
  const initial = JSON.parse(saved);
  return {
    authKey: initial?.authKey || null,
    expires: initial?.expires ? new Date(initial.expires) : null,
  };
}
diff --git a/chartered-frontend/src/util.tsx b/chartered-frontend/src/util.tsx
index e0fea43..8b3da03 100644
--- a/chartered-frontend/src/util.tsx
+++ a/chartered-frontend/src/util.tsx
@@ -1,34 +1,40 @@
import React = require("react");
import { AuthContext } from "./useAuth";

export const BASE_URL = 'http://localhost:8888';
export const BASE_URL = "http://localhost:8888";

export function unauthenticatedEndpoint(endpoint: string): string {
    return `${BASE_URL}/a/-/web/v1/${endpoint}`;
  return `${BASE_URL}/a/-/web/v1/${endpoint}`;
}

export function authenticatedEndpoint(auth: AuthContext, endpoint: string): string {
    return `${BASE_URL}/a/${auth.authKey}/web/v1/${endpoint}`;
export function authenticatedEndpoint(
  auth: AuthContext,
  endpoint: string
): string {
  return `${BASE_URL}/a/${auth.authKey}/web/v1/${endpoint}`;
}

export function useAuthenticatedRequest<S>({ auth, endpoint }: { auth: AuthContext, endpoint: string }, reloadOn = []): { response: S | null, error: string | null } {
    const [error, setError] = React.useState(null);
    const [response, setResponse] = React.useState(null);

    React.useEffect(async () => {
        try {
            let req = await fetch(authenticatedEndpoint(auth, endpoint));
            let res = await req.json();

            if (res.error) {
                setError(res.error);
            } else {
                setResponse(res);
            }
        } catch (e) {
            setError(e.message);
        }
    }, reloadOn);

    return { response, error };
}
export function useAuthenticatedRequest<S>(
  { auth, endpoint }: { auth: AuthContext; endpoint: string },
  reloadOn = []
): { response: S | null; error: string | null } {
  const [error, setError] = React.useState(null);
  const [response, setResponse] = React.useState(null);

  React.useEffect(async () => {
    try {
      let req = await fetch(authenticatedEndpoint(auth, endpoint));
      let res = await req.json();

      if (res.error) {
        setError(res.error);
      } else {
        setResponse(res);
      }
    } catch (e) {
      setError(e.message);
    }
  }, reloadOn);

  return { response, error };
}
diff --git a/chartered-frontend/src/pages/Dashboard.tsx b/chartered-frontend/src/pages/Dashboard.tsx
index c01f663..ea1ee13 100644
--- a/chartered-frontend/src/pages/Dashboard.tsx
+++ a/chartered-frontend/src/pages/Dashboard.tsx
@@ -1,77 +1,90 @@
import React = require('react');
import React = require("react");

import { Link } from "react-router-dom";
import { useAuth } from '../useAuth';
import { useAuth } from "../useAuth";
import Nav from "../sections/Nav";
import { ChevronRight } from 'react-bootstrap-icons';
import { ChevronRight } from "react-bootstrap-icons";

export default function Dashboard() {
    const auth = useAuth();

    const recentlyUpdated = [
        {
            name: "hello-world-rs",
            version: "0.0.1",
        },
        {
            name: "cool-beans-rs",
            version: "0.0.1",
        }
    ];

    return (
        <div className="text-white">
            <Nav />

            <div className="container mt-4 pb-4">
                <h1 className="mb-0">Welcome to Chartered.</h1>
                <p style={{maxWidth: '72ch'}}>
                    A private, authenticated Cargo registry. Everything published to this registry is <em>private and visible only to you</em>,
                    until explicit permissions are granted to others.
                </p>
                <a href="https://github.com/w4/chartered" target="_blank" className="btn btn-outline-light shadow-sm">Getting Started</a>

                <hr />

                <div className="row">
                    <div className="col-md-4">
                        <h4>Your Crates</h4>
                        { recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
                    </div>

                    <div className="col-md-4">
                        <h4>Recently Updated</h4>
                        { recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
                    </div>

                    <div className="col-md-4">
                        <h4>Most Downloaded</h4>
                        { recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
                    </div>
                </div>
            </div>
  const auth = useAuth();

  const recentlyUpdated = [
    {
      name: "hello-world-rs",
      version: "0.0.1",
    },
    {
      name: "cool-beans-rs",
      version: "0.0.1",
    },
  ];

  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <h1 className="mb-0">Welcome to Chartered.</h1>
        <p style={{ maxWidth: "72ch" }}>
          A private, authenticated Cargo registry. Everything published to this
          registry is <em>private and visible only to you</em>, until explicit
          permissions are granted to others.
        </p>
        <a
          href="https://github.com/w4/chartered"
          target="_blank"
          className="btn btn-outline-light shadow-sm"
        >
          Getting Started
        </a>

        <hr />

        <div className="row">
          <div className="col-md-4">
            <h4>Your Crates</h4>
            {recentlyUpdated.map((v) => (
              <CrateCard key={v.name} crate={v} />
            ))}
          </div>

          <div className="col-md-4">
            <h4>Recently Updated</h4>
            {recentlyUpdated.map((v) => (
              <CrateCard key={v.name} crate={v} />
            ))}
          </div>

          <div className="col-md-4">
            <h4>Most Downloaded</h4>
            {recentlyUpdated.map((v) => (
              <CrateCard key={v.name} crate={v} />
            ))}
          </div>
        </div>
    );
      </div>
    </div>
  );
}

interface Crate {
    name: string;
    version: string;
  name: string;
  version: string;
}

function CrateCard({ crate }: { crate: Crate }) {
    return (
        <Link to={`/crates/${crate.name}`} className="text-decoration-none">
            <div className="card border-0 mb-2 shadow-sm">
                <div className="card-body text-black d-flex flex-row">
                    <div className="flex-grow-1 align-self-center">
                        <h6 className="text-primary my-0">{crate.name}</h6>
                        <small className="text-secondary">v{crate.version}</small>
                    </div>

                    <ChevronRight size={16} className="align-self-center" />
                </div>
            </div>
        </Link>
    );
  return (
    <Link to={`/crates/${crate.name}`} className="text-decoration-none">
      <div className="card border-0 mb-2 shadow-sm">
        <div className="card-body text-black d-flex flex-row">
          <div className="flex-grow-1 align-self-center">
            <h6 className="text-primary my-0">{crate.name}</h6>
            <small className="text-secondary">v{crate.version}</small>
          </div>

          <ChevronRight size={16} className="align-self-center" />
        </div>
      </div>
    </Link>
  );
}
diff --git a/chartered-frontend/src/pages/ErrorPage.tsx b/chartered-frontend/src/pages/ErrorPage.tsx
index 726646c..a631e80 100644
--- a/chartered-frontend/src/pages/ErrorPage.tsx
+++ a/chartered-frontend/src/pages/ErrorPage.tsx
@@ -1,11 +1,11 @@
import React = require("react");

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="alert alert-danger" role="alert">
                {message}
            </div>
        </div>
    );
}
  return (
    <div className="bg-primary min-vh-100 d-flex justify-content-center align-items-center">
      <div className="alert alert-danger" role="alert">
        {message}
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/Loading.tsx b/chartered-frontend/src/pages/Loading.tsx
index 87793bf..90055d1 100644
--- a/chartered-frontend/src/pages/Loading.tsx
+++ a/chartered-frontend/src/pages/Loading.tsx
@@ -1,9 +1,11 @@
import React = require("react");

export default function Loading() {
    return <div className="min-vh-100 bg-primary d-flex justify-content-center align-items-center">
        <div className="spinner-border text-light" role="status">
            <span className="visually-hidden">Loading...</span>
        </div>
    </div>;
}
  return (
    <div className="min-vh-100 bg-primary d-flex justify-content-center align-items-center">
      <div className="spinner-border text-light" role="status">
        <span className="visually-hidden">Loading...</span>
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/Login.tsx b/chartered-frontend/src/pages/Login.tsx
index 5963f80..b1c4fdb 100644
--- a/chartered-frontend/src/pages/Login.tsx
+++ a/chartered-frontend/src/pages/Login.tsx
@@ -1,74 +1,113 @@
import React = require("react");
import { useState, useEffect, useRef } from "react";

import { useAuth } from "../useAuth";

export default function Login() {
    const auth = useAuth();

    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState("");
    const [loading, setLoading] = useState(false);
    const isMountedRef = useRef(null);

    useEffect(() => {
        isMountedRef.current = true;
        return () => isMountedRef.current = false;
    });

    const handleSubmit = async (evt) => {
        evt.preventDefault();

        setError("");
        setLoading(true);

        try {
            await auth.login(username, password);
        } catch (e) {
            setError(e.message);
        } finally {
            if (isMountedRef.current) {
                setLoading(false);
            }
        }
    };

    return (
        <div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
            <div>
                <h1>chartered ✈️</h1>
                <h6>a private, authenticated cargo registry</h6>

                <div className="card border-0 shadow-sm text-black p-2" style={{width: "40rem"}}>
                    <div className="card-body">
                        <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("")}>
                            </button>
                        </div>

                        <form onSubmit={handleSubmit}>
                            <div className="mb-3">
                                <label htmlFor="username" className="form-label">Username</label>
                                <input type="text" className="form-control" id="username" disabled={loading} value={username} onChange={e => setUsername(e.target.value)} />
                            </div>
                            <div className="mb-3">
                                <label htmlFor="password" className="form-label">Password</label>
                                <input type="password" className="form-control" id="password" disabled={loading} value={password} onChange={e => setPassword(e.target.value)} />
                            </div>
                            <div className="ml-auto">
                                <button type="submit" className="btn btn-primary" style={{ display: !loading ? 'block' : 'none' }}>Login</button>

                                <div className="spinner-border text-primary mt-4" role="status" style={{ display: loading ? 'block' : 'none' }}>
                                    <span className="visually-hidden">Logging in...</span>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>
  const auth = useAuth();

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const isMountedRef = useRef(null);

  useEffect(() => {
    isMountedRef.current = true;
    return () => (isMountedRef.current = false);
  });

  const handleSubmit = async (evt) => {
    evt.preventDefault();

    setError("");
    setLoading(true);

    try {
      await auth.login(username, password);
    } catch (e) {
      setError(e.message);
    } finally {
      if (isMountedRef.current) {
        setLoading(false);
      }
    }
  };

  return (
    <div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
      <div>
        <h1>chartered ✈️</h1>
        <h6>a private, authenticated cargo registry</h6>

        <div
          className="card border-0 shadow-sm text-black p-2"
          style={{ width: "40rem" }}
        >
          <div className="card-body">
            <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("")}
              ></button>
            </div>

            <form onSubmit={handleSubmit}>
              <div className="mb-3">
                <label htmlFor="username" className="form-label">
                  Username
                </label>
                <input
                  type="text"
                  className="form-control"
                  id="username"
                  disabled={loading}
                  value={username}
                  onChange={(e) => setUsername(e.target.value)}
                />
              </div>
              <div className="mb-3">
                <label htmlFor="password" className="form-label">
                  Password
                </label>
                <input
                  type="password"
                  className="form-control"
                  id="password"
                  disabled={loading}
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                />
              </div>
              <div className="ml-auto">
                <button
                  type="submit"
                  className="btn btn-primary"
                  style={{ display: !loading ? "block" : "none" }}
                >
                  Login
                </button>

                <div
                  className="spinner-border text-primary mt-4"
                  role="status"
                  style={{ display: loading ? "block" : "none" }}
                >
                  <span className="visually-hidden">Logging in...</span>
                </div>
              </div>
            </form>
          </div>
        </div>
    );
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index 210efa2..a46927e 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -1,44 +1,65 @@
import React = require("react");
import { NavLink, Link } from "react-router-dom";

import { BoxArrowRight } from 'react-bootstrap-icons';
import { BoxArrowRight } from "react-bootstrap-icons";
import { useAuth } from "../useAuth";

export default function Nav() {
    const auth = useAuth();
  const auth = useAuth();

    const logout = async (e) => {
        e.preventDefault();
        await auth.logout();
    };
  const logout = async (e) => {
    e.preventDefault();
    await auth.logout();
  };

    return (
        <nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
            <div className="container-fluid">
                <Link className="navbar-brand" to="/dashboard">✈️ chartered</Link>
                <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                    <span className="navbar-toggler-icon"></span>
                </button>
  return (
    <nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
      <div className="container-fluid">
        <Link className="navbar-brand" to="/dashboard">
          ✈️ chartered
        </Link>
        <button
          className="navbar-toggler"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#navbarSupportedContent"
          aria-controls="navbarSupportedContent"
          aria-expanded="false"
          aria-label="Toggle navigation"
        >
          <span className="navbar-toggler-icon"></span>
        </button>

                <div className="collapse navbar-collapse" id="navbarSupportedContent">
                    <ul className="navbar-nav me-auto mb-2 mb-lg-0">
                        <li className="nav-item">
                            <NavLink to="/dashboard" className="nav-link">Home</NavLink>
                        </li>
                        <li className="nav-item">
                            <NavLink to="/ssh-keys/list" className="nav-link">SSH Keys</NavLink>
                        </li>
                    </ul>
        <div className="collapse navbar-collapse" id="navbarSupportedContent">
          <ul className="navbar-nav me-auto mb-2 mb-lg-0">
            <li className="nav-item">
              <NavLink to="/dashboard" className="nav-link">
                Home
              </NavLink>
            </li>
            <li className="nav-item">
              <NavLink to="/ssh-keys/list" className="nav-link">
                SSH Keys
              </NavLink>
            </li>
          </ul>

                    <form className="d-flex">
                        <input className="form-control me-2" type="search" placeholder="Search" aria-label="Search" />
                    </form>
          <form className="d-flex">
            <input
              className="form-control me-2"
              type="search"
              placeholder="Search"
              aria-label="Search"
            />
          </form>

                    <div>
                        <a href="#" onClick={logout} className="nav-link text-danger">Logout <BoxArrowRight /></a>
                    </div>
                </div>
            </div>
        </nav>
    );
}
          <div>
            <a href="#" onClick={logout} className="nav-link text-danger">
              Logout <BoxArrowRight />
            </a>
          </div>
        </div>
      </div>
    </nav>
  );
}
diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index b54305b..95b4e6b 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -1,177 +1,222 @@
import React = require('react');
import React = require("react");

import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";

import { useAuth } from '../../useAuth';
import { useAuth } from "../../useAuth";
import Nav from "../../sections/Nav";
import Loading from '../Loading';
import ErrorPage from '../ErrorPage';
import { Box, HouseDoor, Book, Building } from 'react-bootstrap-icons';
import Loading from "../Loading";
import ErrorPage from "../ErrorPage";
import { Box, HouseDoor, Book, Building } from "react-bootstrap-icons";
import { useParams } from "react-router-dom";
import { useAuthenticatedRequest } from '../../util';
import { useAuthenticatedRequest } from "../../util";

import Prism from 'react-syntax-highlighter/dist/cjs/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Members from './Members';
import Prism from "react-syntax-highlighter/dist/cjs/prism";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Members from "./Members";

type Tab = 'readme' | 'versions' | 'members';
type Tab = "readme" | "versions" | "members";

export interface CrateInfo {
    versions: CrateInfoVersion[],
  versions: CrateInfoVersion[];
}

export interface CrateInfoVersion {
    vers: string,
    homepage: string | null,
    description: string | null,
    documentation: string | null,
    repository: string | null,
    deps: CrateInfoVersionDependency[],
  vers: string;
  homepage: string | null;
  description: string | null;
  documentation: string | null;
  repository: string | null;
  deps: CrateInfoVersionDependency[];
}

export interface CrateInfoVersionDependency {
    name: string,
    version_req: string,
  name: string;
  version_req: string;
}

export default function SingleCrate() {
    const auth = useAuth();
    const { crate } = useParams();

    const [currentTab, setCurrentTab] = useState<Tab>('readme');

    const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
        auth,
        endpoint: `crates/${crate}`,
    });

    if (error) {
        return <ErrorPage message={error} />;
    } else if (!crateInfo) {
        return <Loading />;
    }

    const crateVersion = crateInfo.versions[crateInfo.versions.length - 1];

    return (
        <div className="text-white">
            <Nav />

            <div className="container mt-4 pb-4">
                <div className="row align-items-stretch">
                    <div className="col-md-6">
                        <div className="card border-0 shadow-sm text-black h-100">
                            <div className="card-body">
                                <div className="d-flex flex-row align-items-center">
                                    <div className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
                                        style={{ width: '2rem', height: '2rem' }}>
                                        <Box />
                                    </div>
                                    <h1 className="text-primary d-inline px-2">{crate}</h1>
                                    <h2 className="text-secondary m-0">{crateVersion.vers}</h2>
                                </div>

                                <p className="m-0">{crateVersion.description}</p>
                            </div>
                        </div>
                    </div>

                    <div className="col-md-6">
                        <div className="card border-0 shadow-sm text-black h-100">
                            <div className="card-body">
                                <HouseDoor /> <a href={crateVersion.homepage}>{crateVersion.homepage}</a><br />
                                <Book /> <a href={crateVersion.documentation}>{crateVersion.documentation}</a><br />
                                <Building /> <a href={crateVersion.repository}>{crateVersion.repository}</a>
                            </div>
                        </div>
                    </div>
  const auth = useAuth();
  const { crate } = useParams();

  const [currentTab, setCurrentTab] = useState<Tab>("readme");

  const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
    auth,
    endpoint: `crates/${crate}`,
  });

  if (error) {
    return <ErrorPage message={error} />;
  } else if (!crateInfo) {
    return <Loading />;
  }

  const crateVersion = crateInfo.versions[crateInfo.versions.length - 1];

  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <div className="row align-items-stretch">
          <div className="col-md-6">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
                  <div
                    className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
                    style={{ width: "2rem", height: "2rem" }}
                  >
                    <Box />
                  </div>
                  <h1 className="text-primary d-inline px-2">{crate}</h1>
                  <h2 className="text-secondary m-0">{crateVersion.vers}</h2>
                </div>

                <div className="row my-4">
                    <div className="col-md-9">
                        <div className="card border-0 shadow-sm text-black">
                            <div className="card-header">
                                <ul className="nav nav-pills card-header-pills">
                                    <li className="nav-item">
                                        <a className={`nav-link ${currentTab == 'readme' ? 'bg-primary bg-gradient active' : ''}`} href="#"
                                            onClick={() => setCurrentTab('readme')}>
                                            Readme
                                        </a>
                                    </li>
                                    <li className="nav-item">
                                        <a className={`nav-link ${currentTab == 'versions' ? 'bg-primary bg-gradient active' : ''}`} href="#"
                                            onClick={() => setCurrentTab('versions')}>
                                            Versions
                                            <span className={`badge rounded-pill bg-danger ms-1`}>{crateInfo.versions.length}</span>
                                        </a>
                                    </li>
                                    <li className="nav-item">
                                        <a className={`nav-link ${currentTab == 'members' ? 'bg-primary bg-gradient active' : ''}`} href="#"
                                            onClick={() => setCurrentTab('members')}>
                                            Members
                                        </a>
                                    </li>
                                </ul>
                            </div>

                            <div className="card-body">
                                {currentTab == 'readme' ? <ReadMe crateInfo={crateVersion} /> : <></>}
                                {currentTab == 'versions' ? <>Versions</> : <></>}
                                {currentTab == 'members' ? <Members crate={crate} /> : <></>}
                            </div>
                        </div>
                    </div>

                    <div className="col-md-3">
                        <div className="card border-0 shadow-sm text-black">
                            <div className="card-body pb-0">
                                <h5 className="card-title">Dependencies</h5>
                            </div>

                            <ul className="list-group list-group-flush mb-2">
                                {crateVersion.deps.map(dep => (
                                    <li key={`${dep.name}-${dep.version_req}`} className="list-group-item">{dep.name} = "<strong>{dep.version_req}</strong>"</li>
                                ))}
                            </ul>
                        </div>

                        <div className="card border-0 shadow-sm text-black mt-4">
                            <div className="card-body pb-0">
                                <h5 className="card-title">Dependents</h5>
                            </div>

                            <ul className="list-group list-group-flush">
                                <li className="list-group-item">An item</li>
                                <li className="list-group-item">A second item</li>
                                <li className="list-group-item">A third item</li>
                            </ul>
                        </div>
                    </div>
                </div>
                <p className="m-0">{crateVersion.description}</p>
              </div>
            </div>
          </div>

          <div className="col-md-6">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <HouseDoor />{" "}
                <a href={crateVersion.homepage}>{crateVersion.homepage}</a>
                <br />
                <Book />{" "}
                <a href={crateVersion.documentation}>
                  {crateVersion.documentation}
                </a>
                <br />
                <Building />{" "}
                <a href={crateVersion.repository}>{crateVersion.repository}</a>
              </div>
            </div>
          </div>
        </div>

        <div className="row my-4">
          <div className="col-md-9">
            <div className="card border-0 shadow-sm text-black">
              <div className="card-header">
                <ul className="nav nav-pills card-header-pills">
                  <li className="nav-item">
                    <a
                      className={`nav-link ${
                        currentTab == "readme"
                          ? "bg-primary bg-gradient active"
                          : ""
                      }`}
                      href="#"
                      onClick={() => setCurrentTab("readme")}
                    >
                      Readme
                    </a>
                  </li>
                  <li className="nav-item">
                    <a
                      className={`nav-link ${
                        currentTab == "versions"
                          ? "bg-primary bg-gradient active"
                          : ""
                      }`}
                      href="#"
                      onClick={() => setCurrentTab("versions")}
                    >
                      Versions
                      <span className={`badge rounded-pill bg-danger ms-1`}>
                        {crateInfo.versions.length}
                      </span>
                    </a>
                  </li>
                  <li className="nav-item">
                    <a
                      className={`nav-link ${
                        currentTab == "members"
                          ? "bg-primary bg-gradient active"
                          : ""
                      }`}
                      href="#"
                      onClick={() => setCurrentTab("members")}
                    >
                      Members
                    </a>
                  </li>
                </ul>
              </div>

              <div className="card-body">
                {currentTab == "readme" ? (
                  <ReadMe crateInfo={crateVersion} />
                ) : (
                  <></>
                )}
                {currentTab == "versions" ? <>Versions</> : <></>}
                {currentTab == "members" ? <Members crate={crate} /> : <></>}
              </div>
            </div>
          </div>

          <div className="col-md-3">
            <div className="card border-0 shadow-sm text-black">
              <div className="card-body pb-0">
                <h5 className="card-title">Dependencies</h5>
              </div>

              <ul className="list-group list-group-flush mb-2">
                {crateVersion.deps.map((dep) => (
                  <li
                    key={`${dep.name}-${dep.version_req}`}
                    className="list-group-item"
                  >
                    {dep.name} = "<strong>{dep.version_req}</strong>"
                  </li>
                ))}
              </ul>
            </div>

            <div className="card border-0 shadow-sm text-black mt-4">
              <div className="card-body pb-0">
                <h5 className="card-title">Dependents</h5>
              </div>

              <ul className="list-group list-group-flush">
                <li className="list-group-item">An item</li>
                <li className="list-group-item">A second item</li>
                <li className="list-group-item">A third item</li>
              </ul>
            </div>
          </div>
        </div>
    );
      </div>
    </div>
  );
}

function ReadMe(props: { crateInfo: any }) {
    return (
        <ReactMarkdown children={props.crateInfo.readme} remarkPlugins={[remarkGfm]} components={{
            code({node, inline, className, children, ...props}) {
                const match = /language-(\w+)/.exec(className || '')
                return !inline && match ? (
                <Prism
                    children={String(children).replace(/\n$/, '')}
                    language={match[1]}
                    PreTag="pre"
                    {...props}
                />
                ) : (
                <code className={className} {...props}>
                    {children}
                </code>
                )
            }
        }} />
    );
  return (
    <ReactMarkdown
      children={props.crateInfo.readme}
      remarkPlugins={[remarkGfm]}
      components={{
        code({ node, inline, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || "");
          return !inline && match ? (
            <Prism
              children={String(children).replace(/\n$/, "")}
              language={match[1]}
              PreTag="pre"
              {...props}
            />
          ) : (
            <code className={className} {...props}>
              {children}
            </code>
          );
        },
      }}
    />
  );
}
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index f4c0258..e9742de 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -1,6 +1,12 @@
import React = require("react");
import { useState } from "react";
import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons';
import {
  PersonPlus,
  Trash,
  CheckLg,
  Save,
  PlusLg,
} from "react-bootstrap-icons";
import { authenticatedEndpoint, useAuthenticatedRequest } from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
@@ -9,362 +15,467 @@
import _ = require("lodash");

interface CratesMembersResponse {
    allowed_permissions: string[],
    members: Member[],
  allowed_permissions: string[];
  members: Member[];
}

interface Member {
    uuid: string,
    username: string,
    permissions: string[],
  uuid: string;
  username: string;
  permissions: string[];
}

export default function Members({ crate }: { crate: string }) {
    const auth = useAuth();
    const [reload, setReload] = useState(0);
    const { response, error } = useAuthenticatedRequest<CratesMembersResponse>({
        auth,
        endpoint: `crates/${crate}/members`,
    }, [reload]);
    const [prospectiveMembers, setProspectiveMembers] = useState([]);

    React.useEffect(() => {
        if (response && response.members) {
            setProspectiveMembers(prospectiveMembers.filter((prospectiveMember) => {
                _.findIndex(response.members, (responseMember) => responseMember.uuid === prospectiveMember.uuid) === -1
            }));
        }
    }, [response])

    if (error) {
        return <>{error}</>;
    } else if (!response) {
        return <div className="d-flex justify-content-center align-items-center">
            <div className="spinner-border text-light" role="status">
                <span className="visually-hidden">Loading...</span>
            </div>
        </div>;
  const auth = useAuth();
  const [reload, setReload] = useState(0);
  const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
    {
      auth,
      endpoint: `crates/${crate}/members`,
    },
    [reload]
  );
  const [prospectiveMembers, setProspectiveMembers] = useState([]);

  React.useEffect(() => {
    if (response && response.members) {
      setProspectiveMembers(
        prospectiveMembers.filter((prospectiveMember) => {
          _.findIndex(
            response.members,
            (responseMember) => responseMember.uuid === prospectiveMember.uuid
          ) === -1;
        })
      );
    }

    const allowedPermissions = response.allowed_permissions;

    return <div className="container-fluid g-0">
        <div className={/*"table-responsive"*/ ""}>
            <table className="table table-striped">
                <tbody>
                    {response.members.map((member, index) =>
                        <MemberListItem
                            key={index}
                            crate={crate}
                            member={member}
                            prospectiveMember={false}
                            allowedPermissions={allowedPermissions}
                            onUpdateComplete={() => setReload(reload + 1)}
                        />
                    )}

                    {prospectiveMembers.map((member, index) =>
                        <MemberListItem
                            key={index}
                            crate={crate}
                            member={member}
                            prospectiveMember={true}
                            allowedPermissions={allowedPermissions}
                            onUpdateComplete={() => setReload(reload + 1)}
                        />
                    )}

                    <MemberListInserter
                        onInsert={(username, userUuid) => setProspectiveMembers([
                            ...prospectiveMembers,
                            {
                                uuid: userUuid,
                                username,
                                permissions: ["VISIBLE"],
                            }
                        ])}
                        existingMembers={response.members}
                    />
                </tbody>
            </table>
  }, [response]);

  if (error) {
    return <>{error}</>;
  } else if (!response) {
    return (
      <div className="d-flex justify-content-center align-items-center">
        <div className="spinner-border text-light" role="status">
          <span className="visually-hidden">Loading...</span>
        </div>
    </div>;
}
      </div>
    );
  }

  const allowedPermissions = response.allowed_permissions;

  return (
    <div className="container-fluid g-0">
      <div className={/*"table-responsive"*/ ""}>
        <table className="table table-striped">
          <tbody>
            {response.members.map((member, index) => (
              <MemberListItem
                key={index}
                crate={crate}
                member={member}
                prospectiveMember={false}
                allowedPermissions={allowedPermissions}
                onUpdateComplete={() => setReload(reload + 1)}
              />
            ))}

function MemberListItem({ crate, member, prospectiveMember, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, prospectiveMember: boolean, allowedPermissions: string[], onUpdateComplete: () => any }) {
    const auth = useAuth();
    const [selectedPermissions, setSelectedPermissions] = useState(member.permissions);
    const [deleting, setDeleting] = useState(false);
    const [saving, setSaving] = useState(false);
    const [error, setError] = useState(null);

    let itemAction = <></>;

    const saveUserPermissions = async () => {
        setSaving(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
                method: prospectiveMember ? 'PUT' : 'PATCH',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    user_uuid: member.uuid,
                    permissions: selectedPermissions,
                }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            onUpdateComplete();
        } catch (e) {
            setError(error);
        } finally {
            setSaving(false);
        }
    };

    const doDelete = async () => {
        setSaving(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
                method: 'DELETE',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    user_uuid: member.uuid,
                }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            onUpdateComplete();
        } catch (e) {
            setError(error);
        } finally {
            setSaving(false);
        }
    };

    if (saving) {
        itemAction = <button type="button" className="btn">
            <div className="spinner-grow spinner-grow-sm text-primary" role="status">
                <span className="visually-hidden">Loading...</span>
            </div>
        </button>;
    } else if (!prospectiveMember && selectedPermissions.indexOf("VISIBLE") === -1) {
        itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
            <Trash />
        </button>;
    } else if (prospectiveMember || selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
        itemAction = <button type="button" className="btn text-success" onClick={saveUserPermissions}>
            <CheckLg />
        </button>;
    }
            {prospectiveMembers.map((member, index) => (
              <MemberListItem
                key={index}
                crate={crate}
                member={member}
                prospectiveMember={true}
                allowedPermissions={allowedPermissions}
                onUpdateComplete={() => setReload(reload + 1)}
              />
            ))}

    return <>
        <DeleteModal show={deleting === true}
            onCancel={() => setDeleting(false)}
            onConfirm={() => doDelete()}
            username={member.username} />

        <ErrorModal error={error} onClose={() => setError(null)} />

        <tr>
            <td className="align-middle fit">
                <img src="http://placekitten.com/48/48" className="rounded-circle" />
            </td>

            <td className="align-middle">
                <strong>{member.username}</strong><br />
                <em>(that's you!)</em>
            </td>

            <td className="align-middle">
                <RenderPermissions
                    allowedPermissions={allowedPermissions}
                    selectedPermissions={selectedPermissions}
                    userUuid={member.uuid}
                    onChange={setSelectedPermissions}
                />
            </td>

            <td className="align-middle fit">
                {itemAction}
            </td>
        </tr>
    </>;
            <MemberListInserter
              onInsert={(username, userUuid) =>
                setProspectiveMembers([
                  ...prospectiveMembers,
                  {
                    uuid: userUuid,
                    username,
                    permissions: ["VISIBLE"],
                  },
                ])
              }
              existingMembers={response.members}
            />
          </tbody>
        </table>
      </div>
    </div>
  );
}

function MemberListInserter({ onInsert, existingMembers }: { existingMembers: Member[], onInsert: (username, user_uuid) => any }) {
    const auth = useAuth();
    const searchRef = React.useRef(null);
    const [loading, setLoading] = useState(false);
    const [options, setOptions] = useState([]);
    const [error, setError] = useState("");

    const handleSearch = async (query) => {
        setLoading(true);
        setError("");

        try {
            let res = await fetch(authenticatedEndpoint(auth, `users/search?q=` + encodeURIComponent(query)));
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setOptions(json.users || []);
        } catch (e) {
            setError(e.message);
        } finally {
            setLoading(false);
function MemberListItem({
  crate,
  member,
  prospectiveMember,
  allowedPermissions,
  onUpdateComplete,
}: {
  crate: string;
  member: Member;
  prospectiveMember: boolean;
  allowedPermissions: string[];
  onUpdateComplete: () => any;
}) {
  const auth = useAuth();
  const [selectedPermissions, setSelectedPermissions] = useState(
    member.permissions
  );
  const [deleting, setDeleting] = useState(false);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);

  let itemAction = <></>;

  const saveUserPermissions = async () => {
    setSaving(true);

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `crates/${crate}/members`),
        {
          method: prospectiveMember ? "PUT" : "PATCH",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            user_uuid: member.uuid,
            permissions: selectedPermissions,
          }),
        }
    };

    const handleChange = (selected) => {
        onInsert(selected[0].username, selected[0].user_uuid);
        searchRef.current.clear();
      );
      let json = await res.json();

      if (json.error) {
        throw new Error(json.error);
      }

      onUpdateComplete();
    } catch (e) {
      setError(error);
    } finally {
      setSaving(false);
    }

    return <tr>
  };

  const doDelete = async () => {
    setSaving(true);

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `crates/${crate}/members`),
        {
          method: "DELETE",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            user_uuid: member.uuid,
          }),
        }
      );
      let json = await res.json();

      if (json.error) {
        throw new Error(json.error);
      }

      onUpdateComplete();
    } catch (e) {
      setError(error);
    } finally {
      setSaving(false);
    }
  };

  if (saving) {
    itemAction = (
      <button type="button" className="btn">
        <div
          className="spinner-grow spinner-grow-sm text-primary"
          role="status"
        >
          <span className="visually-hidden">Loading...</span>
        </div>
      </button>
    );
  } else if (
    !prospectiveMember &&
    selectedPermissions.indexOf("VISIBLE") === -1
  ) {
    itemAction = (
      <button
        type="button"
        className="btn text-danger"
        onClick={() => setDeleting(true)}
      >
        <Trash />
      </button>
    );
  } else if (
    prospectiveMember ||
    selectedPermissions.sort().join(",") != member.permissions.sort().join(",")
  ) {
    itemAction = (
      <button
        type="button"
        className="btn text-success"
        onClick={saveUserPermissions}
      >
        <CheckLg />
      </button>
    );
  }

  return (
    <>
      <DeleteModal
        show={deleting === true}
        onCancel={() => setDeleting(false)}
        onConfirm={() => doDelete()}
        username={member.username}
      />

      <ErrorModal error={error} onClose={() => setError(null)} />

      <tr>
        <td className="align-middle fit">
            <div
                className="d-flex align-items-center justify-content-center rounded-circle"
                style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
            >
                <PersonPlus />
            </div>
          <img src="http://placekitten.com/48/48" className="rounded-circle" />
        </td>

        <td className="align-middle">
            <AsyncTypeahead
                id="search-new-user"
                onSearch={handleSearch}
                filterBy={(option) => _.findIndex(existingMembers, (existing) => option.user_uuid === existing.uuid) === -1}
                labelKey="username"
                options={options}
                isLoading={loading}
                placeholder="Search for User"
                onChange={handleChange}
                ref={searchRef}
                renderMenuItemChildren={(option, props) => <>
                    <img
                        alt={option.username}
                        src="http://placekitten.com/24/24"
                        className="rounded-circle me-2"
                    />
                    <span>{option.username}</span>
                </>}
            />

            <div className="text-danger">{error}</div>
          <strong>{member.username}</strong>
          <br />
          <em>(that's you!)</em>
        </td>

        <td className="align-middle">
          <RenderPermissions
            allowedPermissions={allowedPermissions}
            selectedPermissions={selectedPermissions}
            userUuid={member.uuid}
            onChange={setSelectedPermissions}
          />
        </td>

        <td className="align-middle">
            <button type="button" className="btn text-dark pe-none">
                <PlusLg />
            </button>
        </td>
    </tr>;
        <td className="align-middle fit">{itemAction}</td>
      </tr>
    </>
  );
}

function RenderPermissions({ allowedPermissions, selectedPermissions, userUuid, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userUuid: number, onChange: (permissions) => any }) {
    return (
        <div className="row ms-2">
            {allowedPermissions.map((permission) => (
                <div key={permission + userUuid} className="form-check col-12 col-md-6">
                    <input
                        className="form-check-input"
                        type="checkbox"
                        value="1"
                        id={`checkbox-${userUuid}-${permission}`}
                        checked={selectedPermissions.indexOf(permission) > -1}
                        onChange={(e) => {
                            let newUserPermissions = new Set(selectedPermissions);

                            if (e.target.checked) {
                                newUserPermissions.add(permission);
                            } else {
                                newUserPermissions.delete(permission);
                            }

                            onChange(Array.from(newUserPermissions));
                        }}
                    />
                    <label className="form-check-label" htmlFor={`checkbox-${userUuid}-${permission}`}>
                        {permission}
                    </label>
                </div>
            ))}
function MemberListInserter({
  onInsert,
  existingMembers,
}: {
  existingMembers: Member[];
  onInsert: (username, user_uuid) => any;
}) {
  const auth = useAuth();
  const searchRef = React.useRef(null);
  const [loading, setLoading] = useState(false);
  const [options, setOptions] = useState([]);
  const [error, setError] = useState("");

  const handleSearch = async (query) => {
    setLoading(true);
    setError("");

    try {
      let res = await fetch(
        authenticatedEndpoint(
          auth,
          `users/search?q=` + encodeURIComponent(query)
        )
      );
      let json = await res.json();

      if (json.error) {
        throw new Error(json.error);
      }

      setOptions(json.users || []);
    } catch (e) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  };

  const handleChange = (selected) => {
    onInsert(selected[0].username, selected[0].user_uuid);
    searchRef.current.clear();
  };

  return (
    <tr>
      <td className="align-middle fit">
        <div
          className="d-flex align-items-center justify-content-center rounded-circle"
          style={{
            width: "48px",
            height: "48px",
            background: "#DEDEDE",
            fontSize: "1rem",
          }}
        >
          <PersonPlus />
        </div>
    );
      </td>

      <td className="align-middle">
        <AsyncTypeahead
          id="search-new-user"
          onSearch={handleSearch}
          filterBy={(option) =>
            _.findIndex(
              existingMembers,
              (existing) => option.user_uuid === existing.uuid
            ) === -1
          }
          labelKey="username"
          options={options}
          isLoading={loading}
          placeholder="Search for User"
          onChange={handleChange}
          ref={searchRef}
          renderMenuItemChildren={(option, props) => (
            <>
              <img
                alt={option.username}
                src="http://placekitten.com/24/24"
                className="rounded-circle me-2"
              />
              <span>{option.username}</span>
            </>
          )}
        />

        <div className="text-danger">{error}</div>
      </td>

      <td className="align-middle"></td>

      <td className="align-middle">
        <button type="button" className="btn text-dark pe-none">
          <PlusLg />
        </button>
      </td>
    </tr>
  );
}

function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, username: string }) {
    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 remove this member from the crate?
                </Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <p>
                Are you sure you wish to remove <strong>{props.username}</strong> from the crate?
                </p>
            </Modal.Body>
            <Modal.Footer>
                <Button onClick={props.onCancel} variant="primary">Close</Button>
                <Button onClick={props.onConfirm} variant="danger">Delete</Button>
            </Modal.Footer>
        </Modal>
    );
function RenderPermissions({
  allowedPermissions,
  selectedPermissions,
  userUuid,
  onChange,
}: {
  allowedPermissions: string[];
  selectedPermissions: string[];
  userUuid: number;
  onChange: (permissions) => any;
}) {
  return (
    <div className="row ms-2">
      {allowedPermissions.map((permission) => (
        <div key={permission + userUuid} className="form-check col-12 col-md-6">
          <input
            className="form-check-input"
            type="checkbox"
            value="1"
            id={`checkbox-${userUuid}-${permission}`}
            checked={selectedPermissions.indexOf(permission) > -1}
            onChange={(e) => {
              let newUserPermissions = new Set(selectedPermissions);

              if (e.target.checked) {
                newUserPermissions.add(permission);
              } else {
                newUserPermissions.delete(permission);
              }

              onChange(Array.from(newUserPermissions));
            }}
          />
          <label
            className="form-check-label"
            htmlFor={`checkbox-${userUuid}-${permission}`}
          >
            {permission}
          </label>
        </div>
      ))}
    </div>
  );
}

function ErrorModal(props: { error?: string, onClose: () => void }) {
    return (
        <Modal
            show={props.error != null}
            onHide={props.onClose}
            size="lg"
            aria-labelledby="error-modal-title"
            centered
        >
            <Modal.Header closeButton>
                <Modal.Title id="error-modal-title">
                    Error
                </Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <p>
                    {props.error}
                </p>
            </Modal.Body>
            <Modal.Footer>
                <Button onClick={props.onClose} variant="primary">Close</Button>
            </Modal.Footer>
        </Modal>
    );
function DeleteModal(props: {
  show: boolean;
  onCancel: () => void;
  onConfirm: () => void;
  username: string;
}) {
  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 remove this member from the crate?
        </Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <p>
          Are you sure you wish to remove <strong>{props.username}</strong> from
          the crate?
        </p>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={props.onCancel} variant="primary">
          Close
        </Button>
        <Button onClick={props.onConfirm} variant="danger">
          Delete
        </Button>
      </Modal.Footer>
    </Modal>
  );
}

function ErrorModal(props: { error?: string; onClose: () => void }) {
  return (
    <Modal
      show={props.error != null}
      onHide={props.onClose}
      size="lg"
      aria-labelledby="error-modal-title"
      centered
    >
      <Modal.Header closeButton>
        <Modal.Title id="error-modal-title">Error</Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <p>{props.error}</p>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={props.onClose} variant="primary">
          Close
        </Button>
      </Modal.Footer>
    </Modal>
  );
}
diff --git a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
index adb7a5e..11483ae 100644
--- a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
@@ -1,6 +1,6 @@
import React = require("react");
import { useState, useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link, useHistory } from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -9,77 +9,102 @@
import { Plus } from "react-bootstrap-icons";

export default function ListSshKeys() {
    const auth = useAuth();
    const router = useHistory();

    const [sshKey, setSshKey] = useState("");
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState("");

    const submitSshKey = async (evt) => {
        evt.preventDefault();

        setError("");
        setLoading(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, 'ssh-key'), {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ key: sshKey }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setSshKey("");
            router.push("/ssh-keys/list");
        } catch (e) {
            setError(e.message);
        } finally {
            setLoading(false);
        }
    };

    return (
        <div className="text-white">
            <Nav />

            <div className="container mt-4 pb-4">
                <h1>New SSH Key</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("")}>
                    </button>
                </div>

                <div className="card border-0 shadow-sm text-black">
                    <div className="card-body">
                        <form onSubmit={submitSshKey}>
                            <textarea className="form-control" rows={3}
                                placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILYAIoV2OKRSh/DcM3TicD/NK/4TdqwwBPbKgFQKmGZ3 john@home"
                                onChange={e => setSshKey(e.target.value)}
                                value={sshKey}
                            />

                            <div className="clearfix"></div>

                            <button type="submit" className="btn btn-success mt-2 float-end" style={{ display: !loading ? 'block' : 'none' }}>Submit</button>
                            <div className="spinner-border text-primary mt-4 float-end" role="status" style={{ display: loading ? 'block' : 'none' }}>
                                <span className="visually-hidden">Submitting...</span>
                            </div>

                            <Link to="/ssh-keys/list" className="btn btn-danger mt-2 float-end me-1">Cancel</Link>
                        </form>
                    </div>
                </div>
            </div>
  const auth = useAuth();
  const router = useHistory();

  const [sshKey, setSshKey] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const submitSshKey = async (evt) => {
    evt.preventDefault();

    setError("");
    setLoading(true);

    try {
      let res = await fetch(authenticatedEndpoint(auth, "ssh-key"), {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ key: sshKey }),
      });
      let json = await res.json();

      if (json.error) {
        throw new Error(json.error);
      }

      setSshKey("");
      router.push("/ssh-keys/list");
    } catch (e) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <h1>New SSH Key</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("")}
          ></button>
        </div>
    );
}

        <div className="card border-0 shadow-sm text-black">
          <div className="card-body">
            <form onSubmit={submitSshKey}>
              <textarea
                className="form-control"
                rows={3}
                placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILYAIoV2OKRSh/DcM3TicD/NK/4TdqwwBPbKgFQKmGZ3 john@home"
                onChange={(e) => setSshKey(e.target.value)}
                value={sshKey}
              />

              <div className="clearfix"></div>

              <button
                type="submit"
                className="btn btn-success mt-2 float-end"
                style={{ display: !loading ? "block" : "none" }}
              >
                Submit
              </button>
              <div
                className="spinner-border text-primary mt-4 float-end"
                role="status"
                style={{ display: loading ? "block" : "none" }}
              >
                <span className="visually-hidden">Submitting...</span>
              </div>

              <Link
                to="/ssh-keys/list"
                className="btn btn-danger mt-2 float-end me-1"
              >
                Cancel
              </Link>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
index c7b17a9..e9f54dd 100644
--- a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
@@ -1,6 +1,6 @@
import React = require("react");
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -13,140 +13,191 @@
import Loading from "../Loading";

interface SshKeysResponse {
    keys: SshKeysResponseKey[],
  keys: SshKeysResponseKey[];
}

interface SshKeysResponseKey {
    uuid: string,
    name: string,
    fingerprint: string,
    created_at: string,
    last_used_at: string,
  uuid: string;
  name: string;
  fingerprint: string;
  created_at: string;
  last_used_at: string;
}

export default function ListSshKeys() {
    const auth = useAuth();
  const auth = useAuth();

  const [error, setError] = useState("");
  const [deleting, setDeleting] = useState(null);
  const [reloadSshKeys, setReloadSshKeys] = useState(0);

  const { response: sshKeys, error: loadError } =
    useAuthenticatedRequest<SshKeysResponse>(
      {
        auth,
        endpoint: "ssh-key",
      },
      [reloadSshKeys]
    );

    const [error, setError] = useState("");
    const [deleting, setDeleting] = useState(null);
    const [reloadSshKeys, setReloadSshKeys] = useState(0);
  if (loadError) {
    return <ErrorPage message={loadError} />;
  } else if (!sshKeys) {
    return <Loading />;
  }

    const { response: sshKeys, error: loadError } = useAuthenticatedRequest<SshKeysResponse>({
        auth,
        endpoint: 'ssh-key',
    }, [reloadSshKeys]);

    if (loadError) {
        return <ErrorPage message={loadError} />;
    } else if (!sshKeys) {
        return <Loading />;
  const deleteKey = async () => {
    setError("");

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `ssh-key/${deleting.uuid}`),
        {
          method: "DELETE",
          headers: {
            "Content-Type": "application/json",
          },
        }
      );
      let json = await res.json();

      if (json.error) {
        throw new Error(json.error);
      }

      setReloadSshKeys(reloadSshKeys + 1);
    } catch (e) {
      setError(e.message);
    } finally {
      setDeleting(null);
    }
  };

  const dateMonthAgo = new Date();
  dateMonthAgo.setMonth(dateMonthAgo.getMonth() - 1);

  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <h1>Manage your SSH Keys</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("")}
          ></button>
        </div>

    const deleteKey = async () => {
        setError("");

        try {
            let res = await fetch(authenticatedEndpoint(auth, `ssh-key/${deleting.uuid}`), {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                },
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setReloadSshKeys(reloadSshKeys + 1);
        } catch (e) {
            setError(e.message);
        } finally {
            setDeleting(null);
        }
    };

    const dateMonthAgo = new Date();
    dateMonthAgo.setMonth(dateMonthAgo.getMonth() - 1);

    return (
        <div className="text-white">
            <Nav />

            <div className="container mt-4 pb-4">
                <h1>Manage your SSH Keys</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("")}>
                    </button>
                </div>

                <div className="card border-0 shadow-sm text-black">
                    <div className="table-responsive">
                        <table className="table table-striped">
                            <tbody>
                                {sshKeys.keys.map(key => (
                                    <tr key={key.uuid}>
                                        <td className="align-middle">
                                            <h6 className="m-0 lh-sm">{key.name}</h6>
                                            <pre className="m-0">{key.fingerprint}</pre>
                                            <div className="lh-sm" style={{ fontSize: '.75rem' }}>
                                                <span className="text-muted">Added <HumanTime time={key.created_at} /></span>
                                                <span className="mx-2"></span>
                                                <span className={`text-${key.last_used_at ? (new Date(key.last_used_at) > dateMonthAgo ? 'success' : 'danger') : 'muted'}`}>
                                                    Last used {key.last_used_at ? <HumanTime time={key.last_used_at} /> : <>never</>}
                                                </span>
                                            </div>
                                        </td>

                                        <td className="align-middle fit">
                                            <button type="button" className="btn text-danger" onClick={() => setDeleting(key)}>
                                                <Trash />
                                            </button>
                                        </td>
                                    </tr>
                                ))}
                            </tbody>
                        </table>
                    </div>
                </div>

                <Link to="/ssh-keys/add" className="btn btn-outline-light mt-2 float-end"><Plus /> Add New</Link>
            </div>

            <DeleteModal show={deleting != null}
                onCancel={() => setDeleting(null)}
                onConfirm={() => deleteKey()}
                fingerprint={deleting?.fingerprint} />
        <div className="card border-0 shadow-sm text-black">
          <div className="table-responsive">
            <table className="table table-striped">
              <tbody>
                {sshKeys.keys.map((key) => (
                  <tr key={key.uuid}>
                    <td className="align-middle">
                      <h6 className="m-0 lh-sm">{key.name}</h6>
                      <pre className="m-0">{key.fingerprint}</pre>
                      <div className="lh-sm" style={{ fontSize: ".75rem" }}>
                        <span className="text-muted">
                          Added <HumanTime time={key.created_at} />
                        </span>
                        <span className="mx-2"></span>
                        <span
                          className={`text-${
                            key.last_used_at
                              ? new Date(key.last_used_at) > dateMonthAgo
                                ? "success"
                                : "danger"
                              : "muted"
                          }`}
                        >
                          Last used{" "}
                          {key.last_used_at ? (
                            <HumanTime time={key.last_used_at} />
                          ) : (
                            <>never</>
                          )}
                        </span>
                      </div>
                    </td>

                    <td className="align-middle fit">
                      <button
                        type="button"
                        className="btn text-danger"
                        onClick={() => setDeleting(key)}
                      >
                        <Trash />
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
    );

        <Link
          to="/ssh-keys/add"
          className="btn btn-outline-light mt-2 float-end"
        >
          <Plus /> Add New
        </Link>
      </div>

      <DeleteModal
        show={deleting != null}
        onCancel={() => setDeleting(null)}
        onConfirm={() => deleteKey()}
        fingerprint={deleting?.fingerprint}
      />
    </div>
  );
}

function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, fingerprint: string }) {
    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 SSH key?
          </Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <p>
            Are you sure you wish to delete the SSH key with the fingerprint: <strong>{props.fingerprint}</strong>?
          </p>
        </Modal.Body>
        <Modal.Footer>
          <Button onClick={props.onCancel} variant="primary">Close</Button>
          <Button onClick={props.onConfirm} variant="danger">Delete</Button>
        </Modal.Footer>
      </Modal>
    );
  }
function DeleteModal(props: {
  show: boolean;
  onCancel: () => void;
  onConfirm: () => void;
  fingerprint: string;
}) {
  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 SSH key?
        </Modal.Title>
      </Modal.Header>
      <Modal.Body>
        <p>
          Are you sure you wish to delete the SSH key with the fingerprint:{" "}
          <strong>{props.fingerprint}</strong>?
        </p>
      </Modal.Body>
      <Modal.Footer>
        <Button onClick={props.onCancel} variant="primary">
          Close
        </Button>
        <Button onClick={props.onConfirm} variant="danger">
          Delete
        </Button>
      </Modal.Footer>
    </Modal>
  );
}