From c4c6416671856e7d7bcb85c060e1f4c08398cc14 Mon Sep 17 00:00:00 2001
From: Leonardo <leomilho@gmail.com>
Date: Fri, 15 Nov 2024 18:57:12 +0100
Subject: [PATCH] feat: share link changes its authorization

---
 apps/web/src/components/navbar/navbar.tsx     | 34 +++++++-
 libs/shared/lib/data-access/api/eventBus.tsx  | 19 +++--
 libs/shared/lib/data-access/api/url.ts        |  1 +
 libs/shared/lib/data-access/broker/broker.tsx | 15 ++--
 libs/shared/lib/data-access/broker/types.ts   |  3 +-
 .../shared/lib/data-access/broker/wsState.tsx | 39 +++++++++-
 .../security/useAuthentication.tsx            | 78 ++++++++++++-------
 .../shared/lib/data-access/store/authSlice.ts |  6 +-
 .../lib/data-access/store/sessionSlice.ts     |  1 +
 .../lib/management/database/Databases.tsx     | 10 ++-
 10 files changed, 152 insertions(+), 54 deletions(-)

diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx
index 046b545d4..1c17a0b31 100644
--- a/apps/web/src/components/navbar/navbar.tsx
+++ b/apps/web/src/components/navbar/navbar.tsx
@@ -27,6 +27,7 @@ export const Navbar = () => {
   const buildInfo = getEnvVariable('GRAPHPOLARIS_VERSION');
   const [managementOpen, setManagementOpen] = useState<boolean>(false);
   const [current, setCurrent] = useState<ManagementViews>('overview');
+  const [sharing, setSharing] = useState<boolean>(false);
 
   useEffect(() => {
     const handleClickOutside = (event: MouseEvent) => {
@@ -45,7 +46,19 @@ export const Navbar = () => {
       <GpLogo className="h-7" includeText={false} />
       <ManagementTrigger managementOpen={managementOpen} setManagementOpen={setManagementOpen} current={current} setCurrent={setCurrent} />
 
-      <Button label="Share" variantType="primary" size="sm" onClick={() => auth.newShareRoom()} />
+      <Button
+        label="Share"
+        variantType="primary"
+        size="sm"
+        iconComponent={sharing ? 'icon-[line-md--loading-loop]' : ''}
+        disabled={sharing}
+        iconPosition="trailing"
+        onClick={async () => {
+          setSharing(true);
+          await auth.shareLink();
+          setSharing(false);
+        }}
+      />
       <div className="ml-auto">
         <div className="w-fit" ref={dropdownRef}>
           <Popover>
@@ -68,9 +81,12 @@ export const Navbar = () => {
                 <>
                   <FeatureEnabled featureFlag="SHARABLE_EXPLORATION">
                     <DropdownItem
-                      value="Share"
-                      onClick={() => {
-                        auth.newShareRoom();
+                      value={sharing ? 'Creating Share Link' : 'Share'}
+                      disabled={sharing}
+                      onClick={async () => {
+                        setSharing(true);
+                        await auth.shareLink();
+                        setSharing(false);
                       }}
                     />
                   </FeatureEnabled>
@@ -92,6 +108,16 @@ export const Navbar = () => {
                       location.replace(`${getEnvVariable('GP_AUTH_URL')}/outpost.goauthentik.io/sign_out`);
                     }}
                   />
+                  {authCache.authorization?.demoUser?.R && (
+                    <DropdownItem
+                      value="Impersonate Demo User"
+                      onClick={() => {
+                        const url = new URL(window.location.href);
+                        url.searchParams.append('impersonateID', 'demoUser');
+                        location.replace(url.toString());
+                      }}
+                    />
+                  )}
                 </>
               ) : (
                 <>
diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx
index fb61c6fe5..03d9e7f29 100644
--- a/libs/shared/lib/data-access/api/eventBus.tsx
+++ b/libs/shared/lib/data-access/api/eventBus.tsx
@@ -18,11 +18,7 @@ import {
 import { Broker, wsQuerySubscription, wsQueryTranslationSubscription } from '@graphpolaris/shared/lib/data-access/broker';
 import { addInfo } from '@graphpolaris/shared/lib/data-access/store/configSlice';
 import { allMLTypes, LinkPredictionInstance, setMLResult } from '@graphpolaris/shared/lib/data-access/store/mlSlice';
-import {
-  QueryBuilderText,
-  setQueryText,
-  setQuerybuilderNodes,
-} from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice';
+import { QueryBuilderText, setQueryText, setQuerybuilderNodes } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice';
 import { useEffect } from 'react';
 import {
   SaveStateI,
@@ -140,8 +136,8 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }
     });
 
     unsubs.push(
-      wsGetStatesSubscription((data) => {
-        console.debug('Save States updated', data);
+      wsGetStatesSubscription((data, status) => {
+        console.debug('Save States updated', data, status);
         dispatch(updateSaveStateList(data));
         const d = Object.fromEntries(data.map((x) => [x.id, x]));
         loadSaveState(session.currentSaveState, d);
@@ -156,9 +152,13 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }
     );
 
     unsubs.push(
-      wsGetStateSubscription((data) => {
+      wsGetStateSubscription((data, status) => {
+        if (status !== 'success') {
+          dispatch(addError('Failed to fetch state'));
+          return;
+        }
         if (data.id !== nilUUID) {
-          console.debug('Save State updated', data);
+          console.debug('Save State updated', data, status);
           dispatch(addSaveState(data));
           dispatch(selectSaveState(data.id));
           loadSaveState(data.id, session.saveStates);
@@ -251,7 +251,6 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }
 
           // Process URL Params
           const paramSaveState = getParam(URLParams.saveState);
-          deleteParam(URLParams.saveState);
           if (paramSaveState && paramSaveState !== nilUUID) {
             wsGetState(paramSaveState);
           }
diff --git a/libs/shared/lib/data-access/api/url.ts b/libs/shared/lib/data-access/api/url.ts
index 0f841a7b9..8ce2ffd9c 100644
--- a/libs/shared/lib/data-access/api/url.ts
+++ b/libs/shared/lib/data-access/api/url.ts
@@ -1,5 +1,6 @@
 export enum URLParams {
   saveState = 'saveState',
+  impersonateID = 'impersonateID',
 }
 
 export function getParam(param: URLParams) {
diff --git a/libs/shared/lib/data-access/broker/broker.tsx b/libs/shared/lib/data-access/broker/broker.tsx
index 1f577c33f..41cfe6f8e 100644
--- a/libs/shared/lib/data-access/broker/broker.tsx
+++ b/libs/shared/lib/data-access/broker/broker.tsx
@@ -25,7 +25,7 @@ export class Broker {
   private static singletonInstance: Broker;
 
   private listeners: Record<string, Record<string, Function>> = {};
-  private catchAllListener: ((data: Record<string, any>, routingKey: string) => void) | undefined;
+  private catchAllListener: ((data: Record<string, any>, status: string, routingKey: string) => void) | undefined;
   private callbackListeners: Record<string, Function> = {};
 
   private webSocket: WebSocket | undefined;
@@ -134,7 +134,9 @@ export class Broker {
 
     const params = new URLSearchParams(window.location.search);
     // Most of these parameters are only really used in DEV
-    // if (this.authHeader?.userID) params.set('userID', this.authHeader?.userID ?? '');
+    const impersonateID = params.get('impersonateID');
+    if (impersonateID) params.set('impersonateID', impersonateID);
+
     if (this.authHeader?.roomID) params.set('roomID', this.authHeader?.roomID ?? '');
     if (this.saveStateID) params.set('saveStateID', this.saveStateID ?? '');
     if (this.authHeader?.sessionID) params.set('sessionID', this.authHeader?.sessionID ?? '');
@@ -217,12 +219,13 @@ export class Broker {
     let jsonObject: ReceiveMessageI = JSON.parse(event.data);
     const routingKey = jsonObject.type;
     const data = jsonObject.value;
+    const status = jsonObject.status;
     const uuid = jsonObject.callID;
 
     let stop = false; // check in case there is a specific callback listener and, if its response is true, also call the subscriptions
     this.mostRecentMessages[routingKey] = data;
     if (uuid in this.callbackListeners) {
-      stop = this.callbackListeners[uuid](data) !== true;
+      stop = this.callbackListeners[uuid](data, status) !== true;
       console.debug(
         '%c' + routingKey + ` WS response WITH CALLBACK`,
         'background: #222; color: #DBAB2F',
@@ -236,15 +239,15 @@ export class Broker {
 
     if (!stop && this.listeners[routingKey] && Object.keys(this.listeners[routingKey]).length != 0) {
       if (this.catchAllListener) {
-        this.catchAllListener(data, routingKey);
+        this.catchAllListener(data, status, routingKey);
       }
-      Object.values(this.listeners[routingKey]).forEach((listener) => listener(data, routingKey));
+      Object.values(this.listeners[routingKey]).forEach((listener) => listener(data, status, routingKey));
       console.debug('%c' + routingKey + ` WS response`, 'background: #222; color: #DBAB2F', data);
     }
     // If there are no listeners, log the message
     else if (!stop) {
       if (this.catchAllListener) {
-        this.catchAllListener(data, routingKey);
+        this.catchAllListener(data, status, routingKey);
         console.debug(routingKey, `catch all used for message with routing key`, data);
       } else {
         console.debug('%c' + routingKey + ` no listeners for message with routing key`, 'background: #663322; color: #DBAB2F', data);
diff --git a/libs/shared/lib/data-access/broker/types.ts b/libs/shared/lib/data-access/broker/types.ts
index 0477e7f24..dc3dd7c22 100644
--- a/libs/shared/lib/data-access/broker/types.ts
+++ b/libs/shared/lib/data-access/broker/types.ts
@@ -40,7 +40,8 @@ export type subKeyTypeI =
   | 'runQuery'
   | 'manual'
   | 'getPolicy'
-  | 'policyCheck';
+  | 'policyCheck'
+  | 'shareState';
 
 export type SendMessageI = {
   key: keyTypeI;
diff --git a/libs/shared/lib/data-access/broker/wsState.tsx b/libs/shared/lib/data-access/broker/wsState.tsx
index 24a87ed3f..9dd85998d 100644
--- a/libs/shared/lib/data-access/broker/wsState.tsx
+++ b/libs/shared/lib/data-access/broker/wsState.tsx
@@ -53,7 +53,7 @@ export type SaveStateI = {
   shareState: any;
 };
 
-type GetStateResponse = (data: SaveStateI) => void;
+type GetStateResponse = (data: SaveStateI, status: string) => void;
 export function wsGetState(saveStateId: string, callback?: GetStateResponse) {
   Broker.instance().sendMessage(
     {
@@ -71,7 +71,7 @@ export function wsGetStateSubscription(callback: GetStateResponse) {
   };
 }
 
-type GetStatesResponse = (data: SaveStateI[]) => void | boolean;
+type GetStatesResponse = (data: SaveStateI[], status: string) => void | boolean;
 export function wsGetStates(callback?: GetStatesResponse) {
   Broker.instance().sendMessage(
     {
@@ -82,7 +82,11 @@ export function wsGetStates(callback?: GetStatesResponse) {
   );
 }
 export function wsGetStatesSubscription(callback: GetStatesResponse) {
-  const id = Broker.instance().subscribe(callback, 'save_states');
+  const id = Broker.instance().subscribe((data: any, status: string) => {
+    if (data && status) {
+      callback(data, status);
+    }
+  }, 'save_states');
   return () => {
     Broker.instance().unSubscribe('save_states', id);
   };
@@ -197,3 +201,32 @@ export function wsStateGetPolicy(saveStateID: string, callback?: StateGetPolicyR
     callback,
   );
 }
+
+type StateSetPolicyRequest = {
+  saveStateId: string;
+  users: {
+    userId: string;
+    sharing: SaveStateAuthorizationHeaders;
+  }[];
+};
+type ShareStateResponse = (data: boolean) => void;
+export function wsShareSaveState(request: StateSetPolicyRequest, callback?: ShareStateResponse) {
+  Broker.instance().sendMessage(
+    {
+      key: 'state',
+      subKey: 'shareState',
+      body: request,
+    },
+    callback,
+  );
+}
+export function wsShareStateSubscription(callback: ShareStateResponse) {
+  const id = Broker.instance().subscribe((data: any) => {
+    if (data) {
+      callback(data);
+    }
+  }, 'share_save_state');
+  return () => {
+    Broker.instance().unSubscribe('share_save_state', id);
+  };
+}
diff --git a/libs/shared/lib/data-access/security/useAuthentication.tsx b/libs/shared/lib/data-access/security/useAuthentication.tsx
index 0da759abb..33893871a 100644
--- a/libs/shared/lib/data-access/security/useAuthentication.tsx
+++ b/libs/shared/lib/data-access/security/useAuthentication.tsx
@@ -1,7 +1,9 @@
 import { getEnvVariable } from 'config';
-import { useAppDispatch, useAuthCache } from '../store';
-import { authenticated, changeRoom, UserAuthenticationHeader } from '../store/authSlice';
+import { useAppDispatch, useAuthCache, useSessionCache } from '../store';
+import { authenticated, UserAuthenticationHeader } from '../store/authSlice';
 import { addInfo, addError } from '../store/configSlice';
+import { wsShareSaveState } from '../api';
+import { getParam, URLParams } from '../api/url';
 
 const domain = getEnvVariable('BACKEND_URL');
 const userURI = getEnvVariable('BACKEND_USER');
@@ -14,7 +16,7 @@ export const fetchSettings: RequestInit = {
 
 export const useAuthentication = () => {
   const dispatch = useAppDispatch();
-  const auth = useAuthCache();
+  const session = useSessionCache();
 
   const handleError = (err: any) => {
     console.error(err);
@@ -22,7 +24,8 @@ export const useAuthentication = () => {
   };
 
   const login = () => {
-    fetch(`${domain}${userURI}/headers`, fetchSettings)
+    const impersonateID = getParam(URLParams.impersonateID);
+    fetch(`${domain}${userURI}/headers${impersonateID ? '?impersonateID=' + impersonateID : ''}`, fetchSettings)
       .then((res) =>
         res
           .json()
@@ -53,32 +56,55 @@ export const useAuthentication = () => {
     }
   };
 
-  const newShareRoom = async () => {
+  const shareLink = async (): Promise<boolean> => {
+    if (!session.currentSaveState) {
+      dispatch(addError('No save state to share'));
+      return false;
+    }
+
     try {
-      // TODO: Implement share room functionality when backend is ready
-      // fetch(`${domain}${userURI}/share`, { ...fetchSettings, method: 'POST' })
-      //   .then((res) =>
-      //     res
-      //       .json()
-      //       .then((res: { Roomid: string; Sessionid: string }) => {
-      //         dispatch(changeRoom(res.Roomid));
-      //       })
-      //       .catch(handleError),
-      //   )
-      //   .catch(handleError);
+      return await new Promise((resolve, reject) => {
+        wsShareSaveState(
+          {
+            saveStateId: session.currentSaveState || '',
+            users: [
+              {
+                userId: '*', // Everyone
+                sharing: {
+                  database: { R: true, W: false },
+                  visualization: { R: true, W: false },
+                  query: { R: true, W: false },
+                  schema: { R: true, W: false },
+                },
+              },
+            ],
+          },
+          async (data) => {
+            if (!data) {
+              dispatch(addError('Failed to share link'));
+              resolve(false);
+            } else {
+              // Authorization done, now just copy the link
+              const shareUrl = window.location.href;
+              const copied = await copyToClipboard(shareUrl);
 
-      const shareUrl = window.location.href;
-      const copied = await copyToClipboard(shareUrl);
-      
-      if (copied) {
-        dispatch(addInfo('Link copied to clipboard!'));
-      } else {
-        throw new Error('Failed to copy to clipboard');
-      }
+              if (copied) {
+                dispatch(addInfo('Link copied to clipboard!'));
+                resolve(true);
+              } else {
+                console.warn('Failed to copy link to clipboard');
+                dispatch(addError('Failed to copy link to clipboard'));
+                resolve(false);
+              }
+            }
+          },
+        );
+      });
     } catch (error: any) {
       handleError(error);
+      return false;
     }
   };
 
-  return { login, newShareRoom };
-};
\ No newline at end of file
+  return { login, shareLink };
+};
diff --git a/libs/shared/lib/data-access/store/authSlice.ts b/libs/shared/lib/data-access/store/authSlice.ts
index 160ca557c..12c3b14fd 100644
--- a/libs/shared/lib/data-access/store/authSlice.ts
+++ b/libs/shared/lib/data-access/store/authSlice.ts
@@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import type { RootState } from './store';
 import { cloneDeep } from 'lodash-es';
 
-export const UserAuthorizationObjectsArray = ['savestate'] as const;
+export const UserAuthorizationObjectsArray = ['savestate', 'demoUser'] as const;
 export type UserAuthorizationObjects = (typeof UserAuthorizationObjectsArray)[number];
 
 export const AuthorizationOperationsArray = ['R', 'W'] as const;
@@ -19,6 +19,10 @@ export const UserAuthorizationHeadersDefaults: UserAuthorizationHeaders = {
     R: false,
     W: false,
   },
+  demoUser: { // checks if user can impersonate demoUser to create and share demo save states
+    R: false,
+    W: false,
+  },
 };
 
 export type UserAuthenticationHeader = {
diff --git a/libs/shared/lib/data-access/store/sessionSlice.ts b/libs/shared/lib/data-access/store/sessionSlice.ts
index b1d84da89..b2aa4c8d2 100644
--- a/libs/shared/lib/data-access/store/sessionSlice.ts
+++ b/libs/shared/lib/data-access/store/sessionSlice.ts
@@ -64,6 +64,7 @@ export const sessionSlice = createSlice({
     updateSaveStateList: (state: SessionCacheI, action: PayloadAction<SaveStateI[]>) => {
       // Does NOT clear the states, just adds in new data
       let newState: Record<string, SaveStateI> = {};
+      if (!action.payload) return;
       action.payload.forEach((ss: SaveStateI) => {
         newState[ss.id] = ss;
       });
diff --git a/libs/shared/lib/management/database/Databases.tsx b/libs/shared/lib/management/database/Databases.tsx
index 786a8ab09..8bb4d9f40 100644
--- a/libs/shared/lib/management/database/Databases.tsx
+++ b/libs/shared/lib/management/database/Databases.tsx
@@ -57,6 +57,7 @@ export function Databases({ onClose, saveStates, changeActive, setSelectedSaveSt
   const session = useSessionCache();
   const databaseHandler = useHandleDatabase();
   const [orderBy, setOrderBy] = useState<[DatabaseTableHeaderTypes, 'asc' | 'desc']>(['name', 'desc']);
+  const [sharing, setSharing] = useState<boolean>(false);
 
   const orderedSaveStates = useMemo(
     () =>
@@ -174,9 +175,12 @@ export function Databases({ onClose, saveStates, changeActive, setSelectedSaveSt
                         }}
                       />
                       <DropdownItem
-                        value="Share"
-                        onClick={() => {
-                          auth.newShareRoom();
+                        value={sharing ? 'Creating Share Link' : 'Share'}
+                        disabled={sharing}
+                        onClick={async () => {
+                          setSharing(true);
+                          await auth.shareLink();
+                          setSharing(false);
                         }}
                       />
                       <DropdownItem
-- 
GitLab