🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2021-09-16 1:26:40.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2021-09-16 1:27:07.0 +01:00:00
commit
aecee669016ab41bb7a1936c10d23d28708e2cbe [patch]
tree
079f830f14c73603beabe3b2375e6d84fa055d9c
parent
5450ece8eb22fff0f8de6a0a22e3274647c6e4ab
download
aecee669016ab41bb7a1936c10d23d28708e2cbe.tar.gz

SSH key management via Web UI



Diff

 Cargo.lock                                            |   1 +
 chartered-db/Cargo.toml                               |   1 +
 chartered-frontend/package-lock.json                  | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/package.json                       |   1 +
 chartered-db/src/lib.rs                               |   2 ++
 chartered-db/src/users.rs                             |  92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/index.tsx                      |   4 ++++
 chartered-web/src/main.rs                             |  20 +++++++++++++-------
 chartered-frontend/src/sections/Nav.tsx               |  18 +-----------------
 chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx  |  85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 chartered-web/src/endpoints/web_api/mod.rs            |   4 ++++
 chartered-web/src/endpoints/web_api/ssh_key.rs        |  95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 13 files changed, 770 insertions(+), 30 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index fcb4322..82e042f 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -198,6 +198,7 @@
 "serde",
 "serde_json",
 "thiserror",
 "thrussh-keys",
 "tokio",
]

diff --git a/chartered-db/Cargo.toml b/chartered-db/Cargo.toml
index d864b79..b3305ba 100644
--- a/chartered-db/Cargo.toml
+++ a/chartered-db/Cargo.toml
@@ -20,3 +20,4 @@
thiserror = "1"
tokio = "1"
dotenv = "0.15"
thrussh-keys = "0.21"
diff --git a/chartered-frontend/package-lock.json b/chartered-frontend/package-lock.json
index a37e503..968988d 100644
--- a/chartered-frontend/package-lock.json
+++ a/chartered-frontend/package-lock.json
@@ -11,6 +11,7 @@
      "dependencies": {
        "bootstrap": "^5.1.1",
        "react": "^17.0.2",
        "react-bootstrap": "^2.0.0-beta.6",
        "react-bootstrap-icons": "^1.5.0",
        "react-dom": "^17.0.2",
        "react-markdown": "^7.0.1",
@@ -1778,12 +1779,73 @@
      "version": "2.10.1",
      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.10.1.tgz",
      "integrity": "sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==",
      "peer": true,
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/popperjs"
      }

    },

    "node_modules/@react-aria/ssr": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.1.0.tgz",
      "integrity": "sha512-RxqQKmE8sO7TGdrcSlHTcVzMP450hqowtBSd2bBS9oPlcokVkaGq28c3Rwa8ty5ctw4EBCjXqjP7xdcKMGDzug==",
      "dependencies": {
        "@babel/runtime": "^7.6.2"
      },

      "peerDependencies": {
        "react": "^16.8.0 || ^17.0.0-rc.1"
      }

    },

    "node_modules/@restart/context": {
      "version": "2.1.4",
      "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz",
      "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==",
      "peerDependencies": {
        "react": ">=16.3.2"
      }

    },

    "node_modules/@restart/hooks": {
      "version": "0.3.27",
      "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.27.tgz",
      "integrity": "sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==",
      "dependencies": {
        "dequal": "^2.0.2"
      },

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

    },

    "node_modules/@restart/ui": {
      "version": "0.2.2",
      "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-0.2.2.tgz",
      "integrity": "sha512-PgNkiyOaWwx8ttQ45KNABXU3780fB/UxNFxcsCpC4RRAlaByZHHbNLOKfhuFs+ZUU0uLxEH9wYQEhDAZc6ajkA==",
      "dependencies": {
        "@babel/runtime": "^7.13.16",
        "@popperjs/core": "^2.9.2",
        "@react-aria/ssr": "^3.0.1",
        "@restart/hooks": "^0.4.0",
        "@types/warning": "^3.0.0",
        "dequal": "^2.0.2",
        "dom-helpers": "^5.2.0",
        "prop-types": "^15.7.2",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      },

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

    },

    "node_modules/@restart/ui/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/@types/debug": {
      "version": "4.1.7",
      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
@@ -1800,6 +1862,11 @@
        "@types/unist": "*"
      }

    },

    "node_modules/@types/invariant": {
      "version": "2.2.35",
      "resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz",
      "integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg=="
    },

    "node_modules/@types/mdast": {
      "version": "3.0.10",
      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -1844,6 +1911,14 @@
      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz",
      "integrity": "sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==",
      "dev": true,
      "dependencies": {
        "@types/react": "*"
      }

    },

    "node_modules/@types/react-transition-group": {
      "version": "4.4.2",
      "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
      "integrity": "sha512-KibDWL6nshuOJ0fu8ll7QnV/LVTo3PzQ9aCPnRUYPfX7eZohHwLIdNHj7pftanREzHNP4/nJa8oeM73uSiavMQ==",
      "dependencies": {
        "@types/react": "*"
      }

@@ -1857,6 +1932,11 @@
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
      "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="
    },

    "node_modules/@types/warning": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
      "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI="
    },

    "node_modules/@zeit/schemas": {
      "version": "2.6.0",
@@ -2946,6 +3026,11 @@
      "engines": {
        "node": ">=0.10.0"
      }

    },

    "node_modules/classnames": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
      "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
    },

    "node_modules/cli-boxes": {
      "version": "1.0.0",
@@ -3839,6 +3924,14 @@
      "dev": true,
      "engines": {
        "node": ">= 0.6"
      }

    },

    "node_modules/dequal": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz",
      "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==",
      "engines": {
        "node": ">=6"
      }

    },

    "node_modules/des.js": {
@@ -3873,6 +3966,15 @@
      "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
      "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
      "dev": true
    },

    "node_modules/dom-helpers": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
      "dependencies": {
        "@babel/runtime": "^7.8.7",
        "csstype": "^3.0.2"
      }

    },

    "node_modules/dom-serializer": {
      "version": "0.2.2",
@@ -5410,6 +5512,14 @@
      },

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

    },

    "node_modules/invariant": {
      "version": "2.2.4",
      "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
      "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
      "dependencies": {
        "loose-envify": "^1.0.0"
      }

    },

    "node_modules/is-absolute-url": {
@@ -8490,6 +8600,18 @@
        "react-is": "^16.8.1"
      }

    },

    "node_modules/prop-types-extra": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
      "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
      "dependencies": {
        "react-is": "^16.3.2",
        "warning": "^4.0.0"
      },

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

    },

    "node_modules/property-information": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.0.1.tgz",
@@ -8706,6 +8828,34 @@
      },

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

    },

    "node_modules/react-bootstrap": {
      "version": "2.0.0-beta.6",
      "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.0.0-beta.6.tgz",
      "integrity": "sha512-eHEkmESWYYgNLjqbz31G696Eytu+6GeF8CPHQ8t9Se12dUEej8OjBakyQP0OGms9yy1ZZeLG/Fvuo7VxiwMcuQ==",
      "dependencies": {
        "@babel/runtime": "^7.14.0",
        "@restart/context": "^2.1.4",
        "@restart/hooks": "^0.3.26",
        "@restart/ui": "^0.2.1",
        "@types/invariant": "^2.2.33",
        "@types/prop-types": "^15.7.3",
        "@types/react": ">=16.14.8",
        "@types/react-transition-group": "^4.4.1",
        "@types/warning": "^3.0.0",
        "classnames": "^2.3.1",
        "dom-helpers": "^5.2.1",
        "invariant": "^2.2.4",
        "prop-types": "^15.7.2",
        "prop-types-extra": "^1.1.0",
        "react-transition-group": "^4.4.1",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      },

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

    },

    "node_modules/react-bootstrap-icons": {
@@ -8736,6 +8886,11 @@
      "version": "16.13.1",
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
    },

    "node_modules/react-lifecycles-compat": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
    },

    "node_modules/react-markdown": {
      "version": "7.0.1",
@@ -8820,6 +8975,21 @@
      },

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

    },

    "node_modules/react-transition-group": {
      "version": "4.4.2",
      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
      "dependencies": {
        "@babel/runtime": "^7.5.5",
        "dom-helpers": "^5.0.1",
        "loose-envify": "^1.4.0",
        "prop-types": "^15.6.2"
      },

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

    },

    "node_modules/readable-stream": {
@@ -10742,6 +10912,20 @@
      },

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

    },

    "node_modules/uncontrollable": {
      "version": "7.2.1",
      "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
      "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
      "dependencies": {
        "@babel/runtime": "^7.6.3",
        "@types/react": ">=16.9.11",
        "invariant": "^2.2.4",
        "react-lifecycles-compat": "^3.0.4"
      },

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

    },

    "node_modules/uncss": {
@@ -11304,6 +11488,14 @@
        "domexception": "^1.0.1",
        "webidl-conversions": "^4.0.2",
        "xml-name-validator": "^3.0.0"
      }

    },

    "node_modules/warning": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
      "dependencies": {
        "loose-envify": "^1.0.0"
      }

    },

    "node_modules/wcwidth": {
@@ -12675,9 +12867,57 @@
    "@popperjs/core": {
      "version": "2.10.1",
      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.10.1.tgz",
      "integrity": "sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==",
      "peer": true
      "integrity": "sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw=="
    },

    "@react-aria/ssr": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.1.0.tgz",
      "integrity": "sha512-RxqQKmE8sO7TGdrcSlHTcVzMP450hqowtBSd2bBS9oPlcokVkaGq28c3Rwa8ty5ctw4EBCjXqjP7xdcKMGDzug==",
      "requires": {
        "@babel/runtime": "^7.6.2"
      }

    },

    "@restart/context": {
      "version": "2.1.4",
      "resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz",
      "integrity": "sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q==",
      "requires": {}
    },

    "@restart/hooks": {
      "version": "0.3.27",
      "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.3.27.tgz",
      "integrity": "sha512-s984xV/EapUIfkjlf8wz9weP2O9TNKR96C68FfMEy2bE69+H4cNv3RD4Mf97lW7Htt7PjZrYTjSC8f3SB9VCXw==",
      "requires": {
        "dequal": "^2.0.2"
      }

    },

    "@restart/ui": {
      "version": "0.2.2",
      "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-0.2.2.tgz",
      "integrity": "sha512-PgNkiyOaWwx8ttQ45KNABXU3780fB/UxNFxcsCpC4RRAlaByZHHbNLOKfhuFs+ZUU0uLxEH9wYQEhDAZc6ajkA==",
      "requires": {
        "@babel/runtime": "^7.13.16",
        "@popperjs/core": "^2.9.2",
        "@react-aria/ssr": "^3.0.1",
        "@restart/hooks": "^0.4.0",
        "@types/warning": "^3.0.0",
        "dequal": "^2.0.2",
        "dom-helpers": "^5.2.0",
        "prop-types": "^15.7.2",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      },

      "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"
          }

        }

      }

    },

    "@types/debug": {
      "version": "4.1.7",
      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz",
@@ -12694,6 +12934,11 @@
        "@types/unist": "*"
      }

    },

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

    "@types/mdast": {
      "version": "3.0.10",
      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz",
@@ -12738,6 +12983,14 @@
      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.9.tgz",
      "integrity": "sha512-wIvGxLfgpVDSAMH5utdL9Ngm5Owu0VsGmldro3ORLXV8CShrL8awVj06NuEXFQ5xyaYfdca7Sgbk/50Ri1GdPg==",
      "dev": true,
      "requires": {
        "@types/react": "*"
      }

    },

    "@types/react-transition-group": {
      "version": "4.4.2",
      "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
      "integrity": "sha512-KibDWL6nshuOJ0fu8ll7QnV/LVTo3PzQ9aCPnRUYPfX7eZohHwLIdNHj7pftanREzHNP4/nJa8oeM73uSiavMQ==",
      "requires": {
        "@types/react": "*"
      }

@@ -12751,6 +13004,11 @@
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
      "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ=="
    },

    "@types/warning": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
      "integrity": "sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI="
    },

    "@zeit/schemas": {
      "version": "2.6.0",
@@ -13632,6 +13890,11 @@
          }

        }

      }

    },

    "classnames": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
      "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
    },

    "cli-boxes": {
      "version": "1.0.0",
@@ -14371,6 +14634,11 @@
      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
      "dev": true
    },

    "dequal": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.2.tgz",
      "integrity": "sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug=="
    },

    "des.js": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz",
@@ -14404,6 +14672,15 @@
          "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
          "dev": true
        }

      }

    },

    "dom-helpers": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
      "requires": {
        "@babel/runtime": "^7.8.7",
        "csstype": "^3.0.2"
      }

    },

    "dom-serializer": {
@@ -15618,6 +15895,14 @@
        "get-intrinsic": "^1.1.0",
        "has": "^1.0.3",
        "side-channel": "^1.0.4"
      }

    },

    "invariant": {
      "version": "2.2.4",
      "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
      "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
      "requires": {
        "loose-envify": "^1.0.0"
      }

    },

    "is-absolute-url": {
@@ -17940,6 +18225,15 @@
        "loose-envify": "^1.4.0",
        "object-assign": "^4.1.1",
        "react-is": "^16.8.1"
      }

    },

    "prop-types-extra": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
      "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
      "requires": {
        "react-is": "^16.3.2",
        "warning": "^4.0.0"
      }

    },

    "property-information": {
@@ -18116,6 +18410,30 @@
      "requires": {
        "loose-envify": "^1.1.0",
        "object-assign": "^4.1.1"
      }

    },

    "react-bootstrap": {
      "version": "2.0.0-beta.6",
      "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.0.0-beta.6.tgz",
      "integrity": "sha512-eHEkmESWYYgNLjqbz31G696Eytu+6GeF8CPHQ8t9Se12dUEej8OjBakyQP0OGms9yy1ZZeLG/Fvuo7VxiwMcuQ==",
      "requires": {
        "@babel/runtime": "^7.14.0",
        "@restart/context": "^2.1.4",
        "@restart/hooks": "^0.3.26",
        "@restart/ui": "^0.2.1",
        "@types/invariant": "^2.2.33",
        "@types/prop-types": "^15.7.3",
        "@types/react": ">=16.14.8",
        "@types/react-transition-group": "^4.4.1",
        "@types/warning": "^3.0.0",
        "classnames": "^2.3.1",
        "dom-helpers": "^5.2.1",
        "invariant": "^2.2.4",
        "prop-types": "^15.7.2",
        "prop-types-extra": "^1.1.0",
        "react-transition-group": "^4.4.1",
        "uncontrollable": "^7.2.1",
        "warning": "^4.0.3"
      }

    },

    "react-bootstrap-icons": {
@@ -18141,6 +18459,11 @@
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
    },

    "react-lifecycles-compat": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
      "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
    },

    "react-markdown": {
      "version": "7.0.1",
      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-7.0.1.tgz",
@@ -18209,6 +18532,17 @@
        "lowlight": "^1.17.0",
        "prismjs": "^1.22.0",
        "refractor": "^3.2.0"
      }

    },

    "react-transition-group": {
      "version": "4.4.2",
      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
      "requires": {
        "@babel/runtime": "^7.5.5",
        "dom-helpers": "^5.0.1",
        "loose-envify": "^1.4.0",
        "prop-types": "^15.6.2"
      }

    },

    "readable-stream": {
@@ -19761,6 +20095,17 @@
        "has-bigints": "^1.0.1",
        "has-symbols": "^1.0.2",
        "which-boxed-primitive": "^1.0.2"
      }

    },

    "uncontrollable": {
      "version": "7.2.1",
      "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
      "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
      "requires": {
        "@babel/runtime": "^7.6.3",
        "@types/react": ">=16.9.11",
        "invariant": "^2.2.4",
        "react-lifecycles-compat": "^3.0.4"
      }

    },

    "uncss": {
@@ -20200,6 +20545,14 @@
        "domexception": "^1.0.1",
        "webidl-conversions": "^4.0.2",
        "xml-name-validator": "^3.0.0"
      }

    },

    "warning": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
      "requires": {
        "loose-envify": "^1.0.0"
      }

    },

    "wcwidth": {
diff --git a/chartered-frontend/package.json b/chartered-frontend/package.json
index c51949e..b938b02 100644
--- a/chartered-frontend/package.json
+++ a/chartered-frontend/package.json
@@ -22,6 +22,7 @@
  "dependencies": {
    "bootstrap": "^5.1.1",
    "react": "^17.0.2",
    "react-bootstrap": "^2.0.0-beta.6",
    "react-bootstrap-icons": "^1.5.0",
    "react-dom": "^17.0.2",
    "react-markdown": "^7.0.1",
diff --git a/chartered-db/src/lib.rs b/chartered-db/src/lib.rs
index d81889b..37d3bf7 100644
--- a/chartered-db/src/lib.rs
+++ a/chartered-db/src/lib.rs
@@ -59,6 +59,8 @@
    Query(#[from] diesel::result::Error),
    /// Failed to complete query task: `{0}`

    TaskJoin(#[from] tokio::task::JoinError),
    /// Key parse failure: `{0}`

    KeyParse(#[from] thrussh_keys::Error),
}

diesel_infix_operator!(BitwiseAnd, " & ", diesel::sql_types::Integer);
diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 70e3b95..f0e6c40 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -1,10 +1,11 @@
use super::{
    schema::{user_api_keys, user_crate_permissions, user_ssh_keys, users},
    ConnectionPool, Result,
};
use diesel::{insert_into, prelude::*, Associations, Identifiable, Queryable};
use rand::{thread_rng, Rng};
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use thrussh_keys::PublicKeyBase64;

#[derive(Identifiable, Queryable, Associations, PartialEq, Eq, Hash, Debug)]
pub struct User {
@@ -60,8 +61,6 @@
    ) -> Result<Option<(UserSshKey, User)>> {
        use crate::schema::user_ssh_keys::dsl::ssh_key;

        eprintln!("ssh key: {:x?}", given_ssh_key);

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

@@ -71,6 +70,93 @@
                .select((user_ssh_keys::all_columns, users::all_columns))
                .get_result(&conn)
                .optional()?)
        })
        .await?
    }

    /// Parses an ssh key from its `ssh-add -L` format (`ssh-ed25519 AAAAC3N...`) and

    /// inserts it to the database for the user.

    pub async fn insert_ssh_key(
        self: Arc<Self>,
        conn: ConnectionPool,
        ssh_key: &str,
    ) -> Result<()> {
        let mut split = ssh_key.split_whitespace();

        let key = match (split.next(), split.next()) {
            (Some(_), Some(key)) => key,
            (Some(key), None) => key,
            _ => return Err(thrussh_keys::Error::CouldNotReadKey.into()),
        };

        let parsed_key = thrussh_keys::parse_public_key_base64(key)?;

        tokio::task::spawn_blocking(move || {
            use crate::schema::user_ssh_keys::dsl::{ssh_key, user_id};

            let conn = conn.get()?;

            insert_into(crate::schema::user_ssh_keys::dsl::user_ssh_keys)
                .values((
                    ssh_key.eq(parsed_key.public_key_bytes()),
                    user_id.eq(self.id),
                ))
                .execute(&conn)?;

            Ok(())
        })
        .await?
    }

    pub async fn delete_user_ssh_key_by_id(
        self: Arc<Self>,
        conn: ConnectionPool,
        ssh_key_id: i32,
    ) -> Result<bool> {
        use crate::schema::user_ssh_keys::dsl::{id, user_id, user_ssh_keys};

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

            let rows = diesel::delete(
                user_ssh_keys
                    .filter(user_id.eq(self.id))
                    .filter(id.eq(ssh_key_id)),
            )
            .execute(&conn)?;

            Ok(rows > 0)
        })
        .await?
    }

    /// Get all the SSH keys for the user, returned as `id:fingerprint`.

    pub async fn list_ssh_keys(
        self: Arc<Self>,
        conn: ConnectionPool,
    ) -> Result<HashMap<i32, String>> {
        tokio::task::spawn_blocking(move || {
            use crate::schema::user_ssh_keys::dsl::user_id;

            let conn = conn.get()?;

            let selected: Vec<(i32, Vec<u8>)> = crate::schema::user_ssh_keys::table
                .filter(user_id.eq(self.id))
                .inner_join(users::table)
                .select((user_ssh_keys::id, user_ssh_keys::ssh_key))
                .load(&conn)?;

            Ok(selected
                .into_iter()
                .map(|(id, key)| {
                    (
                        id,
                        thrussh_keys::key::parse_public_key(&key)
                            .map(|v| v.fingerprint())
                            .unwrap_or_else(|e| format!("INVALID: {}", e)),
                    )
                })
                .collect())
        })
        .await?
    }
diff --git a/chartered-frontend/src/index.tsx b/chartered-frontend/src/index.tsx
index 0567dd3..b8aaad5 100644
--- a/chartered-frontend/src/index.tsx
+++ a/chartered-frontend/src/index.tsx
@@ -17,6 +17,8 @@
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import SingleCrate from "./pages/SingleCrate";
import ListSshKeys from "./pages/ssh-keys/ListSshKeys";
import AddSshKeys from "./pages/ssh-keys/AddSshKeys";

function App() {
    return (
@@ -28,6 +30,8 @@
                    <PrivateRoute exact path="/" component={() => <Redirect to="/dashboard" />} />
                    <PrivateRoute exact path="/dashboard" component={() => <Dashboard />} />
                    <PrivateRoute exact path="/crates/:crate" component={() => <SingleCrate />} />
                    <PrivateRoute exact path="/ssh-keys/list" component={() => <ListSshKeys />} />
                    <PrivateRoute exact path="/ssh-keys/add" component={() => <AddSshKeys />} />
                </Switch>
            </Router>
        </ProvideAuth>
diff --git a/chartered-web/src/main.rs b/chartered-web/src/main.rs
index f9adac0..ae2c61b 100644
--- a/chartered-web/src/main.rs
+++ a/chartered-web/src/main.rs
@@ -5,16 +5,14 @@
mod middleware;

use axum::{
    body::Body,
    handler::{delete, get, post, put},
    http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
    http::Method,
    AddExtensionLayer, Router,
};
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use tower_http::set_header::SetResponseHeaderLayer;


#[allow(clippy::unused_async)]
async fn hello_world() -> &'static str {
    "hello, world!"
@@ -71,9 +69,11 @@
    let web_unauthenticated =
        axum_box_after_every_route!(Router::new().route("/login", post(endpoints::web_api::login)));

    let web_authenticated = axum_box_after_every_route!(
        Router::new().route("/crates/:crate", get(endpoints::web_api::crate_info))
    )
    let web_authenticated = axum_box_after_every_route!(Router::new()
        .route("/crates/:crate", get(endpoints::web_api::crate_info))
        .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)))
    .layer(
        ServiceBuilder::new()
            .layer_fn(middleware::auth::AuthMiddleware)
@@ -93,7 +93,13 @@
        // TODO!!!
        .layer(
            CorsLayer::new()
                .allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS])
                .allow_methods(vec![
                    Method::GET,
                    Method::POST,
                    Method::DELETE,
                    Method::PUT,
                    Method::OPTIONS,
                ])
                .allow_origin(Any)
                .allow_credentials(false),
        )
diff --git a/chartered-frontend/src/sections/Nav.tsx b/chartered-frontend/src/sections/Nav.tsx
index 8002bc4..210efa2 100644
--- a/chartered-frontend/src/sections/Nav.tsx
+++ a/chartered-frontend/src/sections/Nav.tsx
@@ -26,23 +26,7 @@
                            <NavLink to="/dashboard" className="nav-link">Home</NavLink>
                        </li>
                        <li className="nav-item">
                            <a className="nav-link" href="#">Link</a>
                        </li>
                        <li className="nav-item dropdown">
                            <a className="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                                Dropdown
                            </a>

                            <ul className="dropdown-menu" aria-labelledby="navbarDropdown">
                                <li><a className="dropdown-item" href="#">Action</a></li>
                                <li><a className="dropdown-item" href="#">Another action</a></li>
                                <li><hr className="dropdown-divider" /></li>
                                <li><a className="dropdown-item" href="#">Something else here</a></li>
                            </ul>
                        </li>

                        <li className="nav-item">
                            <a className="nav-link disabled" href="#" tabIndex={-1} aria-disabled="true">Disabled</a>
                            <NavLink to="/ssh-keys/list" className="nav-link">SSH Keys</NavLink>
                        </li>
                    </ul>

diff --git a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
new file mode 100644
index 0000000..adb7a5e 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/ssh-keys/AddSshKeys.tsx
@@ -1,0 +1,85 @@
import React = require("react");
import { useState, useEffect } 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();

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

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

        setError("");
        setLoading(true);

        try {
            let res = await fetch(authenticatedEndpoint(auth, 'ssh-key'), {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ key: sshKey }),
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setSshKey("");
            router.push("/ssh-keys/list");
        } catch (e) {
            setError(e.message);
        } finally {
            setLoading(false);
        }
    };

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

            <div className="container mt-4 pb-4">
                <h1>New SSH Key</h1>

                <div className="alert alert-danger alert-dismissible" role="alert" style={{ display: error ? 'block' : 'none' }}>
                    {error}

                    <button type="button" className="btn-close" aria-label="Close" onClick={() => setError("")}>
                    </button>
                </div>

                <div className="card border-0 shadow-sm text-black">
                    <div className="card-body">
                        <form onSubmit={submitSshKey}>
                            <textarea className="form-control" rows={3}
                                placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILYAIoV2OKRSh/DcM3TicD/NK/4TdqwwBPbKgFQKmGZ3 john@home"
                                onChange={e => setSshKey(e.target.value)}
                                value={sshKey}
                            />

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

                            <button type="submit" className="btn btn-success mt-2 float-end" style={{ display: !loading ? 'block' : 'none' }}>Submit</button>
                            <div className="spinner-border text-primary mt-4 float-end" role="status" style={{ display: loading ? 'block' : 'none' }}>
                                <span className="visually-hidden">Submitting...</span>
                            </div>

                            <Link to="/ssh-keys/list" className="btn btn-danger mt-2 float-end me-1">Cancel</Link>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    );
}
diff --git a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx b/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
new file mode 100644
index 0000000..914f0e4 100644
--- /dev/null
+++ a/chartered-frontend/src/pages/ssh-keys/ListSshKeys.tsx
@@ -1,0 +1,118 @@
import React = require("react");
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';

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

import { Plus, Trash } from "react-bootstrap-icons";
import { Button, Modal } from "react-bootstrap";

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

    const [error, setError] = useState("");
    const [deleting, setDeleting] = useState(null);
    const [sshKeys, setSshKeys] = useState(null);
    const [reloadSshKeys, setReloadSshKeys] = useState(0);
    useEffect(async () => {
        let res = await fetch(authenticatedEndpoint(auth, 'ssh-key'));
        let json = await res.json();
        setSshKeys(json);
    }, [reloadSshKeys]);

    if (!sshKeys) {
        return (<div>loading...</div>);
    }

    const deleteKey = async () => {
        setError("");

        try {
            let res = await fetch(authenticatedEndpoint(auth, `ssh-key/${deleting.id}`), {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                },
            });
            let json = await res.json();

            if (json.error) {
                throw new Error(json.error);
            }

            setReloadSshKeys(reloadSshKeys + 1);
        } catch (e) {
            setError(e.message);
        } finally {
            setDeleting(null);
        }
    };

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

            <div className="container mt-4 pb-4">
                <h1>Manage your SSH Keys</h1>

                <div className="alert alert-danger alert-dismissible" role="alert" style={{ display: error ? 'block' : 'none' }}>
                    {error}

                    <button type="button" className="btn-close" aria-label="Close" onClick={() => setError("")}>
                    </button>
                </div>

                <div className="card border-0 shadow-sm text-black">
                    <ul className="list-group list-group-flush">
                        {sshKeys.keys.map(key => (
                            <li key={key.id} className="list-group-item">
                                <div className="d-flex">
                                    <div className="flex-grow-1"><strong>{key.fingerprint}</strong></div>
                                    <div>
                                        <button type="button" className="btn text-danger" onClick={() => setDeleting(key)}><Trash /></button>
                                    </div>
                                </div>
                            </li>
                        ))}
                    </ul>
                </div>

                <Link to="/ssh-keys/add" className="btn btn-outline-light mt-2 float-end"><Plus /> Add New</Link>
            </div>

            <DeleteModal show={deleting != null}
                onCancel={() => setDeleting(null)}
                onConfirm={() => deleteKey()}
                fingerprint={deleting?.fingerprint} />
        </div>
    );
}

function DeleteModal(props: { show: boolean, onCancel: () => void, onConfirm: () => void, fingerprint: string }) {
    return (
      <Modal
        show={props.show}
        onHide={props.onCancel}
        size="lg"
        aria-labelledby="delete-modal-title"
        centered
      >
        <Modal.Header closeButton>
          <Modal.Title id="delete-modal-title">
            Are you sure you wish to delete this SSH key?
          </Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <p>
            Are you sure you wish to delete the SSH key with the fingerprint: <strong>{props.fingerprint}</strong>?
          </p>
        </Modal.Body>
        <Modal.Footer>
          <Button onClick={props.onCancel} variant="primary">Close</Button>
          <Button onClick={props.onConfirm} variant="danger">Delete</Button>
        </Modal.Footer>
      </Modal>
    );
  }
diff --git a/chartered-web/src/endpoints/web_api/mod.rs b/chartered-web/src/endpoints/web_api/mod.rs
index 844b0e4..127f1eb 100644
--- a/chartered-web/src/endpoints/web_api/mod.rs
+++ a/chartered-web/src/endpoints/web_api/mod.rs
@@ -1,5 +1,9 @@
mod crate_info;
mod login;
mod ssh_key;

pub use crate_info::handle as crate_info;
pub use login::handle as login;
pub use ssh_key::{
    handle_delete as delete_ssh_key, handle_get as get_ssh_keys, handle_put as add_ssh_key,
};
diff --git a/chartered-web/src/endpoints/web_api/ssh_key.rs b/chartered-web/src/endpoints/web_api/ssh_key.rs
new file mode 100644
index 0000000..9c60157 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/ssh_key.rs
@@ -1,0 +1,95 @@
use chartered_db::{users::User, ConnectionPool};

use axum::{extract, Json};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use thiserror::Error;

#[derive(Serialize)]
pub struct GetResponse {
    keys: Vec<GetResponseKey>,
}

#[derive(Serialize)]
pub struct GetResponseKey {
    id: i32, // TODO: this should be a UUID so we don't leak incremental IDs
    fingerprint: String,
}

pub async fn handle_get(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
) -> Result<Json<GetResponse>, Error> {
    let keys = user
        .list_ssh_keys(db)
        .await?
        .into_iter()
        .map(|(id, fingerprint)| GetResponseKey { id, fingerprint })
        .collect();

    Ok(Json(GetResponse { keys }))
}

#[derive(Deserialize)]
pub struct PutRequest {
    key: String,
}

#[derive(Serialize)]
pub struct PutResponse {
    error: bool,
}

pub async fn handle_put(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Json(req): extract::Json<PutRequest>,
) -> Result<Json<PutResponse>, Error> {
    match user.insert_ssh_key(db, &req.key).await {
        Ok(()) => Ok(Json(PutResponse { error: false })),
        Err(e @ chartered_db::Error::KeyParse(_)) => Err(Error::KeyParse(e)),
        Err(e) => Err(Error::Database(e)),
    }
}

#[derive(Serialize)]
pub struct DeleteResponse {
    error: bool,
}

pub async fn handle_delete(
    extract::Extension(db): extract::Extension<ConnectionPool>,
    extract::Extension(user): extract::Extension<Arc<User>>,
    extract::Path((_api_key, ssh_key_id)): extract::Path<(String, i32)>,
) -> Result<Json<DeleteResponse>, Error> {
    let deleted = user.delete_user_ssh_key_by_id(db, ssh_key_id).await?;

    if deleted {
        Ok(Json(DeleteResponse { error: false }))
    } else {
        Err(Error::NonExistentKey)
    }
}

#[derive(Error, Debug)]
pub enum Error {
    #[error("Failed to query database")]
    Database(#[from] chartered_db::Error),
    #[error("Failed to parse SSH key: {0}")]
    KeyParse(chartered_db::Error),
    #[error("The key given does not exist")]
    NonExistentKey,
}

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

        match self {
            Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::KeyParse(_) | Self::NonExistentKey => StatusCode::BAD_REQUEST,
        }
    }
}

define_error_response!(Error);