From 3ed5326ea18fd97ac2420a43f3393cba3c126a0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Tue, 7 Mar 2023 17:02:27 +0100
Subject: [PATCH] Create page to list all workflows of a developer

#43
---
 src/App.vue                                   |   5 +
 .../workflow/services/WorkflowService.ts      |   5 +-
 src/components/NavbarTop.vue                  |  14 ++
 src/components/modals/SearchUserModal.vue     |   2 +-
 .../workflows/WorkflowWithVersionsCard.vue    | 130 ++++++++++++++++++
 src/router/index.ts                           |   6 +
 src/stores/auth.ts                            |  53 ++++++-
 src/stores/buckets.ts                         |   6 +-
 src/stores/workflows.ts                       |  12 +-
 src/views/object-storage/S3KeysView.vue       |  14 +-
 src/views/workflows/MyWorkflowsView.vue       |  63 +++++++++
 src/views/workflows/WorkflowView.vue          |  91 +++++++++---
 12 files changed, 364 insertions(+), 37 deletions(-)
 create mode 100644 src/components/workflows/WorkflowWithVersionsCard.vue
 create mode 100644 src/views/workflows/MyWorkflowsView.vue

diff --git a/src/App.vue b/src/App.vue
index 405ef89..bd04244 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -27,6 +27,11 @@ onBeforeMount(() => {
     ) {
       // redirect the user to the login page and preserve query params for login error message
       return { name: "login", query: to.query };
+    } else if (
+      to.meta.requiresDeveloperRole &&
+      !(store.workflowDev || store.admin)
+    ) {
+      return { name: "dashboard" };
     }
   });
 });
diff --git a/src/client/workflow/services/WorkflowService.ts b/src/client/workflow/services/WorkflowService.ts
index ba3fe73..38cff14 100644
--- a/src/client/workflow/services/WorkflowService.ts
+++ b/src/client/workflow/services/WorkflowService.ts
@@ -20,13 +20,15 @@ export class WorkflowService {
      *
      * Permission "workflow:list" required.
      * @param nameSubstring Filter workflows by a substring in their name.
-     * @param versionStatus Which versions of the workflow to include in the response. Permission 'workflow:list_filter required'. Default PUBLISHED and DEPRECATED.
+     * @param versionStatus Which versions of the workflow to include in the response. Permission 'workflow:list_filter required', unless 'developer_id' is provided and current user is developer, then only permission 'workflow:list' required. Default PUBLISHED and DEPRECATED.
+     * @param developerId Filter for workflow by developer. If current user is the same as developer ID, permission 'workflow:list' required, otherwise 'workflow:list_filter'.
      * @returns WorkflowOut Successful Response
      * @throws ApiError
      */
     public static workflowListWorkflows(
         nameSubstring?: string,
         versionStatus?: Array<Status>,
+        developerId?: string,
     ): CancelablePromise<Array<WorkflowOut>> {
         return __request(OpenAPI, {
             method: 'GET',
@@ -34,6 +36,7 @@ export class WorkflowService {
             query: {
                 'name_substring': nameSubstring,
                 'version_status': versionStatus,
+                'developer_id': developerId,
             },
             errors: {
                 400: `Error decoding JWT Token`,
diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue
index 94a91a0..2c68c63 100644
--- a/src/components/NavbarTop.vue
+++ b/src/components/NavbarTop.vue
@@ -123,6 +123,20 @@ watch(
               <li>
                 <a class="dropdown-item" href="#">Executions</a>
               </li>
+              <li
+                v-if="
+                  store.workflowDev || store.workflowReviewer || store.admin
+                "
+              >
+                <hr class="dropdown-divider" />
+              </li>
+              <li v-if="store.workflowDev || store.admin">
+                <router-link
+                  class="dropdown-item"
+                  :to="{ name: 'workflows-developer' }"
+                  >My Workflows</router-link
+                >
+              </li>
             </ul>
           </li>
         </ul>
diff --git a/src/components/modals/SearchUserModal.vue b/src/components/modals/SearchUserModal.vue
index 658f095..9fb35d4 100644
--- a/src/components/modals/SearchUserModal.vue
+++ b/src/components/modals/SearchUserModal.vue
@@ -58,7 +58,7 @@ function searchUser(name: string) {
   UserService.userListUsers(name)
     .then((userSuggestions) => {
       formState.potentialUsers = userSuggestions.filter(
-        (user) => store.user?.uid != user.uid
+        (user) => store.currentUID != user.uid
       );
     })
     .catch((err) => {
diff --git a/src/components/workflows/WorkflowWithVersionsCard.vue b/src/components/workflows/WorkflowWithVersionsCard.vue
new file mode 100644
index 0000000..bd423d0
--- /dev/null
+++ b/src/components/workflows/WorkflowWithVersionsCard.vue
@@ -0,0 +1,130 @@
+<script setup lang="ts">
+import type { WorkflowOut } from "@/client/workflow";
+import { ref } from "vue";
+import type { Ref } from "vue";
+import { Status } from "@/client/workflow";
+import BootstrapIcon from "@/components/BootstrapIcon.vue";
+import dayjs from "dayjs";
+
+const props = defineProps<{
+  workflow: WorkflowOut;
+  loading: boolean;
+}>();
+const truncateDescription: Ref<boolean> = ref(true);
+
+const statusToIconMapping: Record<string, string> = {
+  PUBLISHED: "check-circle-fill",
+  DENIED: "x-lg",
+  CREATED: "check-circle",
+  DEPRECATED: "archive-fill",
+};
+</script>
+
+<template>
+  <div class="card-hover border border-secondary card text-bg-dark m-2">
+    <div class="card-body">
+      <div
+        class="card-title fs-3 d-flex justify-content-between align-items-center"
+      >
+        <div v-if="props.loading" class="placeholder-glow w-100">
+          <span class="placeholder col-6"></span>
+        </div>
+        <span v-else class="text-truncate">{{ props.workflow.name }}</span>
+        <button
+          type="button"
+          class="btn btn-success"
+          :class="{ disabled: props.loading, placeholder: props.loading }"
+        >
+          Update
+        </button>
+      </div>
+      <p class="card-text" :class="{ 'text-truncate': truncateDescription }">
+        <span v-if="props.loading" class="placeholder-glow"
+          ><span class="placeholder col-12"></span
+        ></span>
+        <span
+          v-else
+          @click="truncateDescription = false"
+          :class="{
+            'cursor-pointer': truncateDescription,
+          }"
+          >{{ props.workflow.short_description }}</span
+        >
+      </p>
+      <div>
+        <div v-if="props.loading" class="row placeholder-glow p-3">
+          <span class="placeholder col-md-1"></span>
+          <span class="placeholder col-md-2 offset-md-1 bg-success"></span>
+          <span class="placeholder col-md-2 offset-md-3"></span>
+          <span class="placeholder col-md-1 offset-md-1 bg-primary"></span>
+        </div>
+        <div v-else>
+          <table class="table table-dark table-sm table-hover">
+            <tbody>
+              <tr
+                v-for="version in [...props.workflow.versions].sort((a, b) =>
+                  dayjs(a.created_at).isBefore(b.created_at) ? 1 : -1
+                )"
+                :key="version.git_commit_hash"
+              >
+                <td class="w-fit">
+                  <img
+                    v-if="version.icon_url != null"
+                    :src="version.icon_url"
+                  />
+                </td>
+                <th scope="row" class="fw-bold">{{ version.version }}</th>
+                <td
+                  :class="{
+                    'text-success': version.status === Status.PUBLISHED,
+                    'text-danger': version.status === Status.DENIED,
+                    'text-secondary': version.status === Status.CREATED,
+                    'text-warning': version.status === Status.DEPRECATED,
+                  }"
+                >
+                  <bootstrap-icon :icon="statusToIconMapping[version.status]" />
+                  {{ version.status }}
+                </td>
+                <td>{{ dayjs(version.created_at).format("DD.MM.YYYY") }}</td>
+                <td>
+                  <router-link
+                    class="w-fit mx-0"
+                    :to="{
+                      name: 'workflow-version',
+                      params: {
+                        workflowId: props.workflow.workflow_id,
+                        versionId: version.git_commit_hash,
+                      },
+                    }"
+                    >View</router-link
+                  >
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.card-hover {
+  transition: transform 0.3s ease-out;
+}
+
+@media (min-width: 992px) {
+  .card-hover {
+    transition: transform 0.3s ease-out;
+    width: 48%;
+  }
+}
+
+.card-hover:hover {
+  transform: translate(0, -5px);
+}
+td > img {
+  max-width: 1em;
+  max-height: 1em;
+}
+</style>
diff --git a/src/router/index.ts b/src/router/index.ts
index f98facb..28865df 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -33,6 +33,12 @@ const router = createRouter({
           name: "workflows",
           component: () => import("../views/workflows/ListWorkflowsView.vue"),
         },
+        {
+          path: "developer/workflows",
+          name: "workflows-developer",
+          component: () => import("../views/workflows/MyWorkflowsView.vue"),
+          meta: { requiresDeveloperRole: true },
+        },
         {
           path: "workflows/:workflowId/",
           name: "workflow",
diff --git a/src/stores/auth.ts b/src/stores/auth.ts
index 3926c95..6c52e59 100644
--- a/src/stores/auth.ts
+++ b/src/stores/auth.ts
@@ -7,39 +7,79 @@ import { OpenAPI as S3ProxyOpenAPI } from "@/client/s3proxy";
 import { OpenAPI as AuthOpenAPI } from "@/client/auth";
 import { OpenAPI as WorkflowOpenAPI } from "@/client/workflow";
 
+type DecodedToken = {
+  exp: number;
+  iss: string;
+  roles: RoleEnum[];
+  sub: string;
+};
+
 export type RootState = {
   token: string | null;
+  decodedToken: DecodedToken | null;
   user: User | null;
   s3key: S3Key | null;
 };
 
+function parseJwt(token: string): DecodedToken {
+  const base64Url = token.split(".")[1];
+  const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
+  const jsonPayload = decodeURIComponent(
+    window
+      .atob(base64)
+      .split("")
+      .map(function (c) {
+        return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
+      })
+      .join("")
+  );
+
+  return JSON.parse(jsonPayload) as DecodedToken;
+}
+
 export const useAuthStore = defineStore({
   id: "auth",
   state: () =>
     ({
       token: null,
+      decodedToken: null,
       user: null,
       s3key: null,
     } as RootState),
   getters: {
     authenticated: (state) => state.token != null,
+    currentUID(): string {
+      return this.decodedToken?.["sub"] ?? "";
+    },
     foreignUser: (state) =>
-      state.user?.roles?.includes(RoleEnum.FOREIGN_USER) ?? true,
-    normalUser: (state) => state.user?.roles?.includes(RoleEnum.USER) ?? false,
+      state.user?.roles?.includes(RoleEnum.FOREIGN_USER) ??
+      state.decodedToken?.roles.includes(RoleEnum.FOREIGN_USER) ??
+      true,
+    normalUser: (state) =>
+      state.user?.roles?.includes(RoleEnum.USER) ??
+      state.decodedToken?.roles.includes(RoleEnum.USER) ??
+      false,
     workflowReviewer: (state) =>
-      state.user?.roles?.includes(RoleEnum.REVIEWER) ?? false,
+      state.user?.roles?.includes(RoleEnum.REVIEWER) ??
+      state.decodedToken?.roles.includes(RoleEnum.REVIEWER) ??
+      false,
     workflowDev: (state) =>
-      state.user?.roles?.includes(RoleEnum.DEVELOPER) ?? false,
+      state.user?.roles?.includes(RoleEnum.DEVELOPER) ??
+      state.decodedToken?.roles.includes(RoleEnum.DEVELOPER) ??
+      false,
     admin: (state) =>
-      state.user?.roles?.includes(RoleEnum.ADMINISTRATOR) ?? false,
+      state.user?.roles?.includes(RoleEnum.ADMINISTRATOR) ??
+      state.decodedToken?.roles.includes(RoleEnum.ADMINISTRATOR) ??
+      false,
   },
   actions: {
     setToken(token: string | null) {
       if (token != null) {
+        this.token = token;
+        this.decodedToken = parseJwt(token);
         S3ProxyOpenAPI.TOKEN = token;
         AuthOpenAPI.TOKEN = token;
         WorkflowOpenAPI.TOKEN = token;
-        this.token = token;
         UserService.userGetLoggedInUser()
           .then((user) => {
             this.updateUser(user);
@@ -49,6 +89,7 @@ export const useAuthStore = defineStore({
           });
       } else {
         this.token = null;
+        this.decodedToken = null;
         this.user = null;
       }
     },
diff --git a/src/stores/buckets.ts b/src/stores/buckets.ts
index b12c2be..e3ab60b 100644
--- a/src/stores/buckets.ts
+++ b/src/stores/buckets.ts
@@ -103,9 +103,9 @@ export const useBucketStore = defineStore({
       onFinally: (() => void) | null | undefined = null
     ) {
       const authStore = useAuthStore();
-      if (authStore.user != null && !authStore.foreignUser) {
+      if (authStore.authenticated && !authStore.foreignUser) {
         BucketPermissionService.bucketPermissionListPermissionsPerUser(
-          authStore.user.uid
+          authStore.currentUID
         )
           .then((permissions) => {
             const new_permissions: Record<string, BucketPermissionOut> = {};
@@ -132,7 +132,7 @@ export const useBucketStore = defineStore({
       onFinally: (() => void) | null | undefined = null
     ) {
       const authStore = useAuthStore();
-      BucketService.bucketListBuckets(authStore.user?.uid)
+      BucketService.bucketListBuckets(authStore.currentUID)
         .then((buckets) => {
           this.buckets = buckets;
           onFulfilled?.(buckets);
diff --git a/src/stores/workflows.ts b/src/stores/workflows.ts
index a15e6c1..9a2df34 100644
--- a/src/stores/workflows.ts
+++ b/src/stores/workflows.ts
@@ -2,6 +2,7 @@ import { defineStore } from "pinia";
 import { WorkflowService } from "@/client/workflow";
 import type { WorkflowVersionReduced } from "@/client/workflow";
 import type { WorkflowOut } from "@/client/workflow";
+import dayjs from "dayjs";
 
 export const useWorkflowStore = defineStore({
   id: "workflows",
@@ -19,9 +20,14 @@ export const useWorkflowStore = defineStore({
         const workflow = this.workflows.find(
           (w) => workflowId == w.workflow_id
         );
-        return workflow?.versions[
-          Math.max(workflow?.versions?.length - 1 ?? 0, 0)
-        ];
+        if (workflow == null || workflow.versions.length == 0) {
+          return undefined;
+        }
+        const vs = [...workflow.versions];
+        vs.sort((a, b) =>
+          dayjs(a.created_at).isBefore(b.created_at) ? -1 : 1
+        );
+        return vs[vs.length - 1];
       };
     },
   },
diff --git a/src/views/object-storage/S3KeysView.vue b/src/views/object-storage/S3KeysView.vue
index 4c09afb..3d0e964 100644
--- a/src/views/object-storage/S3KeysView.vue
+++ b/src/views/object-storage/S3KeysView.vue
@@ -47,8 +47,8 @@ function refreshKeys(uid: string) {
 }
 
 function deleteKey(accessKey: string) {
-  if (allowKeyDeletion.value && authStore.user != null) {
-    S3KeyService.s3KeyDeleteUserKey(accessKey, authStore.user.uid)
+  if (allowKeyDeletion.value && authStore.authenticated) {
+    S3KeyService.s3KeyDeleteUserKey(accessKey, authStore.currentUID)
       .then(() => {
         keyState.deletedKey = accessKey;
         keyState.activeKey = 0;
@@ -63,8 +63,8 @@ function deleteKey(accessKey: string) {
 }
 
 function createKey() {
-  if (authStore.user != null) {
-    S3KeyService.s3KeyCreateUserKey(authStore.user.uid)
+  if (authStore.authenticated) {
+    S3KeyService.s3KeyCreateUserKey(authStore.currentUID)
       .then((s3key) => {
         keyState.keys.push(s3key);
         keyState.keys = [...keyState.keys].sort((keyA, keyB) =>
@@ -77,8 +77,8 @@ function createKey() {
 
 onMounted(() => {
   successToast = new Toast("#successKeyToast");
-  if (authStore.user != null) {
-    refreshKeys(authStore.user.uid);
+  if (authStore.authenticated) {
+    refreshKeys(authStore.currentUID);
   }
 });
 </script>
@@ -116,7 +116,7 @@ onMounted(() => {
         <button
           type="button"
           class="btn btn-light"
-          @click="refreshKeys(authStore.user?.uid ?? 'impossible')"
+          @click="refreshKeys(authStore.currentUID)"
         >
           <bootstrap-icon icon="arrow-clockwise" class="fs-5" />
           <span class="visually-hidden">Refresh S3 Keys</span>
diff --git a/src/views/workflows/MyWorkflowsView.vue b/src/views/workflows/MyWorkflowsView.vue
new file mode 100644
index 0000000..7f091d3
--- /dev/null
+++ b/src/views/workflows/MyWorkflowsView.vue
@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import { onMounted, reactive } from "vue";
+import { Status, WorkflowService } from "@/client/workflow";
+import type { WorkflowOut } from "@/client/workflow";
+import { useAuthStore } from "@/stores/auth";
+import WorkflowWithVersionsCard from "@/components/workflows/WorkflowWithVersionsCard.vue";
+
+const userRepository = useAuthStore();
+const workflowsState = reactive<{
+  workflows: WorkflowOut[];
+  loading: boolean;
+}>({
+  workflows: [],
+  loading: true,
+});
+
+onMounted(() => {
+  WorkflowService.workflowListWorkflows(
+    undefined,
+    Object.values(Status),
+    userRepository.currentUID
+  )
+    .then((workflows) => {
+      workflowsState.workflows = workflows;
+    })
+    .finally(() => {
+      workflowsState.loading = false;
+    });
+});
+</script>
+
+<template>
+  <h1 class="mt-5">My Workflows</h1>
+  <div
+    v-if="!workflowsState.loading"
+    class="d-flex flex-wrap align-items-center justify-content-between mt-5"
+  >
+    <workflow-with-versions-card
+      v-for="workflow in workflowsState.workflows"
+      :key="workflow.workflow_id"
+      :workflow="workflow"
+      :loading="false"
+    />
+  </div>
+  <div
+    v-else
+    class="d-flex flex-wrap align-items-center justify-content-between mt-5"
+  >
+    <workflow-with-versions-card
+      v-for="n in 4"
+      :key="n"
+      :workflow="{
+        workflow_id: '',
+        versions: [],
+        name: '',
+        repository_url: '',
+        short_description: '',
+      }"
+      loading
+    />
+  </div>
+</template>
+<style scoped></style>
diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue
index a13beb3..bf1eaeb 100644
--- a/src/views/workflows/WorkflowView.vue
+++ b/src/views/workflows/WorkflowView.vue
@@ -1,13 +1,15 @@
 <script setup lang="ts">
-import { computed, onMounted, reactive, watch } from "vue";
 import type { ComputedRef } from "vue";
-import type { WorkflowOut } from "@/client/workflow";
-import { WorkflowService } from "@/client/workflow";
+import { computed, onMounted, reactive, watch } from "vue";
+import type { WorkflowOut, WorkflowVersionReduced } from "@/client/workflow";
+import { Status, WorkflowService } from "@/client/workflow";
 import { useRoute, useRouter } from "vue-router";
 import BootstrapIcon from "@/components/BootstrapIcon.vue";
+import dayjs from "dayjs";
 
 const props = defineProps<{
   workflowId: string;
+  versionId: string | undefined;
 }>();
 const router = useRouter();
 const route = useRoute();
@@ -16,10 +18,12 @@ const workflowState = reactive({
   loading: true,
   workflow: undefined,
   activeVersionId: "",
+  initialOpen: true,
 } as {
   loading: boolean;
   workflow?: WorkflowOut;
   activeVersionId: string;
+  initialOpen: boolean;
 });
 
 watch(
@@ -31,6 +35,13 @@ watch(
   }
 );
 
+watch(
+  () => props.versionId,
+  (newWorkflowId) => {
+    workflowState.activeVersionId = newWorkflowId ?? "";
+  }
+);
+
 watch(
   () => workflowState.activeVersionId,
   (newVersionId, oldVersionId) => {
@@ -44,35 +55,60 @@ watch(
   }
 );
 
+function calculateLatestVersion(
+  versions: WorkflowVersionReduced[] | undefined
+): WorkflowVersionReduced | undefined {
+  if (versions == undefined || versions.length == 0) {
+    return undefined;
+  }
+  const vs = [...versions];
+  vs.sort((a, b) => (dayjs(a.created_at).isBefore(b.created_at) ? -1 : 1));
+  return vs[versions.length - 1];
+}
+
+const latestVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed(
+  () => calculateLatestVersion(workflowState.workflow?.versions)
+);
+
 function updateWorkflow(workflowId: string) {
   workflowState.loading = true;
   WorkflowService.workflowGetWorkflow(workflowId)
     .then((workflow) => {
       workflowState.workflow = workflow;
-      workflowState.activeVersionId =
-        workflow.versions[workflow.versions.length - 1].git_commit_hash;
+      if (!workflowState.initialOpen) {
+        workflowState.activeVersionId =
+          workflow.versions[workflow.versions.length - 1].git_commit_hash;
+      } else {
+        workflowState.activeVersionId = route.params.versionId as string;
+      }
     })
     .catch(() => {
       workflowState.workflow = undefined;
     })
     .finally(() => {
       workflowState.loading = false;
+      workflowState.initialOpen = false;
     });
 }
 
-const activeVersionString: ComputedRef<string> = computed(() => {
-  return (
+const activeVersion: ComputedRef<WorkflowVersionReduced | undefined> = computed(
+  () =>
     workflowState.workflow?.versions.find(
       (w) => w.git_commit_hash === workflowState.activeVersionId
-    )?.version ?? ""
-  );
-});
+    )
+);
 
-const activeVersionIcon: ComputedRef<string | undefined> = computed(() => {
-  return workflowState.workflow?.versions.find(
-    (w) => w.git_commit_hash === workflowState.activeVersionId
-  )?.icon_url;
-});
+const activeVersionString: ComputedRef<string> = computed(
+  () => activeVersion.value?.version ?? ""
+);
+
+const activeVersionIcon: ComputedRef<string | undefined> = computed(
+  () => activeVersion.value?.icon_url
+);
+
+const versionLaunchable: ComputedRef<boolean> = computed(
+  () => activeVersion.value?.status == Status.PUBLISHED ?? false
+);
 
 onMounted(() => {
   updateWorkflow(props.workflowId);
@@ -110,8 +146,31 @@ onMounted(() => {
       /></a>
     </div>
     <p class="fs-4 mb-5 mt-3">{{ workflowState.workflow.short_description }}</p>
+    <div
+      v-if="!versionLaunchable"
+      class="alert alert-warning w-fit mx-auto"
+      role="alert"
+    >
+      This version can not be used.
+      <router-link
+        v-if="latestVersion != null"
+        class="alert-link"
+        :to="{
+          name: 'workflow-version',
+          params: {
+            versionId: latestVersion.git_commit_hash,
+          },
+        }"
+        >Try the latest version {{ latestVersion?.version }}.</router-link
+      >
+    </div>
     <div class="row align-items-center">
-      <a role="button" class="btn btn-success btn-lg w-fit mx-auto" href="#">
+      <a
+        role="button"
+        class="btn btn-success btn-lg w-fit mx-auto"
+        :class="{ disabled: !versionLaunchable }"
+        href="#"
+      >
         <bootstrap-icon icon="rocket-takeoff-fill" class="me-2" />
         <span class="align-middle">Launch {{ activeVersionString }}</span>
       </a>
-- 
GitLab