🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-26 18:45:40.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-26 18:48:07.0 +01:00:00
commit
9ef7e8c46391ffc3c53879ae40c72fc724780235 [patch]
tree
820acdb57b2490c834baac5547e54ddafc82deea
parent
e2b3e99f2e7e955b3ed4392a17949875f533dbe6
download
9ef7e8c46391ffc3c53879ae40c72fc724780235.tar.gz

Allow users to create organisations in Web UI



Diff

 chartered-db/src/lib.rs                                           |   2 +-
 chartered-db/src/organisations.rs                                 |  44 ++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/index.tsx                                  |   6 ++++++
 chartered-frontend/src/util.tsx                                   |  32 ++++++++++++++++++++++++++++++--
 chartered-web/src/main.rs                                         |   4 ++++
 chartered-frontend/src/pages/crate/CrateView.tsx                  |   9 +++++++--
 chartered-frontend/src/pages/crate/Members.tsx                    |  13 +++++++++++--
 chartered-frontend/src/pages/crate/OrganisationView.tsx           |  30 +++++++++++++++++++++++++++---
 chartered-frontend/src/pages/organisations/CreateOrganisation.tsx | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/organisations/ListOrganisations.tsx  |  14 +++++++++++++-
 chartered-web/src/endpoints/web_api/organisations/crud.rs         |  39 +++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/organisations/list.rs         |   4 +---
 chartered-web/src/endpoints/web_api/organisations/mod.rs          |   2 ++
 13 files changed, 319 insertions(+), 18 deletions(-)

diff --git a/chartered-db/src/lib.rs b/chartered-db/src/lib.rs
index bc4c9af..b18f8af 100644
--- a/chartered-db/src/lib.rs
+++ a/chartered-db/src/lib.rs
@@ -62,7 +62,7 @@
pub enum Error {
    /// Failed to initialise to database connection pool

    Connection(#[from] diesel::r2d2::PoolError),
    /// Failed to run query

    /// {0}

    Query(#[from] diesel::result::Error),
    /// Failed to complete query task

    TaskJoin(#[from] tokio::task::JoinError),
diff --git a/chartered-db/src/organisations.rs b/chartered-db/src/organisations.rs
index aadf4b9..259fdc2 100644
--- a/chartered-db/src/organisations.rs
+++ a/chartered-db/src/organisations.rs
@@ -87,6 +87,50 @@
        })
        .await?
    }

    pub async fn create(
        conn: ConnectionPool,
        given_name: String,
        given_description: String,
        requesting_user_id: i32,
    ) -> Result<()> {
        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            conn.transaction::<_, crate::Error, _>(|| {
                use organisations::dsl::{description, id, name, uuid};
                use user_organisation_permissions::dsl::{organisation_id, permissions, user_id};

                let generated_uuid = SqlUuid::random();

                diesel::insert_into(organisations::table)
                    .values((
                        uuid.eq(generated_uuid),
                        name.eq(given_name),
                        description.eq(given_description),
                    ))
                    .execute(&conn)?;

                let inserted_id: i32 = organisations::table
                    .filter(uuid.eq(generated_uuid))
                    .select(id)
                    .get_result(&conn)?;

                diesel::insert_into(user_organisation_permissions::table)
                    .values((
                        user_id.eq(requesting_user_id),
                        organisation_id.eq(inserted_id),
                        permissions.eq(UserPermission::all().bits()),
                    ))
                    .execute(&conn)?;

                Ok(())
            })?;

            Ok(())
        })
        .await?
    }
}

pub struct OrganisationWithPermissions {
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index aa3b7f3..783ed87 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -21,6 +21,7 @@
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";
import ListOrganisations from "./pages/organisations/ListOrganisations";
import OrganisationView from "./pages/crate/OrganisationView";
import CreateOrganisation from "./pages/organisations/CreateOrganisation";

function App() {
  return (
@@ -78,6 +79,11 @@
            exact
            path="/organisations/list"
            component={() => <ListOrganisations />}
          />
          <PrivateRoute
            exact
            path="/organisations/create"
            component={() => <CreateOrganisation />}
          />
        </Switch>
      </Router>
diff --git a/chartered-frontend/src/util.tsx b/chartered-frontend/src/util.tsx
index 23b861c..27b8940 100644
--- a/chartered-frontend/src/util.tsx
+++ a/chartered-frontend/src/util.tsx
@@ -40,14 +40,38 @@
  return { response, error };
}

export function RoundedPicture({ src, height, width, className }: { src: string, height: string, width: string, className?: string }) {
export function RoundedPicture({
  src,
  height,
  width,
  className,
}: {
  src: string;
  height: string;
  width: string;
  className?: string;
}) {
  const [imageLoaded, setImageLoaded] = React.useState(false);

  return (
    <div className={`position-relative d-inline-block ${className || ''}`} style={{height, width}}>
      <ReactPlaceholder showLoadingAnimation type="round" style={{height, width, position: "absolute"}} ready={imageLoaded}><></></ReactPlaceholder>
    <div
      className={`position-relative d-inline-block ${className || ""}`}
      style={{ height, width }}
    >
      <ReactPlaceholder
        showLoadingAnimation
        type="round"
        style={{ height, width, position: "absolute" }}
        ready={imageLoaded}
      >
        <></>
      </ReactPlaceholder>
      <img
        style={{visibility: imageLoaded ? "visible" : "hidden", height, width}}
        style={{
          visibility: imageLoaded ? "visible" : "hidden",
          height,
          width,
        }}
        src={src}
        onLoad={() => setImageLoaded(true)}
        className="rounded-circle"
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 21ea132..dbb71e7 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -76,6 +76,10 @@
            get(endpoints::web_api::organisations::list)
        )
        .route(
            "/organisations",
            put(endpoints::web_api::organisations::create)
        )
        .route(
            "/organisations/:org",
            get(endpoints::web_api::organisations::info)
        )
diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index b849334..3a86021 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -19,7 +19,12 @@
  Square,
} from "react-bootstrap-icons";
import { useParams, NavLink, Redirect, Link } from "react-router-dom";
import { authenticatedEndpoint, RoundedPicture, RoundedPicture, useAuthenticatedRequest } from "../../util";
import {
  authenticatedEndpoint,
  RoundedPicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../../util";

import Prism from "react-syntax-highlighter/dist/cjs/prism";
import ReactMarkdown from "react-markdown";
@@ -188,7 +193,7 @@
                </ul>
              </div>

              <div className={currentTab != 'members' ? 'card-body' : ''}>
              <div className={currentTab != "members" ? "card-body" : ""}>
                {currentTab == "readme" ? <ReadMe crate={crateInfo} /> : <></>}
                {currentTab == "versions" ? (
                  <Versions crate={crateInfo} />
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 0ab68c1..ceb1502 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -7,7 +7,12 @@
  Save,
  PlusLg,
} from "react-bootstrap-icons";
import { authenticatedEndpoint, RoundedPicture, RoundedPicture, useAuthenticatedRequest } from "../../util";
import {
  authenticatedEndpoint,
  RoundedPicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
import { AsyncTypeahead } from "react-bootstrap-typeahead";
@@ -204,7 +209,11 @@

      <tr>
        <td className="align-middle fit">
          <RoundedPicture src="http://placekitten.com/48/48" height="48px" width="48px" />
          <RoundedPicture
            src="http://placekitten.com/48/48"
            height="48px"
            width="48px"
          />
        </td>

        <td className="align-middle">
diff --git a/chartered-frontend/src/pages/crate/OrganisationView.tsx b/chartered-frontend/src/pages/crate/OrganisationView.tsx
index 2020605..9005417 100644
--- a/chartered-frontend/src/pages/crate/OrganisationView.tsx
+++ a/chartered-frontend/src/pages/crate/OrganisationView.tsx
@@ -1,10 +1,14 @@
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, RoundedPicture } from "../../util";
import {
  useAuthenticatedRequest,
  authenticatedEndpoint,
  RoundedPicture,
} from "../../util";

import { BoxSeam, Plus, Trash } from "react-bootstrap-icons";
import {
@@ -76,12 +80,26 @@
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
                  <RoundedPicture src="http://placekitten.com/96/96" height="96px" width="96px" />
                  <RoundedPicture
                    src="http://placekitten.com/96/96"
                    height="96px"
                    width="96px"
                  />

                  <div className="px-2">
                    <h1 className="text-primary my-0">{organisation}</h1>
                    <ReactPlaceholder showLoadingAnimation type="text" rows={1} ready={ready} style={{height: "1.4rem"}}>
                      <p className="m-0">{organisationDetails?.description || <i>No description given.</i>}</p>
                    <ReactPlaceholder
                      showLoadingAnimation
                      type="text"
                      rows={1}
                      ready={ready}
                      style={{ height: "1.4rem" }}
                    >
                      <p className="m-0">
                        {organisationDetails?.description || (
                          <i>No description given.</i>
                        )}
                      </p>
                    </ReactPlaceholder>
                  </div>
                </div>
@@ -136,7 +154,9 @@
                    <ListMembers
                      organisation={organisation}
                      members={organisationDetails.members}
                      possiblePermissions={organisationDetails.possible_permissions}
                      possiblePermissions={
                        organisationDetails.possible_permissions
                      }
                      reload={() => setReload(reload + 1)}
                    />
                  ) : (
diff --git a/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx b/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx
new file mode 100644
index 0000000..3b1e9cd 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx
@@ -1,0 +1,138 @@
import React = require("react");
import { useState, useEffect } from "react";
import { Link, useHistory } from "react-router-dom";

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

import { Plus } from "react-bootstrap-icons";

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

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

  const [name, setName] = useState("");
  const [description, setDescription] = useState("");

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

    setError("");
    setLoading(true);

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

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

      setName("");
      setDescription("");
      router.push(`/crates/${name}`);
    } catch (e) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  };

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

      <div className="container mt-4 pb-4">
        <h1>Create New Organisation</h1>

        <div
          className="alert alert-danger alert-dismissible"
          role="alert"
          style={{ display: error ? "block" : "none" }}
        >
          {error}

          <button
            type="button"
            className="btn-close"
            aria-label="Close"
            onClick={() => setError("")}
          ></button>
        </div>

        <div className="card border-0 shadow-sm text-black">
          <div className="card-body">
            <form onSubmit={createOrganisation}>
              <div className="mb-3">
                <label htmlFor="org-name" className="form-label">
                  Name
                </label>
                <input
                  id="org-name"
                  type="text"
                  className="form-control"
                  pattern="[a-zA-Z0-9-]*"
                  placeholder="backend-team"
                  onChange={(e) => setName(e.target.value)}
                  disabled={loading}
                  value={name}
                />
                <div className="form-text">
                  Must be in the format <code>[a-zA-Z0-9-]*</code>
                </div>
              </div>

              <div>
                <label htmlFor="org-description" className="form-label">
                  Description
                </label>
                <textarea
                  id="org-description"
                  className="form-control"
                  rows={3}
                  onChange={(e) => setDescription(e.target.value)}
                  disabled={loading}
                  value={description}
                />
              </div>

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

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

              <Link
                to="/ssh-keys/list"
                className="btn btn-danger mt-2 float-end me-1"
              >
                Cancel
              </Link>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx b/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
index 6ee604f..bcafc46 100644
--- a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
+++ a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
@@ -1,4 +1,5 @@
import React = require("react");
import { Plus } from "react-bootstrap-icons";
import { Link } from "react-router-dom";

import Nav from "../../sections/Nav";
@@ -48,7 +49,11 @@
                {list.organisations.map((v, i) => (
                  <tr key={i}>
                    <td className="align-middle fit">
                      <RoundedPicture src="http://placekitten.com/48/48" height="48px" width="48px" />
                      <RoundedPicture
                        src="http://placekitten.com/48/48"
                        height="48px"
                        width="48px"
                      />
                    </td>

                    <td className="align-middle" style={{ lineHeight: "1.1" }}>
@@ -67,6 +72,13 @@
            </table>
          )}
        </div>

        <Link
          to="/organisations/create"
          className="btn btn-outline-light mt-2 float-end"
        >
          <Plus /> Create
        </Link>
      </div>
    </div>
  );
diff --git a/chartered-web/src/endpoints/web_api/organisations/crud.rs b/chartered-web/src/endpoints/web_api/organisations/crud.rs
new file mode 100644
index 0000000..396975a 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/organisations/crud.rs
@@ -1,0 +1,39 @@
use axum::{extract, Json};
use chartered_db::{organisations::Organisation, users::User, ConnectionPool};
use serde::Deserialize;
use std::sync::Arc;
use thiserror::Error;

use crate::endpoints::ErrorResponse;

#[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);

#[derive(Deserialize)]
pub struct PutRequest {
    name: String,
    description: String,
}

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<ErrorResponse>, Error> {
    Organisation::create(db, req.name, req.description, user.id).await?;
    
    Ok(Json(ErrorResponse { error: None }))
}
diff --git a/chartered-web/src/endpoints/web_api/organisations/list.rs b/chartered-web/src/endpoints/web_api/organisations/list.rs
index cf973cf..480f122 100644
--- a/chartered-web/src/endpoints/web_api/organisations/list.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/list.rs
@@ -1,7 +1,5 @@
use axum::{extract, Json};
use chartered_db::{
    organisations::Organisation, users::User, ConnectionPool,
};
use chartered_db::{organisations::Organisation, users::User, ConnectionPool};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;
diff --git a/chartered-web/src/endpoints/web_api/organisations/mod.rs b/chartered-web/src/endpoints/web_api/organisations/mod.rs
index 5a50049..7ddfc99 100644
--- a/chartered-web/src/endpoints/web_api/organisations/mod.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/mod.rs
@@ -1,7 +1,9 @@
mod crud;
mod info;
mod list;
mod members;

pub use crud::handle_put as create;
pub use info::handle_get as info;
pub use list::handle_get as list;
pub use members::{