From 7edac797ef5fdae9aa24552a1dacb459b181864b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Mon, 4 Mar 2024 16:10:04 +0100
Subject: [PATCH] Add view to accept and reject resource synchronization
 requests

#100
---
 .gitlab-ci.yml                                |   4 +-
 src/App.vue                                   |   1 -
 .../resource/services/ResourceService.ts      |   2 +-
 src/components/AppHeader.vue                  |   9 +-
 src/components/modals/ReasonModal.vue         |   6 +-
 src/router/adminRoutes.ts                     |   9 +
 src/router/index.ts                           |   9 +-
 src/stores/resources.ts                       |  39 +++
 src/views/ImprintView.vue                     |   6 +-
 src/views/PrivacyPolicyView.vue               |   6 +-
 src/views/TermsOfUsageView.vue                |   6 +-
 src/views/admin/AdminResourcesView.vue        |  44 ++-
 src/views/admin/AdminSyncRequestsView.vue     | 266 ++++++++++++++++++
 13 files changed, 365 insertions(+), 42 deletions(-)
 create mode 100644 src/views/admin/AdminSyncRequestsView.vue

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7f2ea9f..15d4639 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -37,7 +37,7 @@ build:
 publish-main-docker-container-job:
   stage: deploy
   image:
-    name: gcr.io/kaniko-project/executor:v1.20.0-debug
+    name: gcr.io/kaniko-project/executor:v1.21.0-debug
     entrypoint: [""]
   only:
     refs:
@@ -54,7 +54,7 @@ publish-main-docker-container-job:
 publish-docker-container-job:
   stage: deploy
   image:
-    name: gcr.io/kaniko-project/executor:v1.20.0-debug
+    name: gcr.io/kaniko-project/executor:v1.21.0-debug
     entrypoint: [""]
   only:
     - tags
diff --git a/src/App.vue b/src/App.vue
index 972b73d..0ba8e44 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -54,7 +54,6 @@ onBeforeMount(() => {
     window._paq.push(["setCustomUrl", to.path]);
     window._paq.push(["setDocumentTitle", to.name]);
     if (store.currentUID.length > 0) {
-      console.log(store.currentUID);
       window._paq.push(["setUserId", store.currentUID]);
     }
     window._paq.push(["trackPageView"]);
diff --git a/src/client/resource/services/ResourceService.ts b/src/client/resource/services/ResourceService.ts
index a8fa541..7edb063 100644
--- a/src/client/resource/services/ResourceService.ts
+++ b/src/client/resource/services/ResourceService.ts
@@ -80,7 +80,7 @@ export class ResourceService {
      * @returns UserSynchronizationRequestOut Successful Response
      * @throws ApiError
      */
-    public static resourceListReviewableResources(): CancelablePromise<Array<UserSynchronizationRequestOut>> {
+    public static resourceListSyncRequests(): CancelablePromise<Array<UserSynchronizationRequestOut>> {
         return __request(OpenAPI, {
             method: 'GET',
             url: '/resources/sync_requests',
diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue
index 4a85939..254d218 100644
--- a/src/components/AppHeader.vue
+++ b/src/components/AppHeader.vue
@@ -216,8 +216,6 @@ watch(
                   >Users
                 </router-link>
               </li>
-              <li><a class="dropdown-item disabled" href="#">Bucket</a></li>
-              <li><a class="dropdown-item disabled" href="#">Workflow</a></li>
               <li>
                 <router-link
                   class="dropdown-item"
@@ -225,6 +223,13 @@ watch(
                   >Resources
                 </router-link>
               </li>
+              <li>
+                <router-link
+                  class="dropdown-item"
+                  :to="{ name: 'admin-sync-requests' }"
+                  >Resources Synchronization
+                </router-link>
+              </li>
             </ul>
           </li>
         </ul>
diff --git a/src/components/modals/ReasonModal.vue b/src/components/modals/ReasonModal.vue
index e41c4f9..38d4b2a 100644
--- a/src/components/modals/ReasonModal.vue
+++ b/src/components/modals/ReasonModal.vue
@@ -33,7 +33,11 @@ function sendSaveEvent() {
 </script>
 
 <template>
-  <bootstrap-modal :modal-id="props.modalId" :modal-label="props.modalLabel">
+  <bootstrap-modal
+    :modal-id="props.modalId"
+    :modal-label="props.modalLabel"
+    size-modifier="lg"
+  >
     <template #header>
       <slot name="header" />
     </template>
diff --git a/src/router/adminRoutes.ts b/src/router/adminRoutes.ts
index 4015a57..00093a7 100644
--- a/src/router/adminRoutes.ts
+++ b/src/router/adminRoutes.ts
@@ -19,4 +19,13 @@ export const adminRoutes: RouteRecordRaw[] = [
       title: "Manage Users",
     },
   },
+  {
+    path: "admin/sync-requests",
+    name: "admin-sync-requests",
+    component: () => import("../views/admin/AdminSyncRequestsView.vue"),
+    meta: {
+      requiresAdminRole: true,
+      title: "Sync Requests",
+    },
+  },
 ];
diff --git a/src/router/index.ts b/src/router/index.ts
index 53a4e46..7c4188f 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -5,6 +5,9 @@ import { workflowRoutes } from "@/router/workflowRoutes";
 import { s3Routes } from "@/router/s3Routes";
 import { resourceRoutes } from "@/router/resourceRoutes";
 import { adminRoutes } from "@/router/adminRoutes";
+import ImprintView from "@/views/ImprintView.vue";
+import PrivacyPolicyView from "@/views/PrivacyPolicyView.vue";
+import TermsOfUsageView from "@/views/TermsOfUsageView.vue";
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -44,7 +47,7 @@ const router = createRouter({
       meta: {
         title: "Privacy Policy",
       },
-      component: import("../views/PrivacyPolicyView.vue"),
+      component: PrivacyPolicyView,
     },
     {
       path: "/terms",
@@ -52,7 +55,7 @@ const router = createRouter({
       meta: {
         title: "Terms of Usage",
       },
-      component: import("../views/TermsOfUsageView.vue"),
+      component: TermsOfUsageView,
     },
     {
       path: "/imprint",
@@ -60,7 +63,7 @@ const router = createRouter({
       meta: {
         title: "Imprint",
       },
-      component: import("../views/ImprintView.vue"),
+      component: ImprintView,
     },
     {
       path: "/:pathMatch(.*)",
diff --git a/src/stores/resources.ts b/src/stores/resources.ts
index 3c9edd0..08e9338 100644
--- a/src/stores/resources.ts
+++ b/src/stores/resources.ts
@@ -6,6 +6,7 @@ import type {
   ResourceVersionOut,
   UserRequestAnswer,
   UserSynchronizationRequestIn,
+  UserSynchronizationRequestOut,
 } from "@/client/resource";
 import {
   ResourceService,
@@ -22,10 +23,14 @@ export const useResourceStore = defineStore({
       resourceMapping: {},
       ownResourceMapping: {},
       reviewableResourceMapping: {},
+      syncRequestMapping: {},
+      __syncRequestsFetched: false,
     }) as {
       resourceMapping: Record<string, ResourceOut>;
       ownResourceMapping: Record<string, ResourceOut>;
       reviewableResourceMapping: Record<string, ResourceOut>;
+      syncRequestMapping: Record<string, UserSynchronizationRequestOut>;
+      __syncRequestsFetched: boolean;
     },
   getters: {
     resources(): ResourceOut[] {
@@ -37,6 +42,9 @@ export const useResourceStore = defineStore({
     reviewableResources(): ResourceOut[] {
       return Object.values(this.reviewableResourceMapping);
     },
+    syncRequests(): UserSynchronizationRequestOut[] {
+      return Object.values(this.syncRequestMapping);
+    },
   },
   actions: {
     fetchResource(
@@ -58,6 +66,24 @@ export const useResourceStore = defineStore({
         return resource;
       });
     },
+    fetchSyncRequests(
+      onFinally?: () => void,
+    ): Promise<UserSynchronizationRequestOut[]> {
+      if (this.__syncRequestsFetched) {
+        onFinally?.();
+      }
+      return ResourceService.resourceListSyncRequests()
+        .then((requests) => {
+          this.__syncRequestsFetched = true;
+          const newMapping: Record<string, UserSynchronizationRequestOut> = {};
+          for (const request of requests) {
+            newMapping[request.resource_version_id] = request;
+          }
+          this.syncRequestMapping = newMapping;
+          return requests;
+        })
+        .finally(onFinally);
+    },
     fetchResources(
       maintainerId?: string,
       versionStatus?: Status[],
@@ -268,6 +294,19 @@ export const useResourceStore = defineStore({
         requestAnswer,
       ).then(this._updateReviewableResourceVersion);
     },
+    syncResource(
+      resourceVersion: ResourceVersionOut,
+      requestAnswer: UserRequestAnswer,
+    ): Promise<ResourceVersionOut> {
+      return ResourceVersionService.resourceVersionResourceVersionSync(
+        resourceVersion.resource_id,
+        resourceVersion.resource_version_id,
+        requestAnswer,
+      ).then((version) => {
+        delete this.syncRequestMapping[version.resource_version_id];
+        return version;
+      });
+    },
     _updateReviewableResourceVersion(
       version: ResourceVersionOut,
     ): ResourceVersionOut {
diff --git a/src/views/ImprintView.vue b/src/views/ImprintView.vue
index 8e4188c..4ea27c7 100644
--- a/src/views/ImprintView.vue
+++ b/src/views/ImprintView.vue
@@ -1,8 +1,10 @@
 <script setup lang="ts"></script>
 
 <template>
-  <h2>Impressum</h2>
-  <p>TBD</p>
+  <div>
+    <h2>Impressum</h2>
+    <p>TBD</p>
+  </div>
 </template>
 
 <style scoped></style>
diff --git a/src/views/PrivacyPolicyView.vue b/src/views/PrivacyPolicyView.vue
index 3656b66..d6d98a1 100644
--- a/src/views/PrivacyPolicyView.vue
+++ b/src/views/PrivacyPolicyView.vue
@@ -1,8 +1,10 @@
 <script setup lang="ts"></script>
 
 <template>
-  <h2>Privacy Policy</h2>
-  <p>TBD</p>
+  <div>
+    <h2>Privacy Policy</h2>
+    <p>TBD</p>
+  </div>
 </template>
 
 <style scoped></style>
diff --git a/src/views/TermsOfUsageView.vue b/src/views/TermsOfUsageView.vue
index 21503d7..5d6374d 100644
--- a/src/views/TermsOfUsageView.vue
+++ b/src/views/TermsOfUsageView.vue
@@ -1,8 +1,10 @@
 <script setup lang="ts"></script>
 
 <template>
-  <h2>Terms of Usage</h2>
-  <p>TBD</p>
+  <div>
+    <h2>Terms of Usage</h2>
+    <p>TBD</p>
+  </div>
 </template>
 
 <style scoped></style>
diff --git a/src/views/admin/AdminResourcesView.vue b/src/views/admin/AdminResourcesView.vue
index fd2c614..b371e7e 100644
--- a/src/views/admin/AdminResourcesView.vue
+++ b/src/views/admin/AdminResourcesView.vue
@@ -110,17 +110,7 @@ function deleteInS3(resourceVersion: ResourceVersionOut) {
 function syncToCluster(resourceVersion: ResourceVersionOut) {
   resourceState.loading = true;
   resourceRepository
-    .reviewResource(resourceVersion, { deny: false })
-    .then(replaceResourceVersion)
-    .finally(() => {
-      resourceState.loading = false;
-    });
-}
-
-function denyResource(resourceVersion: ResourceVersionOut) {
-  resourceState.loading = true;
-  resourceRepository
-    .reviewResource(resourceVersion, { deny: false, reason: "Whatever" })
+    .syncResource(resourceVersion, { deny: false })
     .then(replaceResourceVersion)
     .finally(() => {
       resourceState.loading = false;
@@ -313,11 +303,26 @@ function resetForm() {
                             Set to Latest
                           </button>
                         </li>
+                        <li v-if="version.status === Status.WAIT_FOR_REVIEW">
+                          <router-link
+                            class="dropdown-item"
+                            :to="{ name: 'resource-review' }"
+                          >
+                            Review Resource
+                          </router-link>
+                        </li>
+                        <li v-if="version.status === Status.SYNC_REQUESTED">
+                          <router-link
+                            class="dropdown-item"
+                            :to="{ name: 'admin-sync-requests' }"
+                          >
+                            Review sync request
+                          </router-link>
+                        </li>
                         <li
                           v-if="
                             version.status === Status.APPROVED ||
-                            version.status === Status.SYNC_ERROR ||
-                            version.status === Status.SYNC_REQUESTED
+                            version.status === Status.SYNC_ERROR
                           "
                         >
                           <button
@@ -331,19 +336,6 @@ function resetForm() {
                             <span class="ms-1">Sync to Cluster</span>
                           </button>
                         </li>
-                        <li v-if="version.status === Status.SYNC_REQUESTED">
-                          <button
-                            class="dropdown-item"
-                            type="button"
-                            @click="denyResource(version)"
-                          >
-                            <font-awesome-icon
-                              icon="fa-solid fa-xmark"
-                              class="text-danger"
-                            />
-                            <span class="ms-1">Deny Synchronization</span>
-                          </button>
-                        </li>
                         <li
                           v-if="
                             version.status === Status.SYNCHRONIZED ||
diff --git a/src/views/admin/AdminSyncRequestsView.vue b/src/views/admin/AdminSyncRequestsView.vue
new file mode 100644
index 0000000..856617d
--- /dev/null
+++ b/src/views/admin/AdminSyncRequestsView.vue
@@ -0,0 +1,266 @@
+<script setup lang="ts">
+import BootstrapToast from "@/components/BootstrapToast.vue";
+import ReasonModal from "@/components/modals/ReasonModal.vue";
+import ResourceVersionInfoModal from "@/components/resources/ResourceVersionInfoModal.vue";
+import { onMounted, reactive } from "vue";
+import {
+  type ResourceOut,
+  type ResourceVersionOut,
+  Status,
+  type UserSynchronizationRequestOut,
+} from "@/client/resource";
+import { useResourceStore } from "@/stores/resources";
+import { useNameStore } from "@/stores/names";
+import { useAuthStore } from "@/stores/users";
+import { Modal, Toast } from "bootstrap";
+import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
+
+const resourceState = reactive<{
+  sendingRequest: boolean;
+  loading: boolean;
+  resources: Record<string, ResourceOut>;
+  inspectResource?: ResourceOut;
+  rejectResource?: ResourceVersionOut;
+  inspectVersionIndex: number;
+}>({
+  sendingRequest: false,
+  loading: true,
+  resources: {},
+  inspectResource: undefined,
+  rejectResource: undefined,
+  inspectVersionIndex: 0,
+});
+
+const resourceRepository = useResourceStore();
+const nameRepository = useNameStore();
+const userRepository = useAuthStore();
+
+let rejectReasonModal: Modal | null = null;
+let successToast: Toast | null = null;
+let rejectToast: Toast | null = null;
+let refreshTimeout: NodeJS.Timeout | undefined = undefined;
+
+function rejectSyncRequest(
+  reason: string,
+  resourceVersion?: ResourceVersionOut,
+) {
+  if (resourceVersion != undefined) {
+    resourceState.sendingRequest = true;
+    resourceRepository
+      .syncResource(resourceVersion, { deny: true, reason })
+      .then(() => {
+        rejectReasonModal?.hide();
+        rejectToast?.show();
+      })
+      .finally(() => {
+        resourceState.sendingRequest = false;
+      });
+  }
+}
+
+function syncVersion(resourceId: string, resourceVersionId: string) {
+  resourceState.sendingRequest = true;
+  resourceRepository
+    .syncResource(
+      {
+        resource_version_id: resourceVersionId,
+        resource_id: resourceId,
+        release: "",
+        created_at: 0,
+        s3_path: "",
+        cluster_path: "",
+        status: Status.SYNC_REQUESTED,
+      },
+      { deny: false },
+    )
+    .then(() => {
+      successToast?.show();
+      delete resourceState.resources[resourceId];
+    })
+    .finally(() => {
+      resourceState.sendingRequest = false;
+    });
+}
+
+function fetchResources(
+  syncRequests: UserSynchronizationRequestOut[],
+): Promise<ResourceOut[]> {
+  return Promise.all(
+    syncRequests.map((request) =>
+      resourceRepository.fetchResource(request.resource_id, [
+        Status.SYNC_REQUESTED,
+      ]),
+    ),
+  );
+}
+
+function fetchUserNames(
+  syncRequests: UserSynchronizationRequestOut[],
+): UserSynchronizationRequestOut[] {
+  userRepository.fetchUsernames(
+    syncRequests.map((request) => request.requester_id),
+  );
+  return syncRequests;
+}
+
+function fetchRequests() {
+  resourceState.loading = true;
+  resourceRepository
+    .fetchSyncRequests(() => {
+      resourceState.loading = false;
+    })
+    .then(fetchUserNames)
+    .then(fetchResources)
+    .then((resources) => {
+      const newMapping: Record<string, ResourceOut> = {};
+      for (const resource of resources) {
+        newMapping[resource.resource_id] = resource;
+      }
+      resourceState.resources = newMapping;
+    });
+}
+
+function clickRefreshRequests() {
+  clearTimeout(refreshTimeout);
+  refreshTimeout = setTimeout(() => {
+    fetchRequests();
+  }, 500);
+}
+
+onMounted(() => {
+  fetchRequests();
+
+  rejectReasonModal = new Modal("#sync-request-reject-modal");
+  successToast = new Toast("#accept-sync-request-toast");
+  rejectToast = new Toast("#reject-sync-request-toast");
+});
+</script>
+
+<template>
+  <bootstrap-toast toast-id="accept-sync-request-toast" color-class="success">
+    Syncing resource to the cluster
+  </bootstrap-toast>
+  <bootstrap-toast toast-id="reject-sync-request-toast" color-class="danger">
+    Rejected resource synchronization request
+  </bootstrap-toast>
+  <resource-version-info-modal
+    modal-id="sync-request-resource-version-info-modal"
+    :resource-version-index="resourceState.inspectVersionIndex"
+    :resource="resourceState.inspectResource"
+  />
+  <reason-modal
+    modal-id="sync-request-reject-modal"
+    modal-label="Resource Synchronization Request Reject Modal"
+    :loading="resourceState.sendingRequest"
+    @save="(reason) => rejectSyncRequest(reason, resourceState.rejectResource)"
+  >
+    <template #header>
+      Reject Resource Synchronization Request
+      <b>{{ resourceState.rejectResource?.release }}</b>
+    </template>
+  </reason-modal>
+  <div
+    class="row border-bottom mb-4 justify-content-between align-items-center"
+  >
+    <h2 class="w-fit">Review resource synchronization requests</h2>
+    <span
+      class="w-fit"
+      tabindex="0"
+      data-bs-title="Refresh Reviewable Resources"
+      data-bs-toggle="tooltip"
+      id="refreshReviewableResourcesButton"
+    >
+      <button
+        type="button"
+        class="btn btn-primary btn-light me-2 shadow-sm border w-fit"
+        :disabled="resourceState.loading || resourceState.sendingRequest"
+        @click="clickRefreshRequests"
+      >
+        <font-awesome-icon icon="fa-solid fa-arrow-rotate-right" />
+        <span class="visually-hidden">Refresh Reviewable Resources</span>
+      </button>
+    </span>
+  </div>
+  <div v-if="resourceState.loading" class="text-center mt-5">
+    <div class="spinner-border" style="width: 3rem; height: 3rem" role="status">
+      <span class="visually-hidden">Loading...</span>
+    </div>
+  </div>
+  <div
+    v-else-if="resourceRepository.syncRequests.length === 0"
+    class="text-center mt-5 fs-4"
+  >
+    There are currently no resource synchronization requests
+  </div>
+  <div v-else class="d-flex flex-column">
+    <div
+      class="border p-2 pb-0 rounded mb-2 d-flex"
+      v-for="request in resourceRepository.syncRequests"
+      :key="request.resource_version_id"
+    >
+      <div class="flex-grow-1">
+        <h6>
+          {{ nameRepository.getName(request.resource_id) }}@{{
+            nameRepository.getName(request.resource_version_id)
+          }}
+        </h6>
+        <div>
+          <b>Requester</b>: {{ nameRepository.getName(request.requester_id) }}
+        </div>
+        <div><b>Reason</b>:</div>
+        <p>{{ request.reason }}</p>
+      </div>
+      <div class="d-flex flex-column justify-content-evenly align-items-center">
+        <button
+          type="button"
+          class="btn btn-secondary btn-sm"
+          data-bs-toggle="modal"
+          data-bs-target="#sync-request-resource-version-info-modal"
+          @click="
+            resourceState.inspectResource =
+              resourceState.resources[request.resource_id];
+            resourceState.inspectVersionIndex = resourceState.resources[
+              request.resource_id
+            ].versions.findIndex(
+              (version) =>
+                version.resource_version_id === request.resource_version_id,
+            );
+          "
+        >
+          Inspect Resource
+        </button>
+        <div class="btn-group">
+          <button
+            type="button"
+            class="btn btn-success btn-sm"
+            :disabled="resourceState.sendingRequest"
+            @click="
+              syncVersion(request.resource_id, request.resource_version_id)
+            "
+          >
+            Accept
+          </button>
+          <button
+            type="button"
+            class="btn btn-danger btn-sm"
+            @click="
+              resourceState.rejectResource = resourceState.resources[
+                request.resource_id
+              ].versions.find(
+                (version) =>
+                  version.resource_version_id === request.resource_version_id,
+              )
+            "
+            data-bs-toggle="modal"
+            data-bs-target="#sync-request-reject-modal"
+            :disabled="resourceState.sendingRequest"
+          >
+            Reject
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped></style>
-- 
GitLab