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