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(-)
@@ -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]]
@@ -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"
@@ -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 || {
@@ -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 @@
KeyParse(#[from] thrussh_keys::Error),
MissingPermission(crate::users::UserCratePermissionValue),
MissingCratePermission(crate::users::UserCratePermissionValue),
MissingOrganisationPermission(crate::users::UserCratePermissionValue),
MissingCrate,
MissingOrganisation,
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,
}
@@ -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?
}
}
@@ -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 {
@@ -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>
@@ -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");
@@ -33,6 +33,7 @@
#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)]
#[allow(clippy::too_many_lines)]
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()
.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)
)
.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)
)
.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))
@@ -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>
@@ -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}
@@ -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 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) {
} 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"
@@ -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}
/>
);
}
@@ -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>
);
}
@@ -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>
@@ -1,5 +1,6 @@
pub mod crates;
mod login;
pub mod organisations;
mod search_users;
mod ssh_key;
@@ -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>,
}
@@ -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);
@@ -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,
};