From fe22372e9d5e11e260f31c769c2ef56ce3d7887f Mon Sep 17 00:00:00 2001
From: Michael Wedl <michael@syslifters.com>
Date: Fri, 22 Dec 2023 07:10:46 +0100
Subject: [PATCH] Consolidated history UI for community

---
 api/src/reportcreator_api/pentests/views.py   |  9 ++++-----
 frontend/src/components/ListView.vue          |  6 +++---
 frontend/src/composables/api.ts               | 19 ++++++++++++-------
 frontend/src/pages/projects/[projectId].vue   |  3 +--
 .../pages/projects/[projectId]/history.vue    | 11 ++++++++++-
 5 files changed, 30 insertions(+), 18 deletions(-)

diff --git a/api/src/reportcreator_api/pentests/views.py b/api/src/reportcreator_api/pentests/views.py
index d89163b23..1b781bbba 100644
--- a/api/src/reportcreator_api/pentests/views.py
+++ b/api/src/reportcreator_api/pentests/views.py
@@ -175,14 +175,13 @@ def get_history_timeline_queryset(self):
         timeline_querysets = self.get_history_timeline_queryset_parts()
         queryset = None
         for qs in timeline_querysets:
-            qs = qs \
-                .annotate(history_model=Value(qs.model.instance_type.__name__)) \
-                .select_related('history_user')
-            if 'history_model_order' not in qs.query.annotations:
-                qs = qs.annotate(history_model_order=F('history_model'))
             if 'model_id' not in qs.query.annotations:
                 qs = qs.annotate(model_id=Cast(F('id'), output_field=CharField()))
+            if 'history_model_order' not in qs.query.annotations:
+                qs = qs.annotate(history_model_order=Value(qs.model.instance_type.__name__))
             qs = qs \
+                .annotate(history_model=Value(qs.model.instance_type.__name__)) \
+                .select_related('history_user') \
                 .only('history_date', 'history_type', 'history_user', 'history_title', 'history_change_reason', 'id')
             if queryset is None:
                 queryset = qs
diff --git a/frontend/src/components/ListView.vue b/frontend/src/components/ListView.vue
index a65ebe09a..2e44ebd82 100644
--- a/frontend/src/components/ListView.vue
+++ b/frontend/src/components/ListView.vue
@@ -33,7 +33,7 @@
         </slot>
         <page-loader :items="items" class="mt-4" />
         <v-list-item
-          v-if="items.data.value.length === 0 && !items.hasNextPage.value"
+          v-if="items.data.value.length === 0 && !items.hasNextPage.value && items.hasBaseURL.value"
           title="No data found"
         />
       </v-list>
@@ -45,7 +45,7 @@
 import { useSearchableCursorPaginationFetcher } from "~/composables/api";
 
 const props = defineProps<{
-  url: string
+  url: string|null;
 }>();
 
 const router = useRouter();
@@ -82,7 +82,7 @@ defineExpose({
 .list-header {
   position: sticky;
   top: 0;
-  z-index: 1;
+  z-index: 10;
   background-color: vuetify.$list-background;
 }
 .list-header-actions {
diff --git a/frontend/src/composables/api.ts b/frontend/src/composables/api.ts
index a900080b4..f29f24cee 100644
--- a/frontend/src/composables/api.ts
+++ b/frontend/src/composables/api.ts
@@ -23,8 +23,8 @@ export async function useAsyncDataE<T>(handler: (ctx?: NuxtApp) => Promise<T>, o
   return res.data as Ref<T>;
 }
 
-export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: string, query?: Object }) {
-  const searchParams = new URLSearchParams(baseURL.split('?')[1]);
+export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: string|null, query?: Object }) {
+  const searchParams = new URLSearchParams((baseURL || '').split('?')[1]);
   for (const [k, v] of Object.entries(query || {})) {
     if (v) {
       if (Array.isArray(v)) {
@@ -39,8 +39,11 @@ export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: str
       searchParams.delete(k);
     }
   }
-  baseURL = baseURL.split('?')[0] + '?' + searchParams.toString();
+  if (baseURL) {
+    baseURL = baseURL.split('?')[0] + '?' + searchParams.toString();
+  }
 
+  const hasBaseURL = computed(() => !!baseURL);
   const nextPageURL = ref<string|null>(baseURL);
   const hasNextPage = computed(() => !!nextPageURL.value);
   const pending = ref(false);
@@ -75,18 +78,19 @@ export function useCursorPaginationFetcher<T>({ baseURL, query }: { baseURL: str
     pending,
     hasError,
     hasNextPage,
+    hasBaseURL,
     fetchNextPage,
   };
 }
 
-export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: string, query?: Object }) {
+export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: string|null, query?: Object }) {
   const initializingFetcher = ref(false);
   const fetcher = ref<ReturnType<typeof useCursorPaginationFetcher<T>>>(null as any);
   const fetchNextPageDebounced = debounce(async () => {
     await fetcher.value.fetchNextPage();
     initializingFetcher.value = true;
   }, 750);
-  function createFetcher(options: { baseURL: string, query?: Object, fetchInitialPage?: boolean, debounce?: boolean }) {
+  function createFetcher(options: { baseURL: string|null, query?: Object, fetchInitialPage?: boolean, debounce?: boolean }) {
     const newFetcher = useCursorPaginationFetcher<T>(options) as any;
     if (options.fetchInitialPage) {
       initializingFetcher.value = true;
@@ -107,7 +111,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri
   }
 
   const currentQuery = computed(() => {
-    return Object.fromEntries(new URLSearchParams(fetcher.value.baseURL.split('?')[1] || ''));
+    return Object.fromEntries(new URLSearchParams((fetcher.value.baseURL || '').split('?')[1] || ''));
   });
 
   function applyFilters(query: Object, { fetchInitialPage = true, debounce = false } = {}) {
@@ -123,7 +127,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri
     set: (val: string) => applyFilters({ search: val }, { debounce: true }),
   });
 
-  function reset(options: { baseURL: string, query?: Object }) {
+  function reset(options: { baseURL: string|null, query?: Object }) {
     createFetcher(options);
   }
 
@@ -136,6 +140,7 @@ export function useSearchableCursorPaginationFetcher<T>(options: { baseURL: stri
     pending: computed(() => initializingFetcher.value || fetcher.value.pending),
     hasError: computed(() => fetcher.value.hasError),
     hasNextPage: computed(() => fetcher.value.hasNextPage),
+    hasBaseURL: computed(() => fetcher.value.hasBaseURL),
     currentQuery,
     search,
     applyFilters,
diff --git a/frontend/src/pages/projects/[projectId].vue b/frontend/src/pages/projects/[projectId].vue
index 60c2d231d..9a7137060 100644
--- a/frontend/src/pages/projects/[projectId].vue
+++ b/frontend/src/pages/projects/[projectId].vue
@@ -17,8 +17,7 @@
         <v-list-item :to="`/projects/${project.id}/publish/`" prepend-icon="mdi-earth" title="Publish">
           <s-tooltip activator="parent" text="Publish" />
         </v-list-item>
-        <v-list-item :to="`/projects/${project.id}/history/`" prepend-icon="mdi-history">
-          <v-list-item-title><pro-info>History</pro-info></v-list-item-title>
+        <v-list-item :to="`/projects/${project.id}/history/`" prepend-icon="mdi-history" title="History">
           <s-tooltip activator="parent" text="History" />
         </v-list-item>
       </template>
diff --git a/frontend/src/pages/projects/[projectId]/history.vue b/frontend/src/pages/projects/[projectId]/history.vue
index f01ec581c..e360c8161 100644
--- a/frontend/src/pages/projects/[projectId]/history.vue
+++ b/frontend/src/pages/projects/[projectId]/history.vue
@@ -1,11 +1,15 @@
 <template>
-  <list-view :url="`/api/v1/pentestprojects/${project.id}/history-timeline/?mode=medium`">
+  <list-view :url="apiSettings.isProfessionalLicense ? `/api/v1/pentestprojects/${project.id}/history-timeline/?mode=medium` : null">
+    <template #title>
+      <pro-info>Version History</pro-info>
+    </template>
     <template #searchbar>
       <!-- hide searchbar -->
       <span />
     </template>
     <template #items="{ items }">
       <v-timeline
+        v-if="apiSettings.isProfessionalLicense"
         direction="vertical"
         side="end"
         align="start"
@@ -27,12 +31,17 @@
           :details="true"
         />
       </v-timeline>
+      <v-list-item v-else>
+        Version history is available in SysReptor Professional.<br><br>
+        See <a href="https://docs.sysreptor.com/features-and-pricing/" target="_blank" class="text-primary">https://docs.sysreptor.com/features-and-pricing/</a>
+      </v-list-item>
     </template>
   </list-view>
 </template>
 
 <script setup lang="ts">
 const route = useRoute();
+const apiSettings = useApiSettings();
 const projectStore = useProjectStore();
 
 const project = await useAsyncDataE(() => projectStore.getById(route.params.projectId as string));