diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 8384d11a825191e1da7bd594f09382bea1b5a7bd..17355b18515c817eda3e5f0c0d4f859ee51ac3d7 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useAppDispatch, - useAuthorizationCache, + useAuthCache, useML, useQuerybuilderGraph, useQuerybuilderSettings, @@ -11,11 +11,10 @@ import { addError, setCurrentTheme } from '@graphpolaris/shared/lib/data-access/ import { resetGraphQueryResults, queryingBackend } from '@graphpolaris/shared/lib/data-access/store/graphQueryResultSlice'; import { Query2BackendQuery, QueryMultiGraph } from '@graphpolaris/shared/lib/querybuilder'; import { Navbar } from '../components/navbar/navbar'; -import { Resizable } from '@graphpolaris/shared/lib/components/layout'; -import { DashboardAlerts } from '@graphpolaris/shared/lib/data-access/authorization/dashboardAlerts'; +import { FrozenOverlay, Resizable } from '@graphpolaris/shared/lib/components/layout'; +import { DashboardAlerts } from '@graphpolaris/shared/lib/data-access/security/dashboardAlerts'; import { EventBus } from '@graphpolaris/shared/lib/data-access/api/eventBus'; import { Onboarding } from '../components/onboarding/onboarding'; -import { wsQueryRequest } from '@graphpolaris/shared/lib/data-access/broker'; import { URLParams, setParam } from '@graphpolaris/shared/lib/data-access/api/url'; import { VisualizationPanel } from '@graphpolaris/shared/lib/vis'; import { QueryBuilder } from '@graphpolaris/shared/lib/querybuilder'; @@ -24,6 +23,7 @@ import { InspectorPanel } from '@graphpolaris/shared/lib/inspector'; import { SearchBar } from '@graphpolaris/shared/lib/sidebar/search/SearchBar'; import { Schema } from '@graphpolaris/shared/lib/schema/panel'; import { InsightDialog } from '@graphpolaris/shared/lib/insight-sharing'; +import { wsQueryRequest } from '@graphpolaris/shared/lib/data-access/broker'; import { ErrorBoundary } from '@graphpolaris/shared/lib/components/errorBoundary'; export type App = { @@ -31,7 +31,7 @@ export type App = { }; export function App(props: App) { - const auth = useAuthorizationCache(); + const auth = useAuthCache(); const query = useQuerybuilderGraph() as QueryMultiGraph; const ml = useML(); const session = useSessionCache(); @@ -76,7 +76,7 @@ export function App(props: App) { <> <Onboarding /> <DashboardAlerts /> - <div className={'h-screen w-screen ' + (!auth.authorized ? 'blur-sm pointer-events-none ' : '')}> + <div className={'h-screen w-screen '}> <div className="flex flex-col h-screen max-h-screen relative"> <aside className="absolute w-full h-12"> <Navbar /> @@ -139,6 +139,12 @@ export function App(props: App) { </main> </div> </div> + <FrozenOverlay> + {!auth.authentication?.authenticated && <span>Not Authenticated</span>} + {!auth.authorization.savestate.W && !session.currentSaveState && ( + <span>Viewer account not authorized. Please load a shared exploration.</span> + )} + </FrozenOverlay> </> )} </div> diff --git a/apps/web/src/components/navbar/DatabaseManagement/dbConnectionSelector.tsx b/apps/web/src/components/navbar/DatabaseManagement/dbConnectionSelector.tsx index 1aa68725524b790b99fd55dec268996fb154fa5a..fabd61ac9c089f7ad3d63a1a99962577bee90c1e 100644 --- a/apps/web/src/components/navbar/DatabaseManagement/dbConnectionSelector.tsx +++ b/apps/web/src/components/navbar/DatabaseManagement/dbConnectionSelector.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { - useAppDispatch, - useSchemaGraph, - useSessionCache, - useAuthorizationCache, - useCheckPermissionPolicy, -} from '@graphpolaris/shared/lib/data-access'; +import { useAppDispatch, useSchemaGraph, useSessionCache, useAuthCache } from '@graphpolaris/shared/lib/data-access'; import { deleteSaveState, selectSaveState } from '@graphpolaris/shared/lib/data-access/store/sessionSlice'; import { SettingsForm } from './forms/settings'; import { LoadingSpinner } from '@graphpolaris/shared/lib/components/LoadingSpinner'; @@ -21,7 +15,7 @@ export default function DatabaseSelector({}) { const dispatch = useAppDispatch(); const session = useSessionCache(); const schemaGraph = useSchemaGraph(); - const authCache = useAuthorizationCache(); + const authCache = useAuthCache(); const [hovered, setHovered] = useState<string | null>(null); const [connecting, setConnecting] = useState<boolean>(false); const [dbSelectionMenuOpen, setDbSelectionMenuOpen] = useState<boolean>(false); @@ -61,33 +55,10 @@ export default function DatabaseSelector({}) { }; }, [connecting]); - const { canRead, canWrite } = useCheckPermissionPolicy(); - const [readAllowed, setReadAllowed] = useState(false); - const [writeAllowed, setWriteAllowed] = useState(false); - const resource = 'database'; - - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); - - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); - - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); - - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); - return ( <div className="menu-walkthrough"> <TooltipProvider delayDuration={1000}> - {settingsMenuOpen !== undefined && ( + {settingsMenuOpen !== undefined && authCache.authorization.savestate.W && ( <SettingsForm open={settingsMenuOpen} saveState={settingsMenuOpen === 'update' ? selectedSaveState : null} @@ -113,14 +84,23 @@ export default function DatabaseSelector({}) { > <DropdownTrigger onClick={() => { - if (connecting || authCache.authorized === false || !!authCache.roomID || writeAllowed) { - console.debug('User blocked from editing query due to being a viewer'); + if ( + connecting || + authCache.authentication?.authenticated === false || + !!authCache.authentication?.roomID || + authCache.authorization.savestate.W + ) { setDbSelectionMenuOpen(!dbSelectionMenuOpen); } }} className="w-[18rem]" size="md" - disabled={connecting || authCache.authorized === false || !!authCache.roomID || !writeAllowed} + noDropdownArrow={ + connecting || + authCache.authentication?.authenticated === false || + !!authCache.authentication?.roomID || + !authCache.authorization.savestate.W + } title={ <div className="flex items-center"> {connecting && session.currentSaveState && session.currentSaveState in session.saveStates ? ( diff --git a/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx index 48a7112228f77189b8c69cf8cf9f6bedbf157e5c..1877544da7e7c934c7252469724a619c85cb17e1 100644 --- a/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx +++ b/apps/web/src/components/navbar/DatabaseManagement/forms/settings.tsx @@ -5,7 +5,7 @@ import { wsUpdateState, wsTestDatabaseConnection, wsCreateState, - useAuthorizationCache, + useAuthCache, nilUUID, } from '@graphpolaris/shared/lib/data-access'; import { Dialog, DialogContent } from '@graphpolaris/shared/lib/components/layout'; @@ -25,9 +25,9 @@ type Connection = { export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; saveState: SaveStateI | null; disableCancel?: boolean }) => { const dispatch = useAppDispatch(); const ref = useRef<HTMLDialogElement>(null); - const auth = useAuthorizationCache(); + const auth = useAuthCache(); const [formData, setFormData] = useImmer( - props.saveState && props.open === 'update' ? props.saveState : { ...INITIAL_SAVE_STATE, user_id: auth.userID || '' }, + props.saveState && props.open === 'update' ? props.saveState : { ...INITIAL_SAVE_STATE, user_id: auth.authentication?.userID || '' }, ); const [hasError, setHasError] = useState(false); const [sampleDataPanel, setSampleDataPanel] = useState<boolean | null>(false); @@ -60,9 +60,13 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s console.error('formData is null'); return; } - if (saveStateData.user_id !== auth.userID && auth.userID) { + if (!auth.authentication) { + console.error('auth is null'); + return; + } + if (saveStateData.user_id !== auth.authentication.userID && auth.authentication.userID) { console.error('user_id is not equal to auth.userID'); - saveStateData.user_id = auth.userID; + saveStateData.user_id = auth.authentication.userID; } if (data && data.status === 'success') { setConnection((prevState) => ({ @@ -107,7 +111,7 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s status: null, verified: null, }); - setFormData({ ...INITIAL_SAVE_STATE, user_id: auth.userID || '' }); + setFormData({ ...INITIAL_SAVE_STATE, user_id: auth.authentication?.userID || '' }); ref.current?.close(); props.onClose(); } @@ -143,7 +147,7 @@ export const SettingsForm = (props: { onClose(): void; open: 'add' | 'update'; s <SampleDatabaseSelector onClick={(data) => { setHasError(false); - handleSubmit({ ...data, user_id: auth.userID || '' }); + handleSubmit({ ...data, user_id: auth.authentication?.userID || '' }); }} /> ) : ( diff --git a/apps/web/src/components/navbar/navbar.tsx b/apps/web/src/components/navbar/navbar.tsx index ab2cbcfa2e880ef155e8084a15434e0af8a36fa1..435a06ecad03130790e262a6ea736dcfc10c890a 100644 --- a/apps/web/src/components/navbar/navbar.tsx +++ b/apps/web/src/components/navbar/navbar.tsx @@ -9,7 +9,7 @@ * We do not test components/renderfunctions/styling files. * See testing plan for more details.*/ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { useAuthorizationCache, useAuth, useCheckPermissionPolicy } from '@graphpolaris/shared/lib/data-access'; +import { useAuthCache, useAuthentication } from '@graphpolaris/shared/lib/data-access'; import DatabaseSelector from './DatabaseManagement/dbConnectionSelector'; import { DropdownItem } from '@graphpolaris/shared/lib/components/dropdowns'; import GpLogo from './gp-logo'; @@ -22,8 +22,8 @@ import { showManagePermissions, showSharableExploration } from 'config'; export const Navbar = () => { const dropdownRef = useRef<HTMLDivElement>(null); - const auth = useAuth(); - const authCache = useAuthorizationCache(); + const auth = useAuthentication(); + const authCache = useAuthCache(); const [menuOpen, setMenuOpen] = useState(false); const dispatch = useDispatch(); const buildInfo = import.meta.env.GRAPHPOLARIS_VERSION; @@ -40,28 +40,28 @@ export const Navbar = () => { }; }, [menuOpen]); - const { canRead, canWrite } = useCheckPermissionPolicy(); + // const { canRead, canWrite } = useCheckPermissionPolicy(); const [readAllowed, setReadAllowed] = useState(false); const [writeAllowed, setWriteAllowed] = useState(false); - const resource = 'policy'; + // const resource = 'policy'; - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); + // const checkReadPermission = useCallback(async () => { + // const result = await canRead(resource); + // setReadAllowed(result); + // }, [canRead]); - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); + // const checkWritePermission = useCallback(async () => { + // const result = await canWrite(resource); + // setWriteAllowed(result); + // }, [canWrite]); - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); + // useEffect(() => { + // checkReadPermission(); + // }, [checkReadPermission]); - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); + // useEffect(() => { + // checkWritePermission(); + // }, [checkWritePermission]); const handleConfirmUsers = (users: { name: string; email: string; type: string }[]) => { //TODO !FIXME: when the user clicks on confirm, users state is ready to be sent to backend @@ -85,17 +85,17 @@ export const Navbar = () => { className="relative inline-flex items-center justify-center w-8 h-8 overflow-hidden bg-secondary-500 rounded-full hover:bg-secondary-600 transition-colors duration-150 ease-in-out cursor-pointer" onClick={() => setMenuOpen(!menuOpen)} > - <span className="font-medium text-light">{authCache.username?.slice(0, 2).toUpperCase()}</span> + <span className="font-medium text-light">{authCache.authentication?.username?.slice(0, 2).toUpperCase()}</span> </div> </PopoverTrigger> <PopoverContent className="w-56 z-30 bg-light rounded-sm border-[1px] outline-none"> <div className="p-2 text-sm border-b"> - <h2 className="font-bold">user: {authCache.username}</h2> - <h3 className="text-xs break-words">session: {authCache.sessionID}</h3> + <h2 className="font-bold">user: {authCache.authentication?.username}</h2> + <h3 className="text-xs break-words">session: {authCache.authentication?.sessionID}</h3> <h3 className="text-xs break-words">license: Creator</h3> </div> - {authCache.authorized ? ( + {authCache.authentication?.authenticated ? ( <> {showSharableExploration() && ( <DropdownItem @@ -115,9 +115,9 @@ export const Navbar = () => { <DropdownItem value="Login" onClick={() => {}} /> </> )} - {authCache?.roomID && ( + {authCache.authentication?.roomID && ( <div className="p-2 border-b"> - <h3 className="text-xs break-words">Share ID: {authCache.roomID}</h3> + <h3 className="text-xs break-words">Share ID: {authCache.authentication?.roomID}</h3> </div> )} <div className="p-2 border-t"> @@ -129,7 +129,7 @@ export const Navbar = () => { <DialogTrigger className="ml-2 text-sm hover:bg-secondary-200">Manage Viewers Permission</DialogTrigger> <DialogContent> <UserManagementContent - sessionId={authCache.sessionID ?? ''} + sessionId={authCache.authentication?.sessionID ?? ''} onConfirm={handleConfirmUsers} onClickShareLink={handleClickShareLink} /> diff --git a/apps/web/src/components/onboarding/onboarding.tsx b/apps/web/src/components/onboarding/onboarding.tsx index 285cdebeecc87810c5165bd470d6628bb3229240..3327615ceaa543b2eed7d4604b6ffe676953151b 100644 --- a/apps/web/src/components/onboarding/onboarding.tsx +++ b/apps/web/src/components/onboarding/onboarding.tsx @@ -3,7 +3,7 @@ import Joyride, { ACTIONS, EVENTS, STATUS, Step } from 'react-joyride'; import { useLocation } from 'react-router-dom'; import { Button } from '@graphpolaris/shared/lib/components/buttons'; import { useCases } from './use-cases'; -import { useAuthorizationCache } from '@graphpolaris/shared/lib/data-access'; +import { useAuthCache } from '@graphpolaris/shared/lib/data-access'; interface OnboardingState { run?: boolean; @@ -12,7 +12,7 @@ interface OnboardingState { export function Onboarding({}) { const location = useLocation(); - const auth = useAuthorizationCache(); + const auth = useAuthCache(); const [showWalkthrough, setShowWalkthrough] = useState<boolean>(false); const [onboarding, setOnboarding] = useState<OnboardingState>({ run: false, diff --git a/libs/shared/lib/components/buttons/Button.tsx b/libs/shared/lib/components/buttons/Button.tsx index 79422a12dde0ff169650ec65fc91770b981728c1..639e7f9c6573916df649effc04a19b0252611512 100644 --- a/libs/shared/lib/components/buttons/Button.tsx +++ b/libs/shared/lib/components/buttons/Button.tsx @@ -139,6 +139,8 @@ export const Button = React.forwardRef<HTMLButtonElement | HTMLAnchorElement | H [iconComponent, label, children], ); + const disabledClass = useMemo(() => (disabled ? 'cursor-not-allowed' : 'cursor-pointer'), [disabled]); + const ButtonComponent = as; const isAnchor = as === 'a'; @@ -148,8 +150,8 @@ export const Button = React.forwardRef<HTMLButtonElement | HTMLAnchorElement | H {tooltip && <TooltipContent>{tooltip}</TooltipContent>} <TooltipTrigger> <ButtonComponent - className={`${styles.btn} ${typeClass} ${variantClass} ${sizeClass} ${blockClass} ${roundedClass} ${iconOnlyClass} ${className ? className : ''}`} - onClick={onClick} + className={`${styles.btn} ${typeClass} ${variantClass} ${sizeClass} ${blockClass} ${roundedClass} ${iconOnlyClass} ${disabledClass} ${className ? className : ''}`} + onClick={disabled ? undefined : onClick} disabled={disabled} aria-label={ariaLabel} href={isAnchor ? href : undefined} diff --git a/libs/shared/lib/components/dropdowns/index.tsx b/libs/shared/lib/components/dropdowns/index.tsx index 47f82156b32113805b40914e5dda9fbc027a10ed..3bbc861cb2d15cc0dd74820264704cc80f06663b 100644 --- a/libs/shared/lib/components/dropdowns/index.tsx +++ b/libs/shared/lib/components/dropdowns/index.tsx @@ -19,6 +19,7 @@ type DropdownTriggerProps = { popover?: boolean; onClick?: () => void; children?: ReactNode; + noDropdownArrow?: boolean; }; export function DropdownTrigger({ @@ -28,23 +29,30 @@ export function DropdownTrigger({ variant, className, onClick, + noDropdownArrow = false, popover = true, children = undefined, }: DropdownTriggerProps) { const paddingClass = size === 'xs' ? 'py-0' : size === 'sm' ? 'px-1 py-1' : size === 'md' ? 'px-2 py-1' : 'px-4 py-2'; const textSizeClass = size === 'xs' ? 'text-xs' : size === 'sm' ? 'text-sm' : size === 'md' ? 'text-base' : 'text-lg'; + + const disabledClass = disabled ? 'cursor-not-allowed' : 'cursor-pointer'; const variantClass = variant === 'primary' || !variant ? 'border bg-light rounded' : variant === 'ghost' ? 'bg-transparent shadow-none' : 'border rounded bg-transparent'; - const inner = children || ( + const inner = children ? ( + React.cloneElement(children as React.ReactElement, { + disabled: disabled, + }) + ) : ( <div - className={`inline-flex w-full truncate justify-between items-center gap-x-1.5 ${variantClass} ${textSizeClass} ${paddingClass} text-secondary-900 shadow-sm ${disabled ? ` cursor-not-allowed text-secondary-400 bg-secondary-100` : 'cursor-pointer'} pl-1 truncate`} + className={`inline-flex w-full truncate justify-between items-center gap-x-1.5 ${variantClass} ${textSizeClass} ${paddingClass} text-secondary-900 shadow-sm ${noDropdownArrow ? `pointer-events-none cursor-default` : ''} ${disabled ? ` cursor-not-allowed text-secondary-400 bg-secondary-100` : 'cursor-pointer'} pl-1 truncate`} > <span className={`text-${size}`}>{title}</span> - <Icon component="icon-[ic--baseline-arrow-drop-down]" size={16} /> + {!noDropdownArrow && <Icon component="icon-[ic--baseline-arrow-drop-down]" size={16} />} </div> ); diff --git a/libs/shared/lib/components/inputs/index.tsx b/libs/shared/lib/components/inputs/index.tsx index 7d4a98f33ae1dfcb04be07f471192f5e6e85a57b..0bf99b5541b30bdebcc9cca3a0f3e6d3ea0d1f95 100644 --- a/libs/shared/lib/components/inputs/index.tsx +++ b/libs/shared/lib/components/inputs/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { MouseEventHandler, useEffect, useState } from 'react'; import styles from './inputs.module.scss'; import { DropdownTrigger, DropdownContainer, DropdownItem, DropdownItemContainer } from '../dropdowns'; import Info from '../info'; @@ -34,7 +34,7 @@ type TextProps = { className?: string; validate?: (value: any) => boolean; onChange?: (value: string) => void; - onClick?: (e: Event) => void; + onClick?: (e: any) => void; }; type NumberProps = { diff --git a/libs/shared/lib/components/layout/FrozenOverlay.tsx b/libs/shared/lib/components/layout/FrozenOverlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92bf9fc14ed9e440458a5b1fb508f4b9e67dc1a6 --- /dev/null +++ b/libs/shared/lib/components/layout/FrozenOverlay.tsx @@ -0,0 +1,9 @@ +export const FrozenOverlay = (props: { children: React.ReactNode; enabled?: boolean }) => { + if (!props.enabled || !props.children || props.children === '') return null; + + return ( + <div className="absolute h-screen w-screen left-0 top-0 flex justify-center items-center blur-sm pointer-events-none z-50"> + <div className="text-3xl font-bold">{props.children}</div> + </div> + ); +}; diff --git a/libs/shared/lib/components/layout/index.ts b/libs/shared/lib/components/layout/index.ts index 93c40e5237fe125e95be4abf2d400f68187b8ea8..e2a065262500480e40025f26d1fa3babaf49de48 100644 --- a/libs/shared/lib/components/layout/index.ts +++ b/libs/shared/lib/components/layout/index.ts @@ -1,3 +1,4 @@ export * from './Dialog'; export * from './Panel'; export * from './Resizable'; +export * from './FrozenOverlay'; diff --git a/libs/shared/lib/data-access/api/eventBus.tsx b/libs/shared/lib/data-access/api/eventBus.tsx index 4a5df9196bb36eee511d364291969ecc565f9206..283f07dd8ce57c9caa1962fd9761f327e4e06635 100644 --- a/libs/shared/lib/data-access/api/eventBus.tsx +++ b/libs/shared/lib/data-access/api/eventBus.tsx @@ -1,6 +1,6 @@ import { - useAuth, - useAuthorizationCache, + useAuthentication, + useAuthCache, useAppDispatch, useSessionCache, useQuerybuilderHash, @@ -32,6 +32,7 @@ import { wsGetStateSubscription, wsSelectStateSubscription, wsTestSaveStateConnectionSubscription, + wsStateGetPolicy, } from '../broker/wsState'; import { addSaveState, @@ -40,6 +41,7 @@ import { updateSaveStateList, updateSelectedSaveState, setFetchingSaveStates, + setStateAuthorization, } from '../store/sessionSlice'; import { URLParams, getParam, deleteParam } from './url'; import { VisState, setVisualizationState } from '../store/visualizationSlice'; @@ -48,10 +50,12 @@ import { setSchemaAttributeDimensions, setSchemaAttributeInformation } from '../ import { addError } from '@graphpolaris/shared/lib/data-access/store/configSlice'; import { unSelect } from '../store/interactionSlice'; import { SchemaGraphStats } from '../../schema'; +import { wsUserGetPolicy, wsUserPolicyCheck } from '../broker/wsUser'; +import { authorized } from '../store/authSlice'; export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function }) => { - const { login } = useAuth(); - const auth = useAuthorizationCache(); + const { login } = useAuthentication(); + const auth = useAuthCache(); const dispatch = useAppDispatch(); const session = useSessionCache(); const queryHash = useQuerybuilderHash(); @@ -134,6 +138,13 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } dispatch(updateSaveStateList(data)); const d = Object.fromEntries(data.map((x) => [x.id, x])); loadSaveState(session.currentSaveState, d); + data.forEach((ss) => { + if (session.saveStatesAuthorization?.[ss.id] === undefined) { + wsStateGetPolicy(ss.id, (ret) => { + dispatch(setStateAuthorization({ id: ss.id, authorization: ret })); + }); + } + }); }), ); @@ -143,6 +154,9 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } dispatch(addSaveState(data)); dispatch(selectSaveState(data.id)); loadSaveState(data.id, session.saveStates); + wsStateGetPolicy(data.id, (ret) => { + dispatch(setStateAuthorization({ id: data.id, authorization: ret })); + }); } }), ); @@ -217,11 +231,12 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } }, [session.currentSaveState]); useEffect(() => { + // debugger; // Newly (un)authorized - if (auth.authorized && auth.jwt) { + if (auth.authentication?.authenticated && auth.authentication?.jwt) { props.onAuthorized(); Broker.instance() - .useAuth(auth) + .useAuth(auth.authentication) .connect(() => { console.debug('WS connected', session.currentSaveState, window.location.search); @@ -233,6 +248,12 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } } dispatch(setFetchingSaveStates(true)); + + // check authorizations + wsUserGetPolicy((res) => { + dispatch(authorized(res)); + }); + wsGetStates((data) => { dispatch(setFetchingSaveStates(false)); return true; @@ -247,7 +268,7 @@ export const EventBus = (props: { onRunQuery: Function; onAuthorized: Function } } else { // dispatch(logout()); } - }, [auth]); + }, [auth.authentication]); useEffect(() => { if (!queryBuilder.ignoreReactivity) { diff --git a/libs/shared/lib/data-access/authorization/index.ts b/libs/shared/lib/data-access/authorization/index.ts deleted file mode 100644 index a9af457e957878d170b5ce0b9cdca1327e8aa9df..0000000000000000000000000000000000000000 --- a/libs/shared/lib/data-access/authorization/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './useAuth'; -export * from './useResourcesPolicy'; diff --git a/libs/shared/lib/data-access/authorization/useResourcesPolicy.tsx b/libs/shared/lib/data-access/authorization/useResourcesPolicy.tsx deleted file mode 100644 index d7390ba3a6c4f5b186c2d3633d3fc738d70cb290..0000000000000000000000000000000000000000 --- a/libs/shared/lib/data-access/authorization/useResourcesPolicy.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useResourcesPolicy } from '@graphpolaris/shared/lib/data-access'; -import { useMemo } from 'react'; -//import casbinjs from 'casbin.js'; -import { Authorizer } from 'casbin.js'; -export const useCheckPermissionPolicy = () => { - const policyPermissions = useResourcesPolicy(); - - const authorizer = useMemo(() => { - // docs tell to go this way, but it doesn't work - //const auth = new casbinjs.Authorizer('manual'); - const auth = new Authorizer('manual'); - - const permission = { - read: policyPermissions.read, - write: policyPermissions.write, - }; - - auth.setPermission(permission); - - return auth; - }, [policyPermissions]); - - const canRead = async (resource: string) => { - return await authorizer.can('read', resource); - }; - - const canWrite = async (resource: string) => { - return await authorizer.can('write', resource); - }; - - return { - canRead, - canWrite, - }; -}; diff --git a/libs/shared/lib/data-access/broker/broker.tsx b/libs/shared/lib/data-access/broker/broker.tsx index c8059b644abb4f35aa9d14dd3ab4adb0407ae154..ab9739aad520ef4d7c2341d20b0929ca98fb1d15 100644 --- a/libs/shared/lib/data-access/broker/broker.tsx +++ b/libs/shared/lib/data-access/broker/broker.tsx @@ -125,7 +125,11 @@ export class Broker { */ public connect(onOpen: () => void): void { // If there already is already a current websocket connection, close it first. - if (this.webSocket) this.close(); + console.debug('WS connection! Connecting to ' + this.url); + if (this.webSocket) { + console.warn('Closing old websocket connection'); + this.close(); + } const params = new URLSearchParams(window.location.search); // Most of these parameters are only really used in DEV @@ -146,6 +150,7 @@ export class Broker { /** Closes the current websocket connection. */ public close = (): void => { + console.warn('Closing WS for some reason'); if (this.webSocket) this.webSocket.close(); this.connected = false; this.webSocket = undefined; diff --git a/libs/shared/lib/data-access/broker/types.ts b/libs/shared/lib/data-access/broker/types.ts index 804715a37f1f0cce55001b47e41d7b64a5b7b1f1..883b2b111a08a7338550f49b9fecbeaac3a40885 100644 --- a/libs/shared/lib/data-access/broker/types.ts +++ b/libs/shared/lib/data-access/broker/types.ts @@ -19,7 +19,7 @@ type QueryOrchestratorMessage = { queryID: string; }; -export type keyTypeI = 'broadcastState' | 'dbConnection' | 'schema' | 'query' | 'state'; +export type keyTypeI = 'broadcastState' | 'dbConnection' | 'schema' | 'query' | 'state' | 'user'; export type subKeyTypeI = // Crud | 'create' @@ -37,7 +37,9 @@ export type subKeyTypeI = | 'testConnection' | 'getSchema' | 'getSchemaStats' - | 'runQuery'; + | 'runQuery' + | 'getPolicy' + | 'policyCheck'; 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 477121dff1e7f6f17c0555f55ff381cf7f6153a3..d938c9f2a17d142fe2e8567ec95d576cd52b039e 100644 --- a/libs/shared/lib/data-access/broker/wsState.tsx +++ b/libs/shared/lib/data-access/broker/wsState.tsx @@ -3,6 +3,7 @@ import { URLParams, setParam } from '../api/url'; import { Broker } from './broker'; import { DateStringStatement } from '../../querybuilder/model/logic/general'; import { VisState } from '../store/visualizationSlice'; +import { AuthorizationOperations } from '../store/authSlice'; export const databaseNameMapping: string[] = ['arangodb', 'neo4j']; export const databaseProtocolMapping: string[] = ['neo4j://', 'neo4j+s://', 'bolt://', 'bolt+s://']; @@ -28,6 +29,15 @@ export type DatabaseInfo = { type: number; }; +export const SaveStateAuthorizationObjectsArray = ['database', 'visualization', 'query', 'schema'] as const; +export type SaveStateAuthorizationObjects = (typeof SaveStateAuthorizationObjectsArray)[number]; + +export type SaveStateAuthorizationHeaders = { + [id in SaveStateAuthorizationObjects]: { + [id in AuthorizationOperations]: boolean; + }; +}; + export const nilUUID = '00000000-0000-0000-0000-000000000000'; export type SaveStateI = { @@ -168,3 +178,15 @@ export function wsTestDatabaseConnection(dbConnection: DatabaseInfo, callback?: callback, ); } + +type StateGetPolicyResponse = (data: SaveStateAuthorizationHeaders) => void; +export function wsStateGetPolicy(saveStateID: string, callback?: StateGetPolicyResponse) { + Broker.instance().sendMessage( + { + key: 'state', + subKey: 'getPolicy', + body: { SaveStateID: saveStateID }, + }, + callback, + ); +} diff --git a/libs/shared/lib/data-access/broker/wsUser.tsx b/libs/shared/lib/data-access/broker/wsUser.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b6e50a6a91cc5a1e82133a992ee48001c8de9eb --- /dev/null +++ b/libs/shared/lib/data-access/broker/wsUser.tsx @@ -0,0 +1,26 @@ +import { UserAuthorizationHeaders } from '../store/authSlice'; +import { Broker } from './broker'; + +type UserPolicyCheckResponse = (data: boolean) => void; +export function wsUserPolicyCheck(policyCheck: { object: string; operation: string }, callback?: UserPolicyCheckResponse) { + Broker.instance().sendMessage( + { + key: 'user', + subKey: 'policyCheck', + body: policyCheck, //messageTypePolicyCheck + }, + callback, + ); +} + +type UserGetPolicyResponse = (data: UserAuthorizationHeaders) => void; +export function wsUserGetPolicy(callback?: UserGetPolicyResponse) { + Broker.instance().sendMessage( + { + key: 'user', + subKey: 'getPolicy', + body: {}, + }, + callback, + ); +} diff --git a/libs/shared/lib/data-access/index.ts b/libs/shared/lib/data-access/index.ts index 1084b2e70f182f2f4be6541f0baef9ea070439f7..59d9138a5504188f287e18abdaf04104209303ee 100644 --- a/libs/shared/lib/data-access/index.ts +++ b/libs/shared/lib/data-access/index.ts @@ -1,3 +1,3 @@ export * from './api'; -export * from './authorization'; +export * from './security'; export * from './store'; diff --git a/libs/shared/lib/data-access/authorization/dashboardAlerts.tsx b/libs/shared/lib/data-access/security/dashboardAlerts.tsx similarity index 100% rename from libs/shared/lib/data-access/authorization/dashboardAlerts.tsx rename to libs/shared/lib/data-access/security/dashboardAlerts.tsx diff --git a/libs/shared/lib/data-access/security/index.ts b/libs/shared/lib/data-access/security/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0015f2acd5a8c4e4ec89b9094df959308e04d4ec --- /dev/null +++ b/libs/shared/lib/data-access/security/index.ts @@ -0,0 +1 @@ +export * from './useAuthentication'; diff --git a/libs/shared/lib/data-access/authorization/useAuth.tsx b/libs/shared/lib/data-access/security/useAuthentication.tsx similarity index 60% rename from libs/shared/lib/data-access/authorization/useAuth.tsx rename to libs/shared/lib/data-access/security/useAuthentication.tsx index 44f631b38ce472a0a7f108bb4d8f892cdcbc319f..f4718b51ca472c82b2a0323db6a396078a285cb7 100644 --- a/libs/shared/lib/data-access/authorization/useAuth.tsx +++ b/libs/shared/lib/data-access/security/useAuthentication.tsx @@ -1,17 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; -import { useAppDispatch, useAuthorizationCache } from '../store'; -import { authorized, changeRoom } from '../store/authSlice'; - -export type AuthenticationHeader = { - username: string; - userID: string; - sessionID: string; - roomID: string; - jwt: string; -}; +import { useAppDispatch, useAuthCache } from '../store'; +import { authenticated, changeRoom, UserAuthenticationHeader } from '../store/authSlice'; const domain = import.meta.env.BACKEND_URL; -const useruri = import.meta.env.BACKEND_USER; +const userURI = import.meta.env.BACKEND_USER; export const fetchSettings: RequestInit = { method: 'GET', @@ -19,27 +10,28 @@ export const fetchSettings: RequestInit = { redirect: 'follow', }; -export const useAuth = () => { +export const useAuthentication = () => { const dispatch = useAppDispatch(); - const auth = useAuthorizationCache(); + const auth = useAuthCache(); const handleError = (err: any) => { console.error(err); }; const login = () => { - fetch(`${domain}${useruri}/headers`, fetchSettings) + fetch(`${domain}${userURI}/headers`, fetchSettings) .then((res) => res .json() - .then((res: AuthenticationHeader) => { + .then((res: UserAuthenticationHeader) => { dispatch( - authorized({ + authenticated({ username: res.username, userID: res.userID, sessionID: res.sessionID, jwt: res.jwt, - authorized: true, + authenticated: true, + roomID: res.roomID, }), ); }) @@ -49,7 +41,7 @@ export const useAuth = () => { }; const newShareRoom = () => { - fetch(`${domain}${useruri}/share`, { ...fetchSettings, method: 'POST' }) + fetch(`${domain}${userURI}/share`, { ...fetchSettings, method: 'POST' }) .then((res) => res .json() @@ -64,4 +56,3 @@ export const useAuth = () => { return { login, newShareRoom }; }; -// export useAuth; diff --git a/libs/shared/lib/data-access/store/authSlice.ts b/libs/shared/lib/data-access/store/authSlice.ts index c89b16d6e340ef1bef8e1e04fed68cd61ed32899..160ca557c476fdd26542f48e60fd3298ffd4b12c 100644 --- a/libs/shared/lib/data-access/store/authSlice.ts +++ b/libs/shared/lib/data-access/store/authSlice.ts @@ -1,26 +1,55 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from './store'; +import { cloneDeep } from 'lodash-es'; + +export const UserAuthorizationObjectsArray = ['savestate'] as const; +export type UserAuthorizationObjects = (typeof UserAuthorizationObjectsArray)[number]; + +export const AuthorizationOperationsArray = ['R', 'W'] as const; +export type AuthorizationOperations = (typeof AuthorizationOperationsArray)[number]; + +export type UserAuthorizationHeaders = { + [id in UserAuthorizationObjects]: { + [id in AuthorizationOperations]: boolean; + }; +}; + +export const UserAuthorizationHeadersDefaults: UserAuthorizationHeaders = { + savestate: { + R: false, + W: false, + }, +}; + +export type UserAuthenticationHeader = { + username: string; + userID: string; + sessionID: string; + roomID: string; + jwt: string; +}; export type UseIsAuthorizedState = SingleIsAuthorizedState & { roomID: string | undefined; }; export type SingleIsAuthorizedState = { - authorized: boolean | undefined; - jwt: string | undefined; - sessionID: string | undefined; - userID: string | undefined; - username: string | undefined; + authenticated: boolean; + jwt: string; + sessionID: string; + userID: string; + username: string; + roomID: string | undefined; }; -// Define the initial state using that type -export const initialState: UseIsAuthorizedState = { - authorized: undefined, - jwt: undefined, - sessionID: undefined, - roomID: undefined, - userID: undefined, - username: undefined, +export type AuthSliceState = { + authentication: SingleIsAuthorizedState | undefined; + authorization: UserAuthorizationHeaders; +}; + +export const initialState: AuthSliceState = { + authentication: undefined, + authorization: cloneDeep(UserAuthorizationHeadersDefaults), }; export const authSlice = createSlice({ @@ -28,17 +57,23 @@ export const authSlice = createSlice({ // `createSlice` will infer the state type from the `initialState` argument initialState, reducers: { - authorized(state, action: PayloadAction<SingleIsAuthorizedState>) { - console.info('%cAuthorized ', 'background-color: blue', action.payload); - state.authorized = action.payload.authorized; - state.jwt = action.payload.jwt; - state.userID = action.payload.userID; - state.sessionID = action.payload.sessionID; - state.username = action.payload.username; + authenticated(state, action: PayloadAction<SingleIsAuthorizedState>) { + console.info('%cAuthenticated ', 'background-color: blue', action.payload); + state.authentication = action.payload; + state.authorization = cloneDeep(UserAuthorizationHeadersDefaults); + }, + authorized(state, action: PayloadAction<UserAuthorizationHeaders>) { + console.info(`%cAuthorized Result: `, 'background-color: green', action.payload); + state.authorization = action.payload; }, changeRoom(state, action: PayloadAction<string | undefined>) { + if (state.authentication === undefined) { + console.warn('Not authenticated'); + return; + } + console.info('Changing Room to', action.payload); - state.roomID = action.payload; + state.authentication.roomID = action.payload; const query = new URLSearchParams(window.location.search); if (!!action?.payload) { query.set('roomID', action?.payload || 'null'); @@ -50,26 +85,22 @@ export const authSlice = createSlice({ }, logout(state) { console.info('Logging out'); - state.authorized = undefined; - state.jwt = undefined; - state.sessionID = undefined; - state.userID = undefined; - state.username = undefined; - const query = new URLSearchParams(window.location.search); - query.delete('roomID'); - history.pushState(null, '', '?' + query.toString()); - }, - unauthorized(state) { - console.warn('Unauthorized'); - state.authorized = false; + state = cloneDeep(initialState); const query = new URLSearchParams(window.location.search); query.delete('roomID'); history.pushState(null, '', '?' + query.toString()); }, + // unauthorized(state) { + // console.warn('Unauthorized'); + // state.authentication.authenticated = false; + // const query = new URLSearchParams(window.location.search); + // query.delete('roomID'); + // history.pushState(null, '', '?' + query.toString()); + // }, }, }); -export const { authorized, unauthorized, logout, changeRoom } = authSlice.actions; +export const { authorized, authenticated, logout, changeRoom } = authSlice.actions; // Other code such as selectors can use the imported `RootState` type export const authState = (state: RootState) => state.auth; diff --git a/libs/shared/lib/data-access/store/hooks.ts b/libs/shared/lib/data-access/store/hooks.ts index 9680186eda78820aa1ba510c73e91d0010b0e6ec..2c57d5045c2a1be3ab4868cdc1007f6741e216f0 100644 --- a/libs/shared/lib/data-access/store/hooks.ts +++ b/libs/shared/lib/data-access/store/hooks.ts @@ -19,8 +19,8 @@ import { selectQuerybuilderGraph, selectQuerybuilderHash, } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; -import { SessionCacheI, sessionCacheState } from './sessionSlice'; -import { UseIsAuthorizedState, authState } from './authSlice'; +import { activeSaveState, activeSaveStateAuthorization, SessionCacheI, sessionCacheState } from './sessionSlice'; +import { AuthSliceState, UseIsAuthorizedState, authState } from './authSlice'; import { visualizationState, VisState, visualizationActive } from './visualizationSlice'; import { ML, allMLEnabled, selectML } from './mlSlice'; import { @@ -40,6 +40,7 @@ import { SelectionStateI, FocusStateI, focusState, selectionState } from './inte import { VisualizationSettingsType } from '../../vis/common'; import { PolicyUsersState, selectPolicyState } from './authorizationUsersSlice'; import { PolicyResourcesState, selectResourcesPolicyState } from './authorizationResourcesSlice'; +import { SaveStateAuthorizationHeaders, SaveStateI } from '..'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; @@ -67,7 +68,9 @@ export const useQuerybuilderAttributesShown: () => QueryGraphEdgeHandle[] = () = // Overall Configuration of the app export const useConfig: () => ConfigStateI = () => useAppSelector(configState); export const useSessionCache: () => SessionCacheI = () => useAppSelector(sessionCacheState); -export const useAuthorizationCache: () => UseIsAuthorizedState = () => useAppSelector(authState); +export const useActiveSaveState: () => SaveStateI = () => useAppSelector(activeSaveState); +export const useActiveSaveStateAuthorization: () => SaveStateAuthorizationHeaders = () => useAppSelector(activeSaveStateAuthorization); +export const useAuthCache: () => AuthSliceState = () => useAppSelector(authState); // Machine Learning Slices export const useML: () => ML = () => useAppSelector(selectML); diff --git a/libs/shared/lib/data-access/store/querybuilderSlice.ts b/libs/shared/lib/data-access/store/querybuilderSlice.ts index fb9e71062326c9c0aae94db64461dd9b2dbfaebf..df417947af928f0c66ed2b874afcb56b8c795315 100644 --- a/libs/shared/lib/data-access/store/querybuilderSlice.ts +++ b/libs/shared/lib/data-access/store/querybuilderSlice.ts @@ -46,7 +46,7 @@ export const initialState: QueryBuilderState = { }, attributesBeingShown: [], // schemaLayout: 'Graphology_noverlap', -}; +} as QueryBuilderState; //@ts-ignore(2589) export const querybuilderSlice = createSlice({ @@ -86,8 +86,8 @@ export const querybuilderSlice = createSlice({ }, }); -export const queryBuilderState = (state: RootState) => state.querybuilder; -export const queryBuilderSettingsState = (state: RootState) => state.querybuilder.settings; +export const queryBuilderState = (state: RootState): QueryBuilderState => state.querybuilder; +export const queryBuilderSettingsState = (state: RootState): QueryBuilderSettings => state.querybuilder.settings; export const setQuerybuilderGraphology = (payload: QueryGraphology) => { return querybuilderSlice.actions.setQuerybuilderGraph(payload.export()); diff --git a/libs/shared/lib/data-access/store/sessionSlice.ts b/libs/shared/lib/data-access/store/sessionSlice.ts index b061b17eeb3a199e6a3cb671e6bfcdfc137342cf..650e0e62982ddeb9d4b55bf20b91893e1f8fabfa 100644 --- a/libs/shared/lib/data-access/store/sessionSlice.ts +++ b/libs/shared/lib/data-access/store/sessionSlice.ts @@ -1,7 +1,9 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from './store'; -import { DatabaseStatus, SaveStateI } from '../broker/wsState'; +import { DatabaseStatus, SaveStateAuthorizationHeaders, SaveStateAuthorizationObjectsArray, SaveStateI } from '../broker/wsState'; import { getParam, URLParams } from '../api/url'; +import { AuthorizationOperations } from './authSlice'; +import { cloneDeep } from 'lodash-es'; /** Message format of the error message from the backend */ export type ErrorMessage = { @@ -13,14 +15,23 @@ export type ErrorMessage = { export type SessionCacheI = { currentSaveState?: string; // id of the current save state saveStates: Record<string, SaveStateI>; + saveStatesAuthorization: Record<string, SaveStateAuthorizationHeaders>; fetchingSaveStates: boolean; testedSaveState: Record<string, DatabaseStatus>; }; +const defaultStateAuthorizationHeaders: SaveStateAuthorizationHeaders = { + query: { W: false, R: false }, + database: { W: false, R: false }, + visualization: { W: false, R: false }, + schema: { W: false, R: false }, +}; + // Define the initial state using that type export const initialState: SessionCacheI = { currentSaveState: undefined, saveStates: {}, + saveStatesAuthorization: {}, fetchingSaveStates: true, // default to true to prevent flashing of the UI testedSaveState: {}, }; @@ -79,9 +90,13 @@ export const sessionSlice = createSlice({ if (state.saveStates === undefined) state.saveStates = {}; state.saveStates[action.payload.id] = action.payload; state.currentSaveState = action.payload.id; + if (!state.saveStatesAuthorization[action.payload.id]) { + state.saveStatesAuthorization[action.payload.id] = cloneDeep(defaultStateAuthorizationHeaders); + } }, deleteSaveState: (state: SessionCacheI, action: PayloadAction<string>) => { delete state.saveStates[action.payload]; + delete state.saveStatesAuthorization[action.payload]; if (state.currentSaveState === action.payload) { if (Object.keys(state.saveStates).length > 0) state.currentSaveState = Object.keys(state.saveStates)[0]; else state.currentSaveState = undefined; @@ -90,6 +105,12 @@ export const sessionSlice = createSlice({ testedSaveState: (state: SessionCacheI, action: PayloadAction<string>) => { state.testedSaveState = { ...state.testedSaveState, [action.payload]: DatabaseStatus.tested }; }, + setStateAuthorization: (state: SessionCacheI, action: PayloadAction<{ id: string; authorization: SaveStateAuthorizationHeaders }>) => { + state.saveStatesAuthorization[action.payload.id] = action.payload.authorization; + }, + deleteAuthorization: (state: SessionCacheI, action: PayloadAction<string>) => { + delete state.saveStatesAuthorization[action.payload]; + }, }, }); @@ -102,9 +123,14 @@ export const { testedSaveState, setFetchingSaveStates, updateSelectedSaveState, + setStateAuthorization, + deleteAuthorization, } = sessionSlice.actions; // Other code such as selectors can use the imported `RootState` type export const sessionCacheState = (state: RootState) => state.sessionCache; +export const activeSaveState = (state: RootState): SaveStateI => state.sessionCache.saveStates?.[state.sessionCache.currentSaveState!]; +export const activeSaveStateAuthorization = (state: RootState): SaveStateAuthorizationHeaders => + state.sessionCache.saveStatesAuthorization?.[state.sessionCache.currentSaveState!] || defaultStateAuthorizationHeaders; export default sessionSlice.reducer; diff --git a/libs/shared/lib/inspector/InspectorPanel.tsx b/libs/shared/lib/inspector/InspectorPanel.tsx index 84257549e272407a47c7a21b345685e3311ee656..a803dd5cc4ad3125eeea16f0b678eeee7b7cf8ce 100644 --- a/libs/shared/lib/inspector/InspectorPanel.tsx +++ b/libs/shared/lib/inspector/InspectorPanel.tsx @@ -20,7 +20,7 @@ export function InspectorPanel(props: { children?: React.ReactNode }) { const inspector = useMemo(() => { if (selection) return <SelectionConfig />; // if (!focus) return <ConnectionInspector />; - // if (activeVisualizationIndex !== -1) return <ConnectionInspector />; + // if (activeVisualizationIndex !== -1) return <ConnectionInspector />; return <VisualizationSettings />; // if (focus.focusType === 'visualization') return <VisualizationConfigPanel />; // else if (focus.focusType === 'schema') return <SchemaDialog />; diff --git a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx index 7bb306349e0948bd7e5044250ed590d0cfe9515d..c1087cd3fad253d5743201c3e0f697751e2bbede 100644 --- a/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx +++ b/libs/shared/lib/querybuilder/panel/QueryBuilder.tsx @@ -1,4 +1,6 @@ import { + useActiveSaveStateAuthorization, + useAppDispatch, useConfig, useQuerybuilderGraph, useQuerybuilderHash, @@ -7,10 +9,8 @@ import { useSchemaInference, useSearchResultQB, } from '@graphpolaris/shared/lib/data-access/store'; -import { useCheckPermissionPolicy } from '@graphpolaris/shared/lib/data-access'; import { setQuerybuilderGraphology, toQuerybuilderGraphology } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; import ReactFlow, { Background, Connection, @@ -52,6 +52,7 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { const [toggleSettings, setToggleSettings] = useState<QueryBuilderToggleSettings>(); const reactFlowWrapper = useRef<HTMLDivElement>(null); const queryBuilderSettings = useQuerybuilderSettings(); + const saveStateAuthorization = useActiveSaveStateAuthorization(); var nodeTypes = useMemo( () => ({ @@ -63,14 +64,13 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { ); var edgeTypes = useMemo(() => ({ connection: ConnectionLine, attribute_connection: ConnectionLine }), []); - const schemaGraph = useSchemaGraph(); const schemaInference = useSchemaInference(); const schema = useMemo(() => toSchemaGraphology(schemaGraph), [schemaGraph]); const graph = useQuerybuilderGraph(); const qbHash = useQuerybuilderHash(); const config = useConfig(); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const isDraggingPill = useRef(false); const connectingNodeId = useRef<ConnectingNodeDataI | null>(null); const editLogicNode = useRef<SchemaReactflowLogicNode | undefined>(undefined); @@ -82,28 +82,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { const searchResults = useSearchResultQB(); const reactFlowInstanceRef = useRef<ReactFlowInstance | null>(null); const [allowZoom, setAllowZoom] = useState(true); - const { canRead, canWrite } = useCheckPermissionPolicy(); - const [readAllowed, setReadAllowed] = useState(false); - const [writeAllowed, setWriteAllowed] = useState(false); - const resource = 'query'; - - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); - - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); - - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); - - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); useEffect(() => { const searchResultKeys = new Set([...searchResults.nodes.map((node) => node.key), ...searchResults.edges.map((edge) => edge.key)]); @@ -129,6 +107,7 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { * TODO: only works if the node is clicked and not moved (maybe use onSelectionChange) */ function onNodesDelete(nodes: Node[]) { + if (!saveStateAuthorization.query.W) return; nodes.forEach((n) => { graphologyGraph.dropNode(n.id); }); @@ -242,12 +221,6 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { * @param event Drag event. */ const onDrop = (event: React.DragEvent<HTMLDivElement>): void => { - if (!writeAllowed) { - console.debug('User blocked from editing query due to being a viewer'); - event.preventDefault(); - return; - } - event.preventDefault(); // The dropped element should be a valid reactflow element @@ -612,17 +585,19 @@ export const QueryBuilderInner = (props: QueryBuilderProps) => { reactFlowInstanceRef.current = reactFlowInstance; onInit(reactFlowInstance); }} - onNodesChange={onNodesChange} - onDragOver={onDragOver} - onConnect={onConnect} - onConnectStart={onConnectStart} - onConnectEnd={onConnectEnd} + onNodesChange={saveStateAuthorization.query.W ? onNodesChange : () => {}} + onDragOver={saveStateAuthorization.query.W ? onDragOver : () => {}} + onConnect={saveStateAuthorization.query.W ? onConnect : () => {}} + onConnectStart={saveStateAuthorization.query.W ? onConnectStart : () => {}} + onConnectEnd={saveStateAuthorization.query.W ? onConnectEnd : () => {}} // onNodeMouseEnter={onNodeMouseEnter} // onNodeMouseLeave={onNodeMouseLeave} - onEdgeUpdate={onEdgeUpdate} - onEdgeUpdateStart={onEdgeUpdateStart} - onEdgeUpdateEnd={onEdgeUpdateEnd} - onDrop={onDrop} + onEdgeUpdate={saveStateAuthorization.query.W ? onEdgeUpdate : () => {}} + onEdgeUpdateStart={saveStateAuthorization.query.W ? onEdgeUpdateStart : () => {}} + onEdgeUpdateEnd={saveStateAuthorization.query.W ? onEdgeUpdateEnd : () => {}} + onDrop={saveStateAuthorization.query.W ? onDrop : () => {}} + // onContextMenu={onContextMenu} + // onNodeContextMenu={saveStateAuthorization.query.W ? onNodeContextMenu : () => {}} // onNodesDelete={onNodesDelete} // onNodesChange={onNodesChange} deleteKeyCode="Backspace" diff --git a/libs/shared/lib/querybuilder/panel/QueryBuilderNav.tsx b/libs/shared/lib/querybuilder/panel/QueryBuilderNav.tsx index fd592f0475798c15cc95ae9d72df061bfdb067a4..bd4149ef43afa7781459304ae570b42d444b308e 100644 --- a/libs/shared/lib/querybuilder/panel/QueryBuilderNav.tsx +++ b/libs/shared/lib/querybuilder/panel/QueryBuilderNav.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { ControlContainer, TooltipProvider, Tooltip, TooltipTrigger, Button, TooltipContent, Input } from '../../components'; import { Popover, PopoverTrigger, PopoverContent } from '../../components/layout/Popover'; -import { useAppDispatch, useGraphQueryResult, useML, useQuerybuilderSettings } from '../../data-access'; +import { useActiveSaveStateAuthorization, useAppDispatch, useGraphQueryResult, useML, useQuerybuilderSettings } from '../../data-access'; import { clearQB, setQuerybuilderSettings } from '../../data-access/store/querybuilderSlice'; import { QueryMLDialog } from './querysidepanel/QueryMLDialog'; import { QuerySettings } from './querysidepanel/QuerySettings'; @@ -23,6 +23,7 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { const result = useGraphQueryResult(); const resultSize = useMemo(() => (result ? result.edges.length : 0), [result]); const ml = useML(); + const saveStateAuthorization = useActiveSaveStateAuthorization(); /** * Clears all nodes in the graph. @@ -61,8 +62,11 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { variantType="secondary" variant="ghost" size="xs" + disabled={!saveStateAuthorization.query.W} iconComponent="icon-[ic--baseline-delete]" - onClick={() => clearAllNodes()} + onClick={() => { + if (saveStateAuthorization.query.W) clearAllNodes(); + }} /> </TooltipTrigger> <TooltipContent> @@ -105,6 +109,7 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { variantType="secondary" variant="ghost" size="xs" + disabled={!saveStateAuthorization.query.W} iconComponent="icon-[ic--baseline-settings]" className="query-settings" /> @@ -138,6 +143,7 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { variantType="secondary" variant="ghost" size="xs" + disabled={!saveStateAuthorization.query.W} iconComponent="icon-[ic--baseline-difference]" onClick={props.onLogic} /> @@ -154,6 +160,7 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { variantType={mlEnabled ? 'primary' : 'secondary'} variant="ghost" size="xs" + disabled={!saveStateAuthorization.query.W} iconComponent="icon-[ic--baseline-lightbulb]" className={mlEnabled ? 'border-primary-600' : ''} /> @@ -231,6 +238,7 @@ export const QueryBuilderNav = (props: QueryBuilderNavProps) => { variantType={qb.limit <= resultSize ? 'primary' : 'secondary'} variant="ghost" size="xs" + disabled={!saveStateAuthorization.query.W} iconComponent="icon-[ic--baseline-filter-alt]" className={qb.limit <= resultSize ? 'border-primary-600' : ''} /> diff --git a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx index abdd1f8412325eea4c5e4481e5f34ecc4203f4c1..1485784c10f6f8cd6981714d70e2b20485ff9e11 100644 --- a/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx +++ b/libs/shared/lib/querybuilder/pills/customFlowPills/entitypill/QueryEntityPill.tsx @@ -1,5 +1,4 @@ -import { useCheckPermissionPolicy, useQuerybuilderGraph, useQuerybuilderHash } from '@graphpolaris/shared/lib/data-access'; -import { setQuerybuilderGraphology, toQuerybuilderGraphology, attributeShownToggle } from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; +import { useQuerybuilderAttributesShown, useQuerybuilderGraph, useQuerybuilderHash } from '@graphpolaris/shared/lib/data-access'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@graphpolaris/shared/lib/components/buttons'; import { Icon } from '@graphpolaris/shared/lib/components/icon'; @@ -10,9 +9,13 @@ import { NodeAttribute, SchemaReactflowEntityNode, toHandleId } from '../../../m import { PillAttributes } from '../../pillattributes/PillAttributes'; import { DropdownTrigger, DropdownContainer, DropdownItemContainer, DropdownItem } from '@graphpolaris/shared/lib/components/dropdowns'; import { PopoverContext } from '@graphpolaris/shared/lib/components/layout/Popover'; -import { useDispatch } from 'react-redux'; -import { useQuerybuilderAttributesShown } from '@graphpolaris/shared/lib/data-access/store'; +import { + toQuerybuilderGraphology, + setQuerybuilderGraphology, + attributeShownToggle, +} from '@graphpolaris/shared/lib/data-access/store/querybuilderSlice'; import { isEqual } from 'lodash-es'; +import { useDispatch } from 'react-redux'; /** * Component to render an entity flow element @@ -41,30 +44,9 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { }; const [openDropdown, setOpenDropdown] = useState(false); - const { canRead, canWrite } = useCheckPermissionPolicy(); - const [readAllowed, setReadAllowed] = useState(false); - const [writeAllowed, setWriteAllowed] = useState(false); const [filter, setFilter] = useState<string>(''); const resource = 'query'; - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); - - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); - - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); - - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); - const filteredAttributes = useMemo(() => { if (filter == null || filter.length == 0) return data.attributes; @@ -88,7 +70,7 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { const attributesBeingShown = useQuerybuilderAttributesShown(); function isAttributeAdded(attribute: NodeAttribute): boolean { - return attributesBeingShown.some((x) => isEqual(x, attribute.handleData)) + return attributesBeingShown.some((x) => isEqual(x, attribute.handleData)); } return ( @@ -111,34 +93,45 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { <DropdownItemContainer> <PopoverContext.Consumer> - {popover => <DropdownItem value={'Add/remove attribute'} onClick={(e) => { - popover?.setOpen(false); - setOpenDropdown(false); - }} submenu={ - [ - <TextInput - type={'text'} - placeholder="Filter" - size="xs" - className="mb-1 min-w-40 rounded-sm" - value={filter} - onClick={(e) => e.stopPropagation()} - onChange={(v) => setFilter(v)} />, - - filteredAttributes.map(attr => - <DropdownItem - key={attr.handleData.attributeName + attr.handleData.nodeId} - value={attr.handleData.attributeName ?? ''} - selected={isAttributeAdded(attr)} - onClick={(_) => addAttribute(attr)}> - <Icon component={attr?.handleData?.attributeDimension != null ? IconMap[attr.handleData.attributeDimension] : undefined} className="ms-2 float-end" size={16} /> - </DropdownItem> - ) - ] - } />} + {(popover) => ( + <DropdownItem + value={'Add/remove attribute'} + onClick={(e) => { + popover?.setOpen(false); + setOpenDropdown(false); + }} + submenu={[ + <TextInput + type={'text'} + placeholder="Filter" + size="xs" + className="mb-1 min-w-40 rounded-sm" + value={filter} + onClick={(e) => e.stopPropagation()} + onChange={(v) => setFilter(v)} + />, + + filteredAttributes.map((attr) => ( + <DropdownItem + key={attr.handleData.attributeName + attr.handleData.nodeId} + value={attr.handleData.attributeName ?? ''} + selected={isAttributeAdded(attr)} + onClick={(_) => addAttribute(attr)} + > + <Icon + component={ + attr?.handleData?.attributeDimension != null ? IconMap[attr.handleData.attributeDimension] : undefined + } + className="ms-2 float-end" + size={16} + /> + </DropdownItem> + )), + ]} + /> + )} </PopoverContext.Consumer> <DropdownItem value="Remove" className="text-danger" onClick={(e) => removeNode()} /> - </DropdownItemContainer> </DropdownContainer> </div> @@ -151,7 +144,7 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { className={'!rounded-none !bg-transparent !w-full !h-full !border-0 !right-0 !left-0'} type="target" style={{ - pointerEvents: writeAllowed ? 'auto' : 'none', + pointerEvents: false ? 'auto' : 'none', }} ></Handle> } @@ -163,17 +156,13 @@ export const QueryEntityPill = React.memo((node: SchemaReactflowEntityNode) => { className={'!rounded-none !bg-transparent !w-full !h-full !border-0 !right-0 !left-0'} type="source" style={{ - pointerEvents: writeAllowed ? 'auto' : 'none', + pointerEvents: false ? 'auto' : 'none', }} ></Handle> } > {data?.attributes && ( - <PillAttributes - node={node} - attributes={data.attributes} - attributeEdges={attributeEdges.map((edge) => edge?.attributes)} - /> + <PillAttributes node={node} attributes={data.attributes} attributeEdges={attributeEdges.map((edge) => edge?.attributes)} /> )} </EntityPill> </div> diff --git a/libs/shared/lib/vis/components/VisualizationTabBar.tsx b/libs/shared/lib/vis/components/VisualizationTabBar.tsx index 35a69a8adc86a533fb4e7d30ad10f2259e400763..9d229a1a142c1438efe5abcfbabec66ed79189d9 100644 --- a/libs/shared/lib/vis/components/VisualizationTabBar.tsx +++ b/libs/shared/lib/vis/components/VisualizationTabBar.tsx @@ -3,38 +3,16 @@ import { Button, DropdownContainer, DropdownItem, DropdownItemContainer, Dropdow import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../components/tooltip'; import { ControlContainer } from '../../components/controls'; import { Tabs, Tab } from '../../components/tabs'; -import { useAppDispatch, useVisualization, useCheckPermissionPolicy } from '../../data-access'; +import { useActiveSaveState, useActiveSaveStateAuthorization, useAppDispatch, useSessionCache, useVisualization } from '../../data-access'; import { addVisualization, removeVisualization, reorderVisState, setActiveVisualization } from '../../data-access/store/visualizationSlice'; import { Visualizations } from './VisualizationPanel'; export default function VisualizationTabBar(props: { fullSize: () => void; exportImage: () => void }) { const { activeVisualizationIndex, openVisualizationArray } = useVisualization(); + const saveStateAuthorization = useActiveSaveStateAuthorization(); const [open, setOpen] = useState(false); const dispatch = useAppDispatch(); - const { canRead, canWrite } = useCheckPermissionPolicy(); - const [readAllowed, setReadAllowed] = useState(false); - const [writeAllowed, setWriteAllowed] = useState(false); - const resource = 'visualization'; - - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); - - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); - - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); - - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); - const handleDragStart = (e: React.DragEvent<HTMLDivElement>, i: number) => { e.dataTransfer.setData('text/plain', i.toString()); }; @@ -96,6 +74,7 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor <Button variantType="secondary" variant="ghost" + disabled={!saveStateAuthorization.database.W} rounded size="2xs" iconComponent="icon-[ic--baseline-close]" @@ -113,16 +92,14 @@ export default function VisualizationTabBar(props: { fullSize: () => void; expor <Tooltip> <TooltipTrigger> <DropdownContainer open={open} onOpenChange={setOpen}> - <DropdownTrigger disabled={!writeAllowed} onClick={() => setOpen((v) => !v)}> + <DropdownTrigger disabled={!saveStateAuthorization.database.W} onClick={() => setOpen((v) => !v)}> <Button as={'a'} variantType="secondary" variant="ghost" size="xs" - disabled={true} iconComponent="icon-[ic--baseline-add]" onClick={() => {}} - className={`${writeAllowed ? 'cursor-pointer' : 'cursor-not-allowed'}`} /> </DropdownTrigger> <DropdownItemContainer> diff --git a/libs/shared/lib/vis/views/Recommender.tsx b/libs/shared/lib/vis/views/Recommender.tsx index 9417884364098bcdf6edea448cd496d105d1895a..93682640b2da431b9ee579b9ba0e75d104cfcf53 100644 --- a/libs/shared/lib/vis/views/Recommender.tsx +++ b/libs/shared/lib/vis/views/Recommender.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import Info from '../../components/info'; import { addVisualization } from '../../data-access/store/visualizationSlice'; -import { useAppDispatch, useCheckPermissionPolicy } from '../../data-access'; +import { useActiveSaveStateAuthorization, useAppDispatch } from '../../data-access'; import { Visualizations } from '../components/VisualizationPanel'; import { isVisualizationReleased } from 'config'; @@ -13,31 +13,9 @@ type VisualizationDescription = { export function Recommender() { const dispatch = useAppDispatch(); + const saveStateAuthorization = useActiveSaveStateAuthorization(); const [visualizationDescriptions, setVisualizationDescriptions] = useState<VisualizationDescription[]>([]); - const { canRead, canWrite } = useCheckPermissionPolicy(); - const [readAllowed, setReadAllowed] = useState(false); - const [writeAllowed, setWriteAllowed] = useState(false); - const resource = 'visualization'; - - const checkReadPermission = useCallback(async () => { - const result = await canRead(resource); - setReadAllowed(result); - }, [canRead]); - - const checkWritePermission = useCallback(async () => { - const result = await canWrite(resource); - setWriteAllowed(result); - }, [canWrite]); - - useEffect(() => { - checkReadPermission(); - }, [checkReadPermission]); - - useEffect(() => { - checkWritePermission(); - }, [checkWritePermission]); - useEffect(() => { const loadVisualizations = async () => { const descriptions = await Promise.all( @@ -64,10 +42,10 @@ export function Recommender() { {visualizationDescriptions.map(({ name, displayName, description }) => ( <div key={name} - className={`p-4 border ${writeAllowed ? 'cursor-pointer hover:bg-secondary-100' : 'cursor-not-allowed opacity-50'}`} + className={`p-4 border ${saveStateAuthorization.visualization.W ? 'cursor-pointer hover:bg-secondary-100' : 'cursor-not-allowed opacity-50'}`} onClick={async (e) => { e.preventDefault(); - if (!writeAllowed) { + if (!saveStateAuthorization.visualization.W) { console.debug('User blocked from editing query due to being a viewer'); return; }