Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ rules:
},
],
}
ignorePatterns: ['node_modules/', 'build/', '*.test.js', '*.test.jsx']
ignorePatterns: ['node_modules/', 'build/', '*.test.js', '*.test.jsx']
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import CommandsDrawer from './CommandsDrawer';
import { act } from 'react';

const openApiJson = {
paths: {
Expand Down Expand Up @@ -37,15 +38,18 @@ vi.stubGlobal('fetch', fetch);
describe('CommandsDrawer', () => {
it('should render CommandsDrawer with fetched data', async () => {
const commands = [];
render(
<CommandsDrawer
open={true}
toggleDrawer={() => {}}
handleInsertCommand={(command) => {
commands.push(command);
}}
/>
);

await act(async () => {
render(
<CommandsDrawer
open={true}
toggleDrawer={() => {}}
handleInsertCommand={(command) => {
commands.push(command);
}}
/>
);
});

expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveFocus();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import '@testing-library/jest-dom';
import ClusterInfo from './ClusterInfo';
import ClusterShardRow from './ClusterShardRow';

// Mock client context
vi.mock('../../../context/client-context', () => ({
useClient: () => ({
client: {},
isRestricted: false,
}),
}));

const CLUSTER_INFO = {
result: {
peer_id: 5644950770669488,
Expand Down
7 changes: 6 additions & 1 deletion src/components/Collections/CollectionInfo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import { bigIntJSON } from '../../common/bigIntJSON';

export const CollectionInfo = ({ collectionName }) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const { client: qdrantClient } = useClient();
const { client: qdrantClient, isRestricted } = useClient();
const [collection, setCollection] = React.useState({});
const [clusterInfo, setClusterInfo] = React.useState(null);

useEffect(() => {
fetchCollection();

if (isRestricted) {
return;
}

qdrantClient
.api('cluster')
.collectionClusterInfo({ collection_name: collectionName })
Expand Down
23 changes: 15 additions & 8 deletions src/components/Sidebar/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { LibraryBooks, Terminal, Animation, Key, RocketLaunch } from '@mui/icons-material';
import Tooltip from '@mui/material/Tooltip';
import SidebarTutorialSection from './SidebarTutorialSection';
import { useClient } from '../../context/client-context';

const drawerWidth = 240;

Expand Down Expand Up @@ -58,25 +59,31 @@ const Drawer = styled(MuiDrawer, {
}));

export default function Sidebar({ open, version, jwtEnabled, jwtVisible }) {
const { isRestricted } = useClient();

return (
<Drawer variant="permanent" open={open}>
<DrawerHeader />
<Divider />
<List>
{sidebarItem('Welcome', <RocketLaunch />, '/welcome', open)}
{!isRestricted && sidebarItem('Welcome', <RocketLaunch />, '/welcome', open)}
{sidebarItem('Console', <Terminal />, '/console', open)}
{sidebarItem('Collections', <LibraryBooks />, '/collections', open)}
<ListItem key={'Tutorial'} disablePadding sx={{ display: 'block' }}>
<SidebarTutorialSection isSidebarOpen={open} />
</ListItem>
{sidebarItem('Datasets', <Animation />, '/datasets', open)}
{!isRestricted && (
<ListItem key={'Tutorial'} disablePadding sx={{ display: 'block' }}>
<SidebarTutorialSection isSidebarOpen={open} />
</ListItem>
)}

{jwtVisible && sidebarItem('Access Tokens', <Key />, '/jwt', open, jwtEnabled)}
{!isRestricted && sidebarItem('Datasets', <Animation />, '/datasets', open)}

{!isRestricted && jwtVisible && sidebarItem('Access Tokens', <Key />, '/jwt', open, jwtEnabled)}
</List>
<List style={{ marginTop: `auto` }}>

<List style={{ marginTop: 'auto' }}>
<ListItem>
<Typography variant="caption">
{open ? `Qdrant ` : ``}v{version}
{open ? 'Qdrant ' : ''}Q{version}
</Typography>
</ListItem>
</List>
Expand Down
30 changes: 25 additions & 5 deletions src/components/Snapshots/SnapshotsUpload.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import { SnapshotUploadForm } from './SnapshotUploadForm';
import { useClient } from '../../context/client-context';

export const SnapshotsUpload = ({ onComplete, sx }) => {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [open, setOpen] = React.useState(false);
const { isRestricted } = useClient();

const handleUploadClick = () => {
setOpen(true);
if (!isRestricted) {
setOpen(true);
}
};

const handleUpload = () => {
Expand All @@ -27,11 +32,26 @@ export const SnapshotsUpload = ({ onComplete, sx }) => {

return (
<Box sx={{ ...sx }}>
<Tooltip title={'Upload snapshot'} placement="left">
<Button variant={'contained'} onClick={handleUploadClick} startIcon={<UploadFile fontSize={'small'} />}>
Upload snapshot
</Button>
<Tooltip
title={
isRestricted
? 'Access Denied: You do not have permission to upload snapshot. ' + 'Please contact your administrator.'
: 'Upload snapshot'
}
placement="left"
>
<span>
<Button
variant={'contained'}
onClick={handleUploadClick}
startIcon={<UploadFile fontSize={'small'} />}
disabled={isRestricted}
>
Upload snapshot
</Button>
</span>
</Tooltip>

<Dialog
fullScreen={fullScreen}
fullWidth={true}
Expand Down
29 changes: 29 additions & 0 deletions src/config/restricted-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// add unavailable routes to show message on these pages
// useful if used bookmarked or shared link
export const restrictedRoutes = ['/datasets', '/jwt', '/tutorial'];

export const isPathRestricted = (path) => {
return restrictedRoutes.some((restrictedPath) => {
if (restrictedPath.includes('*')) {
const regexPath = restrictedPath.replace('*', '.*');
return new RegExp(`^${regexPath}$`).test(path);
}
return path === restrictedPath;
});
};

export const isTokenRestricted = (token) => {
if (!token) {
return false;
}

try {
const decodedToken = JSON.parse(atob(token.split('.')[1]));
if (!decodedToken.access || !Array.isArray(decodedToken.access)) {
return false;
}
return decodedToken.access.some(({ access }) => access === 'prw');
} catch (e) {
return false;
}
};
14 changes: 13 additions & 1 deletion src/context/client-context.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useContext, createContext, useState, useEffect } from 'react';
import { axiosInstance, setupAxios } from '../common/axios';
import qdrantClient from '../common/client';
import { bigIntJSON } from '../common/bigIntJSON';
import { isTokenRestricted } from '../config/restricted-routes';

const DEFAULT_SETTINGS = {
apiKey: '',
Expand All @@ -25,7 +26,18 @@ const getPersistedSettings = () => {
const ClientContext = createContext();

// React hook to access and modify the settings
export const useClient = () => useContext(ClientContext);
export const useClient = () => {
const context = useContext(ClientContext);

if (!context) {
throw new Error('useClient must be used within ClientProvider');
}

return {
...context,
isRestricted: isTokenRestricted(context.settings.apiKey),
};
};

// Client Context Provider
export const ClientProvider = (props) => {
Expand Down
49 changes: 49 additions & 0 deletions src/context/client-context.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useClient, ClientProvider } from './client-context';
import { bigIntJSON } from '../common/bigIntJSON';

// Mock localStorage
const mockLocalStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
};

Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
});

// Mock JWT token
const mockRestrictedToken = 'eyJhbGciOiJIUzI1NiJ9.eyJhY2Nlc3MiOlt7ImFjY2VzcyI6InBydyJ9XX0.x';
const mockUnrestrictedToken = 'eyJhbGciOiJIUzI1NiJ9.eyJhY2Nlc3MiOlt7ImFjY2VzcyI6InIifV19.x';

describe('useClient', () => {
beforeEach(() => {
mockLocalStorage.getItem.mockReset();
mockLocalStorage.setItem.mockReset();
});

it('should return isRestricted=true for restricted token', () => {
mockLocalStorage.getItem.mockReturnValue(bigIntJSON.stringify({ apiKey: mockRestrictedToken }));

const { result } = renderHook(() => useClient(), { wrapper: ClientProvider });

expect(result.current.isRestricted).toBe(true);
});

it('should return isRestricted=false for unrestricted token', () => {
mockLocalStorage.getItem.mockReturnValue(bigIntJSON.stringify({ apiKey: mockUnrestrictedToken }));

const { result } = renderHook(() => useClient(), { wrapper: ClientProvider });

expect(result.current.isRestricted).toBe(false);
});

it('should return isRestricted=false for no token', () => {
mockLocalStorage.getItem.mockReturnValue(null);

const { result } = renderHook(() => useClient(), { wrapper: ClientProvider });

expect(result.current.isRestricted).toBe(false);
});
});
16 changes: 16 additions & 0 deletions src/hooks/useRouteAccess.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useLocation } from 'react-router-dom';
import { useClient } from '../context/client-context';
import { isPathRestricted } from '../config/restricted-routes';

export const useRouteAccess = () => {
const location = useLocation();
const { isRestricted } = useClient();

// Extract path from hash route
const path = location.pathname;

return {
// use this to show restricted message on unavailable routes
isAccessDenied: isRestricted && isPathRestricted(path),
};
};
10 changes: 6 additions & 4 deletions src/pages/Collection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { SnapshotsTab } from '../components/Snapshots/SnapshotsTab';
import CollectionInfo from '../components/Collections/CollectionInfo';
import PointsTabs from '../components/Points/PointsTabs';
import SearchQuality from '../components/Collections/SearchQuality/SearchQuality';
import { useClient } from '../context/client-context';

function Collection() {
const { collectionName } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [currentTab, setCurrentTab] = useState(location.hash.slice(1) || 'points');
const { isRestricted } = useClient();

const handleTabChange = (event, newValue) => {
if (typeof newValue !== 'string') {
Expand All @@ -34,8 +36,8 @@ function Collection() {
<Tabs value={currentTab} onChange={handleTabChange} aria-label="basic tabs example">
<Tab label="Points" value={'points'} />
<Tab label="Info" value={'info'} />
<Tab label="Search Quality" value={'quality'} />
<Tab label="Snapshots" value={'snapshots'} />
{!isRestricted && <Tab label="Search Quality" value={'quality'} />}
{!isRestricted && <Tab label="Snapshots" value={'snapshots'} />}
<Tab label="Visualize" component={Link} to={`${location.pathname}/visualize`} />
<Tab label="Graph" component={Link} to={`${location.pathname}/graph`} />
</Tabs>
Expand All @@ -44,9 +46,9 @@ function Collection() {

<Grid xs={12} item>
{currentTab === 'info' && <CollectionInfo collectionName={collectionName} />}
{currentTab === 'quality' && <SearchQuality collectionName={collectionName} />}
{!isRestricted && currentTab === 'quality' && <SearchQuality collectionName={collectionName} />}
{currentTab === 'points' && <PointsTabs collectionName={collectionName} />}
{currentTab === 'snapshots' && <SnapshotsTab collectionName={collectionName} />}
{!isRestricted && currentTab === 'snapshots' && <SnapshotsTab collectionName={collectionName} />}
</Grid>
</Grid>
</CenteredFrame>
Expand Down
18 changes: 16 additions & 2 deletions src/pages/Datasets.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { CenteredFrame } from '../components/Common/CenteredFrame';
import { Grid, TableContainer, Typography } from '@mui/material';
import { Grid, TableContainer, Typography, Alert } from '@mui/material';
import { TableBodyWithGaps, TableWithGaps } from '../components/Common/TableWithGaps';
import { DatasetsHeader } from '../components/Datasets/DatasetsTableHeader';
import { DatasetsTableRow } from '../components/Datasets/DatasetsTableRow';
Expand All @@ -9,6 +9,7 @@ import { useSnackbar } from 'notistack';
import { getSnackbarOptions } from '../components/Common/utils/snackbarOptions';
import { compareSemver } from '../lib/common-helpers';
import { useOutletContext } from 'react-router-dom';
import { useRouteAccess } from '../hooks/useRouteAccess';

function Datasets() {
const [datasets, setDatasets] = useState([]);
Expand All @@ -17,6 +18,7 @@ function Datasets() {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const errorSnackbarOptions = getSnackbarOptions('error', closeSnackbar);
const { version } = useOutletContext();
const { isAccessDenied } = useRouteAccess();

useEffect(() => {
const fetchData = async () => {
Expand Down Expand Up @@ -64,6 +66,11 @@ function Datasets() {
}, []);

const importDataset = async (fileName, collectionName, setImporting, importing) => {
if (isAccessDenied) {
enqueueSnackbar('Access denied: You do not have permission to import datasets', errorSnackbarOptions);
return;
}

if (importing) {
enqueueSnackbar('Importing in progress', errorSnackbarOptions);
return;
Expand All @@ -87,7 +94,7 @@ function Datasets() {
};

const tableRows = datasets.map((dataset) => (
<DatasetsTableRow key={dataset.name} dataset={dataset} importDataset={importDataset} />
<DatasetsTableRow key={dataset.name} dataset={dataset} importDataset={importDataset} disabled={isAccessDenied} />
));

return (
Expand All @@ -97,6 +104,13 @@ function Datasets() {
<Grid xs={12} item>
<Typography variant="h4">Datasets</Typography>
</Grid>
{isAccessDenied && (
<Grid xs={12} item>
<Alert severity="warning">
You do not have permission to import datasets. Please contact your administrator.
</Alert>
</Grid>
)}
{isLoading && <div>Loading...</div>}
{!isLoading && datasets?.length === 0 && <div>No datasets found</div>}
{!isLoading && datasets?.length > 0 && (
Expand Down
Loading