🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-18 2:09:42.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-18 2:09:49.0 +01:00:00
commit
38bf212911604a84b0d4a487c3fdbc46aaae24a2 [patch]
tree
bc5e8fd29af37f2eaed9acbd58cce3b34062e562
parent
07019257caad5f9822b053b649f84616c6f02f64
download
38bf212911604a84b0d4a487c3fdbc46aaae24a2.tar.gz

Web UI support for adding new members to a crate



Diff

 chartered-frontend/package-lock.json                  | 371 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 chartered-frontend/package.json                       |   3 +++
 chartered-db/src/crates.rs                            |  26 +++++++++++++++++++++++++-
 chartered-db/src/users.rs                             |  18 ++++++++++++++++++
 chartered-web/src/main.rs                             |   7 ++++++-
 chartered-frontend/src/pages/crate/Members.tsx        | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 chartered-web/src/endpoints/web_api/mod.rs            |   2 ++
 chartered-web/src/endpoints/web_api/search_users.rs   |  54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/crates/members.rs |  23 +++++++++++++++++++++--
 chartered-web/src/endpoints/web_api/crates/mod.rs     |   3 ++-
 10 files changed, 583 insertions(+), 70 deletions(-)

diff --git a/chartered-frontend/package-lock.json b/chartered-frontend/package-lock.json
index 0c70238..50d1efa 100644
--- a/chartered-frontend/package-lock.json
+++ a/chartered-frontend/package-lock.json
@@ -10,9 +10,11 @@
      "license": "0BSD",
      "dependencies": {
        "bootstrap": "^5.1.1",
        "lodash": "^4.17.21",
        "react": "^17.0.2",
        "react-bootstrap": "^2.0.0-beta.6",
        "react-bootstrap-icons": "^1.5.0",
        "react-bootstrap-typeahead": "^5.2.0",
        "react-dom": "^17.0.2",
        "react-human-time": "^1.2.0",
        "react-markdown": "^7.0.1",
@@ -22,6 +24,7 @@
        "source-code-pro": "^2.38.0"
      },

      "devDependencies": {
        "@types/lodash": "^4.14.173",
        "@types/react": "^17.0.20",
        "@types/react-dom": "^17.0.9",
        "parcel-bundler": "^1.12.5",
@@ -1686,6 +1689,19 @@
        "node": ">=6.9.0"
      }

    },

    "node_modules/@hypnosphi/create-react-context": {
      "version": "0.3.1",
      "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz",
      "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==",
      "dependencies": {
        "gud": "^1.0.0",
        "warning": "^4.0.3"
      },

      "peerDependencies": {
        "prop-types": "^15.0.0",
        "react": ">=0.14.0"
      }

    },

    "node_modules/@iarna/toml": {
      "version": "2.2.5",
      "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
@@ -1868,6 +1884,12 @@
      "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz",
      "integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg=="
    },

    "node_modules/@types/lodash": {
      "version": "4.14.173",
      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.173.tgz",
      "integrity": "sha512-vv0CAYoaEjCw/mLy96GBTnRoZrSxkGE0BKzKimdR8P3OzrNYNvBgtW7p055A+E8C31vXNUhWKoFCbhq7gbyhFg==",
      "dev": true
    },

    "node_modules/@types/mdast": {
      "version": "3.0.10",
      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -2786,7 +2808,6 @@
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
      "dev": true,
      "dependencies": {
        "function-bind": "^1.1.1",
        "get-intrinsic": "^1.0.2"
@@ -3250,6 +3271,11 @@
      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
      "dev": true
    },

    "node_modules/compute-scroll-into-view": {
      "version": "1.0.17",
      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
      "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
    },

    "node_modules/concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3849,6 +3875,22 @@
      "dev": true,
      "engines": {
        "node": ">=0.10"
      }

    },

    "node_modules/deep-equal": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
      "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
      "dependencies": {
        "is-arguments": "^1.0.4",
        "is-date-object": "^1.0.1",
        "is-regex": "^1.0.4",
        "object-is": "^1.0.1",
        "object-keys": "^1.1.1",
        "regexp.prototype.flags": "^1.2.0"
      },

      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }

    },

    "node_modules/deep-extend": {
@@ -3888,7 +3930,6 @@
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
      "dev": true,
      "dependencies": {
        "object-keys": "^1.0.12"
      },

@@ -4537,8 +4578,7 @@
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "dev": true
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
    },

    "node_modules/fast-glob": {
      "version": "2.2.7",
@@ -4721,8 +4761,7 @@
    "node_modules/function-bind": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
      "dev": true
      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
    },

    "node_modules/gensync": {
      "version": "1.0.0-beta.2",
@@ -4737,7 +4776,6 @@
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
      "dev": true,
      "dependencies": {
        "function-bind": "^1.1.1",
        "has": "^1.0.3",
@@ -4874,6 +4912,11 @@
        "brfs": "^1.2.0",
        "unicode-trie": "^0.3.1"
      }

    },

    "node_modules/gud": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
      "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
    },

    "node_modules/har-schema": {
      "version": "2.0.0",
@@ -4902,7 +4945,6 @@
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
      "dev": true,
      "dependencies": {
        "function-bind": "^1.1.1"
      },

@@ -4953,7 +4995,6 @@
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
      "dev": true,
      "engines": {
        "node": ">= 0.4"
      },

@@ -4965,7 +5006,6 @@
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
      "dev": true,
      "dependencies": {
        "has-symbols": "^1.0.2"
      },

@@ -5578,6 +5618,21 @@
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }

    },

    "node_modules/is-arguments": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
      "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
      "dependencies": {
        "call-bind": "^1.0.2",
        "has-tostringtag": "^1.0.0"
      },

      "engines": {
        "node": ">= 0.4"
      },

      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }

    },

    "node_modules/is-arrayish": {
@@ -5695,7 +5750,6 @@
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
      "dev": true,
      "dependencies": {
        "has-tostringtag": "^1.0.0"
      },

@@ -5897,7 +5951,6 @@
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
      "dev": true,
      "dependencies": {
        "call-bind": "^1.0.2",
        "has-tostringtag": "^1.0.0"
@@ -6206,8 +6259,7 @@
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
      "dev": true
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
    },

    "node_modules/lodash.clone": {
      "version": "4.5.0",
@@ -6218,8 +6270,7 @@
    "node_modules/lodash.debounce": {
      "version": "4.0.8",
      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
      "dev": true
      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
    },

    "node_modules/lodash.memoize": {
      "version": "4.1.2",
@@ -7533,12 +7584,26 @@
      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz",
      "integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==",
      "dev": true
    },

    "node_modules/object-is": {
      "version": "1.1.5",
      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
      "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
      "dependencies": {
        "call-bind": "^1.0.2",
        "define-properties": "^1.1.3"
      },

      "engines": {
        "node": ">= 0.4"
      },

      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }

    },

    "node_modules/object-keys": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
      "dev": true,
      "engines": {
        "node": ">= 0.4"
      }

@@ -7970,6 +8035,16 @@
      "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz",
      "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
      "dev": true
    },

    "node_modules/popper.js": {
      "version": "1.16.1",
      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
      "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
      "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/popperjs"
      }

    },

    "node_modules/posix-character-classes": {
      "version": "0.1.1",
@@ -8873,8 +8948,41 @@
      },

      "peerDependencies": {
        "react": "^16.8.6 || ^17"
      }

    },

    "node_modules/react-bootstrap-typeahead": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-5.2.0.tgz",
      "integrity": "sha512-tM7HiUX4ra/3lF6YmrXCjIreG5o1RIsAuFQiRKeZKtRWLQ+bQfa+rdKpga2YNj+lvIGgJNsyUtVLSMxyZ3czEg==",
      "dependencies": {
        "@babel/runtime": "^7.14.6",
        "@restart/hooks": "^0.4.0",
        "classnames": "^2.2.0",
        "fast-deep-equal": "^3.1.1",
        "invariant": "^2.2.1",
        "lodash.debounce": "^4.0.8",
        "prop-types": "^15.5.8",
        "react-overlays": "^5.1.0",
        "react-popper": "^1.0.0",
        "scroll-into-view-if-needed": "^2.2.20",
        "warning": "^4.0.1"
      },

      "peerDependencies": {
        "react": ">=16.8.0",
        "react-dom": ">=16.8.0"
      }

    },

    "node_modules/react-bootstrap-typeahead/node_modules/@restart/hooks": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.0.tgz",
      "integrity": "sha512-+RenTVobiCHPjUTbhQDV8m0PU1xEWqgloMIIOlf86oKnfghKR/l4tKto7TH543shEQZZa7ARSMTvT0cXN9u8+g==",
      "dependencies": {
        "dequal": "^2.0.2"
      },

      "peerDependencies": {
        "react": ">=16.8.0"
      }

    },

    "node_modules/react-dom": {
      "version": "17.0.2",
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
@@ -8940,6 +9048,42 @@
      "version": "17.0.2",
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
    },

    "node_modules/react-overlays": {
      "version": "5.1.1",
      "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.1.1.tgz",
      "integrity": "sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==",
      "dependencies": {
        "@babel/runtime": "^7.13.8",
        "@popperjs/core": "^2.8.6",
        "@restart/hooks": "^0.3.26",
        "@types/warning": "^3.0.0",
        "dom-helpers": "^5.2.0",
        "prop-types": "^15.7.2",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      },

      "peerDependencies": {
        "react": ">=16.3.0",
        "react-dom": ">=16.3.0"
      }

    },

    "node_modules/react-popper": {
      "version": "1.3.11",
      "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz",
      "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==",
      "dependencies": {
        "@babel/runtime": "^7.1.2",
        "@hypnosphi/create-react-context": "^0.3.1",
        "deep-equal": "^1.1.1",
        "popper.js": "^1.14.4",
        "prop-types": "^15.6.1",
        "typed-styles": "^0.0.7",
        "warning": "^4.0.2"
      },

      "peerDependencies": {
        "react": "0.14.x || ^15.0.0 || ^16.0.0 || ^17.0.0"
      }

    },

    "node_modules/react-router": {
      "version": "5.2.1",
@@ -9212,6 +9356,21 @@
      },

      "engines": {
        "node": ">=0.10.0"
      }

    },

    "node_modules/regexp.prototype.flags": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz",
      "integrity": "sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==",
      "dependencies": {
        "call-bind": "^1.0.2",
        "define-properties": "^1.1.3"
      },

      "engines": {
        "node": ">= 0.4"
      },

      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }

    },

    "node_modules/regexpu-core": {
@@ -9711,6 +9870,14 @@
      "dependencies": {
        "loose-envify": "^1.1.0",
        "object-assign": "^4.1.1"
      }

    },

    "node_modules/scroll-into-view-if-needed": {
      "version": "2.2.28",
      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.28.tgz",
      "integrity": "sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w==",
      "dependencies": {
        "compute-scroll-into-view": "^1.0.17"
      }

    },

    "node_modules/semver": {
@@ -10896,6 +11063,11 @@
        "node": ">= 0.8.0"
      }

    },

    "node_modules/typed-styles": {
      "version": "0.0.7",
      "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",
      "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q=="
    },

    "node_modules/typedarray": {
      "version": "0.0.6",
      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
@@ -12806,6 +12978,15 @@
      "requires": {
        "@babel/helper-validator-identifier": "^7.14.9",
        "to-fast-properties": "^2.0.0"
      }

    },

    "@hypnosphi/create-react-context": {
      "version": "0.3.1",
      "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz",
      "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==",
      "requires": {
        "gud": "^1.0.0",
        "warning": "^4.0.3"
      }

    },

    "@iarna/toml": {
@@ -12954,6 +13135,12 @@
      "version": "2.2.35",
      "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz",
      "integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg=="
    },

    "@types/lodash": {
      "version": "4.14.173",
      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.173.tgz",
      "integrity": "sha512-vv0CAYoaEjCw/mLy96GBTnRoZrSxkGE0BKzKimdR8P3OzrNYNvBgtW7p055A+E8C31vXNUhWKoFCbhq7gbyhFg==",
      "dev": true
    },

    "@types/mdast": {
      "version": "3.0.10",
@@ -13720,7 +13907,6 @@
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
      "dev": true,
      "requires": {
        "function-bind": "^1.1.1",
        "get-intrinsic": "^1.0.2"
@@ -14096,6 +14282,11 @@
        }

      }

    },

    "compute-scroll-into-view": {
      "version": "1.0.17",
      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
      "integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
    },

    "concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -14589,6 +14780,19 @@
      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
      "dev": true
    },

    "deep-equal": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
      "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
      "requires": {
        "is-arguments": "^1.0.4",
        "is-date-object": "^1.0.1",
        "is-regex": "^1.0.4",
        "object-is": "^1.0.1",
        "object-keys": "^1.1.1",
        "regexp.prototype.flags": "^1.2.0"
      }

    },

    "deep-extend": {
      "version": "0.6.0",
@@ -14623,7 +14827,6 @@
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
      "dev": true,
      "requires": {
        "object-keys": "^1.0.12"
      }

@@ -15155,8 +15358,7 @@
    "fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "dev": true
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
    },

    "fast-glob": {
      "version": "2.2.7",
@@ -15300,8 +15502,7 @@
    "function-bind": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
      "dev": true
      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
    },

    "gensync": {
      "version": "1.0.0-beta.2",
@@ -15313,7 +15514,6 @@
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
      "dev": true,
      "requires": {
        "function-bind": "^1.1.1",
        "has": "^1.0.3",
@@ -15422,6 +15622,11 @@
        "brfs": "^1.2.0",
        "unicode-trie": "^0.3.1"
      }

    },

    "gud": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
      "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
    },

    "har-schema": {
      "version": "2.0.0",
@@ -15443,7 +15648,6 @@
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
      "dev": true,
      "requires": {
        "function-bind": "^1.1.1"
      }

@@ -15480,14 +15684,12 @@
    "has-symbols": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
      "dev": true
      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
    },

    "has-tostringtag": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
      "dev": true,
      "requires": {
        "has-symbols": "^1.0.2"
      }

@@ -15961,6 +16163,15 @@
      "requires": {
        "is-alphabetical": "^2.0.0",
        "is-decimal": "^2.0.0"
      }

    },

    "is-arguments": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
      "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
      "requires": {
        "call-bind": "^1.0.2",
        "has-tostringtag": "^1.0.0"
      }

    },

    "is-arrayish": {
@@ -16053,7 +16264,6 @@
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
      "dev": true,
      "requires": {
        "has-tostringtag": "^1.0.0"
      }

@@ -16183,7 +16393,6 @@
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
      "dev": true,
      "requires": {
        "call-bind": "^1.0.2",
        "has-tostringtag": "^1.0.0"
@@ -16425,8 +16634,7 @@
    "lodash": {
      "version": "4.17.21",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
      "dev": true
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
    },

    "lodash.clone": {
      "version": "4.5.0",
@@ -16437,8 +16645,7 @@
    "lodash.debounce": {
      "version": "4.0.8",
      "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
      "dev": true
      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
    },

    "lodash.memoize": {
      "version": "4.1.2",
@@ -17365,12 +17572,20 @@
      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz",
      "integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==",
      "dev": true
    },

    "object-is": {
      "version": "1.1.5",
      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
      "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==",
      "requires": {
        "call-bind": "^1.0.2",
        "define-properties": "^1.1.3"
      }

    },

    "object-keys": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
      "dev": true
      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
    },

    "object-visit": {
      "version": "1.0.1",
@@ -17721,6 +17936,11 @@
      "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
      "dev": true
    },

    "popper.js": {
      "version": "1.16.1",
      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
      "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
    },

    "posix-character-classes": {
      "version": "0.1.1",
      "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@@ -18463,6 +18683,34 @@
      "integrity": "sha512-QC5q4meHQG0cO9RJzeDLSqZ1gbVa9jxFCpONCE3GYl2FkbAKSyJAEsONlzTApbZ8/oG87gPWq0xAyn5SZ/Jafw==",
      "requires": {
        "prop-types": "^15.7.2"
      }

    },

    "react-bootstrap-typeahead": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-5.2.0.tgz",
      "integrity": "sha512-tM7HiUX4ra/3lF6YmrXCjIreG5o1RIsAuFQiRKeZKtRWLQ+bQfa+rdKpga2YNj+lvIGgJNsyUtVLSMxyZ3czEg==",
      "requires": {
        "@babel/runtime": "^7.14.6",
        "@restart/hooks": "^0.4.0",
        "classnames": "^2.2.0",
        "fast-deep-equal": "^3.1.1",
        "invariant": "^2.2.1",
        "lodash.debounce": "^4.0.8",
        "prop-types": "^15.5.8",
        "react-overlays": "^5.1.0",
        "react-popper": "^1.0.0",
        "scroll-into-view-if-needed": "^2.2.20",
        "warning": "^4.0.1"
      },

      "dependencies": {
        "@restart/hooks": {
          "version": "0.4.0",
          "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.0.tgz",
          "integrity": "sha512-+RenTVobiCHPjUTbhQDV8m0PU1xEWqgloMIIOlf86oKnfghKR/l4tKto7TH543shEQZZa7ARSMTvT0cXN9u8+g==",
          "requires": {
            "dequal": "^2.0.2"
          }

        }

      }

    },

    "react-dom": {
@@ -18520,6 +18768,35 @@
          "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
          "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
        }

      }

    },

    "react-overlays": {
      "version": "5.1.1",
      "resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.1.1.tgz",
      "integrity": "sha512-eCN2s2/+GVZzpnId4XVWtvDPYYBD2EtOGP74hE+8yDskPzFy9+pV1H3ZZihxuRdEbQzzacySaaDkR7xE0ydl4Q==",
      "requires": {
        "@babel/runtime": "^7.13.8",
        "@popperjs/core": "^2.8.6",
        "@restart/hooks": "^0.3.26",
        "@types/warning": "^3.0.0",
        "dom-helpers": "^5.2.0",
        "prop-types": "^15.7.2",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      }

    },

    "react-popper": {
      "version": "1.3.11",
      "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz",
      "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==",
      "requires": {
        "@babel/runtime": "^7.1.2",
        "@hypnosphi/create-react-context": "^0.3.1",
        "deep-equal": "^1.1.1",
        "popper.js": "^1.14.4",
        "prop-types": "^15.6.1",
        "typed-styles": "^0.0.7",
        "warning": "^4.0.2"
      }

    },

    "react-router": {
@@ -18735,6 +19012,15 @@
            "is-plain-object": "^2.0.4"
          }

        }

      }

    },

    "regexp.prototype.flags": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz",
      "integrity": "sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==",
      "requires": {
        "call-bind": "^1.0.2",
        "define-properties": "^1.1.3"
      }

    },

    "regexpu-core": {
@@ -19126,6 +19412,14 @@
        "object-assign": "^4.1.1"
      }

    },

    "scroll-into-view-if-needed": {
      "version": "2.2.28",
      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.28.tgz",
      "integrity": "sha512-8LuxJSuFVc92+0AdNv4QOxRL4Abeo1DgLnGNkn1XlaujPH/3cCFz3QI60r2VNu4obJJROzgnIUw5TKQkZvZI1w==",
      "requires": {
        "compute-scroll-into-view": "^1.0.17"
      }

    },

    "semver": {
      "version": "5.7.1",
      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@@ -20103,6 +20397,11 @@
      "requires": {
        "prelude-ls": "~1.1.2"
      }

    },

    "typed-styles": {
      "version": "0.0.7",
      "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",
      "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q=="
    },

    "typedarray": {
      "version": "0.0.6",
diff --git a/chartered-frontend/package.json b/chartered-frontend/package.json
index 7e28737..e2e4938 100644
--- a/chartered-frontend/package.json
+++ a/chartered-frontend/package.json
@@ -12,6 +12,7 @@
  "author": "",
  "license": "0BSD",
  "devDependencies": {
    "@types/lodash": "^4.14.173",
    "@types/react": "^17.0.20",
    "@types/react-dom": "^17.0.9",
    "parcel-bundler": "^1.12.5",
@@ -21,9 +22,11 @@
  },

  "dependencies": {
    "bootstrap": "^5.1.1",
    "lodash": "^4.17.21",
    "react": "^17.0.2",
    "react-bootstrap": "^2.0.0-beta.6",
    "react-bootstrap-icons": "^1.5.0",
    "react-bootstrap-typeahead": "^5.2.0",
    "react-dom": "^17.0.2",
    "react-human-time": "^1.2.0",
    "react-markdown": "^7.0.1",
diff --git a/chartered-db/src/crates.rs b/chartered-db/src/crates.rs
index 0a98e2b..8a19349 100644
--- a/chartered-db/src/crates.rs
+++ a/chartered-db/src/crates.rs
@@ -222,6 +222,30 @@
        .await?
    }

    pub async fn insert_permissions(
        self: Arc<Self>,
        conn: ConnectionPool,
        given_user_id: i32,
        given_permissions: crate::users::UserCratePermissionValue,
    ) -> Result<usize> {
        tokio::task::spawn_blocking(move || {
            use crate::schema::user_crate_permissions::dsl::{
                crate_id, permissions, user_crate_permissions, user_id,
            };

            let conn = conn.get()?;

            Ok(diesel::insert_into(user_crate_permissions)
                .values((
                    user_id.eq(given_user_id),
                    crate_id.eq(self.id),
                    permissions.eq(given_permissions.bits()),
                ))
                .execute(&conn)?)
        })
        .await?
    }

    pub async fn delete_member(
        self: Arc<Self>,
        conn: ConnectionPool,
@@ -237,7 +261,7 @@
            diesel::delete(
                user_crate_permissions
                    .filter(user_id.eq(given_user_id))
                    .filter(crate_id.eq(self.id))
                    .filter(crate_id.eq(self.id)),
            )
            .execute(&conn)?;

diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index e7f9ba7..be6de05 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -16,6 +16,24 @@
}

impl User {
    pub async fn search(
        conn: ConnectionPool,
        given_query: String,
        limit: i64,
    ) -> Result<Vec<User>> {
        use crate::schema::users::dsl::username;

        tokio::task::spawn_blocking(move || {
            let conn = conn.get()?;

            Ok(crate::schema::users::table
                .filter(username.like(format!("%{}%", given_query)))
                .limit(limit)
                .load(&conn)?)
        })
        .await?
    }

    pub async fn find_by_username(
        conn: ConnectionPool,
        given_username: String,
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index 5ba3a94..1041c6d 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -76,12 +76,17 @@
        )
        .route(
            "/crates/:crate/members",
            patch(endpoints::web_api::crates::update_members)
            patch(endpoints::web_api::crates::update_member)
        )
        .route(
            "/crates/:crate/members",
            put(endpoints::web_api::crates::insert_member)
        )
        .route(
            "/crates/:crate/members",
            delete(endpoints::web_api::crates::delete_member)
        )
        .route("/users/search", get(endpoints::web_api::search_users))
        .route("/ssh-key", get(endpoints::web_api::get_ssh_keys))
        .route("/ssh-key", put(endpoints::web_api::add_ssh_key))
        .route("/ssh-key/:id", delete(endpoints::web_api::delete_ssh_key)))
diff --git a/chartered-frontend/src/pages/crate/Members.tsx b/chartered-frontend/src/pages/crate/Members.tsx
index 9412f95..f6dca03 100644
--- a/chartered-frontend/src/pages/crate/Members.tsx
+++ a/chartered-frontend/src/pages/crate/Members.tsx
@@ -1,9 +1,12 @@
import React = require("react");
import { useState } from "react";
import { PersonPlus, Trash, CheckLg, Save, PlusLg } from 'react-bootstrap-icons';
import { authenticatedEndpoint, useAuthenticatedRequest } from "../../util";
import { useAuth } from "../../useAuth";
import { Button, Modal } from "react-bootstrap";
import { AsyncTypeahead } from "react-bootstrap-typeahead";
import { debounce } from "lodash";
import _ = require("lodash");

interface CratesMembersResponse {
    allowed_permissions: string[],
@@ -23,6 +26,15 @@
        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.id === prospectiveMember.id) === -1
            }));
        }
    }, [response])

    if (error) {
        return <>{error}</>;
@@ -37,50 +49,49 @@
    const allowedPermissions = response.allowed_permissions;

    return <div className="container-fluid g-0">
        <div className="table-responsive">
        <div className={/*"table-responsive"*/ ""}>
            <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)}
                        />
                    )}

                    <tr>
                        <td className="align-middle fit">
                            <div
                                className="d-flex align-items-center justify-content-center rounded-circle"
                                style={{ width: '48px', height: '48px', background: '#DEDEDE', fontSize: '1rem' }}
                            >
                                <PersonPlus />
                            </div>
                        </td>

                        <td className="align-middle">
                            <input type="search" className="form-control" placeholder="Search for User" />
                        </td>

                        <td className="align-middle">
                            <RenderPermissions allowedPermissions={allowedPermissions} selectedPermissions={[]} userId={-1} />
                        </td>

                        <td className="align-middle">
                            <button type="button" className="btn text-dark pe-none">
                                <PlusLg />
                            </button>
                        </td>
                    </tr>
                    <MemberListInserter
                        onInsert={(username, userId) => setProspectiveMembers([
                            ...prospectiveMembers,
                            {
                                id: userId,
                                username,
                                permissions: ["VISIBLE"],
                            }
                        ])}
                        existingMembers={response.members}
                    />
                </tbody>
            </table>
        </div>
    </div>;
}

function MemberListItem({ crate, member, allowedPermissions, onUpdateComplete }: { crate: string, member: Member, allowedPermissions: string[], onUpdateComplete: () => any }) {
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);
@@ -94,7 +105,7 @@

        try {
            let res = await fetch(authenticatedEndpoint(auth, `crates/${crate}/members`), {
                method: 'PATCH',
                method: prospectiveMember ? 'PUT' : 'PATCH',
                headers: {
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
@@ -152,11 +163,11 @@
                <span className="visually-hidden">Loading...</span>
            </div>
        </button>;
    } else if (selectedPermissions.indexOf("VISIBLE") === -1) {
    } else if (!prospectiveMember && selectedPermissions.indexOf("VISIBLE") === -1) {
        itemAction = <button type="button" className="btn text-danger" onClick={() => setDeleting(true)}>
            <Trash />
        </button>;
    } else if (selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
    } else if (prospectiveMember || selectedPermissions.sort().join(',') != member.permissions.sort().join(',')) {
        itemAction = <button type="button" className="btn text-success" onClick={saveUserPermissions}>
            <CheckLg />
        </button>;
@@ -194,6 +205,83 @@
            </td>
        </tr>
    </>;
}

function MemberListInserter({ onInsert, existingMembers }: { existingMembers: Member[], onInsert: (username, user_id) => 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_id);
        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_id === existing.id) === -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 RenderPermissions({ allowedPermissions, selectedPermissions, userId, onChange }: { allowedPermissions: string[], selectedPermissions: string[], userId: number, onChange: (permissions) => any }) {
diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs
index 8920f76..df98705 100644
--- a/chartered-web/src/endpoints/web_api/mod.rs
+++ a/chartered-web/src/endpoints/web_api/mod.rs
@@ -1,8 +1,10 @@
pub mod crates;
mod login;
mod search_users;
mod ssh_key;

pub use login::handle as login;
pub use search_users::handle as search_users;
pub use ssh_key::{
    handle_delete as delete_ssh_key, handle_get as get_ssh_keys, handle_put as add_ssh_key,
};
diff --git a/chartered-web/src/endpoints/web_api/search_users.rs b/chartered-web/src/endpoints/web_api/search_users.rs
new file mode 100644
index 0000000..7fddc45 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/search_users.rs
@@ -1,0 +1,54 @@
use axum::{extract, Json};
use chartered_db::{users::User, ConnectionPool};
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Deserialize)]
pub struct RequestParams {
    q: String,
}

#[derive(Serialize)]
pub struct Response {
    users: Vec<ResponseUser>,
}

#[derive(Serialize)]
pub struct ResponseUser {
    user_id: i32,
    username: String,
}

pub async fn handle(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Query(req): extract::Query<RequestParams>,
) -> Result<Json<Response>, Error> {
    let users = User::search(db, req.q, 5)
        .await?
        .into_iter()
        .map(|user| ResponseUser {
            user_id: user.id,
            username: user.username,
        })
        .collect();

    Ok(Json(Response { users }))
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
}

impl Error {
    pub fn status_code(&self) -> axum::http::StatusCode {
        use axum::http::StatusCode;

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

define_error_response!(Error);
diff --git a/chartered-web/src/endpoints/web_api/crates/members.rs b/chartered-web/src/endpoints/web_api/crates/members.rs
index de2942b..4252017 100644
--- a/chartered-web/src/endpoints/web_api/crates/members.rs
+++ a/chartered-web/src/endpoints/web_api/crates/members.rs
@@ -52,7 +52,7 @@
}

#[derive(Deserialize)]
pub struct PatchRequest {
pub struct PutOrPatchRequest {
    user_id: i32,
    permissions: Permission,
}
@@ -61,7 +61,7 @@
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PatchRequest>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
@@ -75,6 +75,25 @@
    if affected_rows == 0 {
        return Err(Error::UpdateConflictRemoved);
    }

    Ok(Json(ErrorResponse { error: None }))
}

pub async fn handle_put(
    extract::Path((_session_key, name)): extract::Path<(String, String)>,
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutOrPatchRequest>,
) -> Result<Json<ErrorResponse>, Error> {
    let crate_ = Crate::find_by_name(db.clone(), name)
        .await?
        .ok_or(Error::NoCrate)
        .map(std::sync::Arc::new)?;
    ensure_has_crate_perm!(db, user, crate_, Permission::VISIBLE | -> Error::NoCrate, Permission::MANAGE_USERS | -> Error::NoPermission);

    crate_
        .insert_permissions(db, req.user_id, req.permissions)
        .await?;

    Ok(Json(ErrorResponse { error: None }))
}
diff --git a/chartered-web/src/endpoints/web_api/crates/mod.rs b/chartered-web/src/endpoints/web_api/crates/mod.rs
index 19bf6bf..6aa79c9 100644
--- a/chartered-web/src/endpoints/web_api/crates/mod.rs
+++ a/chartered-web/src/endpoints/web_api/crates/mod.rs
@@ -1,7 +1,8 @@
mod info;
mod members;

pub use info::handle as info;
pub use members::{
    handle_delete as delete_member, handle_get as get_members, handle_patch as update_members,
    handle_delete as delete_member, handle_get as get_members, handle_patch as update_member,
    handle_put as insert_member,
};