🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-16 2:41:17.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-16 2:41:17.0 +01:00:00
commit
cf7a84d7ce3b2537694a61735fcb75fbad9ae5b2 [patch]
tree
91aca9705951ceef91dec3e03a8606cda232d36d
parent
842a3bf09d82586bf428ed8b06897f483b87a491
download
cf7a84d7ce3b2537694a61735fcb75fbad9ae5b2.tar.gz

proper ts types



Diff

 chartered-frontend/.parcelrc                                      |  3 +++
 chartered-frontend/package.json                                   |  2 ++
 chartered-frontend/tsconfig.json                                  |  7 ++++---
 chartered-frontend/yarn.lock                                      | 40 ++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/index.tsx                                  |  8 ++++----
 chartered-frontend/src/overscrollColourFixer.ts                   |  2 +-
 chartered-frontend/src/useAuth.tsx                                | 14 +++++++-------
 chartered-frontend/src/util.tsx                                   | 11 +++++++----
 chartered-frontend/src/pages/Dashboard.tsx                        | 15 ++++++++++++++-
 chartered-frontend/src/pages/Login.tsx                            | 23 ++++++++++++-----------
 chartered-frontend/src/pages/Search.tsx                           | 23 +++++++++++++++++++----
 chartered-frontend/src/pages/User.tsx                             | 13 ++++++++++---
 chartered-frontend/src/sections/Nav.tsx                           | 16 ++++++++--------
 chartered-frontend/src/pages/crate/CrateView.tsx                  | 42 +++++++++++++++++++++++++++++++-----------
 chartered-frontend/src/pages/crate/Members.tsx                    | 56 ++++++++++++++++++++++++++++++++++++--------------------
 chartered-frontend/src/pages/crate/OrganisationView.tsx           | 49 +++++++++++++++++++++++++++++--------------------
 chartered-frontend/src/pages/organisations/CreateOrganisation.tsx | 14 +++++++-------
 chartered-frontend/src/pages/organisations/ListOrganisations.tsx  |  6 +++++-
 chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx              | 16 +++++++++-------
 chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx             | 22 +++++++++++++++-------
 20 files changed, 252 insertions(+), 130 deletions(-)

diff --git a/chartered-frontend/.parcelrc b/chartered-frontend/.parcelrc
new file mode 100644
index 0000000..47d1b5e 100644
--- /dev/null
+++ a/chartered-frontend/.parcelrc
@@ -1,0 +1,3 @@
{
  "extends": "@parcel/config-default"
}
diff --git a/chartered-frontend/package.json b/chartered-frontend/package.json
index ec4db40..d4f80b1 100644
--- a/chartered-frontend/package.json
+++ a/chartered-frontend/package.json
@@ -14,6 +14,7 @@
  "devDependencies": {
    "@parcel/reporter-bundle-analyzer": "^2.0.0",
    "@parcel/transformer-sass": "^2.0.0",
    "@parcel/validator-typescript": "^2.0.0",
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "@types/react-syntax-highlighter": "^13.5.2",
@@ -29,6 +30,7 @@
    "@fortawesome/free-solid-svg-icons": "^5.15.4",
    "@fortawesome/react-fontawesome": "^0.1.15",
    "@types/node": "^16.10.1",
    "@types/react-router-dom": "^5.3.1",
    "bootstrap": "^5.1.1",
    "react": "^17.0.2",
    "react-bootstrap": "^2.0.0-beta.6",
diff --git a/chartered-frontend/tsconfig.json b/chartered-frontend/tsconfig.json
index 7a170d5..1d6875e 100644
--- a/chartered-frontend/tsconfig.json
+++ a/chartered-frontend/tsconfig.json
@@ -1,7 +1,8 @@
{
  "compilerOptions": {
    "jsx": "react",
    "allowSyntheticDefaultImports": true
    "jsx": "react-jsx",
    "allowSyntheticDefaultImports": true,
    "strict": true
  },

  "lib": ["ES2015"]
  "lib": ["ES2021"]
}

diff --git a/chartered-frontend/yarn.lock b/chartered-frontend/yarn.lock
index c54fccd..3a30f26 100644
--- a/chartered-frontend/yarn.lock
+++ a/chartered-frontend/yarn.lock
@@ -909,6 +909,13 @@
    posthtml-render "^3.0.0"
    semver "^5.4.1"

"@parcel/ts-utils@^2.0.0":
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/@parcel/ts-utils/-/ts-utils-2.0.0.tgz#89954d341a8942cb6ddcda217f5f7b38914bef12"
  integrity sha512-+jMwsBu5+gyp8iw+h6CN1MR8hYDALdO1ccphHPD2WMzQHpvvp+hhZ/gUfN70ioPz9DOG/d6BMlfm/HVaZLePsA==
  dependencies:
    nullthrows "^1.1.1"

"@parcel/types@^2.0.0":
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/@parcel/types/-/types-2.0.0.tgz#31f6eb7c24b2d9ae752ce49adaf01469a9e9067a"
@@ -948,6 +955,17 @@
    nullthrows "^1.1.1"
    open "^7.0.3"
    terminal-link "^2.1.1"

"@parcel/validator-typescript@^2.0.0":
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/@parcel/validator-typescript/-/validator-typescript-2.0.0.tgz#2894cdd06db9e156a3b6e67d4f8e73a94c221a5f"
  integrity sha512-qv3SuwuKwtLU/EC9NzqYoJAkCicQp/t4vdM1dms19Cre1od8ZhSHDjEi+oDvUnwPZ1yZrLLBY/zWxQh/yUsP+A==
  dependencies:
    "@parcel/diagnostic" "^2.0.0"
    "@parcel/plugin" "^2.0.0"
    "@parcel/ts-utils" "^2.0.0"
    "@parcel/types" "^2.0.0"
    "@parcel/utils" "^2.0.0"

"@parcel/watcher@^2.0.0":
  version "2.0.0"
@@ -1039,6 +1057,11 @@
  integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==
  dependencies:
    "@types/unist" "*"

"@types/history@*":
  version "4.7.9"
  resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.9.tgz#1cfb6d60ef3822c589f18e70f8b12f9a28ce8724"
  integrity sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==

"@types/http-proxy@^1.17.5":
  version "1.17.7"
@@ -1088,7 +1111,24 @@
  version "17.0.9"
  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add"
  integrity sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==
  dependencies:
    "@types/react" "*"

"@types/react-router-dom@^5.3.1":
  version "5.3.1"
  resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.1.tgz#76700ccce6529413ec723024b71f01fc77a4a980"
  integrity sha512-UvyRy73318QI83haXlaMwmklHHzV9hjl3u71MmM6wYNu0hOVk9NLTa0vGukf8zXUqnwz4O06ig876YSPpeK28A==
  dependencies:
    "@types/history" "*"
    "@types/react" "*"
    "@types/react-router" "*"

"@types/react-router@*":
  version "5.1.17"
  resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.17.tgz#087091006213b11042f39570e5cd414863693968"
  integrity sha512-RNSXOyb3VyRs/EOGmjBhhGKTbnN6fHWvy5FNLzWfOWOGjgVUKqJZXfpKzLmgoU8h6Hj8mpALj/mbXQASOb92wQ==
  dependencies:
    "@types/history" "*"
    "@types/react" "*"

"@types/react-syntax-highlighter@^13.5.2":
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index bdbd8eb..e9fc46e 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -122,7 +122,7 @@
  return (
    <Route
      {...rest}
      render={(props) => {
      render={(props: any) => {
        // TODO: check if valid key
        if (!unauthedOnly || !auth || !auth?.getAuthKey()) {
          return <Component {...props} />;
@@ -137,7 +137,7 @@
          );
        }
      }}
    ></Route>
    />
  );
}

@@ -158,7 +158,7 @@
  const isAuthenticated = auth?.getAuthKey();
  useEffect(() => {
    if (!isAuthenticated) {
      auth.logout();
      auth?.logout();
    }
  }, [isAuthenticated]);

@@ -177,6 +177,6 @@
          );
        }
      }}
    ></Route>
    />
  );
}
diff --git a/chartered-frontend/src/overscrollColourFixer.ts b/chartered-frontend/src/overscrollColourFixer.ts
index 1701d96..62c4a72 100644
--- a/chartered-frontend/src/overscrollColourFixer.ts
+++ a/chartered-frontend/src/overscrollColourFixer.ts
@@ -1,10 +1,10 @@
// A quick little utility to fix the overscroll colour at the bottom
// of the page vs the top of the page. We don't have a footer so we
// just want to carry on the body background, whereas the header is
// white so we want to use that at the top of the page.

window.addEventListener("load", () => {
  let ticking;
  let ticking = false;

  const backgroundFix = () => {
    if (!ticking) {
diff --git a/chartered-frontend/src/useAuth.tsx b/chartered-frontend/src/useAuth.tsx
index 73fc141..29fd9df 100644
--- a/chartered-frontend/src/useAuth.tsx
+++ a/chartered-frontend/src/useAuth.tsx
@@ -44,8 +44,8 @@
      );
      let json = await result.json();

      auth.handleLoginResponse(json);
    } catch (err) {
      auth?.handleLoginResponse(json);
    } catch (err: any) {
      setResult(
        <Redirect
          to={{
@@ -65,7 +65,7 @@
};

function useProvideAuth(): AuthContext {
  const [auth, setAuth] = useState(() => {
  const [auth, setAuth] = useState<any[] | null>(() => {
    let authStorage = getAuthStorage();
    return [
      authStorage.userUuid,
@@ -141,7 +141,7 @@

  const getAuthKey = () => {
    if (auth?.[2] > new Date()) {
      return auth[1];
      return auth?.[1];
    } else if (auth) {
      return null;
    }
@@ -149,7 +149,7 @@

  const getUserUuid = () => {
    if (auth?.[2] > new Date()) {
      return auth[0];
      return auth?.[0];
    } else if (auth) {
      return null;
    }
@@ -157,7 +157,7 @@

  const getPictureUrl = () => {
    if (auth?.[2] > new Date()) {
      return auth[3];
      return auth?.[3];
    } else if (auth) {
      return null;
    }
@@ -176,7 +176,7 @@

function getAuthStorage() {
  const saved = localStorage.getItem("charteredAuthentication");
  const initial = JSON.parse(saved);
  const initial = saved ? JSON.parse(saved) : {};
  return {
    userUuid: initial?.userUuid || null,
    authKey: initial?.authKey || null,
diff --git a/chartered-frontend/src/util.tsx b/chartered-frontend/src/util.tsx
index cd10aaf..7f217d0 100644
--- a/chartered-frontend/src/util.tsx
+++ a/chartered-frontend/src/util.tsx
@@ -18,7 +18,7 @@

export function useAuthenticatedRequest<S>(
  { auth, endpoint }: { auth: AuthContext; endpoint: string },
  reloadOn = []
  reloadOn: any[] = []
): { response: S | null; error: string | null } {
  const [error, setError] = useState(null);
  const [response, setResponse] = useState(null);
@@ -40,7 +40,7 @@
      } else {
        setResponse(jsonRes);
      }
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    }
  }, reloadOn);
@@ -65,7 +65,7 @@
      } else {
        setResponse(jsonRes);
      }
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    }
  }, reloadOn);
@@ -79,7 +79,7 @@
  width,
  className,
}: {
  src: string;
  src?: string | null;
  height: string;
  width: string;
  className?: string;
@@ -113,11 +113,13 @@

export function RoundedPicture({
  src,
  alt,
  height,
  width,
  className,
}: {
  src: string;
  alt?: string;
  height: string;
  width: string;
  className?: string;
@@ -143,6 +145,7 @@
          height,
          width,
        }}
        alt={alt}
        src={src}
        onLoad={() => setImageLoaded(true)}
        className="rounded-circle"
diff --git a/chartered-frontend/src/pages/Dashboard.tsx b/chartered-frontend/src/pages/Dashboard.tsx
index 74d2851..24ad18a 100644
--- a/chartered-frontend/src/pages/Dashboard.tsx
+++ a/chartered-frontend/src/pages/Dashboard.tsx
@@ -1,6 +1,6 @@
import { PropsWithChildren } from "react";

import { Link } from "react-router-dom";
import {Link, Redirect} from "react-router-dom";
import { useAuth } from "../useAuth";
import Nav from "../sections/Nav";
import { Calendar3, ChevronRight, Download } from "react-bootstrap-icons";
@@ -41,6 +41,10 @@
export default function Dashboard() {
  const auth = useAuth();

  if (!auth) {
    return <Redirect to="/login" />;
  }

  const { response: recentlyCreated, error: recentlyCreatedError } =
    useAuthenticatedRequest<RecentlyCreatedResponse>({
      auth,
@@ -83,6 +87,9 @@
        <div className="row">
          <div className="col-12 col-md-4">
            <h4>Newly Created</h4>
            {recentlyCreatedError ? <div className="alert alert-danger" role="alert">
              {recentlyCreatedError}
            </div> : <></>}
            {(recentlyCreated?.crates || []).map((v) => (
              <CrateCard
                key={v.name}
@@ -107,6 +114,9 @@

          <div className="col-12 col-md-4">
            <h4>Recently Updated</h4>
            {recentlyUpdatedError ? <div className="alert alert-danger" role="alert">
              {recentlyUpdatedError}
            </div> : <></>}
            {(recentlyUpdated?.versions || []).map((v) => (
              <CrateCard
                key={v.name}
@@ -120,6 +130,9 @@

          <div className="col-12 col-md-4">
            <h4>Most Downloaded</h4>
            {mostDownloadedError ? <div className="alert alert-danger" role="alert">
              {mostDownloadedError}
            </div> : <></>}
            {(mostDownloaded?.crates || []).map((v) => (
              <CrateCard
                key={v.name}
diff --git a/chartered-frontend/src/pages/Login.tsx b/chartered-frontend/src/pages/Login.tsx
index a7959f1..6abdec9 100644
--- a/chartered-frontend/src/pages/Login.tsx
+++ a/chartered-frontend/src/pages/Login.tsx
@@ -1,10 +1,9 @@
import { useState, useEffect, useRef } from "react";
import {useState, useEffect, useRef, SyntheticEvent, MouseEventHandler} from "react";
import { useLocation } from "react-router-dom";

import { useAuth } from "../useAuth";
import { useUnauthenticatedRequest } from "../util";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import {
  faGithub,
  faGitlab,
@@ -38,18 +37,18 @@
    }

    isMountedRef.current = true;
    return () => (isMountedRef.current = false);
    return () => { isMountedRef.current = false };
  });

  const handleSubmit = async (evt) => {
  const handleSubmit = async (evt: SyntheticEvent) => {
    evt.preventDefault();

    setError("");
    setLoading("password");

    try {
      await auth.login(username, password);
    } catch (e) {
      await auth?.login(username, password);
    } catch (e: any) {
      setError(e.message);
    } finally {
      if (isMountedRef.current) {
@@ -58,13 +57,13 @@
    }
  };

  const handleOAuthLogin = async (provider) => {
  const handleOAuthLogin = async (provider: string) => {
    setError("");
    setLoading(provider);

    try {
      await auth.oauthLogin(provider);
    } catch (e) {
      await auth?.oauthLogin(provider);
    } catch (e: any) {
      setError(e.message);
    }
  };
@@ -180,7 +179,7 @@
};

function getIconForProvider(provider: string): [IconDefinition, string] {
  return BRANDS[provider] || BRANDS["default"];
  return BRANDS[provider] || BRANDS.default;
}

function ButtonOrSpinner({
@@ -200,7 +199,7 @@
  text: string;
  icon?: IconDefinition;
  background?: string;
  onClick: (evt) => any;
  onClick: MouseEventHandler<HTMLButtonElement>;
}) {
  if (showSpinner) {
    return (
@@ -227,4 +226,6 @@
      </button>
    );
  }

  return <></>;
}
diff --git a/chartered-frontend/src/pages/Search.tsx b/chartered-frontend/src/pages/Search.tsx
index 6c6e3e8..7787aa5 100644
--- a/chartered-frontend/src/pages/Search.tsx
+++ a/chartered-frontend/src/pages/Search.tsx
@@ -1,19 +1,16 @@
import { useState, useEffect } from "react";
import { Link, useHistory, useLocation } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";

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

import { BoxSeam, Plus } from "react-bootstrap-icons";
import { BoxSeam } from "react-bootstrap-icons";
import { LoadingSpinner } from "./Loading";

export default function Search() {
  const auth = useAuth();
  const location = useLocation();

  const query =
@@ -47,6 +44,10 @@

function UsersResults({ query }: { query: string }) {
  const auth = useAuth();

  if (!auth) {
      return <></>;
  }

  const { response: results, error } =
    useAuthenticatedRequest<UsersSearchResponse>(
@@ -56,6 +57,10 @@
      },
      [query]
    );

  if (error) {
      return <div className="alert alert-danger">{error}</div>;
  }

  if (!results) {
    return (
@@ -118,6 +123,10 @@
  className?: string;
}) {
  const auth = useAuth();

  if (!auth) {
      return <></>;
  }

  const { response: results, error } =
    useAuthenticatedRequest<CrateSearchResponse>(
@@ -127,6 +136,10 @@
      },
      [query]
    );

  if (error) {
      return <div className="alert alert-danger">{error}</div>
  }

  if (!results) {
    return (
diff --git a/chartered-frontend/src/pages/User.tsx b/chartered-frontend/src/pages/User.tsx
index a776ac8..af0d27f 100644
--- a/chartered-frontend/src/pages/User.tsx
+++ a/chartered-frontend/src/pages/User.tsx
@@ -1,8 +1,7 @@
import { useParams } from "react-router-dom";
import {Redirect, useParams} from "react-router-dom";
import { useAuth } from "../useAuth";
import {
  ProfilePicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../util";
import Nav from "../sections/Nav";
@@ -20,9 +19,17 @@
  picture_url?: string;
}

interface UrlParams {
  uuid: string;
}

export default function User() {
  const auth = useAuth();
  const { uuid } = useParams();
  const { uuid } = useParams<UrlParams>();

  if (!auth) {
    return <Redirect to="/login" />;
  }

  const { response: user, error } = useAuthenticatedRequest<Response>({
    auth,
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index aaeb23f..ed6a58f 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -1,10 +1,10 @@
import { useState } from "react";
import {SyntheticEvent, useState} from "react";
import { useHistory, useLocation } from "react-router-dom";
import { NavLink, Link } from "react-router-dom";

import { BoxArrowRight, CaretDownFill, Search } from "react-bootstrap-icons";
import { BoxArrowRight, Search } from "react-bootstrap-icons";
import { useAuth } from "../useAuth";
import { Dropdown, Navbar, NavDropdown, NavItem } from "react-bootstrap";
import { Dropdown, Navbar } from "react-bootstrap";
import { ProfilePicture } from "../util";

export default function Nav() {
@@ -12,9 +12,9 @@
  const history = useHistory();
  const location = useLocation();

  const logout = async (e) => {
  const logout = async (e: SyntheticEvent) => {
    e.preventDefault();
    await auth.logout();
    await auth?.logout();
  };

  const [search, setSearch] = useState(
@@ -22,7 +22,7 @@
      ? new URLSearchParams(location.search).get("q") || ""
      : ""
  );
  const submitSearchForm = (e) => {
  const submitSearchForm = (e: SyntheticEvent) => {
    e.preventDefault();

    if (search != "") {
@@ -85,7 +85,7 @@
                  className="d-inline-flex align-items-center"
                >
                  <ProfilePicture
                    src={auth.getPictureUrl()}
                    src={auth?.getPictureUrl()}
                    height="2rem"
                    width="2rem"
                  />
@@ -95,7 +95,7 @@
                  align={{ md: "end" }}
                  style={{ marginTop: "5px" }}
                >
                  <Dropdown.Item as={Link} to={`/users/${auth.getUserUuid()}`}>
                  <Dropdown.Item as={Link} to={`/users/${auth?.getUserUuid()}`}>
                    Your profile
                  </Dropdown.Item>

diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index 493b024..7201119 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -53,7 +53,7 @@
export interface CrateInfoVersion {
  vers: string;
  deps: CrateInfoVersionDependency[];
  features: any[];
  features: { [key: string]: any };
  size: number;
  uploader: CrateInfoVersionUploader;
  created_at: string;
@@ -65,11 +65,20 @@
  registry?: string;
}

interface UrlParameters {
  organisation: string;
  crate: string;
  subview: Tab | undefined;
}

export default function SingleCrate() {
  const auth = useAuth();
  const { organisation, crate, subview } = useParams();
  const currentTab: Tab | undefined = subview;
  const { organisation, crate, subview: currentTab } = useParams<UrlParameters>();

  if (!auth) {
    return <Redirect to="/login" />;
  }

  if (!currentTab) {
    return <Redirect to={`/crates/${organisation}/${crate}/readme`} />;
  }
@@ -274,7 +283,7 @@
}) {
  let link = <>{dep.name}</>;

  if (dep.registry === null) {
  if (dep.registry === null || dep.registry === undefined) {
    link = (
      <a target="_blank" href={`/crates/${organisation}/${dep.name}`}>
        {link}
@@ -299,16 +308,21 @@
      {link} = "<strong>{dep.req}</strong>"
    </li>
  );
}

interface MembersProps {
  organisation: string;
  crate: string;
}

function Members({
  organisation,
  crate,
}: {
  organisation: string;
  crate: string;
}) {
}: MembersProps) {
  const auth = useAuth();

  if (!auth) { return <></>; }

  const [reload, setReload] = useState(0);
  const { response, error } = useAuthenticatedRequest<CratesMembersResponse>(
    {
@@ -331,9 +345,9 @@
  }

  const saveMemberPermissions = async (
    prospectiveMember,
    uuid,
    selectedPermissions
    prospectiveMember: boolean,
    uuid: string,
    selectedPermissions: string[],
  ) => {
    let res = await fetch(
      authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
@@ -358,7 +372,7 @@
    setReload(reload + 1);
  };

  const deleteMember = async (uuid) => {
  const deleteMember = async (uuid: string) => {
    let res = await fetch(
      authenticatedEndpoint(auth, `crates/${organisation}/${crate}/members`),
      {
@@ -392,8 +406,8 @@
}

function Versions(props: { crate: CrateInfo }) {
  const humanFileSize = (size) => {
    var i = Math.floor(Math.log(size) / Math.log(1024));
  const humanFileSize = (size: number) => {
    const i = Math.floor(Math.log(size) / Math.log(1024));
    return (
      Number((size / Math.pow(1024, i)).toFixed(2)) +
      " " +
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 243ee52..eca38c9 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -1,26 +1,22 @@
import { useState, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import {
  PersonPlus,
  Trash,
  CheckLg,
  Save,
  PlusLg,
} from "react-bootstrap-icons";
import {
  authenticatedEndpoint,
  ProfilePicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
import { AsyncTypeahead } from "react-bootstrap-typeahead";
import ReactPlaceholder from "react-placeholder";

interface Member {
  uuid: string;
  permissions?: string[];
  permissions: string[];
  display_name: string;
  picture_url?: string;
}
@@ -40,7 +36,7 @@
  ) => Promise<any>;
  deleteMember: (uuid: string) => Promise<any>;
}) {
  const [prospectiveMembers, setProspectiveMembers] = useState([]);
  const [prospectiveMembers, setProspectiveMembers] = useState<Member[]>([]);

  useEffect(() => {
    setProspectiveMembers(
@@ -122,12 +118,12 @@
  deleteMember: (uuid: string) => Promise<any>;
}) {
  const [selectedPermissions, setSelectedPermissions] = useState(
    member.permissions
    member.permissions || []
  );
  const auth = useAuth();
  const [deleting, setDeleting] = useState(false);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);
  const [error, setError] = useState(undefined);

  let itemAction = <></>;

@@ -151,7 +147,7 @@
    setSaving(true);

    try {
      deleteMember(member.uuid);
      await deleteMember(member.uuid);
    } catch (e) {
      setError(error);
    } finally {
@@ -203,13 +199,13 @@
  return (
    <>
      <DeleteModal
        show={deleting === true}
        show={deleting}
        onCancel={() => setDeleting(false)}
        onConfirm={() => doDelete()}
        username={member.display_name}
      />

      <ErrorModal error={error} onClose={() => setError(null)} />
      <ErrorModal error={error} onClose={() => setError(undefined)} />

      <tr>
        <td className="align-middle fit">
@@ -222,7 +218,7 @@
              {member.display_name}
            </Link>
          </strong>
          {auth.getUserUuid() === member.uuid ? (
          {auth?.getUserUuid() === member.uuid ? (
            <>
              <br />
              <em>(that's you!)</em>
@@ -253,20 +249,32 @@
  );
}

interface MemberListInserterProps {
  existingMembers: Member[];
  onInsert: (username: string, user_uuid: string, picture_url: string | null) => any;
}

interface SearchOption {
  user_uuid: string;
  display_name: string;
  picture_url: string | null;
}

function MemberListInserter({
  onInsert,
  existingMembers,
}: {
  existingMembers: Member[];
  onInsert: (username, picture_url, user_uuid) => any;
}) {
}: MemberListInserterProps) {
  const auth = useAuth();
  const searchRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [options, setOptions] = useState([]);
  const [error, setError] = useState("");

  const handleSearch = async (query) => {
  if (!auth) {
    return <></>;
  }

  const handleSearch = async (query: string) => {
    setLoading(true);
    setError("");

@@ -284,20 +292,20 @@
      }

      setOptions(json.users || []);
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    } finally {
      setLoading(false);
    }
  };

  const handleChange = (selected) => {
  const handleChange = (selected: SearchOption[]) => {
    onInsert(
      selected[0].display_name,
      selected[0].picture_url,
      selected[0].user_uuid
    );
    searchRef.current.clear();
    searchRef.current?.clear();
  };

  return (
@@ -320,7 +328,7 @@
        <AsyncTypeahead
          id="search-new-user"
          onSearch={handleSearch}
          filterBy={(option) => {
          filterBy={(option: SearchOption) => {
            for (const existing of existingMembers) {
              if (option.user_uuid === existing.uuid) {
                return false;
@@ -335,7 +343,7 @@
          placeholder="Search for User"
          onChange={handleChange}
          ref={searchRef}
          renderMenuItemChildren={(option, props) => (
          renderMenuItemChildren={(option: SearchOption) => (
            <>
              <ProfilePicture
                src={option.picture_url}
@@ -351,7 +359,7 @@
        <div className="text-danger">{error}</div>
      </td>

      <td className="align-middle"></td>
      <td className="align-middle" />

      <td className="align-middle">
        <button type="button" className="btn text-dark pe-none">
@@ -371,7 +379,7 @@
  possiblePermissions: string[];
  selectedPermissions: string[];
  userUuid: string;
  onChange: (permissions) => any;
  onChange: (permissions: string[]) => any;
}) {
  return (
    <div className="grid" style={{ "--bs-gap": 0 }}>
diff --git a/chartered-frontend/src/pages/crate/OrganisationView.tsx b/chartered-frontend/src/pages/crate/OrganisationView.tsx
index a0f8799..6c43bbd 100644
--- a/chartered-frontend/src/pages/crate/OrganisationView.tsx
+++ a/chartered-frontend/src/pages/crate/OrganisationView.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Link, useParams } from "react-router-dom";
import { useState } from "react";
import {Link, Redirect, useParams} from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -9,18 +9,8 @@
  RoundedPicture,
} from "../../util";

import { BoxSeam, Plus, Trash } from "react-bootstrap-icons";
import {
  Button,
  Dropdown,
  Modal,
  NavLink,
  OverlayTrigger,
  Tooltip,
} from "react-bootstrap";
import HumanTime from "react-human-time";
import { BoxSeam } from "react-bootstrap-icons";
import ErrorPage from "../ErrorPage";
import Loading from "../Loading";
import Members from "./Members";
import ReactPlaceholder from "react-placeholder";

@@ -38,8 +28,12 @@

interface Member {
  uuid: string;
  username: string;
  permissions?: string[];
  display_name: string;
  permissions: string[];
}

interface UrlParams {
  organisation: string;
}

export default function ShowOrganisation() {
@@ -48,9 +42,13 @@
    members: "Members",
  };

  const { organisation } = useParams();
  const { organisation } = useParams<UrlParams>();
  const auth = useAuth();
  const [activeTab, setActiveTab] = useState(Object.keys(tabs)[0]);

  if (!auth) {
    return <Redirect to="/login" />;
  }

  const [reload, setReload] = useState(0);
  const { response: organisationDetails, error } =
@@ -67,7 +65,6 @@
  }

  const ready = !!organisationDetails;
  const [imageLoaded, setImageLoaded] = useState(false);

  return (
    <div className="text-white">
@@ -216,6 +213,13 @@
      </table>
    </div>
  );
}

interface ListMemberParams {
  organisation: string;
  members: Member[];
  possiblePermissions?: string[];
  reload: () => any;
}

function ListMembers({
@@ -223,13 +227,12 @@
  members,
  possiblePermissions,
  reload,
}: {
  organisation: string;
  members: Member[];
  possiblePermissions?: string[];
  reload: () => any;
}) {
}: ListMemberParams) {
  const auth = useAuth();

  if (!auth) {
    return <></>;
  }

  const saveMemberPermissions = async (
    prospectiveMember: boolean,
diff --git a/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx b/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx
index 702d6ca..3e6eff7 100644
--- a/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx
+++ a/chartered-frontend/src/pages/organisations/CreateOrganisation.tsx
@@ -1,26 +1,26 @@
import { useState, useEffect } from "react";
import {SyntheticEvent, useState} from "react";
import { Link, useHistory } from "react-router-dom";

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

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

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

  if (!auth) {
    return <></>;
  }

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

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

  console.log(publicOrg);

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

    setError("");
@@ -43,7 +43,7 @@
      setName("");
      setDescription("");
      router.push(`/crates/${name}`);
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    } finally {
      setLoading(false);
diff --git a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx b/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
index 5b33267..584ff66 100644
--- a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
+++ a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
@@ -1,5 +1,5 @@
import { Plus } from "react-bootstrap-icons";
import { Link } from "react-router-dom";
import {Link, Redirect} from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -18,6 +18,10 @@

export default function ListOrganisations() {
  const auth = useAuth();

  if (!auth) {
    return <Redirect to="/login" />;
  }

  const { response: list, error } = useAuthenticatedRequest<Response>({
    auth,
diff --git a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
index 050a5b7..f24621b 100644
--- a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
@@ -1,21 +1,23 @@
import { useState, useEffect } from "react";
import {SyntheticEvent, useState} from "react";
import { Link, useHistory } from "react-router-dom";

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

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

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

  if (!auth) {
    return <></>;
  }

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

  const submitSshKey = async (evt) => {
  const submitSshKey = async (evt: SyntheticEvent) => {
    evt.preventDefault();

    setError("");
@@ -37,7 +39,7 @@

      setSshKey("");
      router.push("/ssh-keys/list");
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    } finally {
      setLoading(false);
@@ -63,7 +65,7 @@
            className="btn-close"
            aria-label="Close"
            onClick={() => setError("")}
          ></button>
          />
        </div>

        <div className="card border-0 shadow-sm text-black">
@@ -77,7 +79,7 @@
                value={sshKey}
              />

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

              <button
                type="submit"
diff --git a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
index a70e752..f341f98 100644
--- a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useState } from "react";
import {Link, Redirect} from "react-router-dom";

import Nav from "../../sections/Nav";
import { useAuth } from "../../useAuth";
@@ -9,7 +9,7 @@
import { Button, Modal, OverlayTrigger, Tooltip } from "react-bootstrap";
import HumanTime from "react-human-time";
import ErrorPage from "../ErrorPage";
import Loading, { LoadingSpinner } from "../Loading";
import { LoadingSpinner } from "../Loading";

interface SshKeysResponse {
  keys: SshKeysResponseKey[];
@@ -26,8 +26,12 @@
export default function ListSshKeys() {
  const auth = useAuth();

  if (!auth) {
    return <Redirect to="/login" />;
  }

  const [error, setError] = useState("");
  const [deleting, setDeleting] = useState(null);
  const [deleting, setDeleting] = useState<SshKeysResponseKey | null>(null);
  const [reloadSshKeys, setReloadSshKeys] = useState(0);

  const { response: sshKeys, error: loadError } =
@@ -46,6 +50,10 @@
  const deleteKey = async () => {
    setError("");

    if (!deleting) {
      return;
    }

    try {
      let res = await fetch(
        authenticatedEndpoint(auth, `ssh-key/${deleting.uuid}`),
@@ -63,7 +71,7 @@
      }

      setReloadSshKeys(reloadSshKeys + 1);
    } catch (e) {
    } catch (e: any) {
      setError(e.message);
    } finally {
      setDeleting(null);
@@ -92,7 +100,7 @@
            className="btn-close"
            aria-label="Close"
            onClick={() => setError("")}
          ></button>
          />
        </div>

        <div className="card border-0 shadow-sm text-black">
@@ -211,7 +219,7 @@
  show: boolean;
  onCancel: () => void;
  onConfirm: () => void;
  fingerprint: string;
  fingerprint?: string;
}) {
  return (
    <Modal