diff --git a/src/assets/main.css b/src/assets/main.css index cb6974cba0e2a5c6b64ede79fec9c1befe81c2f9..bdbb663e010b6c5e9b4ab67ecc0de411dbeded73 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/components/AppHeader.vue b/src/components/AppHeader.vue index 32eea49ad2638076157acee2f2bb7b2cf06f70e1..60eea6e114d6aabceae76d62572c9e1caf1fb771 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -344,6 +344,10 @@ watch( </tr> </tbody> </table> + <p> + Personal API tokens are encouraged to use when accessing the REST API of + CloWM directly. + </p> </template> <template v-slot:footer> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> diff --git a/src/components/user/CreateApiTokenModal.vue b/src/components/user/CreateApiTokenModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..7dd479717748e390ad408535934631dc8f5da6ed --- /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> = { + token: "Some description about token scope", + user: "Some desc for user", + x: "Placeholder for x 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/stores/users.ts b/src/stores/users.ts index f0cc80ded9e03dc4eb12f3528376ccba246ac27c..fd2581db96e904071eec41219b99d2373feac51f 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -1,5 +1,12 @@ import { defineStore } from "pinia"; -import type { UserOutExtended, UserOut, UserIn, ApiTokenOut } 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"; @@ -137,6 +144,12 @@ export const useUserStore = defineStore({ }) .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]; diff --git a/src/views/user/ListApiTokenView.vue b/src/views/user/ListApiTokenView.vue index d4d0d06227c46d50f985c70ffd138d15802d3d0f..06154a659582f62d8b4370992e4d7c79080b6f1e 100644 --- a/src/views/user/ListApiTokenView.vue +++ b/src/views/user/ListApiTokenView.vue @@ -3,15 +3,23 @@ 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"; const userRepository = useUserStore(); const state = reactive<{ loading: boolean; deleteTokenId: string; + createdToken?: ApiTokenPrivateOut; + showCreatedToken: boolean; }>({ loading: false, deleteTokenId: "", + createdToken: undefined, + showCreatedToken: false, }); function fetchTokens() { @@ -31,12 +39,25 @@ function confirmedDeleteApiToken() { }); } +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" @@ -49,11 +70,51 @@ onMounted(() => { > <h2 class="w-fit">Personal API tokens</h2> <span class="w-fit" tabindex="0"> - <button type="button" class="btn btn-primary" disabled> + <button + type="button" + class="btn btn-primary" + data-bs-toggle="modal" + data-bs-target="#create-personal-api-token-modal" + > Create token </button> </span> </div> + <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