🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-18 0:35:14.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-18 0:35:14.0 +01:00:00
commit
07019257caad5f9822b053b649f84616c6f02f64 [patch]
tree
2c8b41e2def1c1e2d9132fc4d7a7a19f4c68c91e
parent
c5ead1ddb7ce8e116df14369adb05a6808cbf4b3
download
07019257caad5f9822b053b649f84616c6f02f64.tar.gz

Implement Web UI management of existing member permissions



Diff

 Cargo.lock                                            |  26 ++++++++++++++++++++++++++
 chartered-db/Cargo.toml                               |   1 +
 chartered-db/src/crates.rs                            |  74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-db/src/users.rs                             |  15 ++++++++++++---
 chartered-frontend/src/index.tsx                      |   4 ++--
 chartered-git/src/main.rs                             |   2 +-
 chartered-web/src/main.rs                             |  17 +++++++++++++++--
 chartered-frontend/src/pages/SingleCrate.tsx          | 252 --------------------------------------------------------------------------------
 chartered-web/src/endpoints/mod.rs                    |   4 ++--
 chartered-frontend/src/pages/crate/CrateView.tsx      | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/crate/Members.tsx        | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/crate_info.rs     |  73 -------------------------------------------------------------------------
 chartered-web/src/endpoints/web_api/mod.rs            |   3 +--
 chartered-web/src/endpoints/web_api/ssh_key.rs        |  20 ++++++--------------
 chartered-web/src/endpoints/web_api/crates/info.rs    |  73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/crates/members.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/crates/mod.rs     |   7 +++++++
 17 files changed, 804 insertions(+), 355 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index bbf6099..ba708b9 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -203,6 +203,7 @@
 "dotenv",
 "hex",
 "itertools",
 "option_set",
 "rand",
 "serde",
 "serde_json",
@@ -660,6 +661,15 @@
]

[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
 "unicode-segmentation",
]

[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -956,6 +966,16 @@
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"

[[package]]
name = "option_set"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b2d96bdcfa87852ffdc8e77e5fb544ffe2f85ed60568f5b62c98a05ec9a9b"
dependencies = [
 "heck",
 "serde",
]

[[package]]
name = "parking_lot"
@@ -1592,6 +1612,12 @@
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"

[[package]]
name = "unicode-segmentation"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b"

[[package]]
name = "unicode-xid"
diff --git a/chartered-db/Cargo.toml b/chartered-db/Cargo.toml
index 3f25c0e..04422f0 100644
--- a/chartered-db/Cargo.toml
+++ a/chartered-db/Cargo.toml
@@ -16,6 +16,7 @@
displaydoc = "0.2"
hex = "0.4"
itertools = "0.10"
option_set = "0.1"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs
index 8c5f079..0a98e2b 100644
--- a/chartered-db/src/crates.rs
+++ a/chartered-db/src/crates.rs
@@ -1,3 +1,5 @@
use crate::users::UserCratePermission;

use super::{
    schema::{crate_versions, crates},
    BitwiseExpressionMethods, ConnectionPool, Result,
@@ -161,13 +163,11 @@

    pub async fn owners(self: Arc<Self>, conn: ConnectionPool) -> Result<Vec<crate::users::User>> {
        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::{
                dsl::permissions, dsl::user_crate_permissions,
            };
            use crate::schema::user_crate_permissions::dsl::permissions;

            let conn = conn.get()?;

            Ok(user_crate_permissions
            Ok(UserCratePermission::belonging_to(&*self)
                .filter(
                    permissions
                        .bitwise_and(crate::users::UserCratePermissionValue::MANAGE_USERS.bits())
@@ -176,6 +176,72 @@
                .inner_join(crate::schema::users::dsl::users)
                .select(crate::schema::users::all_columns)
                .load::<crate::users::User>(&conn)?)
        })
        .await?
    }

    pub async fn members(
        self: Arc<Self>,
        conn: ConnectionPool,
    ) -> Result<Vec<(crate::users::User, crate::users::UserCratePermissionValue)>> {
        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(UserCratePermission::belonging_to(&*self)
                .inner_join(crate::schema::users::dsl::users)
                .select((
                    crate::schema::users::all_columns,
                    crate::schema::user_crate_permissions::permissions,
                ))
                .load(&conn)?)
        })
        .await?
    }

    pub async fn update_permissions(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, permissions, user_crate_permissions, user_id,
            };

            let conn = conn.get()?;

            Ok(diesel::update(
                user_crate_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(crate_id.eq(self.id)),
            )
            .set(permissions.eq(given_permissions.bits()))
            .execute(&conn)?)
        })
        .await?
    }

    pub async fn delete_member(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
    ) -> Result<()> {
        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, user_crate_permissions, user_id,
            };

            let conn = conn.get()?;

            diesel::delete(
                user_crate_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(crate_id.eq(self.id))
            )
            .execute(&conn)?;

            Ok(())
        })
        .await?
    }
diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 4bffdaa..e7f9ba7 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -1,8 +1,10 @@
use super::{
    schema::{user_crate_permissions, user_sessions, user_ssh_keys, users},
    ConnectionPool, Result,
};
use bitflags::bitflags;
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
use option_set::{option_set, OptionSet};
use rand::{thread_rng, Rng};
use std::sync::Arc;
use thrussh_keys::PublicKeyBase64;
@@ -232,9 +234,9 @@
    }
}

bitflags::bitflags! {
    #[derive(FromSqlRow, AsExpression, Default)]
    pub struct UserCratePermissionValue: i32 {
option_set! {
    #[derive(FromSqlRow, AsExpression)]
    pub struct UserCratePermissionValue: Identity + i32 {
        const VISIBLE         = 0b0000_0000_0000_0000_0000_0000_0000_0001;
        const PUBLISH_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0010;
        const YANK_VERSION    = 0b0000_0000_0000_0000_0000_0000_0000_0100;
@@ -242,6 +244,12 @@
    }
}

impl UserCratePermissionValue {
    pub fn names() -> &'static [&'static str] {
        Self::NAMES
    }
}

impl<B: diesel::backend::Backend> diesel::deserialize::FromSql<diesel::sql_types::Integer, B>
    for UserCratePermissionValue
where
@@ -258,6 +266,7 @@

#[derive(Identifiable, Queryable, Associations, Default, PartialEq, Eq, Hash, Debug)]
#[belongs_to(User)]
#[belongs_to(super::crates::Crate)]
pub struct UserCratePermission {
    pub id: i32,
    pub user_id: i32,
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index b8aaad5..c1d1caf 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -16,7 +16,7 @@

import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import SingleCrate from "./pages/SingleCrate";
import CrateView from "./pages/crate/CrateView";
import ListSshKeys from "./pages/ssh-keys/ListSshKeys";
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";

@@ -29,7 +29,7 @@

                    <PrivateRoute exact path="/" component={() => <Redirect to="/dashboard" />} />
                    <PrivateRoute exact path="/dashboard" component={() => <Dashboard />} />
                    <PrivateRoute exact path="/crates/:crate" component={() => <SingleCrate />} />
                    <PrivateRoute exact path="/crates/:crate" component={() => <CrateView />} />
                    <PrivateRoute exact path="/ssh-keys/list" component={() => <ListSshKeys />} />
                    <PrivateRoute exact path="/ssh-keys/add" component={() => <AddSshKeys />} />
                </Switch>
diff --git a/chartered-git/src/main.rs b/chartered-git/src/main.rs
index f4ff6bf..7e44478 100644
--- a/chartered-git/src/main.rs
+++ a/chartered-git/src/main.rs
@@ -11,6 +11,7 @@
use bytes::BytesMut;
use chrono::TimeZone;
use futures::future::Future;
use log::warn;
use std::collections::BTreeMap;
use std::{fmt::Write, pin::Pin, sync::Arc};
use thrussh::{
@@ -19,7 +20,6 @@
};
use thrussh_keys::{key, PublicKeyBase64};
use tokio_util::codec::{Decoder, Encoder as TokioEncoder};
use log::warn;

#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)] // broken clippy lint
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 75f6620..5ba3a94 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -5,7 +5,7 @@
mod middleware;

use axum::{
    handler::{delete, get, post, put},
    handler::{delete, get, patch, post, put},
    http::Method,
    AddExtensionLayer, Router,
};
@@ -69,7 +69,19 @@
        axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login)));

    let web_authenticated = axum_box_after_every_route!(Router::new()
        .route("/crates/:crate", get(endpoints::web_api::crate_info))
        .route("/crates/:crate", get(endpoints::web_api::crates::info))
        .route(
            "/crates/:crate/members",
            get(endpoints::web_api::crates::get_members)
        )
        .route(
            "/crates/:crate/members",
            patch(endpoints::web_api::crates::update_members)
        )
        .route(
            "/crates/:crate/members",
            delete(endpoints::web_api::crates::delete_member)
        )
        .route("/ssh-key", get(endpoints::web_api::get_ssh_keys))
        .route("/ssh-key", put(endpoints::web_api::add_ssh_key))
        .route("/ssh-key/:id", delete(endpoints::web_api::delete_ssh_key)))
@@ -95,6 +107,7 @@
                .allow_methods(vec![
                    Method::GET,
                    Method::POST,
                    Method::PATCH,
                    Method::DELETE,
                    Method::PUT,
                    Method::OPTIONS,
diff --git a/chartered-frontend/src/pages/SingleCrate.tsx b/chartered-frontend/src/pages/SingleCrate.tsx
deleted file mode 100644
index 81b3078..0000000 100644
--- a/chartered-frontend/src/pages/SingleCrate.tsx
+++ /dev/null
@@ -1,252 +1,0 @@
import React = require('react');

import { useState, useEffect } from 'react';

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

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

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

interface CrateInfo {
    versions: CrateInfoVersion[],
}

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

interface CrateInfoVersionDependency {
    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>
                </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 crateInfo={crateVersion} /> : <></>}
                            </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>
                )
            }
        }} />
    );
}

function Members(props: { crateInfo: CrateInfoVersion }) {
    const x = ["John Paul", "David Davidson", "Andrew Smith"];

    return <div className="container-fluid g-0">
        <div className="table-responsive">
            <table className="table table-striped">
                <tbody>
                    {x.map(v =>
                        <tr key={v}>
                            <td className="align-middle fit">
                                <img src="http://placekitten.com/48/48" className="rounded-circle" />
                            </td>

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

                            <td className="align-middle">
                                <div className="d-flex">
                                    <div>
                                        <div className="form-check">
                                            <input className="form-check-input" type="checkbox" value="" id="visible" />
                                            <label className="form-check-label" htmlFor="visible">
                                                Visible
                                            </label>
                                        </div>

                                        <div className="form-check">
                                            <input className="form-check-input" type="checkbox" value="" id="publish_version" />
                                            <label className="form-check-label" htmlFor="visible">
                                                Publish Version
                                            </label>
                                        </div>
                                    </div>

                                    <div className="ms-3">
                                        <div className="form-check">
                                            <input className="form-check-input" type="checkbox" value="" id="visible" />
                                            <label className="form-check-label" htmlFor="visible">
                                                Yank Version
                                            </label>
                                        </div>

                                        <div className="form-check">
                                            <input className="form-check-input" type="checkbox" value="" id="publish_version" />
                                            <label className="form-check-label" htmlFor="visible">
                                                Manage Users
                                            </label>
                                        </div>
                                    </div>
                                </div>
                            </td>
                        </tr>
                    )}

                    <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></td>

                        <td></td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>;
}
diff --git a/chartered-web/src/endpoints/mod.rs b/chartered-web/src/endpoints/mod.rs
index e8b3b14..08a6267 100644
--- a/chartered-web/src/endpoints/mod.rs
+++ a/chartered-web/src/endpoints/mod.rs
@@ -12,7 +12,7 @@

#[derive(serde::Serialize)]
pub struct ErrorResponse {
    error: String,
    error: Option<String>,
}

macro_rules! define_error_response {
@@ -25,7 +25,7 @@

            fn into_response(self) -> axum::http::Response<Self::Body> {
                let body = serde_json::to_vec(&crate::endpoints::ErrorResponse {
                    error: self.to_string(),
                    error: Some(self.to_string()),
                })
                .unwrap();

diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
new file mode 100644
index 0000000..b54305b 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -1,0 +1,177 @@
import React = require('react');

import { useState, useEffect } from 'react';

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 { useParams } from "react-router-dom";
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';

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

export interface CrateInfo {
    versions: CrateInfoVersion[],
}

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

export interface CrateInfoVersionDependency {
    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>
                </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>
                )
            }
        }} />
    );
}
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
new file mode 100644
index 0000000..9412f95 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -1,0 +1,282 @@
import React = require("react");
import { useState } from "react";
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";

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

interface Member {
    id: number,
    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]);

    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 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}
                            allowedPermissions={allowedPermissions}
                            onUpdateComplete={() => setReload(reload + 1)}
                        />
                    )}

                    <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">
                            <input type="search" className="form-control" placeholder="Search for User" />
                        </td>

                        <td className="align-middle">
                            <RenderPermissions allowedPermissions={allowedPermissions} selectedPermissions={[]} userId={-1} />
                        </td>

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

function MemberListItem({ crate, member, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, 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: 'PATCH',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    user_id: member.id,
                    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_id: member.id,
                }),
            });
            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 (selectedPermissions.indexOf("VISIBLE") === -1) {
        itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
            <Trash />
        </button>;
    } else if (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">
                <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}
                    userId={member.id}
                    onChange={setSelectedPermissions}
                />
            </td>

            <td className="align-middle fit">
                {itemAction}
            </td>
        </tr>
    </>;
}

function RenderPermissions({ allowedPermissions, selectedPermissions, userId, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userId: number, onChange: (permissions) => any }) {
    return (
        <div className="row ms-2">
            {allowedPermissions.map((permission) => (
                <div key={permission + userId} className="form-check col-12 col-md-6">
                    <input
                        className="form-check-input"
                        type="checkbox"
                        value="1"
                        id={`checkbox-${userId}-${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-${userId}-${permission}`}>
                        {permission}
                    </label>
                </div>
            ))}
        </div>
    );
}

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-web/src/endpoints/web_api/crate_info.rs b/chartered-web/src/endpoints/web_api/crate_info.rs
deleted file mode 100644
index 0359c85..0000000 100644
--- a/chartered-web/src/endpoints/web_api/crate_info.rs
+++ /dev/null
@@ -1,73 +1,0 @@
use axum::{extract, Json};
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_types::cargo::{CrateVersion, CrateVersionMetadata};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to fetch crate file")]
    File(#[from] std::io::Error),
    #[error("The requested crate does not exist")]
    NoCrate,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoCrate => StatusCode::NOT_FOUND,
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate);

    let versions = crate_.clone().versions(db).await?;

    Ok(Json(Response {
        versions: versions
            .into_iter()
            .map(|v| {
                let (inner, meta) = v.into_cargo_format(&crate_);
                ResponseVersion {
                    inner: inner.into_owned(),
                    meta,
                }
            })
            .collect(),
    }))
}

#[derive(Serialize)]
pub struct ResponseVersion {
    #[serde(flatten)]
    meta: CrateVersionMetadata,
    #[serde(flatten)]
    inner: CrateVersion<'static>,
}

#[derive(Serialize)]
pub struct Response {
    versions: Vec<ResponseVersion>,
}
diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs
index 127f1eb..8920f76 100644
--- a/chartered-web/src/endpoints/web_api/mod.rs
+++ a/chartered-web/src/endpoints/web_api/mod.rs
@@ -1,8 +1,7 @@
mod crate_info;
pub mod crates;
mod login;
mod ssh_key;

pub use crate_info::handle as crate_info;
pub use login::handle as login;
pub use ssh_key::{
    handle_delete as delete_ssh_key, handle_get as get_ssh_keys, handle_put as add_ssh_key,
diff --git a/chartered-web/src/endpoints/web_api/ssh_key.rs b/chartered-web/src/endpoints/web_api/ssh_key.rs
index c5cba09..272f170 100644
--- a/chartered-web/src/endpoints/web_api/ssh_key.rs
+++ a/chartered-web/src/endpoints/web_api/ssh_key.rs
@@ -7,6 +7,8 @@
use std::sync::Arc;
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[derive(Serialize)]
pub struct GetResponse {
    keys: Vec<GetResponseKey>,
@@ -47,39 +49,29 @@
#[derive(Deserialize)]
pub struct PutRequest {
    key: String,
}

#[derive(Serialize)]
pub struct PutResponse {
    error: bool,
}

pub async fn handle_put(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutRequest>,
) -> Result<Json<PutResponse>, Error> {
) -> Result<Json<ErrorResponse>, Error> {
    match user.insert_ssh_key(db, &req.key).await {
        Ok(()) => Ok(Json(PutResponse { error: false })),
        Ok(()) => Ok(Json(ErrorResponse { error: None })),
        Err(e @ chartered_db::Error::KeyParse(_)) => Err(Error::KeyParse(e)),
        Err(e) => Err(Error::Database(e)),
    }
}

#[derive(Serialize)]
pub struct DeleteResponse {
    error: bool,
}

pub async fn handle_delete(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Path((_session_key, ssh_key_id)): extract::Path<(String, i32)>,
) -> Result<Json<DeleteResponse>, Error> {
) -> Result<Json<ErrorResponse>, Error> {
    let deleted = user.delete_user_ssh_key_by_id(db, ssh_key_id).await?;

    if deleted {
        Ok(Json(DeleteResponse { error: false }))
        Ok(Json(ErrorResponse { error: None }))
    } else {
        Err(Error::NonExistentKey)
    }
diff --git a/chartered-web/src/endpoints/web_api/crates/info.rs b/chartered-web/src/endpoints/web_api/crates/info.rs
new file mode 100644
index 0000000..0359c85 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/crates/info.rs
@@ -1,0 +1,73 @@
use axum::{extract, Json};
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use chartered_types::cargo::{CrateVersion, CrateVersionMetadata};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to fetch crate file")]
    File(#[from] std::io::Error),
    #[error("The requested crate does not exist")]
    NoCrate,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoCrate => StatusCode::NOT_FOUND,
        }
    }
}

define_error_response!(Error);

pub async fn handle(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate);

    let versions = crate_.clone().versions(db).await?;

    Ok(Json(Response {
        versions: versions
            .into_iter()
            .map(|v| {
                let (inner, meta) = v.into_cargo_format(&crate_);
                ResponseVersion {
                    inner: inner.into_owned(),
                    meta,
                }
            })
            .collect(),
    }))
}

#[derive(Serialize)]
pub struct ResponseVersion {
    #[serde(flatten)]
    meta: CrateVersionMetadata,
    #[serde(flatten)]
    inner: CrateVersion<'static>,
}

#[derive(Serialize)]
pub struct Response {
    versions: Vec<ResponseVersion>,
}
diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs
new file mode 100644
index 0000000..de2942b 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/crates/members.rs
@@ -1,0 +1,129 @@
use axum::{extract, Json};
use chartered_db::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[derive(Serialize)]
pub struct GetResponse {
    allowed_permissions: &'static [&'static str],
    members: Vec<GetResponseMember>,
}

#[derive(Deserialize, Serialize)]
pub struct GetResponseMember {
    id: i32,
    username: String,
    permissions: Permission,
}

pub async fn handle_get(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<GetResponse>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);

    let members = crate_
        .members(db)
        .await?
        .into_iter()
        .map(|(user, permissions)| GetResponseMember {
            id: user.id,
            username: user.username,
            permissions,
        })
        .collect();

    Ok(Json(GetResponse {
        allowed_permissions: Permission::names(),
        members,
    }))
}

#[derive(Deserialize)]
pub struct PatchRequest {
    user_id: i32,
    permissions: Permission,
}

pub async fn handle_patch(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);

    let affected_rows = crate_
        .update_permissions(db, req.user_id, req.permissions)
        .await?;
    if affected_rows == 0 {
        return Err(Error::UpdateConflictRemoved);
    }

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_id: i32,
}

pub async fn handle_delete(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<DeleteRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);

    crate_.delete_member(db, req.user_id).await?;

    Ok(Json(ErrorResponse { error: None }))
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("The requested crate does not exist")]
    NoCrate,
    #[error("You don't have permission to manage users for this crate")]
    NoPermission,
    #[error("Permissions update conflict, user was removed as a member of the crate")]
    UpdateConflictRemoved,
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::NoCrate => StatusCode::NOT_FOUND,
            Self::NoPermission => StatusCode::FORBIDDEN,
            Self::UpdateConflictRemoved => StatusCode::CONFLICT,
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/mod.rs b/chartered-web/src/endpoints/web_api/crates/mod.rs
new file mode 100644
index 0000000..19bf6bf 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/crates/mod.rs
@@ -1,0 +1,7 @@
mod info;
mod members;

pub use info::handle as info;
pub use members::{
    handle_delete as delete_member, handle_get as get_members, handle_patch as update_members,
};