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(-)
@@ -62,7 +62,7 @@
pub enum Error {
Connection(#[from] diesel::r2d2::PoolError),
Query(#[from] diesel::result::Error),
TaskJoin(#[from] tokio::task::JoinError),
@@ -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 {
@@ -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>
@@ -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"
@@ -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)
)
@@ -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} />
@@ -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">
@@ -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)}
/>
) : (
@@ -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>
);
}
@@ -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>
);
@@ -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 }))
}
@@ -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;
@@ -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::{