🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-25 1:34:28.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-25 1:40:28.0 +01:00:00
commit
4ee74ebaa981f4bb0d7907eb8dd06cb79f285d74 [patch]
tree
e978b7f3e5156db180ecf396d64a28faa0b8c990
parent
af2c1d856ba2ef1ceca96762679961b43823319c
download
4ee74ebaa981f4bb0d7907eb8dd06cb79f285d74.tar.gz

Organisations management in Web UI



Diff

 Cargo.lock                                                       |  11 +++++++++++
 chartered-db/Cargo.toml                                          |   1 +
 chartered-db/src/crates.rs                                       |  25 ++++++++++++++-----------
 chartered-db/src/lib.rs                                          |  16 ++++++++++++----
 chartered-db/src/organisations.rs                                | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-db/src/users.rs                                        |   9 +--------
 chartered-frontend/src/index.tsx                                 |  22 ++++++++++++++++++++++
 chartered-git/src/main.rs                                        |   2 +-
 chartered-web/src/main.rs                                        |  20 ++++++++++++++++++++
 chartered-frontend/src/sections/Nav.tsx                          |   7 ++++++-
 chartered-frontend/src/pages/crate/CrateView.tsx                 | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 chartered-frontend/src/pages/crate/Members.tsx                   | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
 chartered-frontend/src/pages/crate/OrganisationView.tsx          | 260 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/organisations/ListOrganisations.tsx |  61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx            |   5 +++++
 chartered-web/src/endpoints/web_api/mod.rs                       |   1 +
 chartered-web/src/endpoints/web_api/organisations/info.rs        |  82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/organisations/members.rs     | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/organisations/mod.rs         |   7 +++++++
 19 files changed, 1074 insertions(+), 218 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index e2a37e5..695139c 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -199,6 +199,7 @@
 "chartered-types",
 "chrono",
 "diesel",
 "diesel_logger",
 "displaydoc",
 "dotenv",
 "hex",
@@ -407,6 +408,16 @@
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "diesel_logger"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1793935ad14586bf2aa51574a7157932640c345205ccfb2db431846d846e3db7"
dependencies = [
 "diesel",
 "log",
]

[[package]]
diff --git a/chartered-db/Cargo.toml b/chartered-db/Cargo.toml
index 622ebc1..0c3880b 100644
--- a/chartered-db/Cargo.toml
+++ a/chartered-db/Cargo.toml
@@ -13,6 +13,7 @@
bitflags = "1"
chrono = "0.4"
diesel = { version = "1", features = ["sqlite", "r2d2", "chrono"] }
diesel_logger = "0.1"
displaydoc = "0.2"
hex = "0.4"
http = "0.2"
diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs
index fbd7729..0cad66b 100644
--- a/chartered-db/src/crates.rs
+++ a/chartered-db/src/crates.rs
@@ -1,4 +1,5 @@
use crate::users::{Organisation, User, UserCratePermission};
use crate::organisations::Organisation;
use crate::users::{User, UserCratePermission};

use super::{
    coalesce,
@@ -144,7 +145,7 @@
                    permissions,
                })
            } else {
                Err(Error::MissingPermission(Permissions::VISIBLE))
                Err(Error::MissingCratePermission(Permissions::VISIBLE))
            }
        })
        .await?
@@ -173,10 +174,11 @@
                .select((id, permissions))
                .first::<(i32, Permissions)>(&conn)?;

            #[allow(clippy::if_not_else)]
            if !perms.contains(Permissions::VISIBLE) {
                Err(Error::MissingPermission(Permissions::VISIBLE))
                Err(Error::MissingCratePermission(Permissions::VISIBLE))
            } else if !perms.contains(Permissions::CREATE_CRATE) {
                Err(Error::MissingPermission(Permissions::CREATE_CRATE))
                Err(Error::MissingCratePermission(Permissions::CREATE_CRATE))
            } else {
                use crate::schema::crates::dsl::{crates, name, organisation_id};

@@ -262,7 +264,7 @@
        conn: ConnectionPool,
    ) -> Result<Vec<(crate::users::User, crate::users::UserCratePermissionValue)>> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
            return Err(Error::MissingCratePermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
@@ -286,7 +288,7 @@
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
            return Err(Error::MissingCratePermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
@@ -314,7 +316,7 @@
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
            return Err(Error::MissingCratePermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
@@ -341,7 +343,7 @@
        given_user_id: i32,
    ) -> Result<()> {
        if !self.permissions.contains(Permissions::MANAGE_USERS) {
            return Err(Error::MissingPermission(Permissions::MANAGE_USERS));
            return Err(Error::MissingCratePermission(Permissions::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
@@ -383,10 +385,12 @@
        };

        if !self.permissions.contains(Permissions::PUBLISH_VERSION) {
            return Err(Error::MissingPermission(Permissions::PUBLISH_VERSION));
            return Err(Error::MissingCratePermission(Permissions::PUBLISH_VERSION));
        }

        tokio::task::spawn_blocking(move || {
            use diesel::result::{DatabaseErrorKind, Error as DieselError};

            let conn = conn.get()?;

            conn.transaction::<_, crate::Error, _>(|| {
@@ -415,7 +419,6 @@
                    ))
                    .execute(&conn);

                use diesel::result::{DatabaseErrorKind, Error as DieselError};
                match res {
                    Ok(_) => Ok(()),
                    Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
@@ -439,7 +442,7 @@
        use crate::schema::crate_versions::dsl::{crate_id, crate_versions, version, yanked};

        if !self.permissions.contains(Permissions::YANK_VERSION) {
            return Err(Error::MissingPermission(Permissions::YANK_VERSION));
            return Err(Error::MissingCratePermission(Permissions::YANK_VERSION));
        }

        tokio::task::spawn_blocking(move || {
diff --git a/chartered-db/src/lib.rs b/chartered-db/src/lib.rs
index c497e19..c66eb8a 100644
--- a/chartered-db/src/lib.rs
+++ a/chartered-db/src/lib.rs
@@ -32,6 +32,7 @@
}

pub mod crates;
pub mod organisations;
pub mod schema;
pub mod users;
pub mod uuid;
@@ -44,11 +45,12 @@
    r2d2::{ConnectionManager, Pool},
    sql_types::{Integer, Nullable},
};
use diesel_logger::LoggingConnection;
use displaydoc::Display;
use std::sync::Arc;
use thiserror::Error;

pub type ConnectionPool = Arc<Pool<ConnectionManager<diesel::SqliteConnection>>>;
pub type ConnectionPool = Arc<Pool<ConnectionManager<LoggingConnection<diesel::SqliteConnection>>>>;
pub type Result<T> = std::result::Result<T, Error>;

pub fn init() -> Result<ConnectionPool> {
@@ -66,9 +68,13 @@
    /// Key parse failure: `{0}`

    KeyParse(#[from] thrussh_keys::Error),
    /// You don't have the {0:?} permission for this crate

    MissingPermission(crate::users::UserCratePermissionValue),
    MissingCratePermission(crate::users::UserCratePermissionValue),
    /// You don't have the {0:?} permission for this organisation

    MissingOrganisationPermission(crate::users::UserCratePermissionValue),
    /// The requested crate does not exist

    MissingCrate,
    /// The requested organisation does not exist

    MissingOrganisation,
    /// Version {0} already exists for this crate

    VersionConflict(String),
}
@@ -78,12 +84,14 @@
    pub fn status_code(&self) -> http::StatusCode {
        match self {
            Self::MissingCrate => http::StatusCode::NOT_FOUND,
            Self::MissingPermission(v)
            Self::MissingCratePermission(v) | Self::MissingOrganisationPermission(v)
                if v.contains(crate::users::UserCratePermissionValue::VISIBLE) =>
            {
                http::StatusCode::NOT_FOUND
            }
            Self::MissingPermission(_) => http::StatusCode::FORBIDDEN,
            Self::MissingCratePermission(_) | Self::MissingOrganisationPermission(_) => {
                http::StatusCode::FORBIDDEN
            }
            Self::KeyParse(_) | Self::VersionConflict(_) => http::StatusCode::BAD_REQUEST,
            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
        }
diff --git a/chartered-db/src/organisations.rs b/chartered-db/src/organisations.rs
new file mode 100644
index 0000000..5a0e735 100644
--- /dev/null
+++ a/chartered-db/src/organisations.rs
@@ -1,0 +1,195 @@
use crate::{
    crates::Crate,
    users::{User, UserCratePermissionValue as Permission},
    Error,
};

use super::{
    schema::{organisations, user_organisation_permissions, users},
    uuid::SqlUuid,
    ConnectionPool, Result,
};

use diesel::{prelude::*, Associations, Identifiable, Queryable};

use std::sync::Arc;

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct Organisation {
    pub id: i32,
    pub uuid: SqlUuid,
    pub name: String,
}

impl Organisation {
    pub async fn find_by_name(
        conn: ConnectionPool,
        requesting_user_id: i32,
        given_name: String,
    ) -> Result<OrganisationWithPermissions> {
        use organisations::dsl::name as organisation_name;

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            let (permissions, organisation) = organisations::table
                .left_join(
                    user_organisation_permissions::table.on(user_organisation_permissions::user_id
                        .eq(requesting_user_id)
                        .and(
                            user_organisation_permissions::organisation_id
                                .eq(organisations::dsl::id),
                        )),
                )
                .filter(organisation_name.eq(given_name))
                .select((
                    user_organisation_permissions::dsl::permissions.nullable(),
                    organisations::all_columns,
                ))
                .get_result::<(Option<Permission>, _)>(&conn)
                .optional()?
                .ok_or(Error::MissingOrganisation)?;

            let permissions =
                permissions.ok_or(Error::MissingOrganisationPermission(Permission::VISIBLE))?;

            Ok(OrganisationWithPermissions {
                organisation,
                permissions,
            })
        })
        .await?
    }
}

pub struct OrganisationWithPermissions {
    organisation: Organisation,
    permissions: Permission,
}

impl OrganisationWithPermissions {
    #[must_use]
    pub fn permissions(&self) -> Permission {
        self.permissions
    }

    pub async fn crates(self: Arc<Self>, conn: ConnectionPool) -> Result<Vec<Crate>> {
        if !self.permissions.contains(Permission::VISIBLE) {
            return Err(Error::MissingOrganisationPermission(Permission::VISIBLE));
        }

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;
            Crate::belonging_to(&self.organisation)
                .load(&conn)
                .map_err(Into::into)
        })
        .await?
    }

    pub async fn members(self: Arc<Self>, conn: ConnectionPool) -> Result<Vec<(User, Permission)>> {
        if !self.permissions.contains(Permission::VISIBLE) {
            return Err(Error::MissingOrganisationPermission(Permission::VISIBLE));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_organisation_permissions::dsl::organisation_id;

            let conn = conn.get()?;
            user_organisation_permissions::table
                .filter(organisation_id.eq(self.organisation.id))
                .inner_join(users::table)
                .select((
                    users::all_columns,
                    user_organisation_permissions::columns::permissions,
                ))
                .load(&conn)
                .map_err(Into::into)
        })
        .await?
    }

    pub async fn update_permissions(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permission::MANAGE_USERS) {
            return Err(Error::MissingCratePermission(Permission::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_organisation_permissions::dsl::{
                organisation_id, permissions, user_id, user_organisation_permissions,
            };

            let conn = conn.get()?;

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

    pub async fn insert_permissions(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        if !self.permissions.contains(Permission::MANAGE_USERS) {
            return Err(Error::MissingCratePermission(Permission::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_organisation_permissions::dsl::{
                organisation_id, permissions, user_id, user_organisation_permissions,
            };

            let conn = conn.get()?;

            Ok(diesel::insert_into(user_organisation_permissions)
                .values((
                    user_id.eq(given_user_id),
                    organisation_id.eq(self.organisation.id),
                    permissions.eq(given_permissions.bits()),
                ))
                .execute(&conn)?)
        })
        .await?
    }

    pub async fn delete_member(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
    ) -> Result<()> {
        if !self.permissions.contains(Permission::MANAGE_USERS) {
            return Err(Error::MissingCratePermission(Permission::MANAGE_USERS));
        }

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_organisation_permissions::dsl::{
                organisation_id, user_id, user_organisation_permissions,
            };

            let conn = conn.get()?;

            diesel::delete(
                user_organisation_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(organisation_id.eq(self.organisation.id)),
            )
            .execute(&conn)?;

            Ok(())
        })
        .await?
    }
}
diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 6743bbb..00d6011 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -1,5 +1,5 @@
use super::{
    schema::{organisations, user_crate_permissions, user_sessions, user_ssh_keys, users},
    schema::{user_crate_permissions, user_sessions, user_ssh_keys, users},
    uuid::SqlUuid,
    ConnectionPool, Result,
};
@@ -9,13 +9,6 @@
use rand::{thread_rng, Rng};
use std::sync::Arc;
use thrussh_keys::PublicKeyBase64;

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct Organisation {
    pub id: i32,
    pub uuid: SqlUuid,
    pub name: String,
}

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct User {
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 09f1e20..aa3b7f3 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -19,6 +19,8 @@
import CrateView from "./pages/crate/CrateView";
import ListSshKeys from "./pages/ssh-keys/ListSshKeys";
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";
import ListOrganisations from "./pages/organisations/ListOrganisations";
import OrganisationView from "./pages/crate/OrganisationView";

function App() {
  return (
@@ -44,8 +46,18 @@
          />
          <PrivateRoute
            exact
            path="/crates/:organisation"
            component={() => <OrganisationView />}
          />
          <PrivateRoute
            exact
            path="/crates/:organisation/:crate/:subview?"
            component={() => <CrateView />}
          />
          <PrivateRoute
            exact
            path="/ssh-keys"
            component={() => <Redirect to="/ssh-keys/list" />}
          />
          <PrivateRoute
            exact
@@ -56,6 +68,16 @@
            exact
            path="/ssh-keys/add"
            component={() => <AddSshKeys />}
          />
          <PrivateRoute
            exact
            path="/organisations"
            component={() => <Redirect to="/organisations/list" />}
          />
          <PrivateRoute
            exact
            path="/organisations/list"
            component={() => <ListOrganisations />}
          />
        </Switch>
      </Router>
diff --git a/chartered-git/src/main.rs b/chartered-git/src/main.rs
index 26d2aaa..982c545 100644
--- a/chartered-git/src/main.rs
+++ a/chartered-git/src/main.rs
@@ -152,7 +152,7 @@
        let args = shlex::split(data);

        Box::pin(async move {
            let mut args = args.into_iter().map(|v| v.into_iter()).flatten();
            let mut args = args.into_iter().flat_map(Vec::into_iter);

            if args.next().as_deref() != Some("git-upload-pack") {
                anyhow::bail!("not git-upload-pack");
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 7e656b7..1b46c75 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -33,6 +33,7 @@

#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)] // lint breaks with tokio::main
#[allow(clippy::too_many_lines)] // todo: refactor
async fn main() {
    env_logger::init();

@@ -69,6 +70,24 @@
        axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login)));

    let web_authenticated = axum_box_after_every_route!(Router::new()
        // organisations endpoints
        .route(
            "/organisations/:org",
            get(endpoints::web_api::organisations::info)
        )
        .route(
            "/organisations/:org/members",
            patch(endpoints::web_api::organisations::update_member)
        )
        .route(
            "/organisations/:org/members",
            put(endpoints::web_api::organisations::insert_member)
        )
        .route(
            "/organisations/:org/members",
            delete(endpoints::web_api::organisations::delete_member)
        )
        // crate endpoints
        .route("/crates/:org/:crate", get(endpoints::web_api::crates::info))
        .route(
            "/crates/:org/:crate/members",
@@ -90,6 +109,7 @@
            "/crates/recently-updated",
            get(endpoints::web_api::crates::list_recently_updated)
        )
        // users endpoints
        .route("/users/search", get(endpoints::web_api::search_users))
        .route("/ssh-key", get(endpoints::web_api::get_ssh_keys))
        .route("/ssh-key", put(endpoints::web_api::add_ssh_key))
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index a46927e..fd4782b 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -38,8 +38,13 @@
              </NavLink>
            </li>
            <li className="nav-item">
              <NavLink to="/ssh-keys/list" className="nav-link">
              <NavLink to="/ssh-keys" className="nav-link">
                SSH Keys
              </NavLink>
            </li>
            <li className="nav-item">
              <NavLink to="/organisations" className="nav-link">
                Organisations
              </NavLink>
            </li>
          </ul>
diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index dc7bcfa..7c1488f 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -19,12 +19,12 @@
  Square,
} from "react-bootstrap-icons";
import { useParams, NavLink, Redirect } from "react-router-dom";
import { useAuthenticatedRequest } from "../../util";
import { authenticatedEndpoint, 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 CommonMembers from "./Members";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";

@@ -76,13 +76,16 @@

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

  const showLinks =
    crateInfo.homepage || crateInfo.documentation || crateInfo.repository;

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

      <div className="container mt-4 pb-4">
        <div className="row align-items-stretch">
          <div className="col-12 col-md-6 mb-3 mb-md-0">
          <div className={`col-12 col-md-${showLinks ? 6 : 12} mb-3 mb-md-0`}>
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
@@ -92,7 +95,10 @@
                  >
                    <BoxSeam />
                  </div>
                  <h1 className="text-primary d-inline px-2">{crate}</h1>
                  <h1 className="text-primary d-inline px-2">
                    <span className="text-secondary">{organisation}/</span>
                    {crate}
                  </h1>
                  <h2 className="text-secondary m-0">{crateVersion.vers}</h2>
                </div>

@@ -101,38 +107,42 @@
            </div>
          </div>

          <div className="col-12 col-md-6">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body d-flex flex-column justify-content-center">
                {crateInfo.homepage ? (
                  <div>
                    <HouseDoor />{" "}
                    <a href={crateInfo.homepage}>{crateInfo.homepage}</a>
                  </div>
                ) : (
                  <></>
                )}
                {crateInfo.documentation ? (
                  <div>
                    <Book />{" "}
                    <a href={crateInfo.documentation}>
                      {crateInfo.documentation}
                    </a>
                  </div>
                ) : (
                  <></>
                )}
                {crateInfo.repository ? (
                  <div>
                    <Building />{" "}
                    <a href={crateInfo.repository}>{crateInfo.repository}</a>
                  </div>
                ) : (
                  <></>
                )}
          {showLinks ? (
            <div className="col-12 col-md-6">
              <div className="card border-0 shadow-sm text-black h-100">
                <div className="card-body d-flex flex-column justify-content-center">
                  {crateInfo.homepage ? (
                    <div>
                      <HouseDoor />{" "}
                      <a href={crateInfo.homepage}>{crateInfo.homepage}</a>
                    </div>
                  ) : (
                    <></>
                  )}
                  {crateInfo.documentation ? (
                    <div>
                      <Book />{" "}
                      <a href={crateInfo.documentation}>
                        {crateInfo.documentation}
                      </a>
                    </div>
                  ) : (
                    <></>
                  )}
                  {crateInfo.repository ? (
                    <div>
                      <Building />{" "}
                      <a href={crateInfo.repository}>{crateInfo.repository}</a>
                    </div>
                  ) : (
                    <></>
                  )}
                </div>
              </div>
            </div>
          </div>
          ) : (
            <></>
          )}
        </div>

        <div className="row my-4">
@@ -196,6 +206,13 @@
              </div>

              <ul className="list-group list-group-flush mb-2">
                {crateVersion.deps.length === 0 ? (
                  <li className="list-group-item">
                    This crate has no dependencies
                  </li>
                ) : (
                  <></>
                )}
                {crateVersion.deps.map((dep) => (
                  <li
                    key={`${dep.name}-${dep.version_req}`}
@@ -222,6 +239,107 @@
        </div>
      </div>
    </div>
  );
}

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

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

function Members({
  organisation,
  crate,
}: {
  organisation: string;
  crate: string;
}) {
  const auth = useAuth();
  const [reload, setReload] = useState(0);
  const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
    {
      auth,
      endpoint: `crates/${organisation}/${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 saveMemberPermissions = async (
    prospectiveMember,
    uuid,
    selectedPermissions
  ) => {
    let res = await fetch(
      authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
      {
        method: prospectiveMember ? "PUT" : "PATCH",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          user_uuid: uuid,
          permissions: selectedPermissions,
        }),
      }
    );
    let json = await res.json();

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

    setReload(reload + 1);
  };

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

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

    setReload(reload + 1);
  };

  return (
    <CommonMembers
      members={response.members}
      possiblePermissions={response.allowed_permissions}
      saveMemberPermissions={saveMemberPermissions}
      deleteMember={deleteMember}
    />
  );
}

@@ -324,6 +442,10 @@
}

function ReadMe(props: { crate: CrateInfo }) {
  if (!props.crate.readme) {
    return <>This crate has not added a README.</>;
  }

  return (
    <ReactMarkdown
      children={props.crate.readme}
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 6e0fa3d..40df340 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -14,127 +14,104 @@
import { debounce } from "lodash";
import _ = require("lodash");

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

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

export default function Members({
  organisation,
  crate,
  members,
  possiblePermissions,
  saveMemberPermissions,
  deleteMember,
}: {
  organisation: string;
  crate: string;
  members: Member[];
  possiblePermissions?: string[];
  saveMemberPermissions: (
    prospectiveMember: boolean,
    uuid: string,
    selectedPermissions: string[]
  ) => Promise<any>;
  deleteMember: (uuid: string) => Promise<any>;
}) {
  const auth = useAuth();
  const [reload, setReload] = useState(0);
  const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
    {
      auth,
      endpoint: `crates/${organisation}/${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>
    setProspectiveMembers(
      prospectiveMembers.filter((prospectiveMember) => {
        _.findIndex(
          members,
          (member) => member.uuid === prospectiveMember.uuid
        ) === -1;
      })
    );
  }

  const allowedPermissions = response.allowed_permissions;
  }, [members]);

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

            {prospectiveMembers.map((member, index) => (
              <MemberListItem
                key={index}
                organisation={organisation}
                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>
      </div>
    </div>
    <table className="table table-striped">
      <tbody>
        {members.map((member, index) => (
          <MemberListItem
            key={index}
            member={member}
            prospectiveMember={false}
            possiblePermissions={possiblePermissions}
            saveMemberPermissions={saveMemberPermissions}
            deleteMember={deleteMember}
          />
        ))}

        {prospectiveMembers.map((member, index) => (
          <MemberListItem
            key={index}
            member={member}
            prospectiveMember={true}
            possiblePermissions={possiblePermissions}
            saveMemberPermissions={saveMemberPermissions}
            deleteMember={deleteMember}
          />
        ))}

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

function MemberListItem({
  organisation,
  crate,
  member,
  prospectiveMember,
  allowedPermissions,
  onUpdateComplete,
  possiblePermissions,
  saveMemberPermissions,
  deleteMember,
}: {
  organisation: string;
  crate: string;
  member: Member;
  prospectiveMember: boolean;
  allowedPermissions: string[];
  onUpdateComplete: () => any;
  possiblePermissions?: string[];
  saveMemberPermissions: (
    prospectiveMember: boolean,
    uuid: string,
    selectedPermissions: string[]
  ) => Promise<any>;
  deleteMember: (uuid: string) => Promise<any>;
}) {
  const auth = useAuth();
  const [selectedPermissions, setSelectedPermissions] = useState(
    member.permissions
  );
@@ -144,31 +121,15 @@

  let itemAction = <></>;

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

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

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

      onUpdateComplete();
    } catch (e) {
      setError(error);
    } finally {
@@ -180,26 +141,7 @@
    setSaving(true);

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `crates/${organisation}/${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();
      deleteMember(member.uuid);
    } catch (e) {
      setError(error);
    } finally {
@@ -207,7 +149,9 @@
    }
  };

  if (saving) {
  if (!possiblePermissions) {
    // the current user can't perform any actions
  } else if (saving) {
    itemAction = (
      <button type="button" className="btn">
        <div
@@ -239,7 +183,7 @@
      <button
        type="button"
        className="btn text-success"
        onClick={saveUserPermissions}
        onClick={doSaveMemberPermissions}
      >
        <CheckLg />
      </button>
@@ -264,20 +208,26 @@

        <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}
          />
          {/*<br />
          <em>(that's you!)</em>*/}
        </td>

        <td className="align-middle fit">{itemAction}</td>
        {possiblePermissions && member.permissions ? (
          <>
            <td className="align-middle">
              <RenderPermissions
                possiblePermissions={possiblePermissions}
                selectedPermissions={selectedPermissions}
                userUuid={member.uuid}
                onChange={setSelectedPermissions}
              />
            </td>

            <td className="align-middle fit">{itemAction}</td>
          </>
        ) : (
          <></>
        )}
      </tr>
    </>
  );
@@ -304,7 +254,7 @@
      let res = await fetch(
        authenticatedEndpoint(
          auth,
          `users/search?q=` + encodeURIComponent(query)
          `users/search?q=${encodeURIComponent(query)}`
        )
      );
      let json = await res.json();
@@ -385,20 +335,23 @@
}

function RenderPermissions({
  allowedPermissions,
  possiblePermissions,
  selectedPermissions,
  userUuid,
  onChange,
}: {
  allowedPermissions: string[];
  possiblePermissions: string[];
  selectedPermissions: string[];
  userUuid: number;
  userUuid: string;
  onChange: (permissions) => any;
}) {
  return (
    <div className="row ms-2">
      {allowedPermissions.map((permission) => (
        <div key={permission + userUuid} className="form-check col-12 col-md-6">
    <div className="grid" style={{ "--bs-gap": 0 }}>
      {possiblePermissions.map((permission) => (
        <div
          key={permission + userUuid}
          className="form-check g-col-12 g-col-md-4"
        >
          <input
            className="form-check-input"
            type="checkbox"
diff --git a/chartered-frontend/src/pages/crate/OrganisationView.tsx b/chartered-frontend/src/pages/crate/OrganisationView.tsx
new file mode 100644
index 0000000..d514142 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/crate/OrganisationView.tsx
@@ -1,0 +1,260 @@
import React = require("react");
import { useState, useEffect } from "react";
import { Link, useParams } from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
import { useAuthenticatedRequest, authenticatedEndpoint } from "../../util";

import { BoxSeam, Plus, Trash } from "react-bootstrap-icons";
import {
  Button,
  Dropdown,
  Modal,
  NavLink,
  OverlayTrigger,
  Tooltip,
} from "react-bootstrap";
import HumanTime from "react-human-time";
import ErrorPage from "../ErrorPage";
import Loading from "../Loading";
import Members from "./Members";

interface OrganisationDetails {
  possible_permissions?: string[];
  crates: Crate[];
  members: Member[];
}

interface Crate {
  name: string;
  description?: string;
}

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

export default function ShowOrganisation() {
  const tabs = {
    crates: "Crates",
    members: "Members",
  };

  const { organisation } = useParams();
  const auth = useAuth();
  const [activeTab, setActiveTab] = useState(Object.keys(tabs)[0]);

  const [reload, setReload] = useState(0);
  const { response: organisationDetails, error } =
    useAuthenticatedRequest<OrganisationDetails>(
      {
        auth,
        endpoint: `organisations/${organisation}`,
      },
      [reload]
    );

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

  const description = "a collection of things and stuff.";

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

      <div className="container mt-4 pb-4">
        <div className="row align-items-stretch">
          <div className="col-12 mb-3">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
                  <img
                    src="http://placekitten.com/96/96"
                    className="rounded-circle"
                  />

                  <div className="px-2">
                    <h1 className="text-primary my-0">{organisation}</h1>
                    <p className="m-0">{description}</p>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div className="col-12 mb-3">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-header">
                <ul className="nav nav-pills card-header-pills">
                  {Object.entries(tabs).map(([key, name]) => (
                    <li key={key} className="nav-item">
                      <a
                        href="#"
                        className={`nav-link ${
                          activeTab == key
                            ? "bg-primary bg-gradient active"
                            : ""
                        }`}
                        onClick={(e) => {
                          e.preventDefault();
                          setActiveTab(key);
                        }}
                      >
                        {name}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>

              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
                  {activeTab == "crates" ? (
                    <ListCrates
                      organisation={organisation}
                      crates={organisationDetails.crates}
                    />
                  ) : (
                    <></>
                  )}
                  {activeTab == "members" ? (
                    <ListMembers
                      organisation={organisation}
                      members={organisationDetails.members}
                      possiblePermissions={
                        organisationDetails.possible_permissions
                      }
                      reload={() => setReload(reload + 1)}
                    />
                  ) : (
                    <></>
                  )}
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function ListCrates({
  organisation,
  crates,
}: {
  organisation: string;
  crates: Crate[];
}) {
  return (
    <div className="table-responsive w-100">
      <table className="table table-striped">
        <tbody>
          {crates.map((v, i) => (
            <tr key={i}>
              <td className="align-middle fit">
                <div
                  className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
                  style={{ width: "48px", height: "48px" }}
                >
                  <BoxSeam />
                </div>
              </td>

              <td className="align-middle">
                <div>
                  <Link to={`/crates/${organisation}/${v.name}`}>
                    <span className="text-secondary">{organisation}/</span>
                    {v.name}
                  </Link>
                </div>
                <div className="text-muted">{v.description}</div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function ListMembers({
  organisation,
  members,
  possiblePermissions,
  reload,
}: {
  organisation: string;
  members: Member[];
  possiblePermissions?: string[];
  reload: () => any;
}) {
  const auth = useAuth();

  const saveMemberPermissions = async (
    prospectiveMember: boolean,
    uuid: string,
    selectedPermissions: string[]
  ) => {
    let res = await fetch(
      authenticatedEndpoint(auth, `organisations/${organisation}/members`),
      {
        method: prospectiveMember ? "PUT" : "PATCH",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          user_uuid: uuid,
          permissions: selectedPermissions,
        }),
      }
    );
    let json = await res.json();

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

    reload();
  };

  const deleteMember = async (uuid: string) => {
    let res = await fetch(
      authenticatedEndpoint(auth, `organisations/${organisation}/members`),
      {
        method: "DELETE",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          user_uuid: uuid,
        }),
      }
    );
    let json = await res.json();

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

    reload();
  };

  return (
    <Members
      members={members}
      possiblePermissions={possiblePermissions}
      saveMemberPermissions={saveMemberPermissions}
      deleteMember={deleteMember}
    />
  );
}
diff --git a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx b/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
new file mode 100644
index 0000000..e3be293 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
@@ -1,0 +1,61 @@
import React = require("react");
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
import { useAuthenticatedRequest, authenticatedEndpoint } from "../../util";

import { Plus, Trash } from "react-bootstrap-icons";
import {
  Button,
  Dropdown,
  Modal,
  OverlayTrigger,
  Tooltip,
} from "react-bootstrap";
import HumanTime from "react-human-time";
import ErrorPage from "../ErrorPage";
import Loading from "../Loading";

export default function ListOrganisations() {
  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <h1>Your Organisations</h1>

        <div className="card border-0 shadow-sm text-black">
          <table className="table table-striped">
            <tbody>
              <tr>
                <td className="align-middle fit">
                  <img
                    src="http://placekitten.com/48/48"
                    className="rounded-circle"
                  />
                </td>

                <td className="align-middle">
                  <Link to="/crates/core">core</Link>
                </td>

                <td className="fit align-middle">
                  <Dropdown>
                    <Dropdown.Toggle variant=""></Dropdown.Toggle>

                    <Dropdown.Menu>
                      <Dropdown.Item href="#/action-1">Members</Dropdown.Item>
                      <Dropdown.Item href="#/action-2">Crates</Dropdown.Item>
                    </Dropdown.Menu>
                  </Dropdown>
                </td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
index 5b72de9..48938c2 100644
--- a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
@@ -99,6 +99,11 @@
        </div>

        <div className="card border-0 shadow-sm text-black">
          {sshKeys.keys.length == 0 ? (
            <div className="card-body">You haven't added any SSH keys yet</div>
          ) : (
            <></>
          )}
          <div className="table-responsive">
            <table className="table table-striped">
              <tbody>
diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs
index df98705..d44e5e5 100644
--- a/chartered-web/src/endpoints/web_api/mod.rs
+++ a/chartered-web/src/endpoints/web_api/mod.rs
@@ -1,5 +1,6 @@
pub mod crates;
mod login;
pub mod organisations;
mod search_users;
mod ssh_key;

diff --git a/chartered-web/src/endpoints/web_api/organisations/info.rs b/chartered-web/src/endpoints/web_api/organisations/info.rs
new file mode 100644
index 0000000..052228f 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/organisations/info.rs
@@ -1,0 +1,82 @@
use axum::{extract, Json};
use chartered_db::{
    organisations::Organisation,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        match self {
            Self::Database(e) => e.status_code(),
        }
    }
}

define_error_response!(Error);

pub async fn handle_get(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
    let organisation =
        Arc::new(Organisation::find_by_name(db.clone(), user.id, organisation).await?);

    let can_manage_users = organisation
        .permissions()
        .contains(Permission::MANAGE_USERS);

    let (crates, users) = tokio::try_join!(
        organisation.clone().crates(db.clone()),
        organisation.clone().members(db),
    )?;

    Ok(Json(Response {
        possible_permissions: can_manage_users.then(Permission::all),
        crates: crates
            .into_iter()
            .map(|v| ResponseCrate {
                name: v.name,
                description: v.description,
            })
            .collect(),
        members: users
            .into_iter()
            .map(|(user, perms)| ResponseUser {
                uuid: user.uuid.to_string(),
                username: user.username,
                permissions: can_manage_users.then(|| perms),
            })
            .collect(),
    }))
}

#[derive(Serialize)]
pub struct Response {
    possible_permissions: Option<Permission>,
    crates: Vec<ResponseCrate>,
    members: Vec<ResponseUser>,
}

#[derive(Serialize)]
pub struct ResponseCrate {
    name: String,
    description: Option<String>,
}

#[derive(Serialize)]
pub struct ResponseUser {
    uuid: String,
    username: String,
    permissions: Option<Permission>,
}
diff --git a/chartered-web/src/endpoints/web_api/organisations/members.rs b/chartered-web/src/endpoints/web_api/organisations/members.rs
new file mode 100644
index 0000000..f80f673 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/organisations/members.rs
@@ -1,0 +1,107 @@
use axum::{extract, Json};
use chartered_db::{
    organisations::Organisation,
    users::{User, UserCratePermissionValue as Permission},
    ConnectionPool,
};
use serde::Deserialize;
use std::sync::Arc;
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[derive(Deserialize)]
pub struct PutOrPatchRequest {
    user_uuid: chartered_db::uuid::Uuid,
    permissions: Permission,
}

pub async fn handle_patch(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let organisation =
        Arc::new(Organisation::find_by_name(db.clone(), user.id, organisation).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

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

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

pub async fn handle_put(
    extract::Path((_session_key, organisation)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let organisation =
        Arc::new(Organisation::find_by_name(db.clone(), user.id, organisation).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

    organisation
        .insert_permissions(db, action_user.id, req.permissions)
        .await?;

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

#[derive(Deserialize)]
pub struct DeleteRequest {
    user_uuid: chartered_db::uuid::Uuid,
}

pub async fn handle_delete(
    extract::Path((_session_key, organisation)): 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 organisation =
        Arc::new(Organisation::find_by_name(db.clone(), user.id, organisation).await?);

    let action_user = User::find_by_uuid(db.clone(), req.user_uuid)
        .await?
        .ok_or(Error::InvalidUserId)?;

    organisation.delete_member(db, action_user.id).await?;

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

#[derive(Error, Debug)]
pub enum Error {
    #[error("{0}")]
    Database(#[from] chartered_db::Error),
    #[error("Permissions update conflict, user was removed as a member of the organisation")]
    UpdateConflictRemoved,
    #[error("An invalid user id was given")]
    InvalidUserId,
}

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

        match self {
            Self::Database(e) => e.status_code(),
            Self::UpdateConflictRemoved => StatusCode::CONFLICT,
            Self::InvalidUserId => StatusCode::BAD_REQUEST,
        }
    }
}

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

pub use info::handle_get as info;
pub use members::{
    handle_delete as delete_member, handle_patch as update_member, handle_put as insert_member,
};