🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-10-07 0:31:21.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-10-07 0:31:21.0 +01:00:00
commit
0a4f4b1b104c2bac8f0354478fa4628401f8f6d0 [patch]
tree
6082ed034e537df401b76e4c277b703ddfa14084
parent
8a7676945cf10f61083566e9343c792871116eee
download
0a4f4b1b104c2bac8f0354478fa4628401f8f6d0.tar.gz

Show user profile pictures instead of placekittens



Diff

 chartered-db/src/users.rs                                        |   7 +++++++
 chartered-frontend/src/index.tsx                                 |  12 ++----------
 chartered-frontend/src/useAuth.tsx                               |  39 ++++++++++++++++++++++++++-------------
 chartered-frontend/src/util.tsx                                  |  30 ++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/Login.tsx                           |  90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
 chartered-frontend/src/pages/User.tsx                            | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 chartered-frontend/src/pages/crate/CrateView.tsx                 |  63 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 chartered-frontend/src/pages/crate/Members.tsx                   |  36 ++++++++++++++++++++++--------------
 chartered-frontend/src/pages/organisations/ListOrganisations.tsx |  43 ++++++++++++++++++++++++++-----------------
 chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx            | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 chartered-web/src/endpoints/web_api/crates/info.rs               |  15 +++++++++++++--
 chartered-web/src/endpoints/web_api/crates/members.rs            |   6 ++++--
 chartered-web/src/endpoints/web_api/organisations/info.rs        |  10 ++++++----
 chartered-web/src/endpoints/web_api/users/info.rs                |   2 +-
 chartered-web/src/endpoints/web_api/users/mod.rs                 |   2 +-
 15 files changed, 467 insertions(+), 249 deletions(-)

diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index a6a9780..62a1cc0 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -268,6 +268,13 @@
            .unwrap_or_default()
            .permissions)
    }

    pub fn display_name(&self) -> &str {
        self.nick
            .as_ref()
            .or(self.name.as_ref())
            .unwrap_or(&self.username)
    }
}

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 940f619..140e9e5 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -62,13 +62,9 @@
            path="/crates/:organisation/:crate/:subview?"
            component={() => <CrateView />}
          />
          <PrivateRoute exact path="/users/:uuid" component={() => <User />} />
          <PrivateRoute
            exact
            path="/users/:uuid"
            component={() => <User />}
          />
          <PrivateRoute
            exact
            path="/ssh-keys"
            component={() => <Redirect to="/ssh-keys/list" />}
          />
@@ -126,11 +122,7 @@
      {...rest}
      render={(props) => {
        // TODO: check if valid key
        if (
          !unauthedOnly ||
          !auth ||
          !auth?.getAuthKey()
        ) {
        if (!unauthedOnly || !auth || !auth?.getAuthKey()) {
          return <Component {...props} />;
        } else {
          return (
diff --git a/chartered-frontend/src/useAuth.tsx b/chartered-frontend/src/useAuth.tsx
index 8703e54..8fd1679 100644
--- a/chartered-frontend/src/useAuth.tsx
+++ a/chartered-frontend/src/useAuth.tsx
@@ -38,16 +38,20 @@

  useEffect(async () => {
    try {
      let result = await fetch(unauthenticatedEndpoint(`login/oauth/complete${location.search}`));
      let result = await fetch(
        unauthenticatedEndpoint(`login/oauth/complete${location.search}`)
      );
      let json = await result.json();

      auth.handleLoginResponse(json);
    } catch (err) {
      setResult(
        <Redirect to={{
          pathname: "/login",
          state: { error: err.message }
        }} />
        <Redirect
          to={{
            pathname: "/login",
            state: { error: err.message },
          }}
        />
      );
    }
  });
@@ -68,7 +72,11 @@
  useEffect(() => {
    localStorage.setItem(
      "charteredAuthentication",
      JSON.stringify({ userUuid: auth?.[0], authKey: auth?.[1], expires: auth?.[2] })
      JSON.stringify({
        userUuid: auth?.[0],
        authKey: auth?.[1],
        expires: auth?.[2],
      })
    );
  }, [auth]);

@@ -78,7 +86,7 @@
    }

    setAuth([response.user_uuid, response.key, new Date(response.expires)]);
  }
  };

  const login = async (username: string, password: string) => {
    let res = await fetch(unauthenticatedEndpoint("login/password"), {
@@ -95,13 +103,16 @@
  };

  const oauthLogin = async (provider: string) => {
    let res = await fetch(unauthenticatedEndpoint(`login/oauth/${provider}/begin`), {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        "User-Agent": window.navigator.userAgent,
    let res = await fetch(
      unauthenticatedEndpoint(`login/oauth/${provider}/begin`),
      {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          "User-Agent": window.navigator.userAgent,
        },
      }
    });
    );
    let json = await res.json();

    if (json.error) {
@@ -109,7 +120,7 @@
    }

    window.location.href = json.redirect_url;
  }
  };

  const logout = async () => {
    // todo call the service so we can purge the key from the db
diff --git a/chartered-frontend/src/util.tsx b/chartered-frontend/src/util.tsx
index 28e26a7..2de96bd 100644
--- a/chartered-frontend/src/util.tsx
+++ a/chartered-frontend/src/util.tsx
@@ -71,6 +71,36 @@
  return { response, error };
}

export function ProfilePicture({
  src,
  height,
  width,
  className,
}: {
  src: string;
  height: string;
  width: string;
  className?: string;
}) {
  if (src !== null) {
    return (
      <RoundedPicture
        src={src}
        height={height}
        width={width}
        className={className}
      />
    );
  } else {
    return (
      <div
        className={`rounded-circle ${className}`}
        style={{ width, height, background: "rgb(235, 235, 235)" }}
      />
    );
  }
}

export function RoundedPicture({
  src,
  height,
diff --git a/chartered-frontend/src/pages/Login.tsx b/chartered-frontend/src/pages/Login.tsx
index 6436fb9..95dffd6 100644
--- a/chartered-frontend/src/pages/Login.tsx
+++ a/chartered-frontend/src/pages/Login.tsx
@@ -19,7 +19,10 @@
  const [loading, setLoading] = useState<string | null>(null);
  const isMountedRef = useRef(null);

  const { response: oauthProviders } = useUnauthenticatedRequest<OAuthProviders>({ endpoint: "login/oauth/providers" });
  const { response: oauthProviders } =
    useUnauthenticatedRequest<OAuthProviders>({
      endpoint: "login/oauth/providers",
    });

  useEffect(() => {
    if (location.state?.error) {
@@ -56,7 +59,7 @@
    } catch (e) {
      setError(e.message);
    }
  }
  };

  return (
    <div className="bg-primary p-4 text-white min-vh-100 d-flex justify-content-center align-items-center">
@@ -96,7 +99,9 @@
                  onChange={(e) => setUsername(e.target.value)}
                />

                <label htmlFor="email" className="form-label">Username</label>
                <label htmlFor="email" className="form-label">
                  Username
                </label>
              </div>

              <div className="form-floating mt-2">
@@ -110,7 +115,9 @@
                  onChange={(e) => setPassword(e.target.value)}
                />

                <label htmlFor="password" className="form-label">Password</label>
                <label htmlFor="password" className="form-label">
                  Password
                </label>
              </div>

              <ButtonOrSpinner
@@ -123,24 +130,28 @@
              />
            </form>

            {oauthProviders?.providers.length > 0 ? (<>
              <div className="side-lines mt-3">or</div>

              {oauthProviders.providers.map((v, i) => (
                <ButtonOrSpinner
                  key={i}
                  type="button"
                  variant="dark"
                  disabled={!!loading}
                  showSpinner={loading === v}
                  text={`Login with ${v}`}
                  onClick={(evt) => {
                    evt.preventDefault();
                    handleOAuthLogin(v);
                  }}
                />
              ))}
            </>): <></>}
            {oauthProviders?.providers.length > 0 ? (
              <>
                <div className="side-lines mt-3">or</div>

                {oauthProviders.providers.map((v, i) => (
                  <ButtonOrSpinner
                    key={i}
                    type="button"
                    variant="dark"
                    disabled={!!loading}
                    showSpinner={loading === v}
                    text={`Login with ${v}`}
                    onClick={(evt) => {
                      evt.preventDefault();
                      handleOAuthLogin(v);
                    }}
                  />
                ))}
              </>
            ) : (
              <></>
            )}
          </div>
        </div>
      </div>
@@ -148,17 +159,27 @@
  );
}

function ButtonOrSpinner({ type, variant, disabled, showSpinner, text, onClick }: {
  type: "button" | "submit",
  variant: string,
  disabled: boolean,
  showSpinner: boolean,
  text: string,
  onClick: (evt) => any,
function ButtonOrSpinner({
  type,
  variant,
  disabled,
  showSpinner,
  text,
  onClick,
}: {
  type: "button" | "submit";
  variant: string;
  disabled: boolean;
  showSpinner: boolean;
  text: string;
  onClick: (evt) => any;
}) {
  if (showSpinner) {
    return (
      <div className="spinner-border text-primary mt-3 m-auto d-block" role="status">
      <div
        className="spinner-border text-primary mt-3 m-auto d-block"
        role="status"
      >
        <span className="visually-hidden">Logging in...</span>
      </div>
    );
@@ -166,9 +187,14 @@

  if (type) {
    return (
      <button type={type} disabled={disabled} onClick={onClick} className={`btn btn-lg mt-2 btn-${variant} w-100`}>
      <button
        type={type}
        disabled={disabled}
        onClick={onClick}
        className={`btn btn-lg mt-2 btn-${variant} w-100`}
      >
        {text}
      </button>
    );
  }
}
}
diff --git a/chartered-frontend/src/pages/User.tsx b/chartered-frontend/src/pages/User.tsx
index 1f07a29..76bcf13 100644
--- a/chartered-frontend/src/pages/User.tsx
+++ a/chartered-frontend/src/pages/User.tsx
@@ -1,91 +1,165 @@
import React = require("react");
import { useParams } from "react-router-dom";
import { useAuth } from "../useAuth";
import { RoundedPicture, useAuthenticatedRequest } from "../util";
import {
  ProfilePicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../util";
import Nav from "../sections/Nav";
import ErrorPage from "./ErrorPage";
import ReactPlaceholder from "react-placeholder/lib";
import { Envelope, HouseDoor } from "react-bootstrap-icons";

interface Response {
    uuid: string;
    username: string;
    name?: string;
    nick?: string;
    email?: string;
    external_profile_url?: string;
    picture_url?: string;
  uuid: string;
  username: string;
  name?: string;
  nick?: string;
  email?: string;
  external_profile_url?: string;
  picture_url?: string;
}

export default function User() {
    const auth = useAuth();
    const { uuid } = useParams();
  
    const { response: user, error } = useAuthenticatedRequest<Response>({
      auth,
      endpoint: "users/info/" + uuid,
    });
  
    if (error) {
      return <ErrorPage message={error} />;
    }

    const ready = !!user;

    console.log(user);
  
    return (
      <div className="text-white">
        <Nav />
  
        <div className="container mt-4 pb-4">
            <div className="row align-items-stretch">
                <div className="col-12 col-md-6 mb-3">
                    <div className="card border-0 shadow-sm text-black h-100">
                    <div className="card-body">
                        <div className="d-flex flex-row align-items-center">
                        <RoundedPicture
                            src={user?.picture_url}
                            height="96px"
                            width="96px"
                        />

                        <div className="px-2">
                            <h1 className="text-primary my-0">
                                <ReactPlaceholder showLoadingAnimation type="text" rows={1} ready={ready} style={{ width: "12rem" }}>
                                    {user?.nick || user?.name || user?.username}
                                </ReactPlaceholder>
                            </h1>
                        </div>
                        </div>
                    </div>
                    </div>
                </div>
  const auth = useAuth();
  const { uuid } = useParams();

  const { response: user, error } = useAuthenticatedRequest<Response>({
    auth,
    endpoint: "users/info/" + uuid,
  });

  if (error) {
    return <ErrorPage message={error} />;
  }

                <div className="col-12 col-md-6 mb-3">
                    <div className="card border-0 shadow-sm text-black h-100">
                        <div className="card-body">
                            <h5>Aliases</h5>

                            {user?.nick ? <>{user?.nick}<br /></> : <></>}
                            {user?.name ? <>{user?.name}<br /></> : <></>}
                            {user?.username ? <>{user?.username}<br /></> : <></>}
                        </div>
                    </div>
  const ready = !!user;

  return (
    <div className="text-white">
      <Nav />

      <div className="container mt-4 pb-4">
        <div className="row align-items-stretch">
          <div className="col-12 col-md-6 mb-3">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <div className="d-flex flex-row align-items-center">
                  <ProfilePicture
                    src={user?.picture_url}
                    height="96px"
                    width="96px"
                  />

                  <div className="px-2">
                    <h1 className="text-primary my-0">
                      <ReactPlaceholder
                        showLoadingAnimation
                        type="text"
                        rows={1}
                        ready={ready}
                        style={{ width: "12rem" }}
                      >
                        {user?.nick || user?.name || user?.username}
                      </ReactPlaceholder>
                    </h1>
                  </div>
                </div>
              </div>
            </div>
          </div>

            <div className="row align-items-stretch">
                <div className="col-12">
                    <div className="card border-0 shadow-sm text-black h-100">
                        <div className="card-body">
                            <div><HouseDoor /> <a href={user?.external_profile_url} target="_blank">{user?.external_profile_url}</a></div>
                            <div><Envelope /> <a href={`mailto:${user?.email}`}>{user?.email}</a></div>
                        </div>
                    </div>
                </div>
          <div className="col-12 col-md-6 mb-3">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <h5>Aliases</h5>

                {user?.nick ? (
                  <>
                    {user?.nick}
                    <br />
                  </>
                ) : (
                  <></>
                )}
                {user?.name ? (
                  <>
                    {user?.name}
                    <br />
                  </>
                ) : (
                  <></>
                )}
                {user?.username ? (
                  <>
                    {user?.username}
                    <br />
                  </>
                ) : (
                  <></>
                )}
              </div>
            </div>
          </div>
        </div>

        <div className="row align-items-stretch">
          <div className="col-12">
            <div className="card border-0 shadow-sm text-black h-100">
              <div className="card-body">
                <h5>Links</h5>

                {ready && !user?.external_profile_url && !user?.email
                  ? `${
                      user?.nick || user?.name || user?.username
                    } hasn't added any links to their profile yet.`
                  : ""}

                {!ready || user?.external_profile_url ? (
                  <div>
                    <HouseDoor />
                    &nbsp;
                    <ReactPlaceholder
                      showLoadingAnimation
                      type="text"
                      rows={1}
                      ready={ready}
                      className="position-relative d-inline-block"
                      style={{ width: "12rem", top: "4px" }}
                    >
                      <a href={user?.external_profile_url} target="_blank">
                        {user?.external_profile_url}
                      </a>
                    </ReactPlaceholder>
                  </div>
                ) : (
                  <></>
                )}

                {!ready || user?.email ? (
                  <div>
                    <Envelope />
                    &nbsp;
                    <ReactPlaceholder
                      showLoadingAnimation
                      type="text"
                      rows={1}
                      ready={ready}
                      className="position-relative d-inline-block"
                      style={{ width: "12rem", top: "4px" }}
                    >
                      <a href={`mailto:${user?.email}`}>{user?.email}</a>
                    </ReactPlaceholder>
                  </div>
                ) : (
                  <></>
                )}
              </div>
            </div>
          </div>
        </div>
      </div>
    );
  }
    </div>
  );
}
diff --git a/chartered-frontend/src/pages/crate/CrateView.tsx b/chartered-frontend/src/pages/crate/CrateView.tsx
index 2f6317e..be5a265 100644
--- a/chartered-frontend/src/pages/crate/CrateView.tsx
+++ a/chartered-frontend/src/pages/crate/CrateView.tsx
@@ -21,6 +21,7 @@
import { useParams, NavLink, Redirect, Link } from "react-router-dom";
import {
  authenticatedEndpoint,
  ProfilePicture,
  RoundedPicture,
  RoundedPicture,
  useAuthenticatedRequest,
@@ -45,12 +46,18 @@
  versions: CrateInfoVersion[];
}

export interface CrateInfoVersionUploader {
  display_name: string;
  picture_url?: string;
  uuid: string;
}

export interface CrateInfoVersion {
  vers: string;
  deps: CrateInfoVersionDependency[];
  features: any[];
  size: number;
  uploader: string;
  uploader: CrateInfoVersionUploader;
  created_at: string;
}

@@ -69,10 +76,13 @@
    return <Redirect to={`/crates/${organisation}/${crate}/readme`} />;
  }

  const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>({
    auth,
    endpoint: `crates/${organisation}/${crate}`,
  }, [organisation, crate]);
  const { response: crateInfo, error } = useAuthenticatedRequest<CrateInfo>(
    {
      auth,
      endpoint: `crates/${organisation}/${crate}`,
    },
    [organisation, crate]
  );

  if (error) {
    return <ErrorPage message={error} />;
@@ -224,7 +234,13 @@
                ) : (
                  <></>
                )}
                {crateVersion.deps.map((dep) => <Dependency key={`${dep.name}-${dep.version_req}`} organisation={organisation} dep={dep} /> )}
                {crateVersion.deps.map((dep) => (
                  <Dependency
                    key={`${dep.name}-${dep.version_req}`}
                    organisation={organisation}
                    dep={dep}
                  />
                ))}
              </ul>
            </div>
          </div>
@@ -241,19 +257,33 @@

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

function Dependency({ organisation, dep }: { organisation: string, dep: CrateInfoVersionDependency }) {
function Dependency({
  organisation,
  dep,
}: {
  organisation: string;
  dep: CrateInfoVersionDependency;
}) {
  let link = <>{dep.name}</>;

  if (dep.registry === null) {
    link = <a target="_blank" href={`/crates/${organisation}/${dep.name}`}>{link}</a>;
    link = (
      <a target="_blank" href={`/crates/${organisation}/${dep.name}`}>
        {link}
      </a>
    );
  } else if (dep.registry === "https://github.com/rust-lang/crates.io-index") {
    link = <a target="_blank" href={`https://crates.io/crates/${dep.name}`}>{link}</a>;
    link = (
      <a target="_blank" href={`https://crates.io/crates/${dep.name}`}>
        {link}
      </a>
    );
  } else if (dep.registry.indexOf("ssh://") === 0) {
    const parts = dep.registry.split('/');
    const parts = dep.registry.split("/");
    const org = parts[parts.length - 1];
    if (org) {
      link = <Link to={`/crates/${org}/${dep.name}`}>{link}</Link>;
@@ -383,13 +413,18 @@
              <div>
                <div className="d-inline-block">
                  By
                  <RoundedPicture
                    src="http://placekitten.com/22/22"
                  <ProfilePicture
                    src={version.uploader.picture_url}
                    height="22px"
                    width="22px"
                    className="ms-1 me-1"
                  />
                  {version.uploader}
                  <Link
                    to={`/users/${version.uploader.uuid}`}
                    className="link-light"
                  >
                    {version.uploader.display_name}
                  </Link>
                </div>

                <div className="ms-3 d-inline-block">
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 762a657..36805a8 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -10,6 +10,7 @@
} from "react-bootstrap-icons";
import {
  authenticatedEndpoint,
  ProfilePicture,
  RoundedPicture,
  useAuthenticatedRequest,
} from "../../util";
@@ -23,7 +24,8 @@
interface Member {
  uuid: string;
  permissions?: string[];
  username: string;
  display_name: string;
  picture_url?: string;
}

export default function Members({
@@ -203,26 +205,30 @@
        show={deleting === true}
        onCancel={() => setDeleting(false)}
        onConfirm={() => doDelete()}
        username={member.username}
        username={member.display_name}
      />

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

      <tr>
        <td className="align-middle fit">
          <RoundedPicture
            src="http://placekitten.com/48/48"
            height="48px"
            width="48px"
          />
          <ProfilePicture src={member.picture_url} height="48px" width="48px" />
        </td>

        <td className="align-middle">
          <strong><Link to={`/users/${member.uuid}`} class="text-decoration-none">{member.username}</Link></strong>
          {auth.getUserUuid() === member.uuid ? <>
            <br />
            <em>(that's you!)</em>
          </> : <></>}
          <strong>
            <Link to={`/users/${member.uuid}`} className="text-decoration-none">
              {member.display_name}
            </Link>
          </strong>
          {auth.getUserUuid() === member.uuid ? (
            <>
              <br />
              <em>(that's you!)</em>
            </>
          ) : (
            <></>
          )}
        </td>

        {possiblePermissions && member.permissions ? (
@@ -323,13 +329,13 @@
          ref={searchRef}
          renderMenuItemChildren={(option, props) => (
            <>
              <RoundedPicture
                src="http://placekitten.com/24/24"
              <ProfilePicture
                src={option.picture_url}
                height="24px"
                width="24px"
                className="me-2"
              />
              <span>{option.username}</span>
              <span>{option.display_name}</span>
            </>
          )}
        />
diff --git a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx b/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
index 7e00c09..1b2d53c 100644
--- a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
+++ a/chartered-frontend/src/pages/organisations/ListOrganisations.tsx
@@ -37,40 +37,47 @@
        <h1>Your Organisations</h1>

        <div className="card border-0 shadow-sm text-black">
          {!list ? <LoadingSpinner /> : <>
            {list.organisations.length === 0 ? (
          {!list ? (
            <LoadingSpinner />
          ) : (
            <>
              {list.organisations.length === 0 ? (
                <div className="card-body">
                You don't belong to any organisations yet.
                  You don't belong to any organisations yet.
                </div>
            ) : (
              ) : (
                <table className="table table-striped">
                <tbody>
                  <tbody>
                    {list.organisations.map((v, i) => (
                    <tr key={i}>
                      <tr key={i}>
                        <td className="align-middle fit">
                        <RoundedPicture
                          <RoundedPicture
                            src="http://placekitten.com/48/48"
                            height="48px"
                            width="48px"
                        />
                          />
                        </td>

                        <td className="align-middle" style={{ lineHeight: "1.1" }}>
                        <div>
                        <td
                          className="align-middle"
                          style={{ lineHeight: "1.1" }}
                        >
                          <div>
                            <Link to={`/crates/${v.name}`}>{v.name}</Link>
                        </div>
                        <div>
                          </div>
                          <div>
                            <small style={{ fontSize: "0.75rem" }}>
                            {v.description}
                              {v.description}
                            </small>
                        </div>
                          </div>
                        </td>
                    </tr>
                      </tr>
                    ))}
                </tbody>
                  </tbody>
                </table>
            )}
          </>}
              )}
            </>
          )}
        </div>

        <Link
diff --git a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
index e446ec8..6350a63 100644
--- a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
+++ a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
@@ -97,82 +97,97 @@
        </div>

        <div className="card border-0 shadow-sm text-black">
          {!sshKeys ? <LoadingSpinner /> : <>
            {sshKeys.keys.length == 0 ? (
              <div className="card-body">You haven't added any SSH keys yet.</div>
            ) : (
              <div className="table-responsive">
                <table className="table table-striped">
                  <tbody>
                    {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" }}>
                            <div className="text-muted d-inline-block me-3">
                              Added{" "}
                              <OverlayTrigger
                                overlay={
                                  <Tooltip id={`${key.uuid}-created-at`}>
                                    {new Date(key.created_at).toLocaleString()}
                                  </Tooltip>
                                }
                              >
                                <span className="text-decoration-underline-dotted">
                                  <HumanTime
                                    time={new Date(key.created_at).getTime()}
                                  />
                                </span>
                              </OverlayTrigger>
                            </div>
                            <span
                              className={`text-${
                                key.last_used_at
                                  ? new Date(key.last_used_at) > dateMonthAgo
                                    ? "success"
                                    : "danger"
                                  : "muted"
                              }`}
          {!sshKeys ? (
            <LoadingSpinner />
          ) : (
            <>
              {sshKeys.keys.length == 0 ? (
                <div className="card-body">
                  You haven't added any SSH keys yet.
                </div>
              ) : (
                <div className="table-responsive">
                  <table className="table table-striped">
                    <tbody>
                      {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" }}
                            >
                              Last used{" "}
                              {key.last_used_at ? (
                              <div className="text-muted d-inline-block me-3">
                                Added{" "}
                                <OverlayTrigger
                                  overlay={
                                    <Tooltip id={`${key.uuid}-last-used`}>
                                      {new Date(key.last_used_at).toLocaleString()}
                                    <Tooltip id={`${key.uuid}-created-at`}>
                                      {new Date(
                                        key.created_at
                                      ).toLocaleString()}
                                    </Tooltip>
                                  }
                                >
                                  <span className="text-decoration-underline-dotted">
                                    <HumanTime
                                      time={new Date(key.last_used_at).getTime()}
                                      time={new Date(key.created_at).getTime()}
                                    />
                                  </span>
                                </OverlayTrigger>
                              ) : (
                                <>never</>
                              )}
                            </span>
                          </div>
                        </td>
                              </div>
                              <span
                                className={`text-${
                                  key.last_used_at
                                    ? new Date(key.last_used_at) > dateMonthAgo
                                      ? "success"
                                      : "danger"
                                    : "muted"
                                }`}
                              >
                                Last used{" "}
                                {key.last_used_at ? (
                                  <OverlayTrigger
                                    overlay={
                                      <Tooltip id={`${key.uuid}-last-used`}>
                                        {new Date(
                                          key.last_used_at
                                        ).toLocaleString()}
                                      </Tooltip>
                                    }
                                  >
                                    <span className="text-decoration-underline-dotted">
                                      <HumanTime
                                        time={new Date(
                                          key.last_used_at
                                        ).getTime()}
                                      />
                                    </span>
                                  </OverlayTrigger>
                                ) : (
                                  <>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>
            )}
          </>}
                          <td className="align-middle fit">
                            <button
                              type="button"
                              className="btn text-danger"
                              onClick={() => setDeleting(key)}
                            >
                              <Trash />
                            </button>
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              )}
            </>
          )}
        </div>

        <Link
diff --git a/chartered-web/src/endpoints/web_api/crates/info.rs b/chartered-web/src/endpoints/web_api/crates/info.rs
index 8e3455b..8addec3 100644
--- a/chartered-web/src/endpoints/web_api/crates/info.rs
+++ a/chartered-web/src/endpoints/web_api/crates/info.rs
@@ -49,7 +49,11 @@
                size: v.size,
                created_at: chrono::Utc.from_local_datetime(&v.created_at).unwrap(),
                inner: v.into_cargo_format(&crate_with_permissions.crate_),
                uploader: user.username,
                uploader: ResponseVersionUploader {
                    uuid: user.uuid.0,
                    display_name: user.display_name().to_string(),
                    picture_url: user.picture_url,
                },
            })
            .collect(),
    })
@@ -69,7 +73,14 @@
    inner: CrateVersion<'a>,
    size: i32,
    created_at: chrono::DateTime<chrono::Utc>,
    uploader: String,
    uploader: ResponseVersionUploader,
}

#[derive(Serialize)]
pub struct ResponseVersionUploader {
    uuid: chartered_db::uuid::Uuid,
    display_name: String,
    picture_url: Option<String>,
}

#[derive(Serialize)]
diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs
index c9a186f..65a31ea 100644
--- a/chartered-web/src/endpoints/web_api/crates/members.rs
+++ a/chartered-web/src/endpoints/web_api/crates/members.rs
@@ -17,7 +17,8 @@
#[derive(Deserialize, Serialize)]
pub struct GetResponseMember {
    uuid: Uuid,
    username: String,
    display_name: String,
    picture_url: Option<String>,
    permissions: UserPermission,
}

@@ -35,7 +36,8 @@
        .into_iter()
        .map(|(user, permissions)| GetResponseMember {
            uuid: user.uuid.0,
            username: user.username,
            display_name: user.display_name().to_string(),
            picture_url: user.picture_url,
            permissions,
        })
        .collect();
diff --git a/chartered-web/src/endpoints/web_api/organisations/info.rs b/chartered-web/src/endpoints/web_api/organisations/info.rs
index 7d9a877..d1015a8 100644
--- a/chartered-web/src/endpoints/web_api/organisations/info.rs
+++ a/chartered-web/src/endpoints/web_api/organisations/info.rs
@@ -52,8 +52,9 @@
        members: users
            .into_iter()
            .map(|(user, perms)| ResponseUser {
                uuid: user.uuid.to_string(),
                username: user.username,
                uuid: user.uuid.0,
                display_name: user.display_name().to_string(),
                picture_url: user.picture_url,
                permissions: can_manage_users.then(|| perms),
            })
            .collect(),
@@ -76,7 +77,8 @@

#[derive(Serialize)]
pub struct ResponseUser {
    uuid: String,
    username: String,
    uuid: chartered_db::uuid::Uuid,
    display_name: String,
    picture_url: Option<String>,
    permissions: Option<UserPermission>,
}
diff --git a/chartered-web/src/endpoints/web_api/users/info.rs b/chartered-web/src/endpoints/web_api/users/info.rs
index c8870bb..221d581 100644
--- a/chartered-web/src/endpoints/web_api/users/info.rs
+++ a/chartered-web/src/endpoints/web_api/users/info.rs
@@ -1,6 +1,6 @@
use axum::{extract, Json};
use chartered_db::{users::User, ConnectionPool};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use thiserror::Error;

#[derive(Serialize)]
diff --git a/chartered-web/src/endpoints/web_api/users/mod.rs b/chartered-web/src/endpoints/web_api/users/mod.rs
index 256bbb5..f8020ae 100644
--- a/chartered-web/src/endpoints/web_api/users/mod.rs
+++ a/chartered-web/src/endpoints/web_api/users/mod.rs
@@ -1,5 +1,5 @@
mod search;
mod info;
mod search;

use axum::{
    body::{Body, BoxBody},