Implement Web UI management of existing member permissions
Diff
Cargo.lock | 26 ++++++++++++++++++++++++++
chartered-db/Cargo.toml | 1 +
chartered-db/src/crates.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-db/src/users.rs | 15 ++++++++++++---
chartered-frontend/src/index.tsx | 4 ++--
chartered-git/src/main.rs | 2 +-
chartered-web/src/main.rs | 17 +++++++++++++++--
chartered-frontend/src/pages/SingleCrate.tsx | 252 --------------------------------------------------------------------------------
chartered-web/src/endpoints/mod.rs | 4 ++--
chartered-frontend/src/pages/crate/CrateView.tsx | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-frontend/src/pages/crate/Members.tsx | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/crate_info.rs | 73 -------------------------------------------------------------------------
chartered-web/src/endpoints/web_api/mod.rs | 3 +--
chartered-web/src/endpoints/web_api/ssh_key.rs | 20 ++++++--------------
chartered-web/src/endpoints/web_api/crates/info.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/crates/members.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/crates/mod.rs | 7 +++++++
17 files changed, 804 insertions(+), 355 deletions(-)
@@ -203,6 +203,7 @@
"dotenv",
"hex",
"itertools",
"option_set",
"rand",
"serde",
"serde_json",
@@ -660,6 +661,15 @@
]
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -956,6 +966,16 @@
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "option_set"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b2d96bdcfa87852ffdc8e77e5fb544ffe2f85ed60568f5b62c98a05ec9a9b"
dependencies = [
"heck",
"serde",
]
[[package]]
name = "parking_lot"
@@ -1592,6 +1612,12 @@
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
[[package]]
name = "unicode-segmentation"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b"
[[package]]
name = "unicode-xid"
@@ -16,6 +16,7 @@
displaydoc = "0.2"
hex = "0.4"
itertools = "0.10"
option_set = "0.1"
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -1,3 +1,5 @@
use crate::users::UserCratePermission;
use super::{
schema::{crate_versions, crates},
BitwiseExpressionMethods, ConnectionPool, Result,
@@ -161,13 +163,11 @@
pub async fn owners(self: Arc<Self>, conn: ConnectionPool) -> Result<Vec<crate::users::User>> {
tokio::task::spawn_blocking(move || {
use crate::schema::user_crate_permissions::{
dsl::permissions, dsl::user_crate_permissions,
};
use crate::schema::user_crate_permissions::dsl::permissions;
let conn = conn.get()?;
Ok(user_crate_permissions
Ok(UserCratePermission::belonging_to(&*self)
.filter(
permissions
.bitwise_and(crate::users::UserCratePermissionValue::MANAGE_USERS.bits())
@@ -176,6 +176,72 @@
.inner_join(crate::schema::users::dsl::users)
.select(crate::schema::users::all_columns)
.load::<crate::users::User>(&conn)?)
})
.await?
}
pub async fn members(
self: Arc<Self>,
conn: ConnectionPool,
) -> Result<Vec<(crate::users::User, crate::users::UserCratePermissionValue)>> {
tokio::task::spawn_blocking(move || {
let conn = conn.get()?;
Ok(UserCratePermission::belonging_to(&*self)
.inner_join(crate::schema::users::dsl::users)
.select((
crate::schema::users::all_columns,
crate::schema::user_crate_permissions::permissions,
))
.load(&conn)?)
})
.await?
}
pub async fn update_permissions(
self: Arc<Self>,
conn: ConnectionPool,
given_user_id: i32,
given_permissions: crate::users::UserCratePermissionValue,
) -> Result<usize> {
tokio::task::spawn_blocking(move || {
use crate::schema::user_crate_permissions::dsl::{
crate_id, permissions, user_crate_permissions, user_id,
};
let conn = conn.get()?;
Ok(diesel::update(
user_crate_permissions
.filter(user_id.eq(given_user_id))
.filter(crate_id.eq(self.id)),
)
.set(permissions.eq(given_permissions.bits()))
.execute(&conn)?)
})
.await?
}
pub async fn delete_member(
self: Arc<Self>,
conn: ConnectionPool,
given_user_id: i32,
) -> Result<()> {
tokio::task::spawn_blocking(move || {
use crate::schema::user_crate_permissions::dsl::{
crate_id, user_crate_permissions, user_id,
};
let conn = conn.get()?;
diesel::delete(
user_crate_permissions
.filter(user_id.eq(given_user_id))
.filter(crate_id.eq(self.id))
)
.execute(&conn)?;
Ok(())
})
.await?
}
@@ -1,8 +1,10 @@
use super::{
schema::{user_crate_permissions, user_sessions, user_ssh_keys, users},
ConnectionPool, Result,
};
use bitflags::bitflags;
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
use option_set::{option_set, OptionSet};
use rand::{thread_rng, Rng};
use std::sync::Arc;
use thrussh_keys::PublicKeyBase64;
@@ -232,9 +234,9 @@
}
}
bitflags::bitflags! {
#[derive(FromSqlRow, AsExpression, Default)]
pub struct UserCratePermissionValue: i32 {
option_set! {
#[derive(FromSqlRow, AsExpression)]
pub struct UserCratePermissionValue: Identity + i32 {
const VISIBLE = 0b0000_0000_0000_0000_0000_0000_0000_0001;
const PUBLISH_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0010;
const YANK_VERSION = 0b0000_0000_0000_0000_0000_0000_0000_0100;
@@ -242,6 +244,12 @@
}
}
impl UserCratePermissionValue {
pub fn names() -> &'static [&'static str] {
Self::NAMES
}
}
impl<B: diesel::backend::Backend> diesel::deserialize::FromSql<diesel::sql_types::Integer, B>
for UserCratePermissionValue
where
@@ -258,6 +266,7 @@
#[derive(Identifiable, Queryable, Associations, Default, PartialEq, Eq, Hash, Debug)]
#[belongs_to(User)]
#[belongs_to(super::crates::Crate)]
pub struct UserCratePermission {
pub id: i32,
pub user_id: i32,
@@ -16,7 +16,7 @@
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import SingleCrate from "./pages/SingleCrate";
import CrateView from "./pages/crate/CrateView";
import ListSshKeys from "./pages/ssh-keys/ListSshKeys";
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";
@@ -29,7 +29,7 @@
<PrivateRoute exact path="/" component={() => <Redirect to="/dashboard" />} />
<PrivateRoute exact path="/dashboard" component={() => <Dashboard />} />
<PrivateRoute exact path="/crates/:crate" component={() => <SingleCrate />} />
<PrivateRoute exact path="/crates/:crate" component={() => <CrateView />} />
<PrivateRoute exact path="/ssh-keys/list" component={() => <ListSshKeys />} />
<PrivateRoute exact path="/ssh-keys/add" component={() => <AddSshKeys />} />
</Switch>
@@ -11,6 +11,7 @@
use bytes::BytesMut;
use chrono::TimeZone;
use futures::future::Future;
use log::warn;
use std::collections::BTreeMap;
use std::{fmt::Write, pin::Pin, sync::Arc};
use thrussh::{
@@ -19,7 +20,6 @@
};
use thrussh_keys::{key, PublicKeyBase64};
use tokio_util::codec::{Decoder, Encoder as TokioEncoder};
use log::warn;
#[tokio::main]
#[allow(clippy::semicolon_if_nothing_returned)]
@@ -5,7 +5,7 @@
mod middleware;
use axum::{
handler::{delete, get, post, put},
handler::{delete, get, patch, post, put},
http::Method,
AddExtensionLayer, Router,
};
@@ -69,7 +69,19 @@
axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login)));
let web_authenticated = axum_box_after_every_route!(Router::new()
.route("/crates/:crate", get(endpoints::web_api::crate_info))
.route("/crates/:crate", get(endpoints::web_api::crates::info))
.route(
"/crates/:crate/members",
get(endpoints::web_api::crates::get_members)
)
.route(
"/crates/:crate/members",
patch(endpoints::web_api::crates::update_members)
)
.route(
"/crates/:crate/members",
delete(endpoints::web_api::crates::delete_member)
)
.route("/ssh-key", get(endpoints::web_api::get_ssh_keys))
.route("/ssh-key", put(endpoints::web_api::add_ssh_key))
.route("/ssh-key/:id", delete(endpoints::web_api::delete_ssh_key)))
@@ -95,6 +107,7 @@
.allow_methods(vec![
Method::GET,
Method::POST,
Method::PATCH,
Method::DELETE,
Method::PUT,
Method::OPTIONS,
@@ -1,252 +1,0 @@
import React = require('react');
import { useState, useEffect } from 'react';
import { useAuth } from '../useAuth';
import Nav from "../sections/Nav";
import Loading from './Loading';
import ErrorPage from './ErrorPage';
import { Box, HouseDoor, Book, Building, PersonPlus } from 'react-bootstrap-icons';
import { useParams } from "react-router-dom";
import { authenticatedEndpoint, useAuthenticatedRequest } from '../util';
import Prism from 'react-syntax-highlighter/dist/cjs/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type Tab = 'readme' | 'versions' | 'members';
interface CrateInfo {
versions: CrateInfoVersion[],
}
interface CrateInfoVersion {
vers: string,
homepage: string | null,
description: string | null,
documentation: string | null,
repository: string | null,
deps: CrateInfoVersionDependency[],
}
interface CrateInfoVersionDependency {
name: string,
version_req: string,
}
export default function SingleCrate() {
const auth = useAuth();
const { crate } = useParams();
const [currentTab, setCurrentTab] = useState<Tab>('readme');
const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
auth,
endpoint: `crates/${crate}`,
});
if (error) {
return <ErrorPage message={error} />;
} else if (!crateInfo) {
return <Loading />;
}
const crateVersion = crateInfo.versions[crateInfo.versions.length - 1];
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<div className="row align-items-stretch">
<div className="col-md-6">
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body">
<div className="d-flex flex-row align-items-center">
<div className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
style={{ width: '2rem', height: '2rem' }}>
<Box />
</div>
<h1 className="text-primary d-inline px-2">{crate}</h1>
<h2 className="text-secondary m-0">{crateVersion.vers}</h2>
</div>
<p className="m-0">{crateVersion.description}</p>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body">
<HouseDoor /> <a href={crateVersion.homepage}>{crateVersion.homepage}</a><br />
<Book /> <a href={crateVersion.documentation}>{crateVersion.documentation}</a><br />
<Building /> <a href={crateVersion.repository}>{crateVersion.repository}</a>
</div>
</div>
</div>
</div>
<div className="row my-4">
<div className="col-md-9">
<div className="card border-0 shadow-sm text-black">
<div className="card-header">
<ul className="nav nav-pills card-header-pills">
<li className="nav-item">
<a className={`nav-link ${currentTab == 'readme' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('readme')}>
Readme
</a>
</li>
<li className="nav-item">
<a className={`nav-link ${currentTab == 'versions' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('versions')}>
Versions
<span className={`badge rounded-pill bg-danger ms-1`}>{crateInfo.versions.length}</span>
</a>
</li>
<li className="nav-item">
<a className={`nav-link ${currentTab == 'members' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('members')}>
Members
</a>
</li>
</ul>
</div>
<div className="card-body">
{currentTab == 'readme' ? <ReadMe crateInfo={crateVersion} /> : <></>}
{currentTab == 'versions' ? <>Versions</> : <></>}
{currentTab == 'members' ? <Members crateInfo={crateVersion} /> : <></>}
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0 shadow-sm text-black">
<div className="card-body pb-0">
<h5 className="card-title">Dependencies</h5>
</div>
<ul className="list-group list-group-flush mb-2">
{crateVersion.deps.map(dep => (
<li key={`${dep.name}-${dep.version_req}`} className="list-group-item">{dep.name} = "<strong>{dep.version_req}</strong>"</li>
))}
</ul>
</div>
<div className="card border-0 shadow-sm text-black mt-4">
<div className="card-body pb-0">
<h5 className="card-title">Dependents</h5>
</div>
<ul className="list-group list-group-flush">
<li className="list-group-item">An item</li>
<li className="list-group-item">A second item</li>
<li className="list-group-item">A third item</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
function ReadMe(props: { crateInfo: any }) {
return (
<ReactMarkdown children={props.crateInfo.readme} remarkPlugins={[remarkGfm]} components={{
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<Prism
children={String(children).replace(/\n$/, '')}
language={match[1]}
PreTag="pre"
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}} />
);
}
function Members(props: { crateInfo: CrateInfoVersion }) {
const x = ["John Paul", "David Davidson", "Andrew Smith"];
return <div className="container-fluid g-0">
<div className="table-responsive">
<table className="table table-striped">
<tbody>
{x.map(v =>
<tr key={v}>
<td className="align-middle fit">
<img src="http://placekitten.com/48/48" className="rounded-circle" />
</td>
<td className="align-middle">
<strong>{v}</strong><br />
<em>(that's you!)</em>
</td>
<td className="align-middle">
<div className="d-flex">
<div>
<div className="form-check">
<input className="form-check-input" type="checkbox" value="" id="visible" />
<label className="form-check-label" htmlFor="visible">
Visible
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="checkbox" value="" id="publish_version" />
<label className="form-check-label" htmlFor="visible">
Publish Version
</label>
</div>
</div>
<div className="ms-3">
<div className="form-check">
<input className="form-check-input" type="checkbox" value="" id="visible" />
<label className="form-check-label" htmlFor="visible">
Yank Version
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="checkbox" value="" id="publish_version" />
<label className="form-check-label" htmlFor="visible">
Manage Users
</label>
</div>
</div>
</div>
</td>
</tr>
)}
<tr>
<td className="align-middle fit">
<div
className="d-flex align-items-center justify-content-center rounded-circle"
style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
>
<PersonPlus />
</div>
</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>;
}
@@ -12,7 +12,7 @@
#[derive(serde::Serialize)]
pub struct ErrorResponse {
error: String,
error: Option<String>,
}
macro_rules! define_error_response {
@@ -25,7 +25,7 @@
fn into_response(self) -> axum::http::Response<Self::Body> {
let body = serde_json::to_vec(&crate::endpoints::ErrorResponse {
error: self.to_string(),
error: Some(self.to_string()),
})
.unwrap();
@@ -1,0 +1,177 @@
import React = require('react');
import { useState, useEffect } from 'react';
import { useAuth } from '../../useAuth';
import Nav from "../../sections/Nav";
import Loading from '../Loading';
import ErrorPage from '../ErrorPage';
import { Box, HouseDoor, Book, Building } from 'react-bootstrap-icons';
import { useParams } from "react-router-dom";
import { useAuthenticatedRequest } from '../../util';
import Prism from 'react-syntax-highlighter/dist/cjs/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Members from './Members';
type Tab = 'readme' | 'versions' | 'members';
export interface CrateInfo {
versions: CrateInfoVersion[],
}
export interface CrateInfoVersion {
vers: string,
homepage: string | null,
description: string | null,
documentation: string | null,
repository: string | null,
deps: CrateInfoVersionDependency[],
}
export interface CrateInfoVersionDependency {
name: string,
version_req: string,
}
export default function SingleCrate() {
const auth = useAuth();
const { crate } = useParams();
const [currentTab, setCurrentTab] = useState<Tab>('readme');
const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
auth,
endpoint: `crates/${crate}`,
});
if (error) {
return <ErrorPage message={error} />;
} else if (!crateInfo) {
return <Loading />;
}
const crateVersion = crateInfo.versions[crateInfo.versions.length - 1];
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<div className="row align-items-stretch">
<div className="col-md-6">
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body">
<div className="d-flex flex-row align-items-center">
<div className="text-white circle bg-primary bg-gradient d-inline rounded-circle d-inline-flex justify-content-center align-items-center"
style={{ width: '2rem', height: '2rem' }}>
<Box />
</div>
<h1 className="text-primary d-inline px-2">{crate}</h1>
<h2 className="text-secondary m-0">{crateVersion.vers}</h2>
</div>
<p className="m-0">{crateVersion.description}</p>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0 shadow-sm text-black h-100">
<div className="card-body">
<HouseDoor /> <a href={crateVersion.homepage}>{crateVersion.homepage}</a><br />
<Book /> <a href={crateVersion.documentation}>{crateVersion.documentation}</a><br />
<Building /> <a href={crateVersion.repository}>{crateVersion.repository}</a>
</div>
</div>
</div>
</div>
<div className="row my-4">
<div className="col-md-9">
<div className="card border-0 shadow-sm text-black">
<div className="card-header">
<ul className="nav nav-pills card-header-pills">
<li className="nav-item">
<a className={`nav-link ${currentTab == 'readme' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('readme')}>
Readme
</a>
</li>
<li className="nav-item">
<a className={`nav-link ${currentTab == 'versions' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('versions')}>
Versions
<span className={`badge rounded-pill bg-danger ms-1`}>{crateInfo.versions.length}</span>
</a>
</li>
<li className="nav-item">
<a className={`nav-link ${currentTab == 'members' ? 'bg-primary bg-gradient active' : ''}`} href="#"
onClick={() => setCurrentTab('members')}>
Members
</a>
</li>
</ul>
</div>
<div className="card-body">
{currentTab == 'readme' ? <ReadMe crateInfo={crateVersion} /> : <></>}
{currentTab == 'versions' ? <>Versions</> : <></>}
{currentTab == 'members' ? <Members crate={crate} /> : <></>}
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0 shadow-sm text-black">
<div className="card-body pb-0">
<h5 className="card-title">Dependencies</h5>
</div>
<ul className="list-group list-group-flush mb-2">
{crateVersion.deps.map(dep => (
<li key={`${dep.name}-${dep.version_req}`} className="list-group-item">{dep.name} = "<strong>{dep.version_req}</strong>"</li>
))}
</ul>
</div>
<div className="card border-0 shadow-sm text-black mt-4">
<div className="card-body pb-0">
<h5 className="card-title">Dependents</h5>
</div>
<ul className="list-group list-group-flush">
<li className="list-group-item">An item</li>
<li className="list-group-item">A second item</li>
<li className="list-group-item">A third item</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}
function ReadMe(props: { crateInfo: any }) {
return (
<ReactMarkdown children={props.crateInfo.readme} remarkPlugins={[remarkGfm]} components={{
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<Prism
children={String(children).replace(/\n$/, '')}
language={match[1]}
PreTag="pre"
{...props}
/>
) : (
<code className={className} {...props}>
{children}
</code>
)
}
}} />
);
}
@@ -1,0 +1,282 @@
import React = require("react");
import { useState } from "react";
import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons';
import { authenticatedEndpoint, useAuthenticatedRequest } from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
interface CratesMembersResponse {
allowed_permissions: string[],
members: Member[],
}
interface Member {
id: number,
username: string,
permissions: string[],
}
export default function Members({ crate }: { crate: string }) {
const auth = useAuth();
const [reload, setReload] = useState(0);
const { response, error } = useAuthenticatedRequest<CratesMembersResponse>({
auth,
endpoint: `crates/${crate}/members`,
}, [reload]);
if (error) {
return <>{error}</>;
} else if (!response) {
return <div className="d-flex justify-content-center align-items-center">
<div className="spinner-border text-light" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>;
}
const allowedPermissions = response.allowed_permissions;
return <div className="container-fluid g-0">
<div className="table-responsive">
<table className="table table-striped">
<tbody>
{response.members.map((member, index) =>
<MemberListItem
key={index}
crate={crate}
member={member}
allowedPermissions={allowedPermissions}
onUpdateComplete={() => setReload(reload + 1)}
/>
)}
<tr>
<td className="align-middle fit">
<div
className="d-flex align-items-center justify-content-center rounded-circle"
style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
>
<PersonPlus />
</div>
</td>
<td className="align-middle">
<input type="search" className="form-control" placeholder="Search for User" />
</td>
<td className="align-middle">
<RenderPermissions allowedPermissions={allowedPermissions} selectedPermissions={[]} userId={-1} />
</td>
<td className="align-middle">
<button type="button" className="btn text-dark pe-none">
<PlusLg />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>;
}
function MemberListItem({ crate, member, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, allowedPermissions: string[], onUpdateComplete: () => any }) {
const auth = useAuth();
const [selectedPermissions, setSelectedPermissions] = useState(member.permissions);
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
let itemAction = <></>;
const saveUserPermissions = async () => {
setSaving(true);
try {
let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: member.id,
permissions: selectedPermissions,
}),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
onUpdateComplete();
} catch (e) {
setError(error);
} finally {
setSaving(false);
}
};
const doDelete = async () => {
setSaving(true);
try {
let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: member.id,
}),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
onUpdateComplete();
} catch (e) {
setError(error);
} finally {
setSaving(false);
}
};
if (saving) {
itemAction = <button type="button" className="btn">
<div className="spinner-grow spinner-grow-sm text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</button>;
} else if (selectedPermissions.indexOf("VISIBLE") === -1) {
itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
<Trash />
</button>;
} else if (selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
itemAction = <button type="button" className="btn text-success" onClick={saveUserPermissions}>
<CheckLg />
</button>;
}
return <>
<DeleteModal show={deleting === true}
onCancel={() => setDeleting(false)}
onConfirm={() => doDelete()}
username={member.username} />
<ErrorModal error={error} onClose={() => setError(null)} />
<tr>
<td className="align-middle fit">
<img src="http://placekitten.com/48/48" className="rounded-circle" />
</td>
<td className="align-middle">
<strong>{member.username}</strong><br />
<em>(that's you!)</em>
</td>
<td className="align-middle">
<RenderPermissions
allowedPermissions={allowedPermissions}
selectedPermissions={selectedPermissions}
userId={member.id}
onChange={setSelectedPermissions}
/>
</td>
<td className="align-middle fit">
{itemAction}
</td>
</tr>
</>;
}
function RenderPermissions({ allowedPermissions, selectedPermissions, userId, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userId: number, onChange: (permissions) => any }) {
return (
<div className="row ms-2">
{allowedPermissions.map((permission) => (
<div key={permission + userId} className="form-check col-12 col-md-6">
<input
className="form-check-input"
type="checkbox"
value="1"
id={`checkbox-${userId}-${permission}`}
checked={selectedPermissions.indexOf(permission) > -1}
onChange={(e) => {
let newUserPermissions = new Set(selectedPermissions);
if (e.target.checked) {
newUserPermissions.add(permission);
} else {
newUserPermissions.delete(permission);
}
onChange(Array.from(newUserPermissions));
}}
/>
<label className="form-check-label" htmlFor={`checkbox-${userId}-${permission}`}>
{permission}
</label>
</div>
))}
</div>
);
}
function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, username: string }) {
return (
<Modal
show={props.show}
onHide={props.onCancel}
size="lg"
aria-labelledby="delete-modal-title"
centered
>
<Modal.Header closeButton>
<Modal.Title id="delete-modal-title">
Are you sure you wish to remove this member from the crate?
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Are you sure you wish to remove <strong>{props.username}</strong> from the crate?
</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={props.onCancel} variant="primary">Close</Button>
<Button onClick={props.onConfirm} variant="danger">Delete</Button>
</Modal.Footer>
</Modal>
);
}
function ErrorModal(props: { error?: string, onClose: () => void }) {
return (
<Modal
show={props.error != null}
onHide={props.onClose}
size="lg"
aria-labelledby="error-modal-title"
centered
>
<Modal.Header closeButton>
<Modal.Title id="error-modal-title">
Error
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
{props.error}
</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={props.onClose} variant="primary">Close</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,73 +1,0 @@
use axum::{extract, Json};
use chartered_db::{
crates::Crate,
users::{User, UserCratePermissionValue as Permission},
ConnectionPool,
};
use chartered_types::cargo::{CrateVersion, CrateVersionMetadata};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to query database")]
Database(#[from] chartered_db::Error),
#[error("Failed to fetch crate file")]
File(#[from] std::io::Error),
#[error("The requested crate does not exist")]
NoCrate,
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NoCrate => StatusCode::NOT_FOUND,
}
}
}
define_error_response!(Error);
pub async fn handle(
extract::Path((_session_key, name)): extract::Path<(String, String)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
let crate_ = Crate::find_by_name(db.clone(), name)
.await?
.ok_or(Error::NoCrate)
.map(std::sync::Arc::new)?;
ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate);
let versions = crate_.clone().versions(db).await?;
Ok(Json(Response {
versions: versions
.into_iter()
.map(|v| {
let (inner, meta) = v.into_cargo_format(&crate_);
ResponseVersion {
inner: inner.into_owned(),
meta,
}
})
.collect(),
}))
}
#[derive(Serialize)]
pub struct ResponseVersion {
#[serde(flatten)]
meta: CrateVersionMetadata,
#[serde(flatten)]
inner: CrateVersion<'static>,
}
#[derive(Serialize)]
pub struct Response {
versions: Vec<ResponseVersion>,
}
@@ -1,8 +1,7 @@
mod crate_info;
pub mod crates;
mod login;
mod ssh_key;
pub use crate_info::handle as crate_info;
pub use login::handle as login;
pub use ssh_key::{
handle_delete as delete_ssh_key, handle_get as get_ssh_keys, handle_put as add_ssh_key,
@@ -7,6 +7,8 @@
use std::sync::Arc;
use thiserror::Error;
use crate::endpoints::ErrorResponse;
#[derive(Serialize)]
pub struct GetResponse {
keys: Vec<GetResponseKey>,
@@ -47,39 +49,29 @@
#[derive(Deserialize)]
pub struct PutRequest {
key: String,
}
#[derive(Serialize)]
pub struct PutResponse {
error: bool,
}
pub async fn handle_put(
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
extract::Json(req): extract::Json<PutRequest>,
) -> Result<Json<PutResponse>, Error> {
) -> Result<Json<ErrorResponse>, Error> {
match user.insert_ssh_key(db, &req.key).await {
Ok(()) => Ok(Json(PutResponse { error: false })),
Ok(()) => Ok(Json(ErrorResponse { error: None })),
Err(e @ chartered_db::Error::KeyParse(_)) => Err(Error::KeyParse(e)),
Err(e) => Err(Error::Database(e)),
}
}
#[derive(Serialize)]
pub struct DeleteResponse {
error: bool,
}
pub async fn handle_delete(
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
extract::Path((_session_key, ssh_key_id)): extract::Path<(String, i32)>,
) -> Result<Json<DeleteResponse>, Error> {
) -> Result<Json<ErrorResponse>, Error> {
let deleted = user.delete_user_ssh_key_by_id(db, ssh_key_id).await?;
if deleted {
Ok(Json(DeleteResponse { error: false }))
Ok(Json(ErrorResponse { error: None }))
} else {
Err(Error::NonExistentKey)
}
@@ -1,0 +1,73 @@
use axum::{extract, Json};
use chartered_db::{
crates::Crate,
users::{User, UserCratePermissionValue as Permission},
ConnectionPool,
};
use chartered_types::cargo::{CrateVersion, CrateVersionMetadata};
use serde::Serialize;
use std::sync::Arc;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to query database")]
Database(#[from] chartered_db::Error),
#[error("Failed to fetch crate file")]
File(#[from] std::io::Error),
#[error("The requested crate does not exist")]
NoCrate,
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Database(_) | Self::File(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NoCrate => StatusCode::NOT_FOUND,
}
}
}
define_error_response!(Error);
pub async fn handle(
extract::Path((_session_key, name)): extract::Path<(String, String)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<Response>, Error> {
let crate_ = Crate::find_by_name(db.clone(), name)
.await?
.ok_or(Error::NoCrate)
.map(std::sync::Arc::new)?;
ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate);
let versions = crate_.clone().versions(db).await?;
Ok(Json(Response {
versions: versions
.into_iter()
.map(|v| {
let (inner, meta) = v.into_cargo_format(&crate_);
ResponseVersion {
inner: inner.into_owned(),
meta,
}
})
.collect(),
}))
}
#[derive(Serialize)]
pub struct ResponseVersion {
#[serde(flatten)]
meta: CrateVersionMetadata,
#[serde(flatten)]
inner: CrateVersion<'static>,
}
#[derive(Serialize)]
pub struct Response {
versions: Vec<ResponseVersion>,
}
@@ -1,0 +1,129 @@
use axum::{extract, Json};
use chartered_db::{
crates::Crate,
users::{User, UserCratePermissionValue as Permission},
ConnectionPool,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;
use crate::endpoints::ErrorResponse;
#[derive(Serialize)]
pub struct GetResponse {
allowed_permissions: &'static [&'static str],
members: Vec<GetResponseMember>,
}
#[derive(Deserialize, Serialize)]
pub struct GetResponseMember {
id: i32,
username: String,
permissions: Permission,
}
pub async fn handle_get(
extract::Path((_session_key, name)): extract::Path<(String, String)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<GetResponse>, Error> {
let crate_ = Crate::find_by_name(db.clone(), name)
.await?
.ok_or(Error::NoCrate)
.map(std::sync::Arc::new)?;
ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);
let members = crate_
.members(db)
.await?
.into_iter()
.map(|(user, permissions)| GetResponseMember {
id: user.id,
username: user.username,
permissions,
})
.collect();
Ok(Json(GetResponse {
allowed_permissions: Permission::names(),
members,
}))
}
#[derive(Deserialize)]
pub struct PatchRequest {
user_id: i32,
permissions: Permission,
}
pub async fn handle_patch(
extract::Path((_session_key, name)): extract::Path<(String, String)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
extract::Json(req): extract::Json<PatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
let crate_ = Crate::find_by_name(db.clone(), name)
.await?
.ok_or(Error::NoCrate)
.map(std::sync::Arc::new)?;
ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);
let affected_rows = crate_
.update_permissions(db, req.user_id, req.permissions)
.await?;
if affected_rows == 0 {
return Err(Error::UpdateConflictRemoved);
}
Ok(Json(ErrorResponse { error: None }))
}
#[derive(Deserialize)]
pub struct DeleteRequest {
user_id: i32,
}
pub async fn handle_delete(
extract::Path((_session_key, name)): extract::Path<(String, String)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
extract::Extension(user): extract::Extension<Arc<User>>,
extract::Json(req): extract::Json<DeleteRequest>,
) -> Result<Json<ErrorResponse>, Error> {
let crate_ = Crate::find_by_name(db.clone(), name)
.await?
.ok_or(Error::NoCrate)
.map(std::sync::Arc::new)?;
ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);
crate_.delete_member(db, req.user_id).await?;
Ok(Json(ErrorResponse { error: None }))
}
#[derive(Error, Debug)]
pub enum Error {
#[error("Failed to query database")]
Database(#[from] chartered_db::Error),
#[error("The requested crate does not exist")]
NoCrate,
#[error("You don't have permission to manage users for this crate")]
NoPermission,
#[error("Permissions update conflict, user was removed as a member of the crate")]
UpdateConflictRemoved,
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NoCrate => StatusCode::NOT_FOUND,
Self::NoPermission => StatusCode::FORBIDDEN,
Self::UpdateConflictRemoved => StatusCode::CONFLICT,
}
}
}
define_error_response!(Error);
@@ -1,0 +1,7 @@
mod info;
mod members;
pub use info::handle as info;
pub use members::{
handle_delete as delete_member, handle_get as get_members, handle_patch as update_members,
};