From 44a1d1a4e354b13b19c832a9302674b8977c619a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Fri, 12 Aug 2022 10:48:55 +0200 Subject: [PATCH] Add a modal where it is possible to create or edit a permission #14 --- src/components/BootstrapModal.vue | 20 +- src/components/BucketView.vue | 107 +++--- src/components/PermissionModal.vue | 433 +++++++++++++++++++++++ src/main.ts | 2 +- src/views/object-storage/BucketsView.vue | 19 +- 5 files changed, 526 insertions(+), 55 deletions(-) create mode 100644 src/components/PermissionModal.vue diff --git a/src/components/BootstrapModal.vue b/src/components/BootstrapModal.vue index f672d02..09aece7 100644 --- a/src/components/BootstrapModal.vue +++ b/src/components/BootstrapModal.vue @@ -15,18 +15,24 @@ defineProps<{ aria-hidden="true" :data-bs-backdrop="staticBackdrop ? 'static' : null" > - <div class="modal-dialog modal-dialog-centered text-dark"> + <div + class="modal-dialog modal-dialog-centered text-dark" + style="min-width: 25%" + > <div class="modal-content"> <div class="modal-header"> <div class="modal-title fs-5" :id="modalLabel"> <slot name="header" /> </div> - <button - type="button" - class="btn-close" - data-bs-dismiss="modal" - aria-label="Close" - ></button> + <div> + <slot name="extra-button" /> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close" + ></button> + </div> </div> <div class="modal-body"> <slot name="body" /> diff --git a/src/components/BucketView.vue b/src/components/BucketView.vue index e65db0f..4e09fa3 100644 --- a/src/components/BucketView.vue +++ b/src/components/BucketView.vue @@ -7,6 +7,7 @@ import BootstrapIcon from "@/components/BootstrapIcon.vue"; import fileSize from "filesize"; import dayjs from "dayjs"; import { Tooltip } from "bootstrap"; +import PermissionModal from "@/components/PermissionModal.vue"; // Constants // ----------------------------------------------------------------------------- @@ -311,52 +312,68 @@ watch( </nav> <!-- Inputs on top --> <!-- Search bucket text input --> - <div class="input-group mt-2"> - <span class="input-group-text" id="objects-search-wrapping" - ><bootstrap-icon icon="search" :width="16" :height="16" - /></span> - <input - type="text" - class="form-control" - placeholder="Search Objects" - aria-label="Search Objects" - aria-describedby="objects-search-wrapping" - disabled - /> + <div class="row"> + <div class="col-8"> + <div class="input-group mt-2"> + <span class="input-group-text" id="objects-search-wrapping" + ><bootstrap-icon icon="search" :width="16" :height="16" + /></span> + <input + type="text" + class="form-control" + placeholder="Search Objects" + aria-label="Search Objects" + aria-describedby="objects-search-wrapping" + disabled + /> + </div> + </div> + <!-- Upload object button --> + <div class="col-auto"> + <button + type="button" + class="btn btn-secondary me-2" + :disabled="errorLoadingObjects" + > + <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> + <span class="visually-hidden">Upload Object</span> + </button> + <!-- Add bucket permission button --> + <button + type="button" + class="btn btn-secondary m-2" + :disabled="errorLoadingObjects" + data-bs-toggle="modal" + data-bs-target="#create-permission-modal" + > + <bootstrap-icon + icon="person-plus-fill" + :width="16" + :height="16" + fill="white" + /> + <span class="visually-hidden">Add Bucket Permission</span> + </button> + <permission-modal + modalID="create-permission-modal" + modal-label="create-permission-modal-label" + :bucket-name="props.bucketName" + :sub-folders="folderStructure" + :edit-user-permission="undefined" + :readonly="false" + /> + <!-- Add folder button --> + <button + type="button" + class="btn btn-secondary m-2" + :disabled="errorLoadingObjects" + > + <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> + Folder + <span class="visually-hidden">Add Folder</span> + </button> + </div> </div> - <!-- Upload object button --> - <button - type="button" - class="btn btn-secondary m-2" - :disabled="errorLoadingObjects" - > - <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> - <span class="visually-hidden">Upload Object</span> - </button> - <!-- Add bucket permission button --> - <button - type="button" - class="btn btn-secondary m-2" - :disabled="errorLoadingObjects" - > - <bootstrap-icon - icon="person-plus-fill" - :width="16" - :height="16" - fill="white" - /> - <span class="visually-hidden">Add Bucket Permission</span> - </button> - <!-- Add folder button --> - <button - type="button" - class="btn btn-secondary m-2" - :disabled="errorLoadingObjects" - > - <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> - Folder - <span class="visually-hidden">Add Folder</span> - </button> <!-- Body --> <div class="pt-3"> <!-- If bucket not found --> diff --git a/src/components/PermissionModal.vue b/src/components/PermissionModal.vue new file mode 100644 index 0000000..56c607c --- /dev/null +++ b/src/components/PermissionModal.vue @@ -0,0 +1,433 @@ +<script setup lang="ts"> +import { onMounted, reactive, watch, computed } from "vue"; +import BootstrapModal from "@/components/BootstrapModal.vue"; +import { Modal } from "bootstrap"; +import dayjs from "dayjs"; +import type { + BucketPermission, + S3ObjectMetaInformation, + BucketPermissionParameters, +} from "@/client"; +import type { ComputedRef } from "vue"; +import { PermissionEnum, BucketPermissionsService } from "@/client"; +import { Toast } from "bootstrap"; +import BootstrapIcon from "@/components/BootstrapIcon.vue"; + +// Types +// ----------------------------------------------------------------------------- +interface S3ObjectWithFolder extends S3ObjectMetaInformation { + folder: string[]; + pseudoFileName: string; +} + +type FolderTree = { + subFolders: Record<string, FolderTree>; + files: S3ObjectWithFolder[]; +}; + +// Props +// ----------------------------------------------------------------------------- +const props = defineProps<{ + modalID: string; + modalLabel: string; + bucketName: string; + subFolders: FolderTree; + editUserPermission: BucketPermission | undefined; + readonly: boolean; +}>(); + +// Variables +// ----------------------------------------------------------------------------- +const toastID = Math.random().toString(16).substr(2, 8); +let createPermissionModal: Modal | null = null; +let successToast: Toast | null = null; + +// Reactive State +// ----------------------------------------------------------------------------- +const formState = reactive({ + loading: false, + error: false, + readonly: props.readonly, +} as { + loading: boolean; + error: boolean; + readonly: boolean; +}); + +const permission = reactive({ + from_timestamp: undefined, + to_timestamp: undefined, + file_prefix: undefined, + permission: undefined, + uid: "", + bucket_name: props.bucketName, +} as BucketPermission); + +// Computes Properties +// ----------------------------------------------------------------------------- +const editPermission: ComputedRef<boolean> = computed( + () => props.editUserPermission != undefined +); + +const possibleSubFolders: ComputedRef<string[]> = computed(() => + findSubFolders(props.subFolders, []) +); + +const permissionUserReadonly: ComputedRef<boolean> = computed(() => { + return formState.readonly || editPermission.value; +}); + +// Watchers +// ----------------------------------------------------------------------------- +watch( + () => props.bucketName, + (newBucketName) => { + updatePermission(); + permission.bucket_name = newBucketName; + } +); + +watch( + () => props.editUserPermission, + () => updatePermission() +); + +// Functions +// ----------------------------------------------------------------------------- +/** + * Reset the form. Triggered when the modal is closed. + */ +function modalClosed() { + formState.readonly = props.readonly; + formState.error = false; + if (editPermission.value) { + updatePermission(); + } +} + +/** + * Check if a input should be visible based on its state + * @param input Input which visibility should be determined. + */ +function inputVisible(input: string | undefined): boolean { + return !formState.readonly || input != undefined; +} + +/** + * Update the form content + */ +function updatePermission() { + if (props.editUserPermission != undefined) { + permission.bucket_name = props.editUserPermission.bucket_name; + permission.file_prefix = props.editUserPermission.file_prefix; + permission.uid = props.editUserPermission.uid; + permission.from_timestamp = + props.editUserPermission.from_timestamp != null + ? dayjs(props.editUserPermission.from_timestamp).format("YYYY-MM-DD") + : undefined; + permission.to_timestamp = + props.editUserPermission.to_timestamp != null + ? dayjs(props.editUserPermission.to_timestamp).format("YYYY-MM-DD") + : undefined; + permission.permission = props.editUserPermission.permission; + } else { + permission.file_prefix = undefined; + permission.uid = ""; + permission.from_timestamp = undefined; + permission.to_timestamp = undefined; + permission.permission = undefined; + } +} + +/** + * Find recursively all sub folders based on the folder structure + * @param currentFolder Current Folder + * @param parentFolders All parent folders + */ +function findSubFolders( + currentFolder: FolderTree, + parentFolders: string[] +): string[] { + const arr: string[] = []; + for (const subFolder of Object.keys(currentFolder.subFolders)) { + const subFolderString = + (parentFolders.length > 0 ? parentFolders.join("/") + "/" : "") + + subFolder + + "/"; + arr.push( + subFolderString, + ...findSubFolders( + currentFolder.subFolders[subFolder], + subFolderString.slice(0, subFolderString.length - 1).split("/") + ) + ); + } + return arr; +} + +/** + * Submit the form + */ +function formSubmit() { + formState.error = false; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const form = document.getElementById( + "permissionCreateForm" + )! as HTMLFormElement; + if (form.checkValidity()) { + const tempPermission: BucketPermission = permission; + if (permission.from_timestamp != null) { + tempPermission.from_timestamp = dayjs( + permission.from_timestamp + ).toISOString(); + } + if (permission.to_timestamp != null) { + tempPermission.to_timestamp = dayjs( + permission.to_timestamp + ).toISOString(); + } + formState.loading = true; + const serverAnswerPromise = editPermission.value + ? BucketPermissionsService.bucketPermissionsUpdatePermission( + permission.bucket_name, + permission.uid, + { + to_timestamp: tempPermission.to_timestamp, + from_timestamp: tempPermission.from_timestamp, + permission: tempPermission.permission, + file_prefix: tempPermission.file_prefix, + } as BucketPermissionParameters + ) + : BucketPermissionsService.bucketPermissionsCreatePermission( + tempPermission + ); + serverAnswerPromise + .then(() => { + createPermissionModal?.hide(); + successToast?.show(); + }) + .catch((err) => { + formState.error = true; + console.error(err); + }) + .finally(() => { + formState.loading = false; + }); + } +} + +// Lifecycle Hooks +// ----------------------------------------------------------------------------- +onMounted(() => { + createPermissionModal = new Modal("#" + props.modalID); + successToast = new Toast("#" + "toast-" + toastID, { autohide: true }); +}); +</script> + +<template> + <div class="toast-container position-fixed top-0 end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-success align-items-center border-0" + data-bs-autohide="false" + :id="'toast-' + toastID" + > + <div class="d-flex"> + <div class="toast-body"> + Successfully + <span v-if="editPermission">created</span> + <span v-else>edited</span> + Permission + </div> + <button + type="button" + class="btn-close btn-close-white me-2 m-auto" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> + <bootstrap-modal + :modalID="modalID" + :static-backdrop="true" + :modal-label="modalLabel" + v-on="{ 'hidden.bs.modal': modalClosed }" + > + <template v-slot:header> Create new Permission </template> + <template v-slot:extra-button v-if="formState.readonly"> + <bootstrap-icon + icon="pencil-fill" + :height="15" + :width="15" + fill="currentColor" + class="pseudo-link" + @click="formState.readonly = false" + /> + </template> + <template v-slot:body> + <form @submit.prevent="formSubmit" id="permissionCreateForm"> + <div class="mb-3 row"> + <label for="bucketNameInput" class="col-2 col-form-label" + >Bucket<span v-if="!formState.readonly">*</span></label + > + <div class="col-10"> + <input + type="text" + readonly + class="form-control-plaintext" + id="bucketNameInput" + required + :value="permission.bucket_name" + /> + </div> + </div> + <div class="mb-3 row"> + <label for="permissionGranteeInput" class="col-2 col-form-label"> + User<span v-if="!formState.readonly">*</span> + </label> + <div class="col-10"> + <input + type="text" + :class="{ + 'form-control-plaintext': permissionUserReadonly, + 'form-control': !permissionUserReadonly, + }" + id="permissionGranteeInput" + required + minlength="3" + maxlength="64" + placeholder="Grantee of the permission" + v-model.trim="permission.uid" + :readonly="permissionUserReadonly" + /> + </div> + </div> + <div class="mb-3 row"> + <label for="permissionTypeInput" class="col-2 col-form-label"> + Type<span v-if="!formState.readonly">*</span> + </label> + <div class="col-10"> + <select + class="form-select text-lowercase" + id="permissionTypeInput" + required + :disabled="formState.readonly" + v-model="permission.permission" + > + <option disabled selected>Select one...</option> + <option v-for="perm in PermissionEnum" :key="perm" :value="perm"> + {{ perm.toLowerCase() }} + </option> + </select> + </div> + </div> + <div class="mb-3 row"> + <label + for="permissionDateFromInput" + class="col-2 col-form-label" + v-if="inputVisible(permission.from_timestamp)" + > + From + </label> + <div class="col-4" v-if="inputVisible(permission.from_timestamp)"> + <input + type="date" + class="form-control" + id="permissionDateFromInput" + :readonly="formState.readonly" + :min="dayjs().format('YYYY-MM-DD')" + v-model="permission.from_timestamp" + /> + </div> + <label + for="permissionDateToInput" + class="col-2 col-form-label" + v-if="inputVisible(permission.to_timestamp)" + > + To + </label> + <div class="col-4" v-if="inputVisible(permission.to_timestamp)"> + <input + type="date" + class="form-control" + id="permissionToFromInput" + :readonly="formState.readonly" + v-model="permission.to_timestamp" + :min=" + permission.from_timestamp != null + ? dayjs(permission.from_timestamp) + .add(1, 'day') + .format('YYYY-MM-DD') + : dayjs().add(1, 'day').format('YYYY-MM-DD') + " + /> + </div> + </div> + <div + class="mb-3 row" + v-if=" + inputVisible(permission.file_prefix) && + possibleSubFolders.length > 0 + " + > + <label for="permissionSubFolderInput" class="col-2 col-form-label"> + Subfolder + </label> + <div class="col-10"> + <select + class="form-select" + id="permissionSubFolderInput" + :disabled="formState.readonly" + v-model="permission.file_prefix" + > + <option disabled selected>Select one folder...</option> + <option + v-for="folder in possibleSubFolders" + :key="folder" + :value="folder" + > + {{ folder }} + </option> + </select> + </div> + </div> + </form> + <span class="text-danger" v-if="formState.error" + >There was some kind of error<br />Try again later</span + > + </template> + <template v-slot:footer> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> + Close + </button> + <button + type="submit" + form="permissionCreateForm" + class="btn btn-primary" + :disabled="formState.loading" + v-if="!formState.readonly" + > + <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> +.pseudo-link { + cursor: pointer; + color: var(--bs-secondary); +} +.pseudo-link:hover { + color: var(--bs-primary); +} +</style> diff --git a/src/main.ts b/src/main.ts index 5844f9b..91485f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,4 +22,4 @@ app.use(router); app.mount("#app"); // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Modal, Collapse, Dropdown, Tooltip } from "bootstrap"; +import { Modal, Collapse, Dropdown, Tooltip, Toast } from "bootstrap"; diff --git a/src/views/object-storage/BucketsView.vue b/src/views/object-storage/BucketsView.vue index c3ba422..a3ae113 100644 --- a/src/views/object-storage/BucketsView.vue +++ b/src/views/object-storage/BucketsView.vue @@ -1,21 +1,25 @@ <script setup lang="ts"> import { onMounted, reactive } from "vue"; -import type { BucketOut } from "@/client"; -import { BucketService } from "@/client"; +import type { BucketOut, BucketPermission } from "@/client"; +import { BucketService, BucketPermissionsService } from "@/client"; import { useRoute, useRouter } from "vue-router"; import BootstrapIcon from "@/components/BootstrapIcon.vue"; import CreateBucketComponent from "@/components/CreateBucketComponent.vue"; import BucketListItem from "@/components/BucketListItem.vue"; +import { useAuthStore } from "@/stores/auth"; const route = useRoute(); const router = useRouter(); +const authStore = useAuthStore(); const bucketsState = reactive({ buckets: [], + permissions: {}, loading: true, } as { buckets: BucketOut[]; loading: boolean; + permissions: Record<string, BucketPermission>; }); function fetchBuckets() { @@ -26,6 +30,17 @@ function fetchBuckets() { .finally(() => { bucketsState.loading = false; }); + if (authStore.user != null) { + BucketPermissionsService.bucketPermissionsListPermissionsPerUser( + authStore.user.uid + ).then((permissions) => { + const new_permissions: Record<string, BucketPermission> = {}; + for (const perm of permissions) { + new_permissions[perm.bucket_name] = perm; + } + bucketsState.permissions = new_permissions; + }); + } } function addBucket(bucket: BucketOut) { -- GitLab