From 548d52476b4f7e6bcfd91791b177b6d842302a64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Mon, 17 Jun 2024 13:15:48 +0000
Subject: [PATCH] Add page to list personal API tokens

#127
---
 src/client/models/ApiTokenOut.ts           |   4 +
 src/client/models/ApiTokenPrivateOut.ts    |   4 +
 src/components/AppHeader.vue               |   5 +
 src/router/index.ts                        |   2 +
 src/router/userRoutes.ts                   |  12 ++
 src/stores/users.ts                        |  40 +++++--
 src/views/resources/ReviewResourceView.vue |   2 +-
 src/views/user/ListApiTokenView.vue        | 122 +++++++++++++++++++++
 8 files changed, 183 insertions(+), 8 deletions(-)
 create mode 100644 src/router/userRoutes.ts
 create mode 100644 src/views/user/ListApiTokenView.vue

diff --git a/src/client/models/ApiTokenOut.ts b/src/client/models/ApiTokenOut.ts
index d10e062..9935a12 100644
--- a/src/client/models/ApiTokenOut.ts
+++ b/src/client/models/ApiTokenOut.ts
@@ -28,5 +28,9 @@ export type ApiTokenOut = {
      * 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
index 6d2d22e..ec2b609 100644
--- a/src/client/models/ApiTokenPrivateOut.ts
+++ b/src/client/models/ApiTokenPrivateOut.ts
@@ -28,6 +28,10 @@ export type ApiTokenPrivateOut = {
      * 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
      */
diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue
index 4312813..32eea49 100644
--- a/src/components/AppHeader.vue
+++ b/src/components/AppHeader.vue
@@ -287,6 +287,11 @@ watch(
               >Advanced Usage</a
             >
           </li>
+          <li>
+            <router-link class="dropdown-item" :to="{ name: 'api-tokens' }"
+              >API tokens
+            </router-link>
+          </li>
           <li>
             <hr class="dropdown-divider" />
           </li>
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 de3c95d..f0cc80d 100644
--- a/src/stores/users.ts
+++ b/src/stores/users.ts
@@ -1,6 +1,6 @@
 import { defineStore } from "pinia";
-import type { UserOutExtended, UserOut, UserIn } from "@/client";
-import { UserService, RoleEnum } from "@/client";
+import type { UserOutExtended, UserOut, UserIn, ApiTokenOut } from "@/client";
+import { UserService, RoleEnum, ApiTokenService } from "@/client";
 import { useWorkflowExecutionStore } from "@/stores/workflowExecutions";
 import { useBucketStore } from "@/stores/buckets";
 import { useWorkflowStore } from "@/stores/workflows";
@@ -36,12 +36,14 @@ export const useUserStore = defineStore({
   state: () =>
     ({
       user: null,
-      token: null,
+      jwt: null,
       roles: [],
+      apiTokensMapping: {},
     }) as {
       user: UserOutExtended | null;
-      token: DecodedToken | null;
+      jwt: DecodedToken | null;
       roles: RoleEnum[];
+      apiTokensMapping: Record<string, ApiTokenOut>;
     },
   getters: {
     roles(): string[] {
@@ -50,11 +52,14 @@ export const useUserStore = defineStore({
         JSON.parse(localStorage.getItem("roles") ?? "[]")
       );
     },
+    apiTokens(): ApiTokenOut[] {
+      return Object.values(this.apiTokensMapping);
+    },
     authenticated(): boolean {
-      return this.token != null || this.user != null;
+      return this.jwt != null || this.user != null;
     },
     currentUID(): string {
-      return this.user?.uid ?? this.token?.sub ?? "";
+      return this.user?.uid ?? this.jwt?.sub ?? "";
     },
     foreignUser(): boolean {
       return this.roles.length === 0;
@@ -83,7 +88,7 @@ export const useUserStore = defineStore({
       });
     },
     updateJWT(jwt: string | null) {
-      this.token = jwt != null ? parseJwt(jwt) : null;
+      this.jwt = jwt != null ? parseJwt(jwt) : null;
     },
     updateUser(user: UserOutExtended) {
       this.user = user;
@@ -116,6 +121,27 @@ 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);
+    },
+    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/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..d4d0d06
--- /dev/null
+++ b/src/views/user/ListApiTokenView.vue
@@ -0,0 +1,122 @@
+<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";
+
+const userRepository = useUserStore();
+
+const state = reactive<{
+  loading: boolean;
+  deleteTokenId: string;
+}>({
+  loading: false,
+  deleteTokenId: "",
+});
+
+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 = "";
+  });
+}
+
+onMounted(() => {
+  fetchTokens();
+});
+</script>
+
+<template>
+  <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" disabled>
+        Create token
+      </button>
+    </span>
+  </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>
-- 
GitLab