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