Run prettier over chartered-frontend
Diff
chartered-frontend/.prettierignore | 2 ++
chartered-frontend/.prettierrc.json | 1 +
chartered-frontend/index.html | 12 ++++++------
chartered-frontend/package-lock.json | 19 +++++++++++++++++++
chartered-frontend/package.json | 4 +++-
chartered-frontend/src/index.tsx | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
chartered-frontend/src/overscrollColourFixer.ts | 29 +++++++++++++++++------------
chartered-frontend/src/useAuth.tsx | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
chartered-frontend/src/util.tsx | 56 +++++++++++++++++++++++++++++++-------------------------
chartered-frontend/src/pages/Dashboard.tsx | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
chartered-frontend/src/pages/ErrorPage.tsx | 16 ++++++++--------
chartered-frontend/src/pages/Loading.tsx | 14 ++++++++------
chartered-frontend/src/pages/Login.tsx | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
chartered-frontend/src/sections/Nav.tsx | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
chartered-frontend/src/pages/crate/CrateView.tsx | 349 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
chartered-frontend/src/pages/crate/Members.tsx | 769 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx | 303 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
18 files changed, 1410 insertions(+), 1015 deletions(-)
@@ -1,0 +1,2 @@
.cache
dist
@@ -1,0 +1,1 @@
{}
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>chartered</title>
</head>
</head>
<body class="min-vh-100">
<body class="min-vh-100">
<div id="root"></div>
<script src="./src/index.tsx"></script>
</body>
</body>
</html>
@@ -28,6 +28,7 @@
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
"parcel-bundler": "^1.12.5",
"prettier": "2.4.1",
"sass": "^1.39.2",
"serve": "^12.0.1",
"typescript": "^4.4.2"
@@ -8651,6 +8652,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/prismjs": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.24.1.tgz",
@@ -18439,6 +18452,12 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
"dev": true
},
"prettier": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
"dev": true
},
"prismjs": {
@@ -6,7 +6,8 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "parcel index.html --open",
"build": "parcel build index.html"
"build": "parcel build index.html",
"fmt": "prettier --write ."
},
"keywords": [],
"author": "",
@@ -16,6 +17,7 @@
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
"parcel-bundler": "^1.12.5",
"prettier": "2.4.1",
"sass": "^1.39.2",
"serve": "^12.0.1",
"typescript": "^4.4.2"
@@ -1,16 +1,16 @@
import "./index.sass";
import './overscrollColourFixer.ts';
import "./overscrollColourFixer.ts";
import React = require("react");
import ReactDOM = require("react-dom");
import {
BrowserRouter as Router,
Switch,
Route,
Redirect,
useLocation
} from "react-router-dom";
BrowserRouter as Router,
Switch,
Route,
Redirect,
useLocation,
} from "react-router-dom";
import { ProvideAuth, useAuth } from "./useAuth";
@@ -21,69 +21,119 @@
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";
function App() {
return (
<ProvideAuth>
<Router>
<Switch>
<PublicRoute exact unauthedOnly path="/login" component={() => <Login />} />
<PrivateRoute exact path="/" component={() => <Redirect to="/dashboard" />} />
<PrivateRoute exact path="/dashboard" component={() => <Dashboard />} />
<PrivateRoute exact path="/crates/:crate" component={() => <CrateView />} />
<PrivateRoute exact path="/ssh-keys/list" component={() => <ListSshKeys />} />
<PrivateRoute exact path="/ssh-keys/add" component={() => <AddSshKeys />} />
</Switch>
</Router>
</ProvideAuth>
);
return (
<ProvideAuth>
<Router>
<Switch>
<PublicRoute
exact
unauthedOnly
path="/login"
component={() => <Login />}
/>
<PrivateRoute
exact
path="/"
component={() => <Redirect to="/dashboard" />}
/>
<PrivateRoute
exact
path="/dashboard"
component={() => <Dashboard />}
/>
<PrivateRoute
exact
path="/crates/:crate"
component={() => <CrateView />}
/>
<PrivateRoute
exact
path="/ssh-keys/list"
component={() => <ListSshKeys />}
/>
<PrivateRoute
exact
path="/ssh-keys/add"
component={() => <AddSshKeys />}
/>
</Switch>
</Router>
</ProvideAuth>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
function PublicRoute({
component: Component,
unauthedOnly,
...rest
component: Component,
unauthedOnly,
...rest
}: {
component: ({ match, location }: { match: any, location: ReturnType<typeof useLocation> }) => JSX.Element;
unauthedOnly: boolean;
component: ({
match,
location,
}: {
match: any;
location: ReturnType<typeof useLocation>;
}) => JSX.Element;
unauthedOnly: boolean;
} & { [r: string]: any }) {
const auth = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (!unauthedOnly || !auth || !auth.authKey || auth.expires < new Date()) {
return <Component {...props} />;
} else {
return <Redirect to={{ pathname: "/dashboard", state: { from: props.location } }} />;
}
}}
></Route>
)
const auth = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (
!unauthedOnly ||
!auth ||
!auth.authKey ||
auth.expires < new Date()
) {
return <Component {...props} />;
} else {
return (
<Redirect
to={{ pathname: "/dashboard", state: { from: props.location } }}
/>
);
}
}}
></Route>
);
}
function PrivateRoute({
component: Component,
...rest
component: Component,
...rest
}: {
component: ({ match, location }: { match: any, location: ReturnType<typeof useLocation> }) => JSX.Element;
component: ({
match,
location,
}: {
match: any;
location: ReturnType<typeof useLocation>;
}) => JSX.Element;
} & { [r: string]: any }) {
const auth = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (auth && auth?.authKey && auth.expires > new Date()) {
return <Component {...props} />;
} else {
return <Redirect to={{ pathname: "/login", state: { from: props.location } }} />;
}
}}
></Route>
)
}
const auth = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (auth && auth?.authKey && auth.expires > new Date()) {
return <Component {...props} />;
} else {
return (
<Redirect
to={{ pathname: "/login", state: { from: props.location } }}
/>
);
}
}}
></Route>
);
}
@@ -1,19 +1,24 @@
window.addEventListener('load', () => {
let ticking;
window.addEventListener("load", () => {
let ticking;
window.addEventListener('scroll', function (event) {
if (!ticking) {
ticking = true;
window.addEventListener(
"scroll",
function (event) {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(() => {
document.documentElement.style.backgroundColor = (window.scrollY > 70) ? 'var(--bs-primary)' : '#fff';
ticking = false;
});
}
}, false);
});
window.requestAnimationFrame(() => {
document.documentElement.style.backgroundColor =
window.scrollY > 70 ? "var(--bs-primary)" : "#fff";
ticking = false;
});
}
},
false
);
});
@@ -1,72 +1,75 @@
import React = require('react');
import React = require("react");
import { useState, useEffect, useContext, createContext } from "react";
import { unauthenticatedEndpoint } from "./util";
export interface AuthContext {
authKey?: string,
expires?: Date,
login: (username: string, password: string) => Promise<void>,
logout: () => Promise<void>,
authKey?: string;
expires?: Date;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const authContext = createContext<AuthContext | null>(null);
export function ProvideAuth({ children }: { children: any }) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{children}</authContext.Provider>;
};
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}
export const useAuth = (): AuthContext | null => {
return useContext(authContext);
return useContext(authContext);
};
function useProvideAuth(): AuthContext {
const [authKey, setAuthKey] = useState(() => getAuthStorage().authKey);
const [expires, setExpires] = useState(() => getAuthStorage().expires);
useEffect(() => {
localStorage.setItem("charteredAuthentication", JSON.stringify({ authKey, expires }));
}, [authKey, expires]);
const login = async (username: string, password: string) => {
let res = await fetch(unauthenticatedEndpoint('login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': window.navigator.userAgent,
},
body: JSON.stringify({ username, password }),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setExpires(new Date(json.expires));
setAuthKey(json.key);
};
const logout = async () => {
localStorage.removeItem("charteredAuthentication");
setExpires(null);
setAuthKey(null);
const [authKey, setAuthKey] = useState(() => getAuthStorage().authKey);
const [expires, setExpires] = useState(() => getAuthStorage().expires);
useEffect(() => {
localStorage.setItem(
"charteredAuthentication",
JSON.stringify({ authKey, expires })
);
}, [authKey, expires]);
const login = async (username: string, password: string) => {
let res = await fetch(unauthenticatedEndpoint("login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": window.navigator.userAgent,
},
body: JSON.stringify({ username, password }),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
return {
authKey,
expires,
login,
logout,
}
setExpires(new Date(json.expires));
setAuthKey(json.key);
};
const logout = async () => {
localStorage.removeItem("charteredAuthentication");
setExpires(null);
setAuthKey(null);
};
return {
authKey,
expires,
login,
logout,
};
}
function getAuthStorage() {
const saved = localStorage.getItem("charteredAuthentication");
const initial = JSON.parse(saved);
return {
authKey: initial?.authKey || null,
expires: initial?.expires ? new Date(initial.expires) : null,
};
const saved = localStorage.getItem("charteredAuthentication");
const initial = JSON.parse(saved);
return {
authKey: initial?.authKey || null,
expires: initial?.expires ? new Date(initial.expires) : null,
};
}
@@ -1,34 +1,40 @@
import React = require("react");
import { AuthContext } from "./useAuth";
export const BASE_URL = 'http://localhost:8888';
export const BASE_URL = "http://localhost:8888";
export function unauthenticatedEndpoint(endpoint: string): string {
return `${BASE_URL}/a/-/web/v1/${endpoint}`;
return `${BASE_URL}/a/-/web/v1/${endpoint}`;
}
export function authenticatedEndpoint(auth: AuthContext, endpoint: string): string {
return `${BASE_URL}/a/${auth.authKey}/web/v1/${endpoint}`;
export function authenticatedEndpoint(
auth: AuthContext,
endpoint: string
): string {
return `${BASE_URL}/a/${auth.authKey}/web/v1/${endpoint}`;
}
export function useAuthenticatedRequest<S>({ auth, endpoint }: { auth: AuthContext, endpoint: string }, reloadOn = []): { response: S | null, error: string | null } {
const [error, setError] = React.useState(null);
const [response, setResponse] = React.useState(null);
React.useEffect(async () => {
try {
let req = await fetch(authenticatedEndpoint(auth, endpoint));
let res = await req.json();
if (res.error) {
setError(res.error);
} else {
setResponse(res);
}
} catch (e) {
setError(e.message);
}
}, reloadOn);
return { response, error };
}
export function useAuthenticatedRequest<S>(
{ auth, endpoint }: { auth: AuthContext; endpoint: string },
reloadOn = []
): { response: S | null; error: string | null } {
const [error, setError] = React.useState(null);
const [response, setResponse] = React.useState(null);
React.useEffect(async () => {
try {
let req = await fetch(authenticatedEndpoint(auth, endpoint));
let res = await req.json();
if (res.error) {
setError(res.error);
} else {
setResponse(res);
}
} catch (e) {
setError(e.message);
}
}, reloadOn);
return { response, error };
}
@@ -1,77 +1,90 @@
import React = require('react');
import React = require("react");
import { Link } from "react-router-dom";
import { useAuth } from '../useAuth';
import { useAuth } from "../useAuth";
import Nav from "../sections/Nav";
import { ChevronRight } from 'react-bootstrap-icons';
import { ChevronRight } from "react-bootstrap-icons";
export default function Dashboard() {
const auth = useAuth();
const recentlyUpdated = [
{
name: "hello-world-rs",
version: "0.0.1",
},
{
name: "cool-beans-rs",
version: "0.0.1",
}
];
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1 className="mb-0">Welcome to Chartered.</h1>
<p style={{maxWidth: '72ch'}}>
A private, authenticated Cargo registry. Everything published to this registry is <em>private and visible only to you</em>,
until explicit permissions are granted to others.
</p>
<a href="https://github.com/w4/chartered" target="_blank" className="btn btn-outline-light shadow-sm">Getting Started</a>
<hr />
<div className="row">
<div className="col-md-4">
<h4>Your Crates</h4>
{ recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
</div>
<div className="col-md-4">
<h4>Recently Updated</h4>
{ recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
</div>
<div className="col-md-4">
<h4>Most Downloaded</h4>
{ recentlyUpdated.map((v) => <CrateCard key={v.name} crate={v} />) }
</div>
</div>
</div>
const auth = useAuth();
const recentlyUpdated = [
{
name: "hello-world-rs",
version: "0.0.1",
},
{
name: "cool-beans-rs",
version: "0.0.1",
},
];
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1 className="mb-0">Welcome to Chartered.</h1>
<p style={{ maxWidth: "72ch" }}>
A private, authenticated Cargo registry. Everything published to this
registry is <em>private and visible only to you</em>, until explicit
permissions are granted to others.
</p>
<a
href="https://github.com/w4/chartered"
target="_blank"
className="btn btn-outline-light shadow-sm"
>
Getting Started
</a>
<hr />
<div className="row">
<div className="col-md-4">
<h4>Your Crates</h4>
{recentlyUpdated.map((v) => (
<CrateCard key={v.name} crate={v} />
))}
</div>
<div className="col-md-4">
<h4>Recently Updated</h4>
{recentlyUpdated.map((v) => (
<CrateCard key={v.name} crate={v} />
))}
</div>
<div className="col-md-4">
<h4>Most Downloaded</h4>
{recentlyUpdated.map((v) => (
<CrateCard key={v.name} crate={v} />
))}
</div>
</div>
);
</div>
</div>
);
}
interface Crate {
name: string;
version: string;
name: string;
version: string;
}
function CrateCard({ crate }: { crate: Crate }) {
return (
<Link to={`/crates/${crate.name}`} className="text-decoration-none">
<div className="card border-0 mb-2 shadow-sm">
<div className="card-body text-black d-flex flex-row">
<div className="flex-grow-1 align-self-center">
<h6 className="text-primary my-0">{crate.name}</h6>
<small className="text-secondary">v{crate.version}</small>
</div>
<ChevronRight size={16} className="align-self-center" />
</div>
</div>
</Link>
);
return (
<Link to={`/crates/${crate.name}`} className="text-decoration-none">
<div className="card border-0 mb-2 shadow-sm">
<div className="card-body text-black d-flex flex-row">
<div className="flex-grow-1 align-self-center">
<h6 className="text-primary my-0">{crate.name}</h6>
<small className="text-secondary">v{crate.version}</small>
</div>
<ChevronRight size={16} className="align-self-center" />
</div>
</div>
</Link>
);
}
@@ -1,11 +1,11 @@
import React = require("react");
export default function ErrorPage({ message }: { message: string }) {
return (
<div className="bg-primary min-vh-100 d-flex justify-content-center align-items-center">
<div className="alert alert-danger" role="alert">
{message}
</div>
</div>
);
}
return (
<div className="bg-primary min-vh-100 d-flex justify-content-center align-items-center">
<div className="alert alert-danger" role="alert">
{message}
</div>
</div>
);
}
@@ -1,9 +1,11 @@
import React = require("react");
export default function Loading() {
return <div className="min-vh-100 bg-primary d-flex justify-content-center align-items-center">
<div className="spinner-border text-light" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>;
}
return (
<div className="min-vh-100 bg-primary d-flex justify-content-center align-items-center">
<div className="spinner-border text-light" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
@@ -1,74 +1,113 @@
import React = require("react");
import { useState, useEffect, useRef } from "react";
import { useAuth } from "../useAuth";
export default function Login() {
const auth = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const isMountedRef = useRef(null);
useEffect(() => {
isMountedRef.current = true;
return () => isMountedRef.current = false;
});
const handleSubmit = async (evt) => {
evt.preventDefault();
setError("");
setLoading(true);
try {
await auth.login(username, password);
} catch (e) {
setError(e.message);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
return (
<div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
<div>
<h1>chartered ✈️</h1>
<h6>a private, authenticated cargo registry</h6>
<div className="card border-0 shadow-sm text-black p-2" style={{width: "40rem"}}>
<div className="card-body">
<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>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="username" className="form-label">Username</label>
<input type="text" className="form-control" id="username" disabled={loading} value={username} onChange={e => setUsername(e.target.value)} />
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">Password</label>
<input type="password" className="form-control" id="password" disabled={loading} value={password} onChange={e => setPassword(e.target.value)} />
</div>
<div className="ml-auto">
<button type="submit" className="btn btn-primary" style={{ display: !loading ? 'block' : 'none' }}>Login</button>
<div className="spinner-border text-primary mt-4" role="status" style={{ display: loading ? 'block' : 'none' }}>
<span className="visually-hidden">Logging in...</span>
</div>
</div>
</form>
</div>
</div>
const auth = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const isMountedRef = useRef(null);
useEffect(() => {
isMountedRef.current = true;
return () => (isMountedRef.current = false);
});
const handleSubmit = async (evt) => {
evt.preventDefault();
setError("");
setLoading(true);
try {
await auth.login(username, password);
} catch (e) {
setError(e.message);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
};
return (
<div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
<div>
<h1>chartered ✈️</h1>
<h6>a private, authenticated cargo registry</h6>
<div
className="card border-0 shadow-sm text-black p-2"
style={{ width: "40rem" }}
>
<div className="card-body">
<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>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="username" className="form-label">
Username
</label>
<input
type="text"
className="form-control"
id="username"
disabled={loading}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="mb-3">
<label htmlFor="password" className="form-label">
Password
</label>
<input
type="password"
className="form-control"
id="password"
disabled={loading}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="ml-auto">
<button
type="submit"
className="btn btn-primary"
style={{ display: !loading ? "block" : "none" }}
>
Login
</button>
<div
className="spinner-border text-primary mt-4"
role="status"
style={{ display: loading ? "block" : "none" }}
>
<span className="visually-hidden">Logging in...</span>
</div>
</div>
</form>
</div>
</div>
);
</div>
</div>
);
}
@@ -1,44 +1,65 @@
import React = require("react");
import { NavLink, Link } from "react-router-dom";
import { BoxArrowRight } from 'react-bootstrap-icons';
import { BoxArrowRight } from "react-bootstrap-icons";
import { useAuth } from "../useAuth";
export default function Nav() {
const auth = useAuth();
const auth = useAuth();
const logout = async (e) => {
e.preventDefault();
await auth.logout();
};
const logout = async (e) => {
e.preventDefault();
await auth.logout();
};
return (
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div className="container-fluid">
<Link className="navbar-brand" to="/dashboard">✈️ chartered</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
return (
<nav className="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div className="container-fluid">
<Link className="navbar-brand" to="/dashboard">
✈️ chartered
</Link>
<button
className="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav me-auto mb-2 mb-lg-0">
<li className="nav-item">
<NavLink to="/dashboard" className="nav-link">Home</NavLink>
</li>
<li className="nav-item">
<NavLink to="/ssh-keys/list" className="nav-link">SSH Keys</NavLink>
</li>
</ul>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav me-auto mb-2 mb-lg-0">
<li className="nav-item">
<NavLink to="/dashboard" className="nav-link">
Home
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/ssh-keys/list" className="nav-link">
SSH Keys
</NavLink>
</li>
</ul>
<form className="d-flex">
<input className="form-control me-2" type="search" placeholder="Search" aria-label="Search" />
</form>
<form className="d-flex">
<input
className="form-control me-2"
type="search"
placeholder="Search"
aria-label="Search"
/>
</form>
<div>
<a href="#" onClick={logout} className="nav-link text-danger">Logout <BoxArrowRight /></a>
</div>
</div>
</div>
</nav>
);
}
<div>
<a href="#" onClick={logout} className="nav-link text-danger">
Logout <BoxArrowRight />
</a>
</div>
</div>
</div>
</nav>
);
}
@@ -1,177 +1,222 @@
import React = require('react');
import React = require("react");
import { useState, useEffect } from 'react';
import { useState, useEffect } from "react";
import { useAuth } from '../../useAuth';
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 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 { 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 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';
type Tab = "readme" | "versions" | "members";
export interface CrateInfo {
versions: CrateInfoVersion[],
versions: CrateInfoVersion[];
}
export interface CrateInfoVersion {
vers: string,
homepage: string | null,
description: string | null,
documentation: string | null,
repository: string | null,
deps: CrateInfoVersionDependency[],
vers: string;
homepage: string | null;
description: string | null;
documentation: string | null;
repository: string | null;
deps: CrateInfoVersionDependency[];
}
export interface CrateInfoVersionDependency {
name: string,
version_req: string,
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>
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>
<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>
<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>
)
}
}} />
);
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,6 +1,12 @@
import React = require("react");
import { useState } from "react";
import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons';
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";
@@ -9,362 +15,467 @@
import _ = require("lodash");
interface CratesMembersResponse {
allowed_permissions: string[],
members: Member[],
allowed_permissions: string[];
members: Member[];
}
interface Member {
uuid: string,
username: string,
permissions: string[],
uuid: string;
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]);
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>;
const auth = useAuth();
const [reload, setReload] = useState(0);
const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
{
auth,
endpoint: `crates/${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;
})
);
}
const allowedPermissions = response.allowed_permissions;
return <div className="container-fluid g-0">
<div className={ ""}>
<table className="table table-striped">
<tbody>
{response.members.map((member, index) =>
<MemberListItem
key={index}
crate={crate}
member={member}
prospectiveMember={false}
allowedPermissions={allowedPermissions}
onUpdateComplete={() => setReload(reload + 1)}
/>
)}
{prospectiveMembers.map((member, index) =>
<MemberListItem
key={index}
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>
}, [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>;
}
</div>
);
}
const allowedPermissions = response.allowed_permissions;
return (
<div className="container-fluid g-0">
<div className={ ""}>
<table className="table table-striped">
<tbody>
{response.members.map((member, index) => (
<MemberListItem
key={index}
crate={crate}
member={member}
prospectiveMember={false}
allowedPermissions={allowedPermissions}
onUpdateComplete={() => setReload(reload + 1)}
/>
))}
function MemberListItem({ crate, member, prospectiveMember, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, prospectiveMember: boolean, 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: prospectiveMember ? 'PUT' : 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_uuid: member.uuid,
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_uuid: member.uuid,
}),
});
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 (!prospectiveMember && selectedPermissions.indexOf("VISIBLE") === -1) {
itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
<Trash />
</button>;
} else if (prospectiveMember || selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
itemAction = <button type="button" className="btn text-success" onClick={saveUserPermissions}>
<CheckLg />
</button>;
}
{prospectiveMembers.map((member, index) => (
<MemberListItem
key={index}
crate={crate}
member={member}
prospectiveMember={true}
allowedPermissions={allowedPermissions}
onUpdateComplete={() => setReload(reload + 1)}
/>
))}
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}
userUuid={member.uuid}
onChange={setSelectedPermissions}
/>
</td>
<td className="align-middle fit">
{itemAction}
</td>
</tr>
</>;
<MemberListInserter
onInsert={(username, userUuid) =>
setProspectiveMembers([
...prospectiveMembers,
{
uuid: userUuid,
username,
permissions: ["VISIBLE"],
},
])
}
existingMembers={response.members}
/>
</tbody>
</table>
</div>
</div>
);
}
function MemberListInserter({ onInsert, existingMembers }: { existingMembers: Member[], onInsert: (username, user_uuid) => any }) {
const auth = useAuth();
const searchRef = React.useRef(null);
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState([]);
const [error, setError] = useState("");
const handleSearch = async (query) => {
setLoading(true);
setError("");
try {
let res = await fetch(authenticatedEndpoint(auth, `users/search?q=` + encodeURIComponent(query)));
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setOptions(json.users || []);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
function MemberListItem({
crate,
member,
prospectiveMember,
allowedPermissions,
onUpdateComplete,
}: {
crate: string;
member: Member;
prospectiveMember: boolean;
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: prospectiveMember ? "PUT" : "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
user_uuid: member.uuid,
permissions: selectedPermissions,
}),
}
};
const handleChange = (selected) => {
onInsert(selected[0].username, selected[0].user_uuid);
searchRef.current.clear();
);
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
onUpdateComplete();
} catch (e) {
setError(error);
} finally {
setSaving(false);
}
return <tr>
};
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_uuid: member.uuid,
}),
}
);
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 (
!prospectiveMember &&
selectedPermissions.indexOf("VISIBLE") === -1
) {
itemAction = (
<button
type="button"
className="btn text-danger"
onClick={() => setDeleting(true)}
>
<Trash />
</button>
);
} else if (
prospectiveMember ||
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">
<div
className="d-flex align-items-center justify-content-center rounded-circle"
style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
>
<PersonPlus />
</div>
<img src="http://placekitten.com/48/48" className="rounded-circle" />
</td>
<td className="align-middle">
<AsyncTypeahead
id="search-new-user"
onSearch={handleSearch}
filterBy={(option) => _.findIndex(existingMembers, (existing) => option.user_uuid === existing.uuid) === -1}
labelKey="username"
options={options}
isLoading={loading}
placeholder="Search for User"
onChange={handleChange}
ref={searchRef}
renderMenuItemChildren={(option, props) => <>
<img
alt={option.username}
src="http://placekitten.com/24/24"
className="rounded-circle me-2"
/>
<span>{option.username}</span>
</>}
/>
<div className="text-danger">{error}</div>
<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}
/>
</td>
<td className="align-middle">
<button type="button" className="btn text-dark pe-none">
<PlusLg />
</button>
</td>
</tr>;
<td className="align-middle fit">{itemAction}</td>
</tr>
</>
);
}
function RenderPermissions({ allowedPermissions, selectedPermissions, userUuid, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userUuid: number, onChange: (permissions) => any }) {
return (
<div className="row ms-2">
{allowedPermissions.map((permission) => (
<div key={permission + userUuid} className="form-check col-12 col-md-6">
<input
className="form-check-input"
type="checkbox"
value="1"
id={`checkbox-${userUuid}-${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-${userUuid}-${permission}`}>
{permission}
</label>
</div>
))}
function MemberListInserter({
onInsert,
existingMembers,
}: {
existingMembers: Member[];
onInsert: (username, user_uuid) => any;
}) {
const auth = useAuth();
const searchRef = React.useRef(null);
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState([]);
const [error, setError] = useState("");
const handleSearch = async (query) => {
setLoading(true);
setError("");
try {
let res = await fetch(
authenticatedEndpoint(
auth,
`users/search?q=` + encodeURIComponent(query)
)
);
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setOptions(json.users || []);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleChange = (selected) => {
onInsert(selected[0].username, selected[0].user_uuid);
searchRef.current.clear();
};
return (
<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">
<AsyncTypeahead
id="search-new-user"
onSearch={handleSearch}
filterBy={(option) =>
_.findIndex(
existingMembers,
(existing) => option.user_uuid === existing.uuid
) === -1
}
labelKey="username"
options={options}
isLoading={loading}
placeholder="Search for User"
onChange={handleChange}
ref={searchRef}
renderMenuItemChildren={(option, props) => (
<>
<img
alt={option.username}
src="http://placekitten.com/24/24"
className="rounded-circle me-2"
/>
<span>{option.username}</span>
</>
)}
/>
<div className="text-danger">{error}</div>
</td>
<td className="align-middle"></td>
<td className="align-middle">
<button type="button" className="btn text-dark pe-none">
<PlusLg />
</button>
</td>
</tr>
);
}
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 RenderPermissions({
allowedPermissions,
selectedPermissions,
userUuid,
onChange,
}: {
allowedPermissions: string[];
selectedPermissions: string[];
userUuid: number;
onChange: (permissions) => any;
}) {
return (
<div className="row ms-2">
{allowedPermissions.map((permission) => (
<div key={permission + userUuid} className="form-check col-12 col-md-6">
<input
className="form-check-input"
type="checkbox"
value="1"
id={`checkbox-${userUuid}-${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-${userUuid}-${permission}`}
>
{permission}
</label>
</div>
))}
</div>
);
}
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>
);
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,6 +1,6 @@
import React = require("react");
import { useState, useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link, useHistory } from "react-router-dom";
import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -9,77 +9,102 @@
import { Plus } from "react-bootstrap-icons";
export default function ListSshKeys() {
const auth = useAuth();
const router = useHistory();
const [sshKey, setSshKey] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const submitSshKey = async (evt) => {
evt.preventDefault();
setError("");
setLoading(true);
try {
let res = await fetch(authenticatedEndpoint(auth, 'ssh-key'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: sshKey }),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setSshKey("");
router.push("/ssh-keys/list");
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1>New SSH Key</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={submitSshKey}>
<textarea className="form-control" rows={3}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILYAIoV2OKRSh/DcM3TicD/NK/4TdqwwBPbKgFQKmGZ3 john@home"
onChange={e => setSshKey(e.target.value)}
value={sshKey}
/>
<div className="clearfix"></div>
<button type="submit" className="btn btn-success mt-2 float-end" style={{ display: !loading ? 'block' : 'none' }}>Submit</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>
const auth = useAuth();
const router = useHistory();
const [sshKey, setSshKey] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const submitSshKey = async (evt) => {
evt.preventDefault();
setError("");
setLoading(true);
try {
let res = await fetch(authenticatedEndpoint(auth, "ssh-key"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: sshKey }),
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setSshKey("");
router.push("/ssh-keys/list");
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1>New SSH Key</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={submitSshKey}>
<textarea
className="form-control"
rows={3}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILYAIoV2OKRSh/DcM3TicD/NK/4TdqwwBPbKgFQKmGZ3 john@home"
onChange={(e) => setSshKey(e.target.value)}
value={sshKey}
/>
<div className="clearfix"></div>
<button
type="submit"
className="btn btn-success mt-2 float-end"
style={{ display: !loading ? "block" : "none" }}
>
Submit
</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,6 +1,6 @@
import React = require("react");
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -13,140 +13,191 @@
import Loading from "../Loading";
interface SshKeysResponse {
keys: SshKeysResponseKey[],
keys: SshKeysResponseKey[];
}
interface SshKeysResponseKey {
uuid: string,
name: string,
fingerprint: string,
created_at: string,
last_used_at: string,
uuid: string;
name: string;
fingerprint: string;
created_at: string;
last_used_at: string;
}
export default function ListSshKeys() {
const auth = useAuth();
const auth = useAuth();
const [error, setError] = useState("");
const [deleting, setDeleting] = useState(null);
const [reloadSshKeys, setReloadSshKeys] = useState(0);
const { response: sshKeys, error: loadError } =
useAuthenticatedRequest<SshKeysResponse>(
{
auth,
endpoint: "ssh-key",
},
[reloadSshKeys]
);
const [error, setError] = useState("");
const [deleting, setDeleting] = useState(null);
const [reloadSshKeys, setReloadSshKeys] = useState(0);
if (loadError) {
return <ErrorPage message={loadError} />;
} else if (!sshKeys) {
return <Loading />;
}
const { response: sshKeys, error: loadError } = useAuthenticatedRequest<SshKeysResponse>({
auth,
endpoint: 'ssh-key',
}, [reloadSshKeys]);
if (loadError) {
return <ErrorPage message={loadError} />;
} else if (!sshKeys) {
return <Loading />;
const deleteKey = async () => {
setError("");
try {
let res = await fetch(
authenticatedEndpoint(auth, `ssh-key/${deleting.uuid}`),
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setReloadSshKeys(reloadSshKeys + 1);
} catch (e) {
setError(e.message);
} finally {
setDeleting(null);
}
};
const dateMonthAgo = new Date();
dateMonthAgo.setMonth(dateMonthAgo.getMonth() - 1);
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1>Manage your SSH Keys</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>
const deleteKey = async () => {
setError("");
try {
let res = await fetch(authenticatedEndpoint(auth, `ssh-key/${deleting.uuid}`), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
let json = await res.json();
if (json.error) {
throw new Error(json.error);
}
setReloadSshKeys(reloadSshKeys + 1);
} catch (e) {
setError(e.message);
} finally {
setDeleting(null);
}
};
const dateMonthAgo = new Date();
dateMonthAgo.setMonth(dateMonthAgo.getMonth() - 1);
return (
<div className="text-white">
<Nav />
<div className="container mt-4 pb-4">
<h1>Manage your SSH Keys</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="table-responsive">
<table className="table table-striped">
<tbody>
{sshKeys.keys.map(key => (
<tr key={key.uuid}>
<td className="align-middle">
<h6 className="m-0 lh-sm">{key.name}</h6>
<pre className="m-0">{key.fingerprint}</pre>
<div className="lh-sm" style={{ fontSize: '.75rem' }}>
<span className="text-muted">Added <HumanTime time={key.created_at} /></span>
<span className="mx-2"></span>
<span className={`text-${key.last_used_at ? (new Date(key.last_used_at) > dateMonthAgo ? 'success' : 'danger') : 'muted'}`}>
Last used {key.last_used_at ? <HumanTime time={key.last_used_at} /> : <>never</>}
</span>
</div>
</td>
<td className="align-middle fit">
<button type="button" className="btn text-danger" onClick={() => setDeleting(key)}>
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Link to="/ssh-keys/add" className="btn btn-outline-light mt-2 float-end"><Plus /> Add New</Link>
</div>
<DeleteModal show={deleting != null}
onCancel={() => setDeleting(null)}
onConfirm={() => deleteKey()}
fingerprint={deleting?.fingerprint} />
<div className="card border-0 shadow-sm text-black">
<div className="table-responsive">
<table className="table table-striped">
<tbody>
{sshKeys.keys.map((key) => (
<tr key={key.uuid}>
<td className="align-middle">
<h6 className="m-0 lh-sm">{key.name}</h6>
<pre className="m-0">{key.fingerprint}</pre>
<div className="lh-sm" style={{ fontSize: ".75rem" }}>
<span className="text-muted">
Added <HumanTime time={key.created_at} />
</span>
<span className="mx-2"></span>
<span
className={`text-${
key.last_used_at
? new Date(key.last_used_at) > dateMonthAgo
? "success"
: "danger"
: "muted"
}`}
>
Last used{" "}
{key.last_used_at ? (
<HumanTime time={key.last_used_at} />
) : (
<>never</>
)}
</span>
</div>
</td>
<td className="align-middle fit">
<button
type="button"
className="btn text-danger"
onClick={() => setDeleting(key)}
>
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
<Link
to="/ssh-keys/add"
className="btn btn-outline-light mt-2 float-end"
>
<Plus /> Add New
</Link>
</div>
<DeleteModal
show={deleting != null}
onCancel={() => setDeleting(null)}
onConfirm={() => deleteKey()}
fingerprint={deleting?.fingerprint}
/>
</div>
);
}
function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, fingerprint: 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 delete this SSH key?
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Are you sure you wish to delete the SSH key with the fingerprint: <strong>{props.fingerprint}</strong>?
</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 DeleteModal(props: {
show: boolean;
onCancel: () => void;
onConfirm: () => void;
fingerprint: 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 delete this SSH key?
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Are you sure you wish to delete the SSH key with the fingerprint:{" "}
<strong>{props.fingerprint}</strong>?
</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={props.onCancel} variant="primary">
Close
</Button>
<Button onClick={props.onConfirm} variant="danger">
Delete
</Button>
</Modal.Footer>
</Modal>
);
}