From 68faa55f80c9ed6b0daba0a84791ad12a42a2f3a Mon Sep 17 00:00:00 2001
From: Petar Hristov <hristov@itc.rwth-aachen.de>
Date: Thu, 5 May 2022 11:56:37 +0200
Subject: [PATCH] New: Migrated Support Admin page to UIv2

---
 .eslintrc.js                                  |   3 +-
 src/App.vue                                   |  10 +-
 src/modules/admin/AdminModule.vue             |   9 +-
 src/modules/admin/components/QuotaTable.vue   | 271 ++++++++++++++++++
 src/modules/admin/i18n/de.ts                  |  57 +++-
 src/modules/admin/i18n/en.ts                  |  57 +++-
 src/modules/admin/pages/Admin.vue             | 107 ++++++-
 src/modules/admin/routes.ts                   |   4 +-
 src/modules/admin/store.ts                    |  49 +++-
 src/modules/admin/types.ts                    |  17 +-
 src/modules/project/ProjectModule.vue         |   2 +
 src/modules/project/i18n/de.ts                |  14 +-
 src/modules/project/i18n/en.ts                |  14 +-
 src/modules/project/pages/CreateProject.vue   |  10 +-
 src/modules/project/pages/Quota.vue           |   7 -
 src/modules/project/pages/Settings.vue        |  10 +-
 .../pages/components/modals/DeleteModal.vue   |  40 ++-
 .../modals/InvitationPendingModal.vue         |  26 +-
 .../components/modals/InviteUserModal.vue     |  41 +--
 src/modules/user/pages/UserProfile.vue        |   2 +-
 .../user/pages/components/AccessToken.vue     |   7 -
 yarn.lock-workspace                           |  57 +++-
 22 files changed, 647 insertions(+), 167 deletions(-)
 create mode 100644 src/modules/admin/components/QuotaTable.vue

diff --git a/.eslintrc.js b/.eslintrc.js
index ba46a82a..5fea712a 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -24,7 +24,8 @@ module.exports = {
         "error",
         { "allowWholeFile": true }
       ],
-    "@typescript-eslint/no-empty-interface": 1, // empty Interfaces will be only warnings for now.
+    // ToDo: REMOVE ONCE error AND pid MODULE'S STORE STATES ARE IMPLEMENTED
+    "@typescript-eslint/no-empty-interface": 0, // Empty Interfaces error/warning will be ignored for now. 
     "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // will only ignore variables that start with an underscore _
     "vue/multi-word-component-names": "off"
   },
diff --git a/src/App.vue b/src/App.vue
index 6d591d9f..318fd7e2 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -75,4 +75,12 @@ export default defineComponent({
 });
 </script>
 
-<style scoped></style>
+<style>
+.h-divider {
+  margin-top: 5px;
+  margin-bottom: 10px;
+  height: 1px;
+  width: 100%;
+  border-top: 1px solid #bebbbb;
+}
+</style>
diff --git a/src/modules/admin/AdminModule.vue b/src/modules/admin/AdminModule.vue
index b5893237..9ac2177a 100644
--- a/src/modules/admin/AdminModule.vue
+++ b/src/modules/admin/AdminModule.vue
@@ -1,6 +1,6 @@
 <template>
   <div>
-    <router-view v-if="moduleIsReady" />
+    <router-view v-if="true" />
   </div>
 </template>
 
@@ -22,7 +22,7 @@ export default defineComponent({
 
   computed: {
     moduleIsReady(): boolean {
-      return true;
+      return this.adminStore.project !== null;
     },
   },
 
@@ -32,8 +32,9 @@ export default defineComponent({
 
   methods: {
     async initialize() {
-      // do initialization stuff (e.g. API calls, element loading, etc.)
-      // ...
+      // await Promise.all([
+      //   //this.adminStore.retrieveProjectQuotas(this.adminStore.project.projectGuid),
+      // ])
     },
   },
 });
diff --git a/src/modules/admin/components/QuotaTable.vue b/src/modules/admin/components/QuotaTable.vue
new file mode 100644
index 00000000..d5bc6e84
--- /dev/null
+++ b/src/modules/admin/components/QuotaTable.vue
@@ -0,0 +1,271 @@
+<template>
+  <div id="quotatable">
+    <CoscineHeadline :headline="$t('page.admin.projectQuotaHeadline')" />
+
+    <!-- Filter Hidden Resources -->
+    <b-form-checkbox
+      v-if="project"
+      v-model="showEnabledResources"
+      class="mt-3 mb-1"
+      switch
+    >
+      {{ $t("page.admin.displayHiddenResources") }}
+    </b-form-checkbox>
+
+    <!-- QuotaTable -->
+    <b-table
+      id="project_quota_table"
+      :fields="headers"
+      :items="filteredProjectQuotas"
+      :busy="!project || isWaitingForResponse"
+      :locale="$i18n.locale"
+      :sort-by.sync="sortBy"
+      :sort-desc="false"
+      :show-empty="true"
+      :empty-text="$t('page.admin.projectNotSelected')"
+      fixed
+      sticky-header="100%"
+      no-border-collapse
+      sort-icon-right
+      striped
+      bordered
+      outlined
+      hover
+      head-variant="dark"
+    >
+      <!-- Quota Table - Resource Type Column -->
+      <template #cell(resourceType)="row">
+        {{
+          row.item.iDisplayName ? row.item.iDisplayName : row.item.resourceType
+        }}
+      </template>
+
+      <!-- Quota Table - Reserved Quota Column -->
+      <template #cell(currentQuota)="row">
+        {{
+          row.item.isQuotaAvailable === false
+            ? $t("default.none")
+            : $t("page.admin.gb", { number: row.item.quota })
+        }}
+      </template>
+
+      <!-- Quota Table - Resource Type Column -->
+      <template #cell(allocatedQuota)="row">
+        {{
+          row.item.isQuotaAvailable === false
+            ? $t("default.none")
+            : $t("page.admin.gb", { number: row.item.allocated })
+        }}
+      </template>
+
+      <!-- Quota Table - New Quota Column -->
+      <template #cell(newQuota)="row">
+        <b-form-input
+          v-if="row.item.isQuotaAvailable !== false"
+          v-model.number="newQuotas[row.item.quotaId]"
+          type="number"
+          aria-describedby="projectHelp"
+          :placeholder="$t('page.admin.newQuotaInputPlaceHolder')"
+        >
+        </b-form-input>
+      </template>
+
+      <!-- Quota Table - Action Column -->
+      <template #cell(action)="row">
+        <div class="text-center">
+          <b-button
+            v-if="row.item.isQuotaAvailable !== false"
+            :id="`action${row.index}`"
+            variant="primary"
+            :disabled="
+              !newQuotas[row.item.quotaId] || newQuotas[row.item.quotaId] < 0
+            "
+            @click.stop.prevent="
+              saveNewQuota(row.item.quotaId, row.item.iDisplayName)
+            "
+          >
+            {{ $t("buttons.save") }}
+          </b-button>
+        </div>
+      </template>
+
+      <!-- Quota Table - Header Row -->
+      <template #head()="header">
+        <span :id="header.label">
+          {{ header.label }}
+          <b-icon v-if="header.field.hint" icon="info-circle" />
+        </span>
+        <!-- Hint Tooltip -->
+        <b-tooltip
+          v-if="header.field.hint"
+          :target="header.label"
+          triggers="hover"
+          boundary="viewport"
+          placement="bottom"
+          variant="light"
+        >
+          {{ header.field.hint }}
+        </b-tooltip>
+      </template>
+    </b-table>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue-demi";
+// import the store for current module
+import useAdminStore from "../store";
+// import the main store
+
+import type { ProjectObject } from "@coscine/api-client/dist/types/Coscine.Api.Admin";
+import type { UpdateQuotaParameterObject } from "@coscine/api-client/dist/types/Coscine.Api.Admin";
+import useResourceStore from "@/modules/resource/store";
+import type { ExtendedProjectQuotaObject } from "../types";
+import { isNumber } from "lodash";
+import useNotificationStore from "@/store/notification";
+
+export default defineComponent({
+  setup() {
+    const adminStore = useAdminStore();
+    const notificationStore = useNotificationStore();
+    const resourceStore = useResourceStore();
+
+    return { adminStore, notificationStore, resourceStore };
+  },
+  data() {
+    return {
+      newQuotas: {} as { [id: string]: number },
+      sortBy: "iDisplayName",
+      isWaitingForResponse: false,
+      showEnabledResources: true,
+    };
+  },
+
+  computed: {
+    project(): ProjectObject | null {
+      return this.adminStore.project;
+    },
+    projectQuotas(): ExtendedProjectQuotaObject[] | null {
+      if (this.project) {
+        // Retrieve all project quotas (incl. for hidden resources)
+        const projectQuotas = this.project.quotas;
+        // Get only enabled resource types
+        const enabledResourceTypes = this.resourceStore.resourceTypes;
+        if (projectQuotas && enabledResourceTypes) {
+          let projectQuotasExtended = [] as ExtendedProjectQuotaObject[];
+          projectQuotas.forEach((entry) => {
+            // Find corresponding entry from enabled resource types
+            const enabledResource = enabledResourceTypes.find(
+              (q) => q.displayName === entry.resourceType
+            );
+            let extended = { iDisplayName: "" } as ExtendedProjectQuotaObject;
+            Object.assign(extended, entry);
+            // Replace name with pretty resource name from i18n
+            extended.iDisplayName = this.$t(
+              `resourceTypes.${entry.resourceType}.displayName`
+            ).toString();
+            extended.isEnabled = enabledResource
+              ? enabledResource.isEnabled
+              : undefined;
+            extended.isQuotaAvailable = enabledResource
+              ? enabledResource.isQuotaAvailable
+              : undefined;
+            // Push to the newly filtered array
+            projectQuotasExtended.push(extended);
+          });
+          return projectQuotasExtended;
+        } else {
+          return projectQuotas as ExtendedProjectQuotaObject[];
+        }
+      } else {
+        return null;
+      }
+    },
+    filteredProjectQuotas(): ExtendedProjectQuotaObject[] | null {
+      if (this.showEnabledResources && this.projectQuotas) {
+        return this.projectQuotas.filter((entry) => entry.isEnabled);
+      } else {
+        return this.projectQuotas;
+      }
+    },
+    headers() {
+      // Define as computed property to have table
+      // header text react on language changes.
+      return [
+        {
+          label: this.$t("page.admin.headers.resourceType"),
+          key: "resourceType",
+          sortable: true,
+        },
+        {
+          label: this.$t("page.admin.headers.currentQuota"),
+          key: "currentQuota",
+          sortable: true,
+          hint: this.$t("page.admin.headers.currentQuotaHint"),
+        },
+        {
+          label: this.$t("page.admin.headers.allocatedQuota"),
+          key: "allocatedQuota",
+          sortable: true,
+          hint: this.$t("page.admin.headers.allocatedQuotaHint"),
+        },
+        {
+          label: this.$t("page.admin.headers.newQuota"),
+          key: "newQuota",
+          sortable: false,
+          hint: this.$t("page.admin.headers.newQuotaHint"),
+        },
+        {
+          label: this.$t("page.admin.headers.action"),
+          key: "action",
+          sortable: false,
+        },
+      ];
+    },
+  },
+
+  created() {
+    // Load list of Enabled Resource Types if not present
+    if (this.resourceStore.resourceTypes === null) {
+      this.resourceStore.retrieveResourceTypes();
+    }
+  },
+
+  methods: {
+    async saveNewQuota(quotaId: string, resourceDisplayName: string) {
+      const updatedQuota: UpdateQuotaParameterObject = {
+        quotaId: quotaId,
+        quota: this.newQuotas[quotaId],
+      };
+      if (isNumber(updatedQuota.quota) && updatedQuota.quota >= 0) {
+        this.isWaitingForResponse = true;
+        const success = await this.adminStore.updateProjectQuota(updatedQuota);
+        if (success) {
+          // On Success
+          delete this.newQuotas[quotaId];
+          if (this.project && this.project.guid) {
+            // Refresh the quota values
+            await this.adminStore.retrieveProjectQuotas(this.project.guid);
+          }
+          this.notificationStore.postNotification({
+            title: this.$t("page.admin.toast.success.title").toString(),
+            body: this.$t("page.admin.toast.success.body", {
+              resourceType: resourceDisplayName,
+              projectName: this.project?.name,
+              newQuota: updatedQuota.quota,
+            }).toString(),
+          });
+        } else {
+          // On Failure
+          this.notificationStore.postNotification({
+            title: this.$t("page.admin.toast.fail.title").toString(),
+            body: this.$t("page.admin.toast.fail.body").toString(),
+            variant: "warning",
+          });
+        }
+        this.isWaitingForResponse = false;
+      }
+    },
+  },
+});
+</script>
diff --git a/src/modules/admin/i18n/de.ts b/src/modules/admin/i18n/de.ts
index 3b562529..036e6a10 100644
--- a/src/modules/admin/i18n/de.ts
+++ b/src/modules/admin/i18n/de.ts
@@ -1,15 +1,58 @@
 import VueI18n from "vue-i18n";
 
 export default {
-  /*
-    --------------------------------------------------------------------------------------
-    GERMAN STRINGS
-    --------------------------------------------------------------------------------------
-  */
   page: {
     admin: {
-      title: "Adminseite",
-      description: "Das ist die @:page.admin.title des Coscine UIv2 Apps",
+      headline: "Adminseite",
+
+      projectInputPlaceholder: "Projekt-GUID oder -Slug eingeben",
+
+      projectFound: "Projekt gefunden",
+      projectNotSelected: "Kein Projekt ausgewählt",
+
+      form: {
+        labelSymbol: ":",
+
+        projectName: "Projektname",
+        projectNameLabel:
+          "@:(page.admin.form.projectName)@:(page.admin.form.labelSymbol)",
+
+        projectShortName: "Anzeigename",
+        projectShortNameLabel:
+          "@:(page.admin.form.projectShortName)@:(page.admin.form.labelSymbol)",
+
+        projectGuid: "GUID",
+        projectGuidLabel:
+          "@:(page.admin.form.projectGuid)@:(page.admin.form.labelSymbol)",
+      },
+      projectQuotaHeadline: "Quota",
+      displayHiddenResources: "Nur aktivierte Ressourcentypen anzeigen",
+
+      headers: {
+        resourceType: "Ressourcentyp",
+        currentQuota: "Aktuelles Projekt Reserviertes Quota",
+        currentQuotaHint:
+          "Dieser Wert gibt die Speicherobergrenze an, die für den ausgewählten Ressourcentyp im Projekt verfügbar ist.",
+        allocatedQuota: "Reservierte Gesamtquota für Ressourcen",
+        allocatedQuotaHint:
+          "Dieser Wert gibt den aktuellen Speicherplatz an, der von allen vorhandenen Ressourcen desselben Ressourcentyps reserviert wird.",
+        newQuota: "Neue Projektquota",
+        newQuotaHint:
+          'Mit diesem Wert wird "@:(page.admin.headers.currentQuota)" angepasst',
+        action: "Aktion",
+      },
+      newQuotaInputPlaceHolder: "Quota in GB angeben",
+      gb: "{number} GB",
+      toast: {
+        success: {
+          title: "Quota erfolgreich geändert",
+          body: "{resourceType} Quota für Projekt {projectName} gesetzt auf {newQuota} GB",
+        },
+        fail: {
+          title: "Aktualisierung fehlgeschlagen",
+          body: "Aktualisierung der Quota fehlgeschlagen.",
+        },
+      },
     },
   },
 } as VueI18n.LocaleMessageObject;
diff --git a/src/modules/admin/i18n/en.ts b/src/modules/admin/i18n/en.ts
index 55dc51af..c43f6876 100644
--- a/src/modules/admin/i18n/en.ts
+++ b/src/modules/admin/i18n/en.ts
@@ -1,15 +1,58 @@
 import VueI18n from "vue-i18n";
 
 export default {
-  /*
-    --------------------------------------------------------------------------------------
-    ENGLISH STRINGS
-    --------------------------------------------------------------------------------------
-  */
   page: {
     admin: {
-      title: "Admin Page",
-      description: "This is the @:page.admin.title for the Coscine UIv2 App",
+      headline: "Quota Admin Panel",
+
+      projectInputPlaceholder: "Enter a project's Slug or GUID",
+
+      projectFound: "Project found",
+      projectNotSelected: "No project selected",
+
+      form: {
+        labelSymbol: ":",
+
+        projectName: "Project Name",
+        projectNameLabel:
+          "@:(page.admin.form.projectName)@:(page.admin.form.labelSymbol)",
+
+        projectShortName: "Display Name",
+        projectShortNameLabel:
+          "@:(page.admin.form.projectShortName)@:(page.admin.form.labelSymbol)",
+
+        projectGuid: "GUID",
+        projectGuidLabel:
+          "@:(page.admin.form.projectGuid)@:(page.admin.form.labelSymbol)",
+      },
+      projectQuotaHeadline: "Quotas",
+      displayHiddenResources: "Display enabled Resource Types only",
+
+      headers: {
+        resourceType: "Resource Type",
+        currentQuota: "Current Project Reserved Quota",
+        currentQuotaHint:
+          "This value indicates the storage upper limit, that is available for the selected resource type in the project.",
+        allocatedQuota: "Total Resource Reserved Quota",
+        allocatedQuotaHint:
+          "This value indicates the current storage reserved by all existing resources of the same resource type.",
+        newQuota: "New Project Quota",
+        newQuotaHint:
+          'This value will change the "@:(page.admin.headers.currentQuota)"',
+        action: "Action",
+      },
+      newQuotaInputPlaceHolder: "Enter Quota in GB",
+      gb: "{number} GB",
+      toast: {
+        success: {
+          title: "Quota successfully changed",
+          body: "{resourceType} quota for project {projectName} set to {newQuota} GB",
+        },
+        fail: {
+          title: "Quota update failed",
+          body: "Updating quota for the selected resource type failed.",
+        },
+      },
     },
   },
 } as VueI18n.LocaleMessageObject;
diff --git a/src/modules/admin/pages/Admin.vue b/src/modules/admin/pages/Admin.vue
index 37f4a860..dbc73a84 100644
--- a/src/modules/admin/pages/Admin.vue
+++ b/src/modules/admin/pages/Admin.vue
@@ -1,16 +1,75 @@
 <template>
-  <div>
-    <section
-      class="container flex flex-col items-center px-5 py-12 mx-auto text-gray-600 body-font md:flex-row"
+  <div id="admin">
+    <CoscineHeadline :headline="$t('page.admin.headline')" />
+    <!-- Project input Tooltip -->
+    <b-form id="project_request_form" @submit.stop.prevent="queryProject()">
+      <b-input-group class="mt-3">
+        <b-form-input
+          v-model="projectString"
+          :placeholder="$t('page.admin.projectInputPlaceholder')"
+        >
+        </b-form-input>
+        <!-- Select Button -->
+        <b-input-group-append>
+          <b-button variant="primary" @click="queryProject()">
+            {{ $t("buttons.submit") }}
+          </b-button>
+        </b-input-group-append>
+      </b-input-group>
+    </b-form>
+    <!-- Project Query Status -->
+    <span class="d-block h6 my-3">
+      {{
+        project && project.guid
+          ? $t("page.admin.projectFound")
+          : $t("page.admin.projectNotSelected")
+      }}
+    </span>
+    <!-- Project Container -->
+    <!-- Project Name -->
+    <CoscineFormGroup
+      label-for="ProjectName"
+      :label="$t('page.admin.form.projectNameLabel')"
+      label-cols-sm="2"
+      label-align-sm="left"
     >
-      <div>
-        <CoscineHeadline :headline="$t('page.admin.title')" />
-        <p class="mb-8 leading-relaxed dark:text-white">
-          {{ $t("page.admin.description") }}
-        </p>
-        <img alt="From Coscine Old" src="@/assets/images/Admin.png" />
-      </div>
-    </section>
+      <b-form-input
+        id="project_name"
+        :value="project ? project.name : ''"
+        readonly
+      />
+    </CoscineFormGroup>
+    <!-- Project Short Name -->
+    <CoscineFormGroup
+      label-for="ProjectShortName"
+      :label="$t('page.admin.form.projectShortNameLabel')"
+      label-cols-sm="2"
+      label-align-sm="left"
+    >
+      <b-form-input
+        id="project_name"
+        :value="project ? project.name : ''"
+        readonly
+      />
+    </CoscineFormGroup>
+    <!-- Project Guid -->
+    <CoscineFormGroup
+      label-for="ProjectGuid"
+      :label="$t('page.admin.form.projectGuidLabel')"
+      label-cols-sm="2"
+      label-align-sm="left"
+    >
+      <b-form-input
+        id="project_guid"
+        :value="project ? project.guid : ''"
+        readonly
+      />
+    </CoscineFormGroup>
+
+    <div class="h-divider" />
+
+    <!-- Quota Table -->
+    <QuotaTable />
   </div>
 </template>
 
@@ -22,16 +81,42 @@ import CoscineHeadline from "@/components/coscine/CoscineHeadline.vue";
 import useAdminStore from "../store";
 // import the main store
 import useMainStore from "@/store/index";
+import type { ProjectObject } from "@coscine/api-client/dist/types/Coscine.Api.Admin";
+import QuotaTable from "../components/QuotaTable.vue";
 
 export default defineComponent({
   components: {
     CoscineHeadline,
+    QuotaTable,
   },
+
   setup() {
     const mainStore = useMainStore();
     const adminStore = useAdminStore();
 
     return { mainStore, adminStore };
   },
+
+  data() {
+    return {
+      projectString: "",
+    };
+  },
+
+  computed: {
+    project(): ProjectObject | null {
+      return this.adminStore.project;
+    },
+  },
+
+  methods: {
+    async queryProject() {
+      if (!this.projectString.trim()) {
+        this.adminStore.project = null;
+      } else {
+        await this.adminStore.retrieveProjectQuotas(this.projectString);
+      }
+    },
+  },
 });
 </script>
diff --git a/src/modules/admin/routes.ts b/src/modules/admin/routes.ts
index 5c4893ea..cf880d24 100644
--- a/src/modules/admin/routes.ts
+++ b/src/modules/admin/routes.ts
@@ -11,7 +11,6 @@ export const AdminRoutes: RouteConfig[] = [
     component: AdminModule,
     // only authenticated users can access admin
     meta: {
-      breadCrumb: "admin",
       requiresAdmin: true,
       requiresAuth: true,
       i18n: AdminI18nMessages,
@@ -21,6 +20,9 @@ export const AdminRoutes: RouteConfig[] = [
         path: "/",
         name: "admin",
         component: Admin,
+        meta: {
+          breadCrumb: "admin",
+        },
       },
     ],
   },
diff --git a/src/modules/admin/store.ts b/src/modules/admin/store.ts
index 63c995bb..c07f3ccf 100644
--- a/src/modules/admin/store.ts
+++ b/src/modules/admin/store.ts
@@ -1,5 +1,10 @@
 import { defineStore } from "pinia";
 import { AdminState } from "./types";
+import { AdminApi } from "@coscine/api-client";
+import type { UpdateQuotaParameterObject } from "@coscine/api-client/dist/types/Coscine.Api.Admin";
+
+import useNotificationStore from "@/store/notification";
+import type { AxiosError } from "axios";
 
 /*  
   Store variable name is "this.<id>Store"
@@ -13,29 +18,45 @@ export const useAdminStore = defineStore({
     STATES
     --------------------------------------------------------------------------------------
   */
-  state: (): AdminState => ({}),
-
+  state: (): AdminState => ({
+    project: null,
+  }),
   /*  
     --------------------------------------------------------------------------------------
     GETTERS
     --------------------------------------------------------------------------------------
     Synchronous code only.
-    
+
     In a component use as e.g.:
-      :label = "this.adminStore.<getter_name>;
+      :label = "this.projectStore.<getter_name>;"
   */
   getters: {},
-  /*  
-    --------------------------------------------------------------------------------------
-    ACTIONS
-    --------------------------------------------------------------------------------------
-    Asynchronous & Synchronous code comes here (e.g. API calls and VueX mutations).
-    To change a state use an action.
 
-    In a component use as e.g.:
-      @click = "this.adminStore.<action_name>();
-  */
-  actions: {},
+  actions: {
+    async retrieveProjectQuotas(projectString: string) {
+      const notificationStore = useNotificationStore();
+      try {
+        const apiResponse = await AdminApi.adminGetProject(projectString);
+        this.project = apiResponse.data;
+      } catch (error) {
+        // Handle other Status Codes
+        notificationStore.postApiErrorNotification(error as AxiosError);
+      }
+    },
+    async updateProjectQuota(
+      quota: UpdateQuotaParameterObject
+    ): Promise<boolean> {
+      const notificationStore = useNotificationStore();
+      try {
+        await AdminApi.adminUpdateQuota(quota);
+        return true;
+      } catch (error) {
+        // Handle other Status Codes
+        notificationStore.postApiErrorNotification(error as AxiosError);
+        return false;
+      }
+    },
+  },
 });
 
 export default useAdminStore;
diff --git a/src/modules/admin/types.ts b/src/modules/admin/types.ts
index 7d6b0eaf..9dc68e1d 100644
--- a/src/modules/admin/types.ts
+++ b/src/modules/admin/types.ts
@@ -1,7 +1,14 @@
+import type {
+  ProjectObject,
+  ProjectQuotaObject,
+} from "@coscine/api-client/dist/types/Coscine.Api.Admin";
+
+export interface ExtendedProjectQuotaObject extends ProjectQuotaObject {
+  iDisplayName?: string;
+  isQuotaAvailable?: boolean;
+  isEnabled?: boolean;
+}
+
 export interface AdminState {
-  /*  
-    --------------------------------------------------------------------------------------
-    STATE TYPE DEFINITION
-    --------------------------------------------------------------------------------------
-  */
+  project: ProjectObject | null;
 }
diff --git a/src/modules/project/ProjectModule.vue b/src/modules/project/ProjectModule.vue
index fa56c285..1faf7ff5 100644
--- a/src/modules/project/ProjectModule.vue
+++ b/src/modules/project/ProjectModule.vue
@@ -90,9 +90,11 @@ export default defineComponent({
       if (this.projectStore.currentQuotas === null) {
         this.projectStore.retrieveQuotas(this.project);
       }
+      // Load list of Enabled Resource Types if not present
       if (this.resourceStore.resourceTypes === null) {
         this.resourceStore.retrieveResourceTypes();
       }
+      // Load list of all Project Role Types if not present
       if (this.projectStore.roles === null) {
         this.projectStore.retrieveRoles();
       }
diff --git a/src/modules/project/i18n/de.ts b/src/modules/project/i18n/de.ts
index 56ee0ebf..130615a2 100644
--- a/src/modules/project/i18n/de.ts
+++ b/src/modules/project/i18n/de.ts
@@ -54,23 +54,21 @@ export default {
       pleaseTypeSomething:
         "Bitte geben Sie einen Namen oder eine E-Mail Adresse ein",
       removeSelectedInvitation:
-        "Sind Sie sicher, dass Sie die Einladung von Benutzer <strong>{user}</strong> zum Projekt <strong>{projectName}</strong> zurückziehen möchten?",
+        "Sind Sie sicher, dass Sie die Einladung von Benutzer {user} zum Projekt {projectName} zurückziehen möchten?",
       removeSelectedUser:
-        "Sind Sie sicher, dass Sie den Benutzer <strong>{user}</strong> aus dem Projekt <strong>{projectName}</strong> entfernen möchten?",
+        "Sind Sie sicher, dass Sie den Benutzer {user} aus dem Projekt {projectName} entfernen möchten?",
       deleteInvitationTitle: "Einladung Zurückziehen",
       deleteUserTitle: "Benutzer entfernen",
       inviteUser: "Einladung abschicken",
       reInviteUser: "Erneut senden",
       inviteUserText:
-        "Sind Sie sicher, dass Sie den Benutzer <strong>{email}</strong> mit einer Rolle als <strong>{role}</strong> zum Projekt <strong>{projectName}</strong> einladen wollen?",
+        "Sind Sie sicher, dass Sie den Benutzer {email} mit einer Rolle als {role} zum Projekt {projectName} einladen wollen?",
       inviteUserTitle: "Benutzer einladen",
       inviteUserCaption: "Wählen Sie {displayName} aus",
       invitedUserText:
         "Eine Einladung wurde an {email} mit einer Rolle als {role} für das Projekt {projectName} verschickt.",
-      invitationPendingTextTop:
-        "Es wurde bereits eine Einladung an <strong>{email}</strong> gesendet.",
-      invitationPendingTextBottom:
-        "Wenn Sie eine neue Einladungsemail an diesen Benutzer schicken möchten, müssen Sie die vorherige Einladung über den Tab Eingeladene Benutzer löschen.",
+      invitationPendingText:
+        "Es wurde bereits eine Einladung an {email} gesendet. {br}Wenn Sie eine neue Einladungsemail an diesen Benutzer schicken möchten, müssen Sie die vorherige Einladung über den Tab Eingeladene Benutzer löschen.",
       invitedUserError:
         "Ein Fehler ist beim Einladen von {email} aufgetreten. Eingaben sind fehlerhaft oder der Benutzer wurde bereits eingeladen.",
       deleteExternalUserError:
@@ -88,7 +86,7 @@ export default {
       removeUser: "Entfernen",
       searchProjectPlaceholder: "Wählen Sie ein Projekt aus...",
       existingEmailInvitation:
-        "Es wurde bereits eine Einladung an <strong>{email}</strong> versendet. Möchten Sie die Einladung erneut versenden?",
+        "Es wurde bereits eine Einladung an {email} versendet. Möchten Sie die Einladung erneut versenden?",
     },
 
     // ProjectPage.vue
diff --git a/src/modules/project/i18n/en.ts b/src/modules/project/i18n/en.ts
index 007f5840..91c1a032 100644
--- a/src/modules/project/i18n/en.ts
+++ b/src/modules/project/i18n/en.ts
@@ -50,23 +50,21 @@ export default {
       userManagement: "@:(page.members.title)",
       pleaseTypeSomething: "Please enter a name or an email address",
       removeSelectedInvitation:
-        "Are you sure you want to revoke the invitation for <strong>{user}</strong> to the project <strong>{projectName}</strong>?",
+        "Are you sure you want to revoke the invitation for {user} to the project {projectName}?",
       removeSelectedUser:
-        "Are you sure you want to remove <strong>{user}</strong> from the project <strong>{projectName}</strong>?",
+        "Are you sure you want to remove {user} from the project {projectName}?",
       deleteInvitationTitle: "Revoke Invitation",
       deleteUserTitle: "Remove User",
       inviteUser: "Send Invitation",
       reInviteUser: "Resend Invitation",
       inviteUserText:
-        "Are you sure you want to invite <strong>{email}</strong> with a role as <strong>{role}</strong> to the project <strong>{projectName}</strong>?",
+        "Are you sure you want to invite {email} with a role as {role} to the project {projectName}?",
       inviteUserTitle: "Invite User",
       inviteUserCaption: "Choose to invite {displayName}",
       invitedUserText:
         "An invitation has been sent to {email} with a role as {role} for project {projectName}.",
-      invitationPendingTextTop:
-        "An invitation has already been sent to <strong>{email}</strong>.",
-      invitationPendingTextBottom:
-        "If you would like to resend an invitation email to this user, you have to cancel the previous invitation via the Invited Users tab.",
+      invitationPendingText:
+        "An invitation has already been sent to {email}. {br}If you would like to resend an invitation email to this user, you have to cancel the previous invitation via the Invited Users tab.",
       invitedUserError:
         "An error ocurred while trying to invite {email}. Invalid input was provided or the user has already been invited.",
       deleteExternalUserError:
@@ -83,7 +81,7 @@ export default {
       removeUser: "Remove",
       searchProjectPlaceholder: "Select a project...",
       existingEmailInvitation:
-        "An invitation has already been sent to <strong>{email}</strong>. Do you want to send the invitation again?",
+        "An invitation has already been sent to {email}. Do you want to send the invitation again?",
     },
 
     // ProjectPage.vue
diff --git a/src/modules/project/pages/CreateProject.vue b/src/modules/project/pages/CreateProject.vue
index 9b7f302c..51f6b2a7 100644
--- a/src/modules/project/pages/CreateProject.vue
+++ b/src/modules/project/pages/CreateProject.vue
@@ -195,12 +195,4 @@ export default defineComponent({
 });
 </script>
 
-<style scoped>
-.h-divider {
-  margin-top: 5px;
-  margin-bottom: 10px;
-  height: 1px;
-  width: 100%;
-  border-top: 1px solid #bebbbb;
-}
-</style>
+<style scoped></style>
diff --git a/src/modules/project/pages/Quota.vue b/src/modules/project/pages/Quota.vue
index 99027643..aaad654c 100644
--- a/src/modules/project/pages/Quota.vue
+++ b/src/modules/project/pages/Quota.vue
@@ -439,13 +439,6 @@ input[type="range"] {
   width: calc(100% - 8em);
   padding-top: 0.6rem;
 }
-.h-divider {
-  margin-top: 5px;
-  margin-bottom: 10px;
-  height: 1px;
-  width: 100%;
-  border-top: 1px solid #bebbbb;
-}
 #quotaManagement >>> .b-table-empty-row {
   background-color: #f5f5f5;
 }
diff --git a/src/modules/project/pages/Settings.vue b/src/modules/project/pages/Settings.vue
index a6923f79..7d67d117 100644
--- a/src/modules/project/pages/Settings.vue
+++ b/src/modules/project/pages/Settings.vue
@@ -259,12 +259,4 @@ export default defineComponent({
 });
 </script>
 
-<style scoped>
-.h-divider {
-  margin-top: 5px;
-  margin-bottom: 10px;
-  height: 1px;
-  width: 100%;
-  border-top: 1px solid #bebbbb;
-}
-</style>
+<style scoped></style>
diff --git a/src/modules/project/pages/components/modals/DeleteModal.vue b/src/modules/project/pages/components/modals/DeleteModal.vue
index 75518c90..6657aafe 100644
--- a/src/modules/project/pages/components/modals/DeleteModal.vue
+++ b/src/modules/project/pages/components/modals/DeleteModal.vue
@@ -1,25 +1,23 @@
 <template>
-  <div>
-    <b-modal
-      :visible="visible"
-      :title="$t(titleKey)"
-      ok-variant="danger"
-      :ok-title="$t('buttons.delete')"
-      :cancel-title="$t('buttons.cancel')"
-      @hidden="$emit('close', $event.target.value)"
-      @ok="$emit('ok', $event.target.value)"
-      @cancel="$emit('close', $event.target.value)"
-    >
-      <div
-        v-html="
-          $t(descriptionKey, {
-            user: selectedUser,
-            projectName: selectedProject,
-          })
-        "
-      />
-    </b-modal>
-  </div>
+  <b-modal
+    :visible="visible"
+    :title="$t(titleKey)"
+    ok-variant="danger"
+    :ok-title="$t('buttons.delete')"
+    :cancel-title="$t('buttons.cancel')"
+    @hidden="$emit('close', $event.target.value)"
+    @ok="$emit('ok', $event.target.value)"
+    @cancel="$emit('close', $event.target.value)"
+  >
+    <i18n :path="descriptionKey" tag="span">
+      <template #user>
+        <b>{{ selectedUser }}</b>
+      </template>
+      <template #projectName>
+        <b>{{ selectedProject }}</b>
+      </template>
+    </i18n>
+  </b-modal>
 </template>
 
 <script lang="ts">
diff --git a/src/modules/project/pages/components/modals/InvitationPendingModal.vue b/src/modules/project/pages/components/modals/InvitationPendingModal.vue
index 74493cec..ecdf31bd 100644
--- a/src/modules/project/pages/components/modals/InvitationPendingModal.vue
+++ b/src/modules/project/pages/components/modals/InvitationPendingModal.vue
@@ -4,22 +4,18 @@
     :title="$t('page.members.inviteUserTitle')"
     :hide-footer="true"
   >
-    <ul>
-      <li
-        v-html="
-          $t('page.members.invitationPendingTextTop', {
-            email: candidateForInvitation.email,
-          })
-        "
-      ></li>
-      <li>
-        {{ $t("page.members.invitationPendingTextBottom") }}
-      </li>
-    </ul>
+    <i18n path="page.members.invitationPendingText" tag="span">
+      <template #email>
+        <b>{{ candidateForInvitation.email }}</b>
+      </template>
+      <template #br>
+        <br />
+      </template>
+    </i18n>
     <br />
-    <b-button @click="$bvModal.hide('invitationPendingModal')">{{
-      $t("buttons.cancel")
-    }}</b-button>
+    <b-button class="mt-3" @click="$bvModal.hide('invitationPendingModal')">
+      {{ $t("buttons.cancel") }}
+    </b-button>
   </b-modal>
 </template>
 
diff --git a/src/modules/project/pages/components/modals/InviteUserModal.vue b/src/modules/project/pages/components/modals/InviteUserModal.vue
index 6368dac6..d0552576 100644
--- a/src/modules/project/pages/components/modals/InviteUserModal.vue
+++ b/src/modules/project/pages/components/modals/InviteUserModal.vue
@@ -5,30 +5,35 @@
     :hide-footer="true"
   >
     <!-- Body Text - New Invitation -->
-    <div
+    <i18n
       v-if="candidateForInvitation && !candidateForInvitation.invited"
-      v-html="
-        $t('page.members.inviteUserText', {
-          email: candidateForInvitation.email,
-          role: getRoleNameFromId(candidateForInvitation.role),
-          projectName: projectName,
-        })
-      "
-    />
+      path="page.members.inviteUserText"
+      tag="span"
+    >
+      <template #email>
+        <b>{{ candidateForInvitation.email }}</b>
+      </template>
+      <template #role>
+        <b>{{ getRoleNameFromId(candidateForInvitation.role) }}</b>
+      </template>
+      <template #projectName>
+        <b>{{ projectName }}</b>
+      </template>
+    </i18n>
 
     <!-- Body Text - Existing Invitation -->
-    <div
+    <i18n
       v-else-if="candidateForInvitation && candidateForInvitation.invited"
-      v-html="
-        $t('page.members.existingEmailInvitation', {
-          email: candidateForInvitation.email,
-        })
-      "
-    />
-    <br />
+      path="page.members.existingEmailInvitation"
+      tag="span"
+    >
+      <template #email>
+        <b>{{ candidateForInvitation.email }}</b>
+      </template>
+    </i18n>
 
     <!-- Buttons -->
-    <div>
+    <div class="mt-3">
       <!-- Invite -->
       <b-button
         class="inviteModalRightBtn"
diff --git a/src/modules/user/pages/UserProfile.vue b/src/modules/user/pages/UserProfile.vue
index 53d07a4e..f12aa1c9 100644
--- a/src/modules/user/pages/UserProfile.vue
+++ b/src/modules/user/pages/UserProfile.vue
@@ -242,7 +242,7 @@
         <AccessToken />
 
         <!-- User Preferences -->
-        <div class="h-divider"></div>
+        <div class="h-divider" />
         <CoscineHeadline
           :headline="$t('page.userprofile.form.userPreferences.header')"
         />
diff --git a/src/modules/user/pages/components/AccessToken.vue b/src/modules/user/pages/components/AccessToken.vue
index 4d4f77d5..f935e6bb 100644
--- a/src/modules/user/pages/components/AccessToken.vue
+++ b/src/modules/user/pages/components/AccessToken.vue
@@ -355,13 +355,6 @@ export default defineComponent({
 #accesstoken .table .b-table-empty-row {
   background-color: #f5f5f5;
 }
-#accesstoken .h-divider {
-  margin-top: 5px;
-  margin-bottom: 10px;
-  height: 1px;
-  width: 100%;
-  border-top: 1px solid #bebbbb;
-}
 #accesstoken .btn-danger:focus {
   outline: none;
   box-shadow: none;
diff --git a/yarn.lock-workspace b/yarn.lock-workspace
index 6fbaf58a..7f49708d 100644
--- a/yarn.lock-workspace
+++ b/yarn.lock-workspace
@@ -3093,7 +3093,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"cacache@npm:*, cacache@npm:^16.0.0, cacache@npm:^16.0.2, cacache@npm:^16.0.6":
+"cacache@npm:*, cacache@npm:^16.0.0, cacache@npm:^16.0.2, cacache@npm:^16.0.6, cacache@npm:^16.0.7":
   version: 16.0.7
   resolution: "cacache@npm:16.0.7"
   dependencies:
@@ -8648,8 +8648,8 @@ __metadata:
   linkType: hard
 
 "npm@npm:^8.3.0":
-  version: 8.8.0
-  resolution: "npm@npm:8.8.0"
+  version: 8.9.0
+  resolution: "npm@npm:8.9.0"
   dependencies:
     "@isaacs/string-locale-compare": ^1.1.0
     "@npmcli/arborist": ^5.0.4
@@ -8661,7 +8661,7 @@ __metadata:
     "@npmcli/run-script": ^3.0.1
     abbrev: ~1.1.1
     archy: ~1.0.0
-    cacache: ^16.0.6
+    cacache: ^16.0.7
     chalk: ^4.1.2
     chownr: ^2.0.0
     cli-columns: ^4.0.0
@@ -8703,7 +8703,7 @@ __metadata:
     npm-user-validate: ^1.0.1
     npmlog: ^6.0.2
     opener: ^1.5.2
-    pacote: ^13.1.1
+    pacote: ^13.3.0
     parse-conflict-json: ^2.0.2
     proc-log: ^2.0.1
     qrcode-terminal: ^0.12.0
@@ -8724,7 +8724,7 @@ __metadata:
   bin:
     npm: bin/npm-cli.js
     npx: bin/npx-cli.js
-  checksum: ece9941f5e3fe1cdeeb80030bd17512ee3f6ea3fd4c9efd7cc682e0170356cd39350de814cb0a6ba4dd3ed993b1e51c3edbcf16a440b3719994e510c3eba654b
+  checksum: ab555b93cfe6070fc5bcdbdfae87185f38e9b30909f0121b6330cbba1ee0c22ba71629431163f37e079d0502b8cc88ef2c58508073c9addb356089fca1706e79
   languageName: node
   linkType: hard
 
@@ -9052,7 +9052,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pacote@npm:*, pacote@npm:^13.0.3, pacote@npm:^13.0.5, pacote@npm:^13.1.1":
+"pacote@npm:*, pacote@npm:^13.0.3, pacote@npm:^13.0.5":
   version: 13.2.0
   resolution: "pacote@npm:13.2.0"
   dependencies:
@@ -9083,6 +9083,37 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pacote@npm:^13.3.0":
+  version: 13.3.0
+  resolution: "pacote@npm:13.3.0"
+  dependencies:
+    "@npmcli/git": ^3.0.0
+    "@npmcli/installed-package-contents": ^1.0.7
+    "@npmcli/promise-spawn": ^3.0.0
+    "@npmcli/run-script": ^3.0.1
+    cacache: ^16.0.0
+    chownr: ^2.0.0
+    fs-minipass: ^2.1.0
+    infer-owner: ^1.0.4
+    minipass: ^3.1.6
+    mkdirp: ^1.0.4
+    npm-package-arg: ^9.0.0
+    npm-packlist: ^5.0.0
+    npm-pick-manifest: ^7.0.0
+    npm-registry-fetch: ^13.0.1
+    proc-log: ^2.0.0
+    promise-retry: ^2.0.1
+    read-package-json: ^5.0.0
+    read-package-json-fast: ^2.0.3
+    rimraf: ^3.0.2
+    ssri: ^9.0.0
+    tar: ^6.1.11
+  bin:
+    pacote: lib/bin.js
+  checksum: 49badbafac64e7cd9c87aa14342424ee90946e0ac729faeffb489a94ed6cdab815bd7ffec7673b4863e20e113ee678b8038ace95ee2fc1f58206bc4482ff0bf5
+  languageName: node
+  linkType: hard
+
 "pako@npm:~1.0.5":
   version: 1.0.11
   resolution: "pako@npm:1.0.11"
@@ -10346,8 +10377,8 @@ __metadata:
   linkType: hard
 
 "rollup@npm:^2.58.0, rollup@npm:^2.59.0":
-  version: 2.71.1
-  resolution: "rollup@npm:2.71.1"
+  version: 2.72.0
+  resolution: "rollup@npm:2.72.0"
   dependencies:
     fsevents: ~2.3.2
   dependenciesMeta:
@@ -10355,7 +10386,7 @@ __metadata:
       optional: true
   bin:
     rollup: dist/bin/rollup
-  checksum: fe2b2fda7bf53c86e970f3b026b784c00e2237089b802755b3e43725db88f5d1869c1f81f8c5257e9b68b0fd1840dcbd3897d2f19768cce97a37c70e1a563dce
+  checksum: d4c213d4250a0455e0ee40664db3339a9e3ca5affa09c348019b4895e7100cdbedeba881a239a7a80c407b4055dca4e97d10a740d99c09b17651efbfedd61a4b
   languageName: node
   linkType: hard
 
@@ -12047,8 +12078,8 @@ __metadata:
   linkType: hard
 
 "vite@npm:^2.7.10, vite@npm:^2.8.6":
-  version: 2.9.7
-  resolution: "vite@npm:2.9.7"
+  version: 2.9.8
+  resolution: "vite@npm:2.9.8"
   dependencies:
     esbuild: ^0.14.27
     fsevents: ~2.3.2
@@ -12071,7 +12102,7 @@ __metadata:
       optional: true
   bin:
     vite: bin/vite.js
-  checksum: d3d2a86855709e037d244c3066e785d7b700e41bf59ceb776818ea06ee2ed579055c10596ca883dd56de46b3654ec82da53369062013a012a1a60819dbb7c0ee
+  checksum: 7de3450bec4caa06f4540d1d252563ef0b7e1bdd167d04abf03db72cfd8c9a93879e18861283bc7d075e1d094b78b71770ca36f9b965bdf28c66665eafdc29dc
   languageName: node
   linkType: hard
 
-- 
GitLab