From 8825e31b99ad61c367fcaad2043eedbd674389d0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Tue, 18 Jun 2024 15:25:16 +0200
Subject: [PATCH] Resolve "Support API tokens"

---
 package-lock.json                             | 655 +++---------------
 package.json                                  |   2 +-
 src/App.vue                                   |  17 +-
 src/assets/main.css                           |   3 +
 src/client/index.ts                           |   5 +
 src/client/models/ApiTokenIn.ts               |  20 +
 src/client/models/ApiTokenOut.ts              |  36 +
 src/client/models/ApiTokenPrivateOut.ts       |  40 ++
 src/client/models/ScopeEnum.ts                |   8 +
 src/client/models/UserOutExtended.ts          |   4 +
 src/client/services/ApiTokenService.ts        | 121 ++++
 .../services/WorkflowCredentialsService.ts    |   2 +-
 src/components/AppHeader.vue                  |  92 +--
 src/components/user/CreateApiTokenModal.vue   | 192 +++++
 .../modals/ArbitraryWorkflowModal.vue         |  43 +-
 .../workflows/modals/CreateWorkflowModal.vue  |  36 +-
 .../modals/UpdateWorkflowCredentialsModal.vue |  47 +-
 .../workflows/modals/UpdateWorkflowModal.vue  |  35 +-
 src/router/index.ts                           |   2 +
 src/router/userRoutes.ts                      |  12 +
 src/stores/users.ts                           | 117 +++-
 src/utils/GitRepository.ts                    |  37 +-
 src/views/admin/AdminUsersView.vue            |  10 +-
 src/views/resources/ReviewResourceView.vue    |   2 +-
 src/views/user/ListApiTokenView.vue           | 202 ++++++
 src/views/workflows/ArbitraryWorkflowView.vue |  23 +-
 src/views/workflows/WorkflowView.vue          |   2 +-
 27 files changed, 1050 insertions(+), 715 deletions(-)
 create mode 100644 src/client/models/ApiTokenIn.ts
 create mode 100644 src/client/models/ApiTokenOut.ts
 create mode 100644 src/client/models/ApiTokenPrivateOut.ts
 create mode 100644 src/client/models/ScopeEnum.ts
 create mode 100644 src/client/services/ApiTokenService.ts
 create mode 100644 src/components/user/CreateApiTokenModal.vue
 create mode 100644 src/router/userRoutes.ts
 create mode 100644 src/views/user/ListApiTokenView.vue

diff --git a/package-lock.json b/package-lock.json
index 110560b..31fe8f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -56,7 +56,7 @@
         "rollup-plugin-node-polyfills": "~0.2.1",
         "sass": "^1.66.0",
         "typescript": "~5.4.0",
-        "vite": "~5.2.0",
+        "vite": "~5.3.0",
         "vue-tsc": "~2.0.0"
       }
     },
@@ -973,7 +973,6 @@
       "os": [
         "aix"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -990,7 +989,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1007,7 +1005,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1024,7 +1021,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1041,7 +1037,6 @@
       "os": [
         "darwin"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1058,7 +1053,6 @@
       "os": [
         "darwin"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1075,7 +1069,6 @@
       "os": [
         "freebsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1092,7 +1085,6 @@
       "os": [
         "freebsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1109,7 +1101,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1126,7 +1117,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1143,7 +1133,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1160,7 +1149,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1177,7 +1165,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1194,7 +1181,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1211,7 +1197,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1228,7 +1213,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1245,7 +1229,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1262,7 +1245,6 @@
       "os": [
         "netbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1279,7 +1261,6 @@
       "os": [
         "openbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1296,7 +1277,6 @@
       "os": [
         "sunos"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1313,7 +1293,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1330,7 +1309,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -1347,7 +1325,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=12"
       }
@@ -2478,9 +2455,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "20.14.2",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.2.tgz",
-      "integrity": "sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==",
+      "version": "20.14.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.5.tgz",
+      "integrity": "sha512-aoRR+fJkZT2l0aGOJhuA8frnCSoNX6W7U2mpNq63+BxBIj5BQFt8rHy627kijCmm63ijdSdwvGgpUsU6MBsZZA==",
       "dev": true,
       "dependencies": {
         "undici-types": "~5.26.4"
@@ -2511,16 +2488,16 @@
       "dev": true
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.13.0.tgz",
-      "integrity": "sha512-FX1X6AF0w8MdVFLSdqwqN/me2hyhuQg4ykN6ZpVhh1ij/80pTvDKclX1sZB9iqex8SjQfVhwMKs3JtnnMLzG9w==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.13.1.tgz",
+      "integrity": "sha512-kZqi+WZQaZfPKnsflLJQCz6Ze9FFSMfXrrIOcyargekQxG37ES7DJNpJUE9Q/X5n3yTIP/WPutVNzgknQ7biLg==",
       "dev": true,
       "dependencies": {
         "@eslint-community/regexpp": "^4.10.0",
-        "@typescript-eslint/scope-manager": "7.13.0",
-        "@typescript-eslint/type-utils": "7.13.0",
-        "@typescript-eslint/utils": "7.13.0",
-        "@typescript-eslint/visitor-keys": "7.13.0",
+        "@typescript-eslint/scope-manager": "7.13.1",
+        "@typescript-eslint/type-utils": "7.13.1",
+        "@typescript-eslint/utils": "7.13.1",
+        "@typescript-eslint/visitor-keys": "7.13.1",
         "graphemer": "^1.4.0",
         "ignore": "^5.3.1",
         "natural-compare": "^1.4.0",
@@ -2544,15 +2521,15 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.13.0.tgz",
-      "integrity": "sha512-EjMfl69KOS9awXXe83iRN7oIEXy9yYdqWfqdrFAYAAr6syP8eLEFI7ZE4939antx2mNgPRW/o1ybm2SFYkbTVA==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.13.1.tgz",
+      "integrity": "sha512-1ELDPlnLvDQ5ybTSrMhRTFDfOQEOXNM+eP+3HT/Yq7ruWpciQw+Avi73pdEbA4SooCawEWo3dtYbF68gN7Ed1A==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "7.13.0",
-        "@typescript-eslint/types": "7.13.0",
-        "@typescript-eslint/typescript-estree": "7.13.0",
-        "@typescript-eslint/visitor-keys": "7.13.0",
+        "@typescript-eslint/scope-manager": "7.13.1",
+        "@typescript-eslint/types": "7.13.1",
+        "@typescript-eslint/typescript-estree": "7.13.1",
+        "@typescript-eslint/visitor-keys": "7.13.1",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -2572,13 +2549,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.0.tgz",
-      "integrity": "sha512-ZrMCe1R6a01T94ilV13egvcnvVJ1pxShkE0+NDjDzH4nvG1wXpwsVI5bZCvE7AEDH1mXEx5tJSVR68bLgG7Dng==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz",
+      "integrity": "sha512-adbXNVEs6GmbzaCpymHQ0MB6E4TqoiVbC0iqG3uijR8ZYfpAXMGttouQzF4Oat3P2GxDVIrg7bMI/P65LiQZdg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.0",
-        "@typescript-eslint/visitor-keys": "7.13.0"
+        "@typescript-eslint/types": "7.13.1",
+        "@typescript-eslint/visitor-keys": "7.13.1"
       },
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -2589,13 +2566,13 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.13.0.tgz",
-      "integrity": "sha512-xMEtMzxq9eRkZy48XuxlBFzpVMDurUAfDu5Rz16GouAtXm0TaAoTFzqWUFPPuQYXI/CDaH/Bgx/fk/84t/Bc9A==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.13.1.tgz",
+      "integrity": "sha512-aWDbLu1s9bmgPGXSzNCxELu+0+HQOapV/y+60gPXafR8e2g1Bifxzevaa+4L2ytCWm+CHqpELq4CSoN9ELiwCg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "7.13.0",
-        "@typescript-eslint/utils": "7.13.0",
+        "@typescript-eslint/typescript-estree": "7.13.1",
+        "@typescript-eslint/utils": "7.13.1",
         "debug": "^4.3.4",
         "ts-api-utils": "^1.3.0"
       },
@@ -2616,9 +2593,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.0.tgz",
-      "integrity": "sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.1.tgz",
+      "integrity": "sha512-7K7HMcSQIAND6RBL4kDl24sG/xKM13cA85dc7JnmQXw2cBDngg7c19B++JzvJHRG3zG36n9j1i451GBzRuHchw==",
       "dev": true,
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -2629,13 +2606,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.0.tgz",
-      "integrity": "sha512-cAvBvUoobaoIcoqox1YatXOnSl3gx92rCZoMRPzMNisDiM12siGilSM4+dJAekuuHTibI2hVC2fYK79iSFvWjw==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.1.tgz",
+      "integrity": "sha512-uxNr51CMV7npU1BxZzYjoVz9iyjckBduFBP0S5sLlh1tXYzHzgZ3BR9SVsNed+LmwKrmnqN3Kdl5t7eZ5TS1Yw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.0",
-        "@typescript-eslint/visitor-keys": "7.13.0",
+        "@typescript-eslint/types": "7.13.1",
+        "@typescript-eslint/visitor-keys": "7.13.1",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2657,15 +2634,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.0.tgz",
-      "integrity": "sha512-jceD8RgdKORVnB4Y6BqasfIkFhl4pajB1wVxrF4akxD2QPM8GNYjgGwEzYS+437ewlqqrg7Dw+6dhdpjMpeBFQ==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.1.tgz",
+      "integrity": "sha512-h5MzFBD5a/Gh/fvNdp9pTfqJAbuQC4sCN2WzuXme71lqFJsZtLbjxfSk4r3p02WIArOF9N94pdsLiGutpDbrXQ==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.4.0",
-        "@typescript-eslint/scope-manager": "7.13.0",
-        "@typescript-eslint/types": "7.13.0",
-        "@typescript-eslint/typescript-estree": "7.13.0"
+        "@typescript-eslint/scope-manager": "7.13.1",
+        "@typescript-eslint/types": "7.13.1",
+        "@typescript-eslint/typescript-estree": "7.13.1"
       },
       "engines": {
         "node": "^18.18.0 || >=20.0.0"
@@ -2679,12 +2656,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "7.13.0",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.0.tgz",
-      "integrity": "sha512-nxn+dozQx+MK61nn/JP+M4eCkHDSxSLDpgE3WcQo0+fkjEolnaB5jswvIKC4K56By8MMgIho7f1PVxERHEo8rw==",
+      "version": "7.13.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.1.tgz",
+      "integrity": "sha512-k/Bfne7lrP7hcb7m9zSsgcBmo+8eicqqfNAJ7uUY+jkTFpKeH2FSkWpFRtimBxgkyvqfu9jTPRbYOvud6isdXA==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "7.13.0",
+        "@typescript-eslint/types": "7.13.1",
         "eslint-visitor-keys": "^3.4.3"
       },
       "engines": {
@@ -2744,12 +2721,12 @@
       }
     },
     "node_modules/@vue/compiler-core": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz",
-      "integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.29.tgz",
+      "integrity": "sha512-TFKiRkKKsRCKvg/jTSSKK7mYLJEQdUiUfykbG49rubC9SfDyvT2JrzTReopWlz2MxqeLyxh9UZhvxEIBgAhtrg==",
       "dependencies": {
-        "@babel/parser": "^7.24.4",
-        "@vue/shared": "3.4.27",
+        "@babel/parser": "^7.24.7",
+        "@vue/shared": "3.4.29",
         "entities": "^4.5.0",
         "estree-walker": "^2.0.2",
         "source-map-js": "^1.2.0"
@@ -2761,24 +2738,24 @@
       "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
     },
     "node_modules/@vue/compiler-dom": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz",
-      "integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.29.tgz",
+      "integrity": "sha512-A6+iZ2fKIEGnfPJejdB7b1FlJzgiD+Y/sxxKwJWg1EbJu6ZPgzaPQQ51ESGNv0CP6jm6Z7/pO6Ia8Ze6IKrX7w==",
       "dependencies": {
-        "@vue/compiler-core": "3.4.27",
-        "@vue/shared": "3.4.27"
+        "@vue/compiler-core": "3.4.29",
+        "@vue/shared": "3.4.29"
       }
     },
     "node_modules/@vue/compiler-sfc": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz",
-      "integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==",
-      "dependencies": {
-        "@babel/parser": "^7.24.4",
-        "@vue/compiler-core": "3.4.27",
-        "@vue/compiler-dom": "3.4.27",
-        "@vue/compiler-ssr": "3.4.27",
-        "@vue/shared": "3.4.27",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.29.tgz",
+      "integrity": "sha512-zygDcEtn8ZimDlrEQyLUovoWgKQic6aEQqRXce2WXBvSeHbEbcAsXyCk9oG33ZkyWH4sl9D3tkYc1idoOkdqZQ==",
+      "dependencies": {
+        "@babel/parser": "^7.24.7",
+        "@vue/compiler-core": "3.4.29",
+        "@vue/compiler-dom": "3.4.29",
+        "@vue/compiler-ssr": "3.4.29",
+        "@vue/shared": "3.4.29",
         "estree-walker": "^2.0.2",
         "magic-string": "^0.30.10",
         "postcss": "^8.4.38",
@@ -2799,12 +2776,12 @@
       }
     },
     "node_modules/@vue/compiler-ssr": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz",
-      "integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.29.tgz",
+      "integrity": "sha512-rFbwCmxJ16tDp3N8XCx5xSQzjhidYjXllvEcqX/lopkoznlNPz3jyy0WGJCyhAaVQK677WWFt3YO/WUEkMMUFQ==",
       "dependencies": {
-        "@vue/compiler-dom": "3.4.27",
-        "@vue/shared": "3.4.27"
+        "@vue/compiler-dom": "3.4.29",
+        "@vue/shared": "3.4.29"
       }
     },
     "node_modules/@vue/devtools-api": {
@@ -2874,48 +2851,49 @@
       }
     },
     "node_modules/@vue/reactivity": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz",
-      "integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.29.tgz",
+      "integrity": "sha512-w8+KV+mb1a8ornnGQitnMdLfE0kXmteaxLdccm2XwdFxXst4q/Z7SEboCV5SqJNpZbKFeaRBBJBhW24aJyGINg==",
       "dependencies": {
-        "@vue/shared": "3.4.27"
+        "@vue/shared": "3.4.29"
       }
     },
     "node_modules/@vue/runtime-core": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz",
-      "integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.29.tgz",
+      "integrity": "sha512-s8fmX3YVR/Rk5ig0ic0NuzTNjK2M7iLuVSZyMmCzN/+Mjuqqif1JasCtEtmtoJWF32pAtUjyuT2ljNKNLeOmnQ==",
       "dependencies": {
-        "@vue/reactivity": "3.4.27",
-        "@vue/shared": "3.4.27"
+        "@vue/reactivity": "3.4.29",
+        "@vue/shared": "3.4.29"
       }
     },
     "node_modules/@vue/runtime-dom": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz",
-      "integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.29.tgz",
+      "integrity": "sha512-gI10atCrtOLf/2MPPMM+dpz3NGulo9ZZR9d1dWo4fYvm+xkfvRrw1ZmJ7mkWtiJVXSsdmPbcK1p5dZzOCKDN0g==",
       "dependencies": {
-        "@vue/runtime-core": "3.4.27",
-        "@vue/shared": "3.4.27",
+        "@vue/reactivity": "3.4.29",
+        "@vue/runtime-core": "3.4.29",
+        "@vue/shared": "3.4.29",
         "csstype": "^3.1.3"
       }
     },
     "node_modules/@vue/server-renderer": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz",
-      "integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==",
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.29.tgz",
+      "integrity": "sha512-HMLCmPI2j/k8PVkSBysrA2RxcxC5DgBiCdj7n7H2QtR8bQQPqKAe8qoaxLcInzouBmzwJ+J0x20ygN/B5mYBng==",
       "dependencies": {
-        "@vue/compiler-ssr": "3.4.27",
-        "@vue/shared": "3.4.27"
+        "@vue/compiler-ssr": "3.4.29",
+        "@vue/shared": "3.4.29"
       },
       "peerDependencies": {
-        "vue": "3.4.27"
+        "vue": "3.4.29"
       }
     },
     "node_modules/@vue/shared": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz",
-      "integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA=="
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.29.tgz",
+      "integrity": "sha512-hQ2gAQcBO/CDpC82DCrinJNgOHI2v+FA7BDW4lMSPeBpQ7sRe2OLHWe5cph1s7D8DUQAwRt18dBDfJJ220APEA=="
     },
     "node_modules/@vue/tsconfig": {
       "version": "0.5.1",
@@ -2924,9 +2902,9 @@
       "dev": true
     },
     "node_modules/acorn": {
-      "version": "8.11.3",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
-      "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+      "version": "8.12.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
+      "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
       "dev": true,
       "bin": {
         "acorn": "bin/acorn"
@@ -3680,7 +3658,6 @@
       "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
       "dev": true,
       "hasInstallScript": true,
-      "peer": true,
       "bin": {
         "esbuild": "bin/esbuild"
       },
@@ -6057,9 +6034,9 @@
       }
     },
     "node_modules/sass": {
-      "version": "1.77.4",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.4.tgz",
-      "integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==",
+      "version": "1.77.6",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz",
+      "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==",
       "dev": true,
       "dependencies": {
         "chokidar": ">=3.0.0 <4.0.0",
@@ -6643,12 +6620,12 @@
       }
     },
     "node_modules/vite": {
-      "version": "5.2.13",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz",
-      "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==",
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz",
+      "integrity": "sha512-XBmSKRLXLxiaPYamLv3/hnP/KXDai1NDexN0FpkTaZXTfycHvkRHoenpgl/fvuK/kPbB6xAgoyiryAhQNxYmAQ==",
       "dev": true,
       "dependencies": {
-        "esbuild": "^0.20.1",
+        "esbuild": "^0.21.3",
         "postcss": "^8.4.38",
         "rollup": "^4.13.0"
       },
@@ -6697,412 +6674,6 @@
         }
       }
     },
-    "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
-      "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-arm": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
-      "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-arm64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
-      "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/android-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
-      "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
-      "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/darwin-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
-      "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
-      "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
-      "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
-      "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-arm64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
-      "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ia32": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
-      "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-loong64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
-      "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
-      "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
-      "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
-      "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-s390x": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
-      "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/linux-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
-      "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
-      "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
-      "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/sunos-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
-      "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-arm64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
-      "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-ia32": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
-      "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/@esbuild/win32-x64": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
-      "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/vite/node_modules/esbuild": {
-      "version": "0.20.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
-      "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
-      "dev": true,
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.20.2",
-        "@esbuild/android-arm": "0.20.2",
-        "@esbuild/android-arm64": "0.20.2",
-        "@esbuild/android-x64": "0.20.2",
-        "@esbuild/darwin-arm64": "0.20.2",
-        "@esbuild/darwin-x64": "0.20.2",
-        "@esbuild/freebsd-arm64": "0.20.2",
-        "@esbuild/freebsd-x64": "0.20.2",
-        "@esbuild/linux-arm": "0.20.2",
-        "@esbuild/linux-arm64": "0.20.2",
-        "@esbuild/linux-ia32": "0.20.2",
-        "@esbuild/linux-loong64": "0.20.2",
-        "@esbuild/linux-mips64el": "0.20.2",
-        "@esbuild/linux-ppc64": "0.20.2",
-        "@esbuild/linux-riscv64": "0.20.2",
-        "@esbuild/linux-s390x": "0.20.2",
-        "@esbuild/linux-x64": "0.20.2",
-        "@esbuild/netbsd-x64": "0.20.2",
-        "@esbuild/openbsd-x64": "0.20.2",
-        "@esbuild/sunos-x64": "0.20.2",
-        "@esbuild/win32-arm64": "0.20.2",
-        "@esbuild/win32-ia32": "0.20.2",
-        "@esbuild/win32-x64": "0.20.2"
-      }
-    },
     "node_modules/vscode-uri": {
       "version": "3.0.8",
       "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
@@ -7110,15 +6681,15 @@
       "dev": true
     },
     "node_modules/vue": {
-      "version": "3.4.27",
-      "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz",
-      "integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==",
-      "dependencies": {
-        "@vue/compiler-dom": "3.4.27",
-        "@vue/compiler-sfc": "3.4.27",
-        "@vue/runtime-dom": "3.4.27",
-        "@vue/server-renderer": "3.4.27",
-        "@vue/shared": "3.4.27"
+      "version": "3.4.29",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.29.tgz",
+      "integrity": "sha512-8QUYfRcYzNlYuzKPfge1UWC6nF9ym0lx7mpGVPJYNhddxEf3DD0+kU07NTL0sXuiT2HuJuKr/iEO8WvXvT0RSQ==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.4.29",
+        "@vue/compiler-sfc": "3.4.29",
+        "@vue/runtime-dom": "3.4.29",
+        "@vue/server-renderer": "3.4.29",
+        "@vue/shared": "3.4.29"
       },
       "peerDependencies": {
         "typescript": "*"
diff --git a/package.json b/package.json
index fece0f7..1d9479a 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,7 @@
     "rollup-plugin-node-polyfills": "~0.2.1",
     "sass": "^1.66.0",
     "typescript": "~5.4.0",
-    "vite": "~5.2.0",
+    "vite": "~5.3.0",
     "vue-tsc": "~2.0.0"
   }
 }
diff --git a/src/App.vue b/src/App.vue
index d48b90e..f679a7a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -26,28 +26,31 @@ const s3KeyRepository = useS3KeyStore();
 
 onBeforeMount(() => {
   OpenAPI.BASE = environment.API_BASE_URL;
+  userRepository.updateJWT(cookies.get("clowm-jwt"));
+  if (userRepository.authenticated) {
+    userRepository.getCurrentUser();
+  }
   axios.interceptors.response.use(
     (res) => res,
     (err) => {
       if (
-        (err.response.status === 400 || err.response.status === 403) &&
-        err.response.data.detail?.includes("JWT")
+        err.response.status === 401 ||
+        (err.response.status === 400 &&
+          err.response.data.detail?.includes("JWT"))
       ) {
         userRepository.logout();
-        cookies.remove("bearer");
         router.push({
           name: "login",
           query: {
             login_error:
-              err.response.status === 400 ? "token_invalid" : "token_expired",
-            next: route.name != "login" ? encodeURI(route.path) : undefined,
+              err.response.status === 401 ? "token_invalid" : "token_expired",
+            next: route.name !== "login" ? encodeURI(route.path) : undefined,
           },
         });
       }
       return Promise.reject(err);
     },
   );
-  userRepository.setToken(cookies.get("bearer"));
   router.afterEach((to, from) => {
     window._paq.push(["setReferrerUrl", from.path]);
     window._paq.push(["deleteCustomVariables", "page"]);
@@ -82,7 +85,7 @@ onBeforeMount(() => {
       return { name: "workflows" };
     } else if (
       to.meta.requiresReviewerRole &&
-      !(userRepository.rewiewer || userRepository.admin)
+      !(userRepository.reviewer || userRepository.admin)
     ) {
       return "/";
     } else if (
diff --git a/src/assets/main.css b/src/assets/main.css
index cb6974c..bdbb663 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -33,3 +33,6 @@ pre {
 .hover-info:hover {
     color: var(--bs-info) !important;
 }
+.hover-danger:hover {
+    color: var(--bs-danger) !important;
+}
diff --git a/src/client/index.ts b/src/client/index.ts
index 4e8c4b3..98752ac 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -8,6 +8,9 @@ export { OpenAPI } from './core/OpenAPI';
 export type { OpenAPIConfig } from './core/OpenAPI';
 
 export type { AnonymizedWorkflowExecution } from './models/AnonymizedWorkflowExecution';
+export type { ApiTokenIn } from './models/ApiTokenIn';
+export type { ApiTokenOut } from './models/ApiTokenOut';
+export type { ApiTokenPrivateOut } from './models/ApiTokenPrivateOut';
 export type { Body_Bucket_update_bucket_public_state } from './models/Body_Bucket_update_bucket_public_state';
 export type { Body_Workflow_Version_upload_workflow_version_icon } from './models/Body_Workflow_Version_upload_workflow_version_icon';
 export type { BucketIn } from './models/BucketIn';
@@ -34,6 +37,7 @@ export type { ResourceVersionOut } from './models/ResourceVersionOut';
 export { ResourceVersionStatus } from './models/ResourceVersionStatus';
 export { RoleEnum } from './models/RoleEnum';
 export type { S3Key } from './models/S3Key';
+export { ScopeEnum } from './models/ScopeEnum';
 export type { UserIn } from './models/UserIn';
 export type { UserOut } from './models/UserOut';
 export type { UserOutExtended } from './models/UserOutExtended';
@@ -57,6 +61,7 @@ export type { WorkflowVersion } from './models/WorkflowVersion';
 export { WorkflowVersionStatus } from './models/WorkflowVersionStatus';
 export type { WorkflowVersionStatusSchema } from './models/WorkflowVersionStatusSchema';
 
+export { ApiTokenService } from './services/ApiTokenService';
 export { AuthService } from './services/AuthService';
 export { BucketService } from './services/BucketService';
 export { BucketPermissionService } from './services/BucketPermissionService';
diff --git a/src/client/models/ApiTokenIn.ts b/src/client/models/ApiTokenIn.ts
new file mode 100644
index 0000000..38a68ca
--- /dev/null
+++ b/src/client/models/ApiTokenIn.ts
@@ -0,0 +1,20 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ScopeEnum } from './ScopeEnum';
+export type ApiTokenIn = {
+    /**
+     * Short name for the API token
+     */
+    name: string;
+    /**
+     * Unix timestamp when the token should expire
+     */
+    expires_at?: (number | null);
+    /**
+     * List of scopes this Api token has
+     */
+    scopes: Array<ScopeEnum>;
+};
+
diff --git a/src/client/models/ApiTokenOut.ts b/src/client/models/ApiTokenOut.ts
new file mode 100644
index 0000000..9935a12
--- /dev/null
+++ b/src/client/models/ApiTokenOut.ts
@@ -0,0 +1,36 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ScopeEnum } from './ScopeEnum';
+export type ApiTokenOut = {
+    /**
+     * Short name for the API token
+     */
+    name: string;
+    /**
+     * Unix timestamp when the token should expire
+     */
+    expires_at?: (number | null);
+    /**
+     * List of scopes this Api token has
+     */
+    scopes: Array<ScopeEnum>;
+    /**
+     * The ID of the token
+     */
+    token_id: string;
+    /**
+     * The ID of the owner
+     */
+    uid: string;
+    /**
+     * The UNIX timestamp when this token was created
+     */
+    created_at: number;
+    /**
+     * The UNIX timestamp when this token was used the last time
+     */
+    last_used?: (number | null);
+};
+
diff --git a/src/client/models/ApiTokenPrivateOut.ts b/src/client/models/ApiTokenPrivateOut.ts
new file mode 100644
index 0000000..ec2b609
--- /dev/null
+++ b/src/client/models/ApiTokenPrivateOut.ts
@@ -0,0 +1,40 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ScopeEnum } from './ScopeEnum';
+export type ApiTokenPrivateOut = {
+    /**
+     * Short name for the API token
+     */
+    name: string;
+    /**
+     * Unix timestamp when the token should expire
+     */
+    expires_at?: (number | null);
+    /**
+     * List of scopes this Api token has
+     */
+    scopes: Array<ScopeEnum>;
+    /**
+     * The ID of the token
+     */
+    token_id: string;
+    /**
+     * The ID of the owner
+     */
+    uid: string;
+    /**
+     * The UNIX timestamp when this token was created
+     */
+    created_at: number;
+    /**
+     * The UNIX timestamp when this token was used the last time
+     */
+    last_used?: (number | null);
+    /**
+     * The actual token used for authentication
+     */
+    token: string;
+};
+
diff --git a/src/client/models/ScopeEnum.ts b/src/client/models/ScopeEnum.ts
new file mode 100644
index 0000000..b689573
--- /dev/null
+++ b/src/client/models/ScopeEnum.ts
@@ -0,0 +1,8 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+export enum ScopeEnum {
+    READ = 'read',
+    WRITE = 'write',
+}
diff --git a/src/client/models/UserOutExtended.ts b/src/client/models/UserOutExtended.ts
index 4b0aa20..a05ecb6 100644
--- a/src/client/models/UserOutExtended.ts
+++ b/src/client/models/UserOutExtended.ts
@@ -24,5 +24,9 @@ export type UserOutExtended = {
      * Timestamp when the invitation token was created as UNIX timestamp
      */
     invitation_token_created_at?: (number | null);
+    /**
+     * URL to the gravatar avatar based on the users email
+     */
+    gravatar_url?: (string | null);
 };
 
diff --git a/src/client/services/ApiTokenService.ts b/src/client/services/ApiTokenService.ts
new file mode 100644
index 0000000..a088864
--- /dev/null
+++ b/src/client/services/ApiTokenService.ts
@@ -0,0 +1,121 @@
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { ApiTokenIn } from '../models/ApiTokenIn';
+import type { ApiTokenOut } from '../models/ApiTokenOut';
+import type { ApiTokenPrivateOut } from '../models/ApiTokenPrivateOut';
+import type { CancelablePromise } from '../core/CancelablePromise';
+import { OpenAPI } from '../core/OpenAPI';
+import { request as __request } from '../core/request';
+export class ApiTokenService {
+    /**
+     * List API token
+     * List meta information about all API token.
+     *
+     * Permissions `api_token:list_all` required. See parameter `uid` for exception.
+     * @param uid UID of the user to filter for. Permission `api_token:list` required if current users is the target.
+     * @returns ApiTokenOut Successful Response
+     * @throws ApiError
+     */
+    public static apiTokenListToken(
+        uid?: string,
+    ): CancelablePromise<Array<ApiTokenOut>> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: '/tokens',
+            query: {
+                'uid': uid,
+            },
+            errors: {
+                400: `Error decoding JWT Token`,
+                401: `Not Authenticated`,
+                403: `Not Authorized`,
+                404: `Entity not Found`,
+                422: `Validation Error`,
+            },
+        });
+    }
+    /**
+     * Create new API token
+     * Create a new API token for the current user.
+     *
+     * Permission `api_token:create` required.
+     * @param requestBody
+     * @returns ApiTokenPrivateOut Successful Response
+     * @throws ApiError
+     */
+    public static apiTokenCreateToken(
+        requestBody: ApiTokenIn,
+    ): CancelablePromise<ApiTokenPrivateOut> {
+        return __request(OpenAPI, {
+            method: 'POST',
+            url: '/tokens',
+            body: requestBody,
+            mediaType: 'application/json',
+            errors: {
+                400: `Error decoding JWT Token`,
+                401: `Not Authenticated`,
+                403: `Not Authorized`,
+                404: `Entity not Found`,
+                422: `Validation Error`,
+            },
+        });
+    }
+    /**
+     * Get API token
+     * Get an API token by id.
+     *
+     * Permission `api_token:read` required if the current user is the owner of the API token,
+     * otherwise `api_token:read_any` required.
+     * @param tid ID of an API token
+     * @returns ApiTokenOut Successful Response
+     * @throws ApiError
+     */
+    public static apiTokenGetToken(
+        tid: string,
+    ): CancelablePromise<ApiTokenOut> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: '/tokens/{tid}',
+            path: {
+                'tid': tid,
+            },
+            errors: {
+                400: `Error decoding JWT Token`,
+                401: `Not Authenticated`,
+                403: `Not Authorized`,
+                404: `Entity not Found`,
+                422: `Validation Error`,
+            },
+        });
+    }
+    /**
+     * Delete API token
+     * Delete an API token by id.
+     *
+     * Permission `api_token:delete` required if the current user is the owner of the API token,
+     * otherwise `api_token:delete_any` required.
+     * @param tid ID of an API token
+     * @returns void
+     * @throws ApiError
+     */
+    public static apiTokenDeleteToken(
+        tid: string,
+    ): CancelablePromise<void> {
+        return __request(OpenAPI, {
+            method: 'DELETE',
+            url: '/tokens/{tid}',
+            path: {
+                'tid': tid,
+            },
+            errors: {
+                400: `Error decoding JWT Token`,
+                401: `Not Authenticated`,
+                403: `Not Authorized`,
+                404: `Entity not Found`,
+                422: `Validation Error`,
+            },
+        });
+    }
+}
diff --git a/src/client/services/WorkflowCredentialsService.ts b/src/client/services/WorkflowCredentialsService.ts
index 4d3b74c..01e4e44 100644
--- a/src/client/services/WorkflowCredentialsService.ts
+++ b/src/client/services/WorkflowCredentialsService.ts
@@ -70,7 +70,7 @@ export class WorkflowCredentialsService {
      * Delete the credentials of a workflow
      * Delete the credentials for the repository of a workflow.
      *
-     * Permission `workflow:delete` required if the developer of the workflow is the same as the the current user,
+     * Permission `workflow:delete` required if the developer of the workflow is the same as the current user,
      * other `workflow:delete_any`.
      * @param wid ID of a workflow
      * @returns void
diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue
index e879949..331c790 100644
--- a/src/components/AppHeader.vue
+++ b/src/components/AppHeader.vue
@@ -1,18 +1,16 @@
 <script setup lang="ts">
-import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
 import { useUserStore } from "@/stores/users";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 import { watch, ref, computed } from "vue";
-import BootstrapModal from "@/components/modals/BootstrapModal.vue";
 import { OpenAPI } from "@/client";
-import CopyToClipboardIcon from "@/components/CopyToClipboardIcon.vue";
-import dayjs from "dayjs";
 
 const userRepository = useUserStore();
 const route = useRoute();
+const router = useRouter();
 
 function logout() {
   userRepository.logout();
+  router.push({ name: "login" });
 }
 
 const activeRoute = ref("");
@@ -136,7 +134,7 @@ watch(
               <li
                 v-if="
                   userRepository.workflowDev ||
-                  userRepository.rewiewer ||
+                  userRepository.reviewer ||
                   userRepository.admin
                 "
               >
@@ -149,7 +147,7 @@ watch(
                   >My Workflows
                 </router-link>
               </li>
-              <li v-if="userRepository.rewiewer || userRepository.admin">
+              <li v-if="userRepository.reviewer || userRepository.admin">
                 <router-link
                   class="dropdown-item"
                   :to="{ name: 'workflows-reviewer' }"
@@ -183,7 +181,7 @@ watch(
               <li
                 v-if="
                   userRepository.resourceMaintainer ||
-                  userRepository.rewiewer ||
+                  userRepository.reviewer ||
                   userRepository.admin
                 "
               >
@@ -198,7 +196,7 @@ watch(
                   >My Resources
                 </router-link>
               </li>
-              <li v-if="userRepository.rewiewer || userRepository.admin">
+              <li v-if="userRepository.reviewer || userRepository.admin">
                 <router-link
                   class="dropdown-item"
                   :to="{ name: 'resource-review' }"
@@ -265,21 +263,23 @@ watch(
           data-bs-toggle="dropdown"
           aria-expanded="false"
         >
-          <strong class="me-2">{{ userRepository.user?.display_name }}</strong>
-          <font-awesome-icon icon="fa-solid fa-circle-user" class="fs-5" />
+          <strong class="me-2">{{ userRepository.user.display_name }}</strong>
+          <img
+            :src="userRepository.user.gravatar_url + '?d=mp&s=32'"
+            class="rounded-circle"
+            height="32"
+            width="32"
+            alt="profile picture"
+          />
         </a>
         <ul
           class="dropdown-menu text-small shadow"
           aria-labelledby="dropdownUser1"
         >
           <li>
-            <a
-              href="#"
-              class="dropdown-item"
-              data-bs-toggle="modal"
-              data-bs-target="#advancedUsageModal"
-              >Advanced Usage</a
-            >
+            <router-link class="dropdown-item" :to="{ name: 'api-tokens' }"
+              >API tokens
+            </router-link>
           </li>
           <li>
             <hr class="dropdown-divider" />
@@ -307,62 +307,6 @@ watch(
       </div>
     </nav>
   </header>
-  <bootstrap-modal
-    modal-id="advancedUsageModal"
-    modal-label="Advanced Usage Modal"
-    v-if="userRepository.authenticated"
-    size-modifier-modal="lg"
-  >
-    <template v-slot:header>
-      <h3>Advanced Usage</h3>
-    </template>
-    <template v-slot:body>
-      <table class="table table-borderless table-sm">
-        <tbody>
-          <tr>
-            <td class="text-end">API:</td>
-            <td>
-              <a :href="OpenAPI.BASE + '/docs'" target="_blank">
-                <font-awesome-icon
-                  class="me-1"
-                  icon="fa-solid fa-arrow-up-right-from-square"
-                />
-                {{ OpenAPI.BASE }}</a
-              >
-            </td>
-          </tr>
-        </tbody>
-      </table>
-      <div class="mt-4">
-        <label for="clowm-jwt" class="form-label"
-          >JWT for Services (expires at
-          {{
-            dayjs
-              .unix(userRepository.decodedToken!.exp)
-              .format("DD.MM.YYYY HH:mm")
-          }})</label
-        >
-        <div class="input-group">
-          <input
-            type="text"
-            readonly
-            class="form-control text-truncate"
-            id="clowm-jwt"
-            :value="userRepository.token"
-            aria-describedby="clowm-jwt-copy"
-          />
-          <span class="input-group-text" id="clowm-jwt-copy"
-            ><copy-to-clipboard-icon :text="userRepository.token ?? ''"
-          /></span>
-        </div>
-      </div>
-    </template>
-    <template v-slot:footer>
-      <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
-        Close
-      </button>
-    </template>
-  </bootstrap-modal>
 </template>
 
 <style scoped>
diff --git a/src/components/user/CreateApiTokenModal.vue b/src/components/user/CreateApiTokenModal.vue
new file mode 100644
index 0000000..4304fdc
--- /dev/null
+++ b/src/components/user/CreateApiTokenModal.vue
@@ -0,0 +1,192 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from "vue";
+import { type ApiTokenIn, type ApiTokenPrivateOut, ScopeEnum } from "@/client";
+import { Modal } from "bootstrap";
+import BootstrapModal from "@/components/modals/BootstrapModal.vue";
+import dayjs from "dayjs";
+import { useUserStore } from "@/stores/users";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+
+const props = defineProps<{
+  modalId: string;
+}>();
+
+const tokenIn = reactive<ApiTokenIn>({
+  name: "",
+  expires_at: undefined,
+  scopes: [],
+});
+
+const formState = reactive<{
+  validated: boolean;
+  loading: boolean;
+}>({
+  validated: false,
+  loading: false,
+});
+
+const userRepository = useUserStore();
+const tokenCreateForm = ref<HTMLFormElement | undefined>(undefined);
+let createTokenModal: Modal | null = null;
+
+const scopeDescription: Record<ScopeEnum, string> = {
+  read: "Grants read access to the CloWM API. All GET requests are allowed with this scope.",
+  write:
+    "Grants write access to the CloWM API. All POST, DELETE and PUT requests are allowed with this scope.",
+};
+
+const emit = defineEmits<{
+  (e: "token-created", token: ApiTokenPrivateOut): void;
+}>();
+
+function modalClosed() {
+  formState.validated = false;
+}
+
+function expiresAtTimestampChanged(target?: HTMLInputElement | null) {
+  tokenIn.expires_at = target?.value ? dayjs(target?.value).unix() : undefined;
+}
+
+function createToken() {
+  formState.validated = true;
+  if (tokenCreateForm.value?.checkValidity()) {
+    formState.loading = true;
+    userRepository
+      .createApiToken(tokenIn)
+      .then((token) => {
+        emit("token-created", token);
+        createTokenModal?.hide();
+        tokenIn.expires_at = undefined;
+        tokenIn.name = "";
+        tokenIn.scopes = [];
+      })
+      .finally(() => {
+        formState.loading = false;
+      });
+  }
+}
+
+onMounted(() => {
+  createTokenModal = new Modal("#" + props.modalId);
+});
+</script>
+
+<template>
+  <bootstrap-modal
+    :modalId="modalId"
+    static-backdrop
+    modal-label="Create API Token Modal"
+    v-on="{ 'hidden.bs.modal': modalClosed }"
+    size-modifier-modal="lg"
+  >
+    <template #header> Create personal API token</template>
+    <template #body>
+      <form
+        id="api-token-create-form"
+        :class="{ 'was-validated': formState.validated }"
+        ref="tokenCreateForm"
+        @submit.prevent="createToken"
+      >
+        <div class="mb-3">
+          <label for="token-name-input" class="form-label">Token name</label>
+          <input
+            type="text"
+            class="form-control"
+            id="token-name-input"
+            required
+            minlength="3"
+            maxlength="63"
+            pattern="^[a-z\d\-]+$"
+            v-model="tokenIn.name"
+          />
+          <div class="invalid-feedback">
+            <div>
+              Name must ...
+              <ul>
+                <li>be at least 3 Characters long</li>
+                <li>contains the characters a-z 0-9 and /</li>
+              </ul>
+            </div>
+          </div>
+        </div>
+        <div class="mb-3">
+          <label for="expires-at-input" class="form-label"
+            >Expiration date</label
+          >
+          <div :class="{ 'input-group': tokenIn.expires_at != undefined }">
+            <input
+              class="form-control"
+              id="expires-at-input"
+              type="date"
+              :min="dayjs().add(1, 'day').format('YYYY-MM-DD')"
+              :value="
+                tokenIn.expires_at
+                  ? dayjs.unix(tokenIn.expires_at).format('YYYY-MM-DD')
+                  : undefined
+              "
+              @input="
+                (event) =>
+                  expiresAtTimestampChanged(event.target as HTMLInputElement)
+              "
+            />
+            <div
+              v-if="tokenIn.expires_at"
+              class="input-group-text cursor-pointer hover-danger"
+              @click="tokenIn.expires_at = undefined"
+            >
+              <font-awesome-icon icon="fa-solid fa-xmark" class="fs-5" />
+            </div>
+          </div>
+        </div>
+        <div class="mb-3">
+          <div>Select scopes</div>
+          <div
+            v-for="scope in Object.values(ScopeEnum)"
+            :key="scope"
+            class="form-check mb-2"
+          >
+            <input
+              class="form-check-input"
+              type="checkbox"
+              :value="scope"
+              :id="`scope-input-${scope}`"
+              v-model="tokenIn.scopes"
+            />
+            <label
+              class="form-check-label pt-1"
+              style="line-height: 0.8rem"
+              :for="`scope-input-${scope}`"
+            >
+              <span>{{ scope.toLowerCase() }}</span>
+              <br />
+              <span class="text-secondary" style="font-size: 0.8rem">
+                {{ scopeDescription[scope] }}
+              </span>
+            </label>
+          </div>
+        </div>
+      </form>
+    </template>
+    <template v-slot:footer>
+      <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
+        Close
+      </button>
+      <button
+        type="submit"
+        form="api-token-create-form"
+        class="btn btn-primary"
+        :disabled="formState.loading || tokenIn.scopes.length === 0"
+      >
+        <span
+          v-if="formState.loading"
+          class="spinner-border spinner-border-sm"
+          role="status"
+          aria-hidden="true"
+        ></span>
+        Save
+      </button>
+    </template>
+  </bootstrap-modal>
+</template>
+
+<style scoped></style>
diff --git a/src/components/workflows/modals/ArbitraryWorkflowModal.vue b/src/components/workflows/modals/ArbitraryWorkflowModal.vue
index c3c4a7a..cff6610 100644
--- a/src/components/workflows/modals/ArbitraryWorkflowModal.vue
+++ b/src/components/workflows/modals/ArbitraryWorkflowModal.vue
@@ -15,6 +15,7 @@ import { environment } from "@/environment";
 import { useS3KeyStore } from "@/stores/s3keys";
 import { useResourceStore } from "@/stores/resources";
 import { createDownloadUrl } from "@/utils/DownloadJson";
+import dayjs from "dayjs";
 
 const props = defineProps<{
   modalId: string;
@@ -72,6 +73,7 @@ const formState = reactive<{
   allowUpload: boolean;
   missingFiles: string[];
   unsupportedRepository: boolean;
+  ratelimit_reset: number;
 }>({
   validated: false,
   allowUpload: false,
@@ -79,6 +81,7 @@ const formState = reactive<{
   checkRepoLoading: false,
   missingFiles: [],
   unsupportedRepository: false,
+  ratelimit_reset: 0,
 });
 
 watch(
@@ -178,6 +181,7 @@ docker {
 }
 
 // Disable unwanted features
+conda.enabled = False
 weblog.enabled = false
 shifter.enabled = false
 singularity.enabled = false
@@ -299,6 +303,7 @@ docker {
 }
 
 // Disable unwanted features
+conda.enabled = False
 tower.enabled = false
 weblog.enabled = false
 shifter.enabled = false
@@ -329,6 +334,7 @@ function checkRepository() {
   workflow.repository_url = workflow.repository_url
     .trim()
     .replace(/(^\/+|\/+$)/g, "");
+  formState.ratelimit_reset = 0;
   if (arbitraryWorkflowForm.value?.checkValidity() && !formState.allowUpload) {
     formState.unsupportedRepository = false;
     formState.missingFiles = [];
@@ -352,14 +358,19 @@ function checkRepository() {
           formState.allowUpload = true;
         })
         .catch((e: Error) => {
-          formState.missingFiles = e.message.split(",");
-          // Allow execution of the workflow if main.nf and parameter schema are not missing
-          if (
-            formState.missingFiles.findIndex(
-              (file) => file === "main.nf" || file === "nextflow_schema.json",
-            ) < 0
-          ) {
-            formState.allowUpload = true;
+          try {
+            const headers = JSON.parse(e.message);
+            formState.ratelimit_reset = parseInt(headers["x-ratelimit-reset"]);
+          } catch {
+            formState.missingFiles = e.message.split(",");
+            // Allow execution of the workflow if main.nf and parameter schema are not missing
+            if (
+              formState.missingFiles.findIndex(
+                (file) => file === "main.nf" || file === "nextflow_schema.json",
+              ) < 0
+            ) {
+              formState.allowUpload = true;
+            }
           }
         });
     } catch (e) {
@@ -518,13 +529,25 @@ onMounted(() => {
               ref="workflowGitCommitHashElement"
               maxlength="40"
               minlength="40"
-              pattern="[0-9a-f]{40}"
+              pattern="^[0-9a-f]$"
               v-model="workflow.git_commit_hash"
               @change="formState.allowUpload = false"
             />
           </div>
         </div>
-        <div v-if="formState.missingFiles.length > 0" class="text-danger">
+        <div v-if="formState.ratelimit_reset > 0" class="text-danger">
+          Can't check GitHub repository because the default
+          <a
+            href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users"
+            target="_blank"
+            >rate-limit</a
+          >
+          for your IP address was exhausted. You could mark this repository as
+          private repository to bypass this constraint. <br />
+          Rate-limit resets
+          {{ dayjs.unix(formState.ratelimit_reset).fromNow() }}
+        </div>
+        <div v-else-if="formState.missingFiles.length > 0" class="text-danger">
           The following files are missing in the repository
           <ul>
             <li v-for="file in formState.missingFiles" :key="file">
diff --git a/src/components/workflows/modals/CreateWorkflowModal.vue b/src/components/workflows/modals/CreateWorkflowModal.vue
index 5eb0dd4..67905a0 100644
--- a/src/components/workflows/modals/CreateWorkflowModal.vue
+++ b/src/components/workflows/modals/CreateWorkflowModal.vue
@@ -14,6 +14,7 @@ import { valid } from "semver";
 import WorkflowModeTransitionGroup from "@/components/transitions/WorkflowModeTransitionGroup.vue";
 import { useWorkflowStore } from "@/stores/workflows";
 import BootstrapToast from "@/components/BootstrapToast.vue";
+import dayjs from "dayjs";
 
 const workflowRepository = useWorkflowStore();
 // Emitted Events
@@ -69,6 +70,7 @@ const formState = reactive<{
   allowUpload: boolean;
   missingFiles: string[];
   unsupportedRepository: boolean;
+  ratelimit_reset: number;
 }>({
   validated: false,
   allowUpload: false,
@@ -76,6 +78,7 @@ const formState = reactive<{
   checkRepoLoading: false,
   missingFiles: [],
   unsupportedRepository: false,
+  ratelimit_reset: 0,
 });
 
 const repositoryCredentials = reactive<{
@@ -251,10 +254,15 @@ function checkRepository() {
           formState.allowUpload = true;
         })
         .catch((e: Error) => {
-          formState.missingFiles = e.message.split(",");
-          workflowGitCommitHashElement.value?.setCustomValidity(
-            "Files are missing in the repository",
-          );
+          try {
+            const headers = JSON.parse(e.message);
+            formState.ratelimit_reset = parseInt(headers["x-ratelimit-reset"]);
+          } catch {
+            formState.missingFiles = e.message.split(",");
+            workflowGitCommitHashElement.value?.setCustomValidity(
+              "Files are missing in the repository",
+            );
+          }
         });
     } catch (e) {
       formState.unsupportedRepository = true;
@@ -328,6 +336,7 @@ onMounted(() => {
     :static-backdrop="true"
     modal-label="Create Workflow Modal"
     v-on="{ 'hidden.bs.modal': modalClosed }"
+    size-modifier-modal="lg"
   >
     <template #header> Create new Workflow</template>
     <template #body>
@@ -426,7 +435,8 @@ onMounted(() => {
                 required
                 ref="workflowGitCommitHashElement"
                 maxlength="40"
-                pattern="[0-9a-f]{40}"
+                minlength="40"
+                pattern="^[0-9a-f]$"
                 v-model="workflow.git_commit_hash"
                 @change="formState.allowUpload = false"
               />
@@ -439,7 +449,21 @@ onMounted(() => {
                 <font-awesome-icon icon="fa-solid fa-circle-question" />
               </div>
             </div>
-            <div v-if="formState.missingFiles.length > 0" class="text-danger">
+            <div v-if="formState.ratelimit_reset > 0" class="text-danger">
+              Can't check GitHub repository because the default
+              <a
+                href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users"
+                target="_blank"
+                >rate-limit</a
+              >
+              for your IP address was exhausted <br />
+              Rate-limit resets
+              {{ dayjs.unix(formState.ratelimit_reset).fromNow() }}
+            </div>
+            <div
+              v-else-if="formState.missingFiles.length > 0"
+              class="text-danger"
+            >
               The following files are missing in the repository
               <ul>
                 <li v-for="file in formState.missingFiles" :key="file">
diff --git a/src/components/workflows/modals/UpdateWorkflowCredentialsModal.vue b/src/components/workflows/modals/UpdateWorkflowCredentialsModal.vue
index 27ccc33..e49fbc3 100644
--- a/src/components/workflows/modals/UpdateWorkflowCredentialsModal.vue
+++ b/src/components/workflows/modals/UpdateWorkflowCredentialsModal.vue
@@ -72,30 +72,31 @@ function updateCredentials() {
       props.workflow.versions[0].workflow_version_id,
       credentials.token,
     );
-    repo.checkFileExist("main.nf").then((result: boolean) => {
-      if (result) {
-        workflowRepository
-          .updateWorkflowCredentials(props.workflow.workflow_id, credentials)
-          .then(() => {
-            formState.updateCredentials = true;
-            successToast?.show();
-            updateCredentialsModal?.hide();
-          })
-          .catch((error) => {
-            console.error(error);
-          })
-          .finally(() => {
-            formState.loading = false;
-          });
-      } else {
-        formState.error = true;
-        credentialsInputElement.value?.setCustomValidity(
-          "Can't access repository.",
-        );
-        formState.loading = false;
-      }
-    });
     formState.loading = true;
+    repo
+      .checkFileExist("main.nf")
+      .then((result: boolean) => {
+        if (result) {
+          workflowRepository
+            .updateWorkflowCredentials(props.workflow.workflow_id, credentials)
+            .then(() => {
+              formState.updateCredentials = true;
+              successToast?.show();
+              updateCredentialsModal?.hide();
+            })
+            .catch((error) => {
+              console.error(error);
+            });
+        } else {
+          formState.error = true;
+          credentialsInputElement.value?.setCustomValidity(
+            "Can't access repository.",
+          );
+        }
+      })
+      .finally(() => {
+        formState.loading = false;
+      });
   }
 }
 
diff --git a/src/components/workflows/modals/UpdateWorkflowModal.vue b/src/components/workflows/modals/UpdateWorkflowModal.vue
index ed2af5e..5081448 100644
--- a/src/components/workflows/modals/UpdateWorkflowModal.vue
+++ b/src/components/workflows/modals/UpdateWorkflowModal.vue
@@ -22,6 +22,7 @@ import { latestVersion as calculateLatestVersion } from "@/utils/Workflow";
 import WorkflowModeTransitionGroup from "@/components/transitions/WorkflowModeTransitionGroup.vue";
 import { useWorkflowStore } from "@/stores/workflows";
 import BootstrapToast from "@/components/BootstrapToast.vue";
+import dayjs from "dayjs";
 
 const workflowRepository = useWorkflowStore();
 // Bootstrap Elements
@@ -77,6 +78,7 @@ const formState = reactive<{
   loadCredentials: boolean;
   workflowToken?: string;
   modesEnabled: boolean;
+  ratelimit_reset: number;
 }>({
   loading: false,
   checkRepoLoading: false,
@@ -86,6 +88,7 @@ const formState = reactive<{
   loadCredentials: false,
   workflowToken: undefined,
   modesEnabled: false,
+  ratelimit_reset: 0,
 });
 
 watch(
@@ -193,10 +196,15 @@ function checkRepository() {
         formState.allowUpload = true;
       })
       .catch((e: Error) => {
-        formState.missingFiles = e.message.split(",");
-        workflowGitCommitHashElement.value?.setCustomValidity(
-          "Files are missing in the repository",
-        );
+        try {
+          const headers = JSON.parse(e.message);
+          formState.ratelimit_reset = parseInt(headers["x-ratelimit-reset"]);
+        } catch {
+          formState.missingFiles = e.message.split(",");
+          workflowGitCommitHashElement.value?.setCustomValidity(
+            "Files are missing in the repository",
+          );
+        }
       });
   }
 }
@@ -368,12 +376,27 @@ onMounted(() => {
                 required
                 ref="workflowGitCommitHashElement"
                 maxlength="40"
-                pattern="[0-9a-f]{40}"
+                minlength="40"
+                pattern="^[0-9a-f]$"
                 v-model="workflowUpdate.git_commit_hash"
                 @change="formState.allowUpload = false"
               />
             </div>
-            <div v-if="formState.missingFiles.length > 0" class="text-danger">
+            <div v-if="formState.ratelimit_reset > 0" class="text-danger">
+              Can't check GitHub repository because the default
+              <a
+                href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users"
+                target="_blank"
+                >rate-limit</a
+              >
+              for your IP address was exhausted <br />
+              Rate-limit resets
+              {{ dayjs.unix(formState.ratelimit_reset).fromNow() }}
+            </div>
+            <div
+              v-else-if="formState.missingFiles.length > 0"
+              class="text-danger"
+            >
               The following files are missing in the repository
               <ul>
                 <li v-for="file in formState.missingFiles" :key="file">
diff --git a/src/router/index.ts b/src/router/index.ts
index a639757..227a779 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -5,6 +5,7 @@ import { workflowRoutes } from "@/router/workflowRoutes";
 import { s3Routes } from "@/router/s3Routes";
 import { resourceRoutes } from "@/router/resourceRoutes";
 import { adminRoutes } from "@/router/adminRoutes";
+import { userRoutes } from "@/router/userRoutes";
 import ImprintView from "@/views/ImprintView.vue";
 import PrivacyPolicyView from "@/views/PrivacyPolicyView.vue";
 import TermsOfUsageView from "@/views/TermsOfUsageView.vue";
@@ -21,6 +22,7 @@ const router = createRouter({
         ...s3Routes,
         ...workflowRoutes,
         ...adminRoutes,
+        ...userRoutes,
       ],
     },
     {
diff --git a/src/router/userRoutes.ts b/src/router/userRoutes.ts
new file mode 100644
index 0000000..1ca6ca2
--- /dev/null
+++ b/src/router/userRoutes.ts
@@ -0,0 +1,12 @@
+import type { RouteRecordRaw } from "vue-router";
+
+export const userRoutes: RouteRecordRaw[] = [
+  {
+    path: "api-tokens",
+    name: "api-tokens",
+    component: () => import("../views/user/ListApiTokenView.vue"),
+    meta: {
+      title: "API Tokens",
+    },
+  },
+];
diff --git a/src/stores/users.ts b/src/stores/users.ts
index 259749f..fd2581d 100644
--- a/src/stores/users.ts
+++ b/src/stores/users.ts
@@ -1,6 +1,13 @@
 import { defineStore } from "pinia";
-import type { UserOutExtended, UserOut, UserIn } from "@/client";
-import { OpenAPI, UserService, RoleEnum } from "@/client";
+import type {
+  UserOutExtended,
+  UserOut,
+  UserIn,
+  ApiTokenOut,
+  ApiTokenIn,
+  ApiTokenPrivateOut,
+} from "@/client";
+import { UserService, RoleEnum, ApiTokenService } from "@/client";
 import { useWorkflowExecutionStore } from "@/stores/workflowExecutions";
 import { useBucketStore } from "@/stores/buckets";
 import { useWorkflowStore } from "@/stores/workflows";
@@ -35,56 +42,69 @@ export const useUserStore = defineStore({
   id: "user",
   state: () =>
     ({
-      token: null,
-      decodedToken: null,
       user: null,
+      jwt: null,
+      roles: [],
+      apiTokensMapping: {},
     }) as {
-      token: string | null;
-      decodedToken: DecodedToken | null;
       user: UserOutExtended | null;
+      jwt: DecodedToken | null;
+      roles: RoleEnum[];
+      apiTokensMapping: Record<string, ApiTokenOut>;
     },
   getters: {
     roles(): string[] {
-      return this.user?.roles?.map((role) => role.toString()) ?? [];
+      return (
+        this.user?.roles?.map((role) => role.toString()) ??
+        JSON.parse(localStorage.getItem("roles") ?? "[]")
+      );
+    },
+    apiTokens(): ApiTokenOut[] {
+      return Object.values(this.apiTokensMapping);
+    },
+    authenticated(): boolean {
+      return this.jwt != null || this.user != null;
     },
-    authenticated: (state) => state.token != null,
     currentUID(): string {
-      return this.user?.uid ?? this.decodedToken?.["sub"] ?? "";
-    },
-    foreignUser: (state) => (state.user?.roles ?? []).length == 0,
-    normalUser: (state) => state.user?.roles.includes(RoleEnum.USER) ?? false,
-    rewiewer: (state) => state.user?.roles.includes(RoleEnum.REVIEWER) ?? false,
-    workflowDev: (state) =>
-      state.user?.roles.includes(RoleEnum.DEVELOPER) ?? false,
-    resourceMaintainer: (state) =>
-      state.user?.roles.includes(RoleEnum.DB_MAINTAINER) ?? false,
-    admin: (state) =>
-      state.user?.roles.includes(RoleEnum.ADMINISTRATOR) ?? false,
+      return this.user?.uid ?? this.jwt?.sub ?? "";
+    },
+    foreignUser(): boolean {
+      return this.roles.length === 0;
+    },
+    normalUser(): boolean {
+      return this.roles.includes(RoleEnum.USER);
+    },
+    reviewer(): boolean {
+      return this.roles.includes(RoleEnum.REVIEWER);
+    },
+    workflowDev(): boolean {
+      return this.roles.includes(RoleEnum.DEVELOPER);
+    },
+    resourceMaintainer(): boolean {
+      return this.roles.includes(RoleEnum.DB_MAINTAINER);
+    },
+    admin(): boolean {
+      return this.roles.includes(RoleEnum.ADMINISTRATOR);
+    },
   },
   actions: {
-    setToken(token: string | null) {
-      if (token != null) {
-        this.token = token;
-        this.decodedToken = parseJwt(token);
-        OpenAPI.TOKEN = token;
-        UserService.userGetLoggedInUser()
-          .then((user) => {
-            this.updateUser(user);
-          })
-          .catch(() => {
-            this.token = null;
-          });
-      } else {
-        this.logout();
-      }
+    getCurrentUser(): Promise<UserOutExtended> {
+      return UserService.userGetLoggedInUser().then((user) => {
+        this.updateUser(user);
+        return user;
+      });
+    },
+    updateJWT(jwt: string | null) {
+      this.jwt = jwt != null ? parseJwt(jwt) : null;
     },
     updateUser(user: UserOutExtended) {
       this.user = user;
+      localStorage.setItem("currentUser", user.uid);
+      localStorage.setItem("roles", JSON.stringify(user.roles));
       useNameStore().addNameToMapping(user.uid, user.display_name);
     },
     logout() {
       window._paq.push(["resetUserId"]);
-      OpenAPI.TOKEN = undefined;
       this.$reset();
       localStorage.clear();
       dbclear();
@@ -108,6 +128,33 @@ export const useUserStore = defineStore({
         },
       );
     },
+    fetchApiTokens(
+      uid?: string,
+      onFinally?: () => void,
+    ): Promise<ApiTokenOut[]> {
+      if (this.apiTokens.length > 0) {
+        onFinally?.();
+      }
+      return ApiTokenService.apiTokenListToken(uid)
+        .then((tokens) => {
+          for (const token of tokens) {
+            this.apiTokensMapping[token.token_id] = token;
+          }
+          return tokens;
+        })
+        .finally(onFinally);
+    },
+    createApiToken(tokenIn: ApiTokenIn): Promise<ApiTokenPrivateOut> {
+      return ApiTokenService.apiTokenCreateToken(tokenIn).then((token) => {
+        this.apiTokensMapping[token.token_id] = token as ApiTokenOut;
+        return token;
+      });
+    },
+    deleteApiToken(tokenId: string): Promise<void> {
+      return ApiTokenService.apiTokenDeleteToken(tokenId).then(() => {
+        delete this.apiTokensMapping[tokenId];
+      });
+    },
     updateUserRoles(uid: string, roles: RoleEnum[]): Promise<UserOutExtended> {
       return UserService.userUpdateRoles(uid, {
         roles: roles,
diff --git a/src/utils/GitRepository.ts b/src/utils/GitRepository.ts
index 5953f28..1ab7565 100644
--- a/src/utils/GitRepository.ts
+++ b/src/utils/GitRepository.ts
@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { AxiosError } from "axios";
 import type { AxiosInstance, AxiosBasicCredentials } from "axios";
 import type { WorkflowModeOut, WorkflowModeIn } from "@/client";
 
@@ -57,8 +57,24 @@ export abstract class GitRepository {
 
   async checkFileExist(filepath: string): Promise<boolean> {
     try {
-      await this.httpClient.head(this.fileUrl(filepath));
+      await this.httpClient.get(this.fileUrl(filepath));
     } catch (e) {
+      if (
+        e instanceof AxiosError &&
+        e.response != undefined &&
+        parseInt(e.response?.headers["x-ratelimit-remaining"] ?? "0") == 0
+      ) {
+        const filtered: Record<string, string> = Object.keys(e.response.headers)
+          .filter((key) => key.startsWith("x-"))
+          .reduce(
+            (obj, key) => {
+              obj[key] = e.response?.headers[key] ?? "";
+              return obj;
+            },
+            {} as Record<string, string>,
+          );
+        throw new Error(JSON.stringify(filtered));
+      }
       return false;
     }
     return true;
@@ -69,6 +85,23 @@ export abstract class GitRepository {
     try {
       return await this.httpClient.get(await this.downloadFileUrl(filepath));
     } catch (e) {
+      if (
+        e instanceof AxiosError &&
+        e.response != undefined &&
+        parseInt(e.response.headers["x-ratelimit-remaining"] ?? "1") == 0
+      ) {
+        //console.log(parseInt(e.response.headers["x-ratelimit-reset"] ?? "0"));
+        const filtered: Record<string, string> = Object.keys(e.response.headers)
+          .filter((key) => key.startsWith("x-"))
+          .reduce(
+            (obj, key) => {
+              obj[key] = e.response?.headers[key] ?? "";
+              return obj;
+            },
+            {} as Record<string, string>,
+          );
+        throw new Error(JSON.stringify(filtered));
+      }
       return "";
     }
   }
diff --git a/src/views/admin/AdminUsersView.vue b/src/views/admin/AdminUsersView.vue
index 378cee0..693fed2 100644
--- a/src/views/admin/AdminUsersView.vue
+++ b/src/views/admin/AdminUsersView.vue
@@ -211,7 +211,15 @@ onMounted(() => {
     </tbody>
     <tbody v-else>
       <tr v-for="(user, index) in userState.users" :key="user.uid">
-        <th scope="row">{{ user.display_name }}</th>
+        <th scope="row">
+          <img
+            :src="user.gravatar_url + '?d=mp&s=32'"
+            class="rounded-circle me-2"
+            height="32"
+            width="32"
+            alt="profile picture"
+          />{{ user.display_name }}
+        </th>
         <td>{{ user.uid }}</td>
         <td
           v-for="role in Object.values(RoleEnum)"
diff --git a/src/views/resources/ReviewResourceView.vue b/src/views/resources/ReviewResourceView.vue
index 61da412..9e57c25 100644
--- a/src/views/resources/ReviewResourceView.vue
+++ b/src/views/resources/ReviewResourceView.vue
@@ -130,7 +130,7 @@ onMounted(() => {
     >
       <button
         type="button"
-        class="btn btn-primary btn-light me-2 shadow-sm border w-fit"
+        class="btn btn-light me-2 shadow-sm border w-fit"
         :disabled="resourceState.loading"
         @click="clickRefreshResources"
       >
diff --git a/src/views/user/ListApiTokenView.vue b/src/views/user/ListApiTokenView.vue
new file mode 100644
index 0000000..b2a773f
--- /dev/null
+++ b/src/views/user/ListApiTokenView.vue
@@ -0,0 +1,202 @@
+<script setup lang="ts">
+import { onMounted, reactive } from "vue";
+import { useUserStore } from "@/stores/users";
+import dayjs from "dayjs";
+import DeleteModal from "@/components/modals/DeleteModal.vue";
+import CreateApiTokenModal from "@/components/user/CreateApiTokenModal.vue";
+import type { ApiTokenPrivateOut } from "@/client";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+import CopyToClipboardIcon from "@/components/CopyToClipboardIcon.vue";
+import { environment } from "../../environment";
+
+const userRepository = useUserStore();
+
+const state = reactive<{
+  loading: boolean;
+  deleteTokenId: string;
+  createdToken?: ApiTokenPrivateOut;
+  showCreatedToken: boolean;
+}>({
+  loading: false,
+  deleteTokenId: "",
+  createdToken: undefined,
+  showCreatedToken: false,
+});
+
+function fetchTokens() {
+  state.loading = true;
+  userRepository.fetchApiTokens(userRepository.currentUID, () => {
+    state.loading = false;
+  });
+}
+
+function deleteApiToken(tokenId: string) {
+  state.deleteTokenId = tokenId;
+}
+
+function confirmedDeleteApiToken() {
+  userRepository.deleteApiToken(state.deleteTokenId).finally(() => {
+    state.deleteTokenId = "";
+  });
+}
+
+function apiTokenCreated(token: ApiTokenPrivateOut) {
+  state.createdToken = token;
+}
+
+function closeSuccessAlert() {
+  state.createdToken = undefined;
+  state.showCreatedToken = false;
+}
+
+onMounted(() => {
+  fetchTokens();
+});
+</script>
+
+<template>
+  <create-api-token-modal
+    modal-id="create-personal-api-token-modal"
+    @token-created="apiTokenCreated"
+  />
+  <delete-modal
+    modal-id="delete-personal-api-token-modal"
+    @confirm-delete="confirmedDeleteApiToken"
+    :object-name-delete="
+      userRepository.apiTokensMapping[state.deleteTokenId]?.name ?? ''
+    "
+  />
+  <div
+    class="row border-bottom mb-4 justify-content-between align-items-center"
+  >
+    <h2 class="w-fit">Personal API tokens</h2>
+    <span class="w-fit" tabindex="0">
+      <button
+        type="button"
+        class="btn btn-primary"
+        data-bs-toggle="modal"
+        data-bs-target="#create-personal-api-token-modal"
+      >
+        Create token
+      </button>
+    </span>
+  </div>
+  <h6 class="text-warning mb-3">
+    <font-awesome-icon icon="fa-solid fa-triangle-exclamation" />
+    If you don't know what an API token is used for then you probably don't need
+    it.
+    <font-awesome-icon icon="fa-solid fa-triangle-exclamation" />
+  </h6>
+  <p>
+    When using the Rest API of CloWM, you need to authenticate yourself with API
+    tokens. With an API token you can make an request to the API in the name of
+    your user account. To make a request to the REST API with an API token,
+    write the token in the HTTP header with the key
+    <code>X-CLOWM-TOKEN</code>.<br />
+    You can find an online documentation of the CloWM API at
+    <a :href="environment.API_BASE_URL + '/docs'" target="_blank">{{
+      environment.API_BASE_URL + "/docs"
+    }}</a
+    >.
+  </p>
+  <div
+    v-if="state.createdToken"
+    class="alert alert-success alert-dismissible"
+    role="alert"
+    style="min-width: 50%"
+  >
+    <strong>Your new API token</strong>
+    <button
+      type="button"
+      class="btn-close"
+      aria-label="Close"
+      @click="closeSuccessAlert"
+    ></button>
+    <br />
+    <div class="input-group my-2 w-fit">
+      <input
+        :type="state.showCreatedToken ? 'text' : 'password'"
+        class="form-control"
+        readonly
+        :value="state.createdToken.token"
+      />
+      <div
+        class="input-group-text cursor-pointer hover-info"
+        @click="state.showCreatedToken = !state.showCreatedToken"
+      >
+        <font-awesome-icon
+          :icon="`fa-solid fa-eye${state.showCreatedToken ? '-slash' : ''}`"
+        />
+      </div>
+      <div class="input-group-text">
+        <copy-to-clipboard-icon :text="state.createdToken.token" />
+      </div>
+    </div>
+    <div>Make sure you save it - you won't be able to access it again.</div>
+  </div>
+  <table class="table table-striped align-middle caption-top">
+    <caption>
+      Displaying
+      {{
+        userRepository.apiTokens.length
+      }}
+      API tokens
+    </caption>
+    <thead>
+      <tr>
+        <th scope="col"><b>Name</b></th>
+        <th scope="col">Scopes</th>
+        <th scope="col">Created</th>
+        <th scope="col">Last used</th>
+        <th scope="col">Expires</th>
+        <th scope="col"></th>
+      </tr>
+    </thead>
+    <tbody v-if="state.loading">
+      <tr>
+        <td colspan="6" class="text-center fst-italic fw-light">Loading ...</td>
+      </tr>
+    </tbody>
+    <tbody v-else-if="userRepository.apiTokens.length === 0">
+      <tr>
+        <td colspan="6" class="text-center fst-italic fw-light">
+          No personal API tokens
+        </td>
+      </tr>
+    </tbody>
+    <tbody v-else>
+      <tr v-for="token in userRepository.apiTokens" :key="token.token_id">
+        <th scope="row">{{ token.name }}</th>
+        <td>{{ token.scopes.join(", ") }}</td>
+        <td>
+          {{ dayjs.unix(token.created_at).format("MMM DD, YYYY") }}
+        </td>
+        <td v-if="token.last_used">
+          {{ dayjs.unix(token.last_used).format("MMM DD, YYYY HH:mm") }}
+        </td>
+        <td v-else>Never</td>
+        <td v-if="token.expires_at">
+          {{
+            dayjs
+              .duration(token.expires_at - dayjs().unix(), "seconds")
+              .humanize(true)
+          }}
+        </td>
+        <td v-else>Never</td>
+        <td class="text-end">
+          <button
+            type="button"
+            class="btn btn-danger btn-sm"
+            data-bs-toggle="modal"
+            data-bs-target="#delete-personal-api-token-modal"
+            @click="deleteApiToken(token.token_id)"
+          >
+            Delete
+          </button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</template>
+
+<style scoped></style>
diff --git a/src/views/workflows/ArbitraryWorkflowView.vue b/src/views/workflows/ArbitraryWorkflowView.vue
index 4ef84be..45f1b3e 100644
--- a/src/views/workflows/ArbitraryWorkflowView.vue
+++ b/src/views/workflows/ArbitraryWorkflowView.vue
@@ -15,6 +15,7 @@ import type {
   WorkflowMetaParameters,
   FlatWorkflowParameters,
 } from "@/types/WorkflowParameters";
+import dayjs from "dayjs";
 
 const props = defineProps<{
   wid: string;
@@ -34,9 +35,11 @@ const workflowState = reactive<{
   outputMarkdown?: string;
   parameterSchema?: Record<string, never>;
   repo: GitRepository;
+  ratelimit_reset: number;
 }>({
   loading: true,
   workflow: undefined,
+  ratelimit_reset: 0,
   repo: GitRepository.buildRepository(
     "https://github.de/eample/example",
     "0123456789abcdef",
@@ -73,9 +76,16 @@ function downloadVersionFiles() {
           }
         }),
       ),
-    ).finally(() => {
-      workflowState.loading = false;
-    });
+    )
+      .catch((e) => {
+        const headers = JSON.parse(e.message) as Record<string, string>;
+        workflowState.ratelimit_reset = parseInt(
+          headers["x-ratelimit-reset"] ?? "0",
+        );
+      })
+      .finally(() => {
+        workflowState.loading = false;
+      });
   }
 }
 
@@ -154,7 +164,7 @@ onMounted(() => {
     toast-id="arbitraryWorkflowExecutionViewErrorToast"
     color-class="danger"
   >
-    <template #default> Error starting workflow </template>
+    <template #default> Error starting workflow</template>
     <template #body>
       <template v-if="workflowExecutionState.errorType === 'limit'">
         You have too many active workflow executions to start a new one.
@@ -197,8 +207,11 @@ onMounted(() => {
         <span class="align-middle">Launch</span>
       </a>
     </div>
+    <div v-if="workflowState.ratelimit_reset > 0">
+      Ratelimit till {{ dayjs.unix(workflowState.ratelimit_reset).fromNow() }}
+    </div>
     <workflow-documentation-tabs
-      v-if="showDocumentation"
+      v-else-if="showDocumentation"
       :loading="workflowState.loading"
       :output-markdown="workflowState.outputMarkdown"
       :usage-markdown="workflowState.usageMarkdown"
diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue
index f99d1d2..f0c7afd 100644
--- a/src/views/workflows/WorkflowView.vue
+++ b/src/views/workflows/WorkflowView.vue
@@ -98,7 +98,7 @@ const gitIcon = computed<string>(() =>
 
 const allowVersionDeprecation = computed<boolean>(() => {
   if (activeVersion.value?.status === WorkflowVersionStatus.PUBLISHED) {
-    if (userRepository.rewiewer || userRepository.admin) {
+    if (userRepository.reviewer || userRepository.admin) {
       return true;
     } else if (
       userRepository.workflowDev &&
-- 
GitLab