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