From 1c68f0a82975def56c3fea4249d77f385a581ab1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Wed, 22 May 2024 12:21:01 +0000
Subject: [PATCH] Add modal to register new users

#119
---
 .gitlab-ci.yml                           |   2 +-
 src/components/AppHeader.vue             |  10 +-
 src/components/admin/CreateUserModal.vue | 171 +++++++++++++++++++++++
 src/stores/users.ts                      |   8 +-
 src/views/admin/AdminUsersView.vue       |  14 +-
 5 files changed, 194 insertions(+), 11 deletions(-)
 create mode 100644 src/components/admin/CreateUserModal.vue

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f340faa..4527b2d 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -37,7 +37,7 @@ build:
 .build-container-job:
   stage: deploy
   image:
-    name: gcr.io/kaniko-project/executor:v1.21.1-debug
+    name: gcr.io/kaniko-project/executor:v1.23.0-debug
     entrypoint: [ "" ]
   dependencies: [ ]
   cache: [ ]
diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue
index 78d7f01..e879949 100644
--- a/src/components/AppHeader.vue
+++ b/src/components/AppHeader.vue
@@ -2,7 +2,6 @@
 import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
 import { useUserStore } from "@/stores/users";
 import { useRoute } from "vue-router";
-import { useCookies } from "vue3-cookies";
 import { watch, ref, computed } from "vue";
 import BootstrapModal from "@/components/modals/BootstrapModal.vue";
 import { OpenAPI } from "@/client";
@@ -10,12 +9,10 @@ import CopyToClipboardIcon from "@/components/CopyToClipboardIcon.vue";
 import dayjs from "dayjs";
 
 const userRepository = useUserStore();
-const { cookies } = useCookies();
 const route = useRoute();
 
 function logout() {
   userRepository.logout();
-  cookies.remove("bearer");
 }
 
 const activeRoute = ref("");
@@ -299,13 +296,12 @@ watch(
             <hr class="dropdown-divider" />
           </li>
           <li>
-            <router-link
-              :to="{ name: 'login' }"
+            <a
               class="dropdown-item"
               @click="logout"
+              :href="OpenAPI.BASE + '/auth/logout'"
+              >Sign out</a
             >
-              Sign out
-            </router-link>
           </li>
         </ul>
       </div>
diff --git a/src/components/admin/CreateUserModal.vue b/src/components/admin/CreateUserModal.vue
new file mode 100644
index 0000000..3556d8d
--- /dev/null
+++ b/src/components/admin/CreateUserModal.vue
@@ -0,0 +1,171 @@
+<script setup lang="ts">
+import { RoleEnum, type UserIn, type UserOutExtended } from "@/client";
+import { onMounted, reactive, ref } from "vue";
+import { Modal, Toast } from "bootstrap";
+import BootstrapModal from "@/components/modals/BootstrapModal.vue";
+import { useUserStore } from "@/stores/users";
+import BootstrapToast from "@/components/BootstrapToast.vue";
+
+let modal: Modal | null = null;
+const createUserForm = ref<HTMLFormElement | undefined>(undefined);
+let successToast: Toast | undefined;
+let errorToast: Toast | undefined;
+
+const userRepository = useUserStore();
+
+const formState = reactive<{
+  loading: boolean;
+  user: UserIn;
+  errorMessage?: string;
+  registeredUserName: string;
+  validated: boolean;
+}>({
+  loading: false,
+  errorMessage: "",
+  validated: false,
+  registeredUserName: "",
+  user: {
+    display_name: "",
+    email: "",
+    roles: [RoleEnum.USER],
+  },
+});
+
+const props = defineProps<{
+  modalId: string;
+}>();
+
+const emit = defineEmits<{
+  (e: "user-created", user: UserOutExtended): void;
+}>();
+
+function createUser() {
+  formState.validated = true;
+  if (createUserForm.value?.checkValidity()) {
+    formState.loading = true;
+    formState.registeredUserName = formState.user.display_name;
+    userRepository
+      .createUser(formState.user)
+      .then((user) => {
+        emit("user-created", user);
+        formState.validated = false;
+        formState.user = {
+          display_name: "",
+          email: "",
+          roles: [RoleEnum.USER],
+        };
+        successToast?.show();
+        modal?.hide();
+      })
+      .catch((err) => {
+        formState.errorMessage = err.body?.detail;
+        errorToast?.show();
+      })
+      .finally(() => {
+        formState.loading = false;
+      });
+  }
+}
+
+onMounted(() => {
+  modal = Modal.getOrCreateInstance(`#${props.modalId}`);
+  successToast = new Toast("#create-user-success-toast");
+  errorToast = new Toast("#create-user-error-toast");
+});
+</script>
+
+<template>
+  <bootstrap-toast toast-id="create-user-success-toast">
+    Successfully registered user {{ formState.registeredUserName }}
+  </bootstrap-toast>
+  <bootstrap-toast toast-id="create-user-error-toast" color-class="danger">
+    <template #default
+      >Couldn't regsiter user
+      {{ formState.registeredUserName }}
+    </template>
+    <template #body>Error: {{ formState.errorMessage }}</template>
+  </bootstrap-toast>
+  <bootstrap-modal
+    :modalId="props.modalId"
+    :static-backdrop="true"
+    modal-label="Create user"
+  >
+    <template #header>Register a user</template>
+    <template #body>
+      <form
+        id="create-user-form"
+        @submit.prevent="createUser()"
+        :class="{ 'was-validated': formState.validated }"
+        ref="createUserForm"
+        novalidate
+      >
+        <div class="mb-3">
+          <label for="create-user-name" class="form-label">Name</label>
+          <input
+            type="text"
+            class="form-control"
+            id="create-user-name"
+            minlength="3"
+            maxlength="256"
+            required
+            placeholder="John Doe"
+            v-model="formState.user.display_name"
+          />
+          <div class="invalid-feedback">Please choose a name.</div>
+        </div>
+        <div class="mb-3">
+          <label for="create-user-email" class="form-label"
+            >Email address</label
+          >
+          <input
+            type="email"
+            class="form-control"
+            id="create-user-email"
+            required
+            placeholder="name@example.com"
+            minlength="3"
+            maxlength="256"
+            v-model="formState.user.email"
+          />
+          <div class="invalid-feedback">
+            Please provide a valid email address.
+          </div>
+        </div>
+        <div class="mb-3">
+          <div class="mb-1">Roles:</div>
+          <div
+            class="form-check"
+            v-for="role in Object.values(RoleEnum)"
+            :key="role"
+          >
+            <input
+              class="form-check-input"
+              type="checkbox"
+              :value="role"
+              :id="`create-user-role-${role}`"
+              v-model="formState.user.roles"
+            />
+            <label class="form-check-label" :for="`create-user-role-${role}`">
+              {{ role.toUpperCase() }}
+            </label>
+          </div>
+        </div>
+      </form>
+    </template>
+    <template #footer>
+      <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
+        Close
+      </button>
+      <button
+        type="submit"
+        form="create-user-form"
+        class="btn btn-primary"
+        :disabled="formState.loading"
+      >
+        Save
+      </button>
+    </template>
+  </bootstrap-modal>
+</template>
+
+<style scoped></style>
diff --git a/src/stores/users.ts b/src/stores/users.ts
index 8c4ca11..6cb49f2 100644
--- a/src/stores/users.ts
+++ b/src/stores/users.ts
@@ -1,5 +1,5 @@
 import { defineStore } from "pinia";
-import type { UserOutExtended, UserOut } from "@/client";
+import type { UserOutExtended, UserOut, UserIn } from "@/client";
 import { OpenAPI, UserService, RoleEnum } from "@/client";
 import { useWorkflowExecutionStore } from "@/stores/workflowExecutions";
 import { useBucketStore } from "@/stores/buckets";
@@ -113,6 +113,12 @@ export const useUserStore = defineStore({
         roles: roles,
       });
     },
+    createUser(userIn: UserIn): Promise<UserOutExtended> {
+      return UserService.userCreateUser(userIn).then((user) => {
+        useNameStore().addNameToMapping(user.uid, user.display_name);
+        return user;
+      });
+    },
     searchUser(searchString: string): Promise<UserOut[]> {
       return UserService.userSearchUsers(searchString).then((users) => {
         const nameStore = useNameStore();
diff --git a/src/views/admin/AdminUsersView.vue b/src/views/admin/AdminUsersView.vue
index 9dde3ff..e390a01 100644
--- a/src/views/admin/AdminUsersView.vue
+++ b/src/views/admin/AdminUsersView.vue
@@ -5,6 +5,7 @@ import { RoleEnum, type UserOutExtended } from "@/client";
 import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
 import BootstrapToast from "@/components/BootstrapToast.vue";
 import { Toast } from "bootstrap";
+import CreateUserModal from "@/components/admin/CreateUserModal.vue";
 
 const userRepository = useUserStore();
 type RoleList = RoleEnum[];
@@ -96,10 +97,19 @@ onMounted(() => {
     </template>
     <template #body>Error: {{ userState.errorMessage }}</template>
   </bootstrap-toast>
+  <create-user-modal modal-id="create-user-modal" />
   <div
-    class="row border-bottom mb-4 justify-content-between align-items-center"
+    class="row border-bottom mb-4 justify-content-between align-items-center pb-2 pe-2"
   >
-    <h2>Manage Users</h2>
+    <h2 class="w-fit">Manage Users</h2>
+    <button
+      type="button"
+      class="btn btn-primary w-fit"
+      data-bs-toggle="modal"
+      data-bs-target="#create-user-modal"
+    >
+      Register User
+    </button>
   </div>
   <form @submit.prevent="searchUsers" id="admin-user-search-form">
     <div class="d-flex justify-content-evenly align-content-center">
-- 
GitLab