diff --git a/packages/core/src/features/gecko/geckoApi.ts b/packages/core/src/features/gecko/geckoApi.ts index c4bcd4c8..cce791eb 100644 --- a/packages/core/src/features/gecko/geckoApi.ts +++ b/packages/core/src/features/gecko/geckoApi.ts @@ -5,6 +5,7 @@ import { CoreState } from '../../reducers'; import { resourcePathFromProjectID } from '../submission/authMappingUtils'; import { gen3Api } from '../gen3'; import { selectCSRFToken } from '../user/userSliceRTK'; +import { handleUnauthorizedStatus } from '../user/unauthorized'; import { getCookie } from 'cookies-next'; export interface GeckoProjectRecord { @@ -272,6 +273,18 @@ export interface GeckoGitTreeResponse { readonly project_id: string; readonly ref: string; readonly path: string; + readonly entry_count: number; + readonly truncated?: boolean; + readonly entries: Array; +} + +export interface GeckoGitManifestResponse { + readonly project_id: string; + readonly ref: string; + readonly path: string; + readonly entry_count: number; + readonly has_more: boolean; + readonly next_cursor?: string; readonly entries: Array; } @@ -426,6 +439,7 @@ const blobToDataURL = async (blob: Blob): Promise => }); const responseErrorMessage = async (response: Response): Promise => { + handleUnauthorizedStatus(response.status); const text = await response.text(); return ( geckoErrorMessage(text) || @@ -995,6 +1009,11 @@ export const geckoApi = geckoTaggedApi.injectEndpoints({ project: string; path?: string; ref?: string; + include_size?: boolean; + include_last_modified?: boolean; + include_lfs_pointer?: boolean; + view?: 'manifest'; + limit?: number; } >({ query: ({ @@ -1004,19 +1023,41 @@ export const geckoApi = geckoTaggedApi.injectEndpoints({ project, path, ref, + include_size, + include_last_modified, + include_lfs_pointer, + view, + limit, }) => { const normalizedPath = path?.trim().replace(/^\/+|\/+$/g, ''); - const params = new URLSearchParams(); + const queryParams = new URLSearchParams(); if (ref) { - params.set('ref', ref); + queryParams.set('ref', ref); } - if (includeLFSPointer) { - params.set('include_lfs_pointer', 'true'); + if (typeof include_size === 'boolean') { + queryParams.set('include_size', String(include_size)); + } + if (typeof include_last_modified === 'boolean') { + queryParams.set('include_last_modified', String(include_last_modified)); } if (includeLastModified) { - params.set('include_last_modified', 'true'); + queryParams.set('include_last_modified', 'true'); + } + if (typeof include_lfs_pointer === 'boolean') { + queryParams.set('include_lfs_pointer', String(include_lfs_pointer)); + } + if (includeLFSPointer) { + queryParams.set('include_lfs_pointer', 'true'); } - const query = params.toString() ? `?${params.toString()}` : ''; + if (view) { + queryParams.set('view', view); + } + if (typeof limit === 'number') { + queryParams.set('limit', String(limit)); + } + const query = queryParams.toString() + ? `?${queryParams.toString()}` + : ''; const suffix = normalizedPath ? `/tree/${normalizedPath .split('/') @@ -1072,6 +1113,58 @@ export const geckoApi = geckoTaggedApi.injectEndpoints({ credentials: 'include', }), }), + getGeckoGitProjectManifest: builder.query< + GeckoGitManifestResponse, + { + organization: string; + project: string; + path?: string; + ref?: string; + cursor?: string; + files_only?: boolean; + limit?: number; + } + >({ + query: ({ + organization, + project, + path, + ref, + cursor, + files_only, + limit, + }) => { + const normalizedPath = path?.trim().replace(/^\/+|\/+$/g, ''); + const queryParams = new URLSearchParams(); + if (ref) { + queryParams.set('ref', ref); + } + if (cursor) { + queryParams.set('cursor', cursor); + } + if (typeof files_only === 'boolean') { + queryParams.set('files_only', String(files_only)); + } + if (typeof limit === 'number') { + queryParams.set('limit', String(limit)); + } + const query = queryParams.toString() + ? `?${queryParams.toString()}` + : ''; + const suffix = normalizedPath + ? `/manifest/${normalizedPath + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/')}${query}` + : `/manifest${query}`; + + return { + url: buildGitProjectApiPath(organization, project, suffix), + method: 'GET', + credentials: 'include', + }; + }, + }), getGeckoGitUploadSession: builder.query< GeckoGitUploadSessionResponse, { diff --git a/packages/core/src/features/gen3/gen3Api.ts b/packages/core/src/features/gen3/gen3Api.ts index 0b1ec014..435ba6d2 100644 --- a/packages/core/src/features/gen3/gen3Api.ts +++ b/packages/core/src/features/gen3/gen3Api.ts @@ -3,6 +3,7 @@ import { fetchBaseQuery, createApi } from '@reduxjs/toolkit/query/react'; import { GEN3_API } from '../../constants'; import { CoreState } from '../../reducers'; import { selectCSRFToken } from '../user/userSliceRTK'; +import { handleUnauthorizedStatus } from '../user/unauthorized'; import { getCookie } from 'cookies-next'; /** @@ -12,27 +13,42 @@ import { getCookie } from 'cookies-next'; * @param endpoints - Base API endpoints that should exist in every slice * @returns: The generated base API */ +const rawBaseQuery = fetchBaseQuery({ + baseUrl: `${GEN3_API}`, + prepareHeaders: (headers, { getState }) => { + const csrfToken = selectCSRFToken(getState() as CoreState); + headers.set('Content-Type', 'application/json'); + if (process.env.NODE_ENV === 'development') { + // NOTE: This cookie can only be accessed from the client side + // in development mode. Otherwise, the cookie is set as httpOnly + const accessToken = getCookie('credentials_token'); + if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); + } + if (csrfToken) headers.set('X-CSRF-Token', csrfToken); + + return headers; + }, + validateStatus: (response) => { + return response.status >= 200 && response.status < 300; + }, +}); + export const gen3Api = createApi({ reducerPath: 'gen3Services', - baseQuery: fetchBaseQuery({ - baseUrl: `${GEN3_API}`, - prepareHeaders: (headers, { getState }) => { - const csrfToken = selectCSRFToken(getState() as CoreState); - headers.set('Content-Type', 'application/json'); - if (process.env.NODE_ENV === 'development') { - // NOTE: This cookie can only be accessed from the client side - // in development mode. Otherwise, the cookie is set as httpOnly - const accessToken = getCookie('credentials_token'); - if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`); - } - if (csrfToken) headers.set('X-CSRF-Token', csrfToken); - - return headers; - }, - validateStatus: (response) => { - return response.status >= 200 && response.status < 300; - }, - }), + baseQuery: async (args, api, extraOptions) => { + const result = await rawBaseQuery(args, api, extraOptions); + if ('error' in result) { + handleUnauthorizedStatus( + typeof result.error === 'object' && + result.error !== null && + 'status' in result.error && + typeof result.error.status === 'number' + ? result.error.status + : undefined, + ); + } + return result; + }, endpoints: () => ({}), }); diff --git a/packages/core/src/features/gen3Apps/Gen3App.tsx b/packages/core/src/features/gen3Apps/Gen3App.tsx index a5a5e1d2..b609c728 100644 --- a/packages/core/src/features/gen3Apps/Gen3App.tsx +++ b/packages/core/src/features/gen3Apps/Gen3App.tsx @@ -17,14 +17,7 @@ import { ReactReduxContextValue, TypedUseSelectorHook, } from 'react-redux'; -import { - FLUSH, - PAUSE, - PERSIST, - PURGE, - REGISTER, - REHYDRATE, -} from 'redux-persist'; + import { registerGen3App } from './gen3AppRegistry'; import { DataStatus } from '../../dataAccess'; import { CookiesProvider } from 'react-cookie'; @@ -140,34 +133,12 @@ export const createAppStore = ( middleware: (getDefaultMiddleware) => middleware ? getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [ - FLUSH, - REHYDRATE, - PAUSE, - PERSIST, - PURGE, - REGISTER, - ], - }, - immutableCheck: { - warnAfter: 128, - }, + serializableCheck: false, + immutableCheck: false, }).concat(middleware) : getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [ - FLUSH, - REHYDRATE, - PAUSE, - PERSIST, - PURGE, - REGISTER, - ], - }, - immutableCheck: { - warnAfter: 128, - }, + serializableCheck: false, + immutableCheck: false, }), }); type AppState = ReturnType; diff --git a/packages/core/src/features/gen3Apps/Gen3AppRTKQ.ts b/packages/core/src/features/gen3Apps/Gen3AppRTKQ.ts index 56cc667a..a64c2cc5 100644 --- a/packages/core/src/features/gen3Apps/Gen3AppRTKQ.ts +++ b/packages/core/src/features/gen3Apps/Gen3AppRTKQ.ts @@ -15,18 +15,12 @@ import { fetchBaseQuery, } from '@reduxjs/toolkit/query/react'; import { configureStore } from '@reduxjs/toolkit'; -import { - FLUSH, - PAUSE, - PERSIST, - PURGE, - REGISTER, - REHYDRATE, -} from 'redux-persist'; + import { GEN3_API } from '../../constants'; import { getCookie } from 'cookies-next'; import { v5 as uuidv5 } from 'uuid'; import { GEN3_APP_NAMESPACE } from './constants'; +import { handleUnauthorizedStatus } from '../user/unauthorized'; export const createAppApiForRTKQ = ( reducerPath: string, @@ -55,25 +49,41 @@ export const createAppApiForRTKQ = ( }), ); + const defaultBaseQuery = fetchBaseQuery({ + baseUrl: `${GEN3_API}`, + prepareHeaders: (headers) => { + headers.set('Content-Type', 'application/json'); + if (process.env.NODE_ENV === 'development') { + // NOTE: This cookie can only be accessed from the client side + // in development mode. Otherwise, the cookie is set as httpOnly + const accessToken = getCookie('credentials_token'); + if (accessToken) + headers.set('Authorization', `Bearer ${accessToken}`); + } + + return headers; + }, + }); + + const delegatedBaseQuery = baseQuery ?? defaultBaseQuery; + const resolvedBaseQuery: BaseQueryFn = async (args, api, extraOptions) => { + const result = await delegatedBaseQuery(args, api, extraOptions); + if ('error' in result) { + handleUnauthorizedStatus( + typeof result.error === 'object' && + result.error !== null && + 'status' in result.error && + typeof result.error.status === 'number' + ? result.error.status + : undefined, + ); + } + return result; + }; + const appRTKQApi = appCreateApi({ reducerPath: reducerPath, - baseQuery: - baseQuery ?? - fetchBaseQuery({ - baseUrl: `${GEN3_API}`, - prepareHeaders: (headers) => { - headers.set('Content-Type', 'application/json'); - if (process.env.NODE_ENV === 'development') { - // NOTE: This cookie can only be accessed from the client side - // in development mode. Otherwise, the cookie is set as httpOnly - const accessToken = getCookie('credentials_token'); - if (accessToken) - headers.set('Authorization', `Bearer ${accessToken}`); - } - - return headers; - }, - }), + baseQuery: resolvedBaseQuery, endpoints: () => ({}), }); @@ -88,12 +98,8 @@ export const createAppApiForRTKQ = ( }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], - }, - immutableCheck: { - warnAfter: 128, - }, + serializableCheck: false, + immutableCheck: false, }).concat(appMiddleware), }); diff --git a/packages/core/src/features/grip/gripApi.ts b/packages/core/src/features/grip/gripApi.ts index ae90f733..c7834c0a 100644 --- a/packages/core/src/features/grip/gripApi.ts +++ b/packages/core/src/features/grip/gripApi.ts @@ -5,6 +5,7 @@ import { GEN3_GRIP_API } from '../../constants'; import { getCookie } from 'cookies-next'; import { selectCSRFToken } from '../user'; import { CoreState } from '../../reducers'; +import { handleUnauthorizedStatus } from '../user/unauthorized'; export interface gripApiResponse { readonly data: H; @@ -79,6 +80,14 @@ export const gripApi = createApi({ const results = await gripApiFetch(request, headers); return { data: results }; } catch (e) { + if ( + e && + typeof e === 'object' && + 'status' in e && + typeof (e as { status?: unknown }).status === 'number' + ) { + handleUnauthorizedStatus((e as { status: number }).status); + } return { error: e }; } }, diff --git a/packages/core/src/features/guppy/guppyApi.ts b/packages/core/src/features/guppy/guppyApi.ts index 9f3131e2..510a99d5 100644 --- a/packages/core/src/features/guppy/guppyApi.ts +++ b/packages/core/src/features/guppy/guppyApi.ts @@ -5,6 +5,7 @@ import { GEN3_GUPPY_API } from '../../constants'; import { CoreState } from '../../reducers'; import { getCookie } from 'cookies-next'; import { selectCSRFToken } from '../user'; +import { handleUnauthorizedStatus } from '../user/unauthorized'; export interface guppyFetchError { readonly url: string; @@ -66,6 +67,17 @@ export const guppyApi = createApi({ method: 'POST', body: JSON.stringify(query), }); + if (response.status === 401) { + handleUnauthorizedStatus(response.status); + } + if (!response.ok) { + return { + error: { + status: response.status, + data: await response.text(), + }, + }; + } return { data: await response.json() }; } catch (e: unknown) { if (e instanceof Error) return { error: e.message }; diff --git a/packages/core/src/features/syfon/syfonApi.ts b/packages/core/src/features/syfon/syfonApi.ts index cbcbd30d..5f2dda64 100644 --- a/packages/core/src/features/syfon/syfonApi.ts +++ b/packages/core/src/features/syfon/syfonApi.ts @@ -3,6 +3,7 @@ import { SYFON_API, SYFON_DRS_API } from '../../constants'; import { SyfonCompleteMultipartUploadArgs, SyfonBucketsResponse, + SyfonBulkDeleteDrsObjectsArgs, SyfonCreateUploadUrlArgs, SyfonDeleteBucketScopeArgs, SyfonDeleteProjectArgs, @@ -209,6 +210,29 @@ export const syfonApi = syfonTags.injectEndpoints({ url: `${SYFON_DRS_API}/objects/${objectId}`, }), }), + bulkDeleteSyfonDrsObjects: builder.mutation< + void, + SyfonBulkDeleteDrsObjectsArgs + >({ + invalidatesTags: ['SyfonDrsObject'], + query: ({ + bulk_object_ids, + delete_object_metadata, + delete_storage_data, + }) => ({ + body: { + bulk_object_ids, + delete_object_metadata, + delete_storage_data, + }, + method: 'PUT', + responseHandler: async (response: Response) => { + await response.text(); + return null; + }, + url: `${SYFON_DRS_API}/objects/delete`, + }), + }), getSyfonDownloadUrl: builder.query({ providesTags: (_result, _error, objectId) => [ { id: objectId, type: 'SyfonDrsObject' }, @@ -531,6 +555,7 @@ export const { useLazyGetSyfonObjectsByChecksumQuery, useRegisterSyfonDrsObjectsMutation, useDeleteSyfonDrsObjectMutation, + useBulkDeleteSyfonDrsObjectsMutation, useGetSyfonDownloadUrlQuery, useLazyGetSyfonDownloadUrlQuery, useCreateSyfonUploadUrlMutation, diff --git a/packages/core/src/features/syfon/types.ts b/packages/core/src/features/syfon/types.ts index d67ad8d9..632c9929 100644 --- a/packages/core/src/features/syfon/types.ts +++ b/packages/core/src/features/syfon/types.ts @@ -75,6 +75,12 @@ export interface SyfonDrsRegisterResponse { objects: Array; } +export interface SyfonBulkDeleteDrsObjectsArgs { + bulk_object_ids: Array; + delete_object_metadata?: boolean; + delete_storage_data?: boolean; +} + export interface SyfonSignedUrlResponse { url?: string; } diff --git a/packages/core/src/features/user/unauthorized.ts b/packages/core/src/features/user/unauthorized.ts new file mode 100644 index 00000000..f2334daa --- /dev/null +++ b/packages/core/src/features/user/unauthorized.ts @@ -0,0 +1,31 @@ +const FORCE_LOGOUT_EVENT = 'gen3-force-logout'; + +export const requestSessionLogout = ({ + showLoginModal = true, +}: { + showLoginModal?: boolean; +} = {}) => { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent( + new CustomEvent(FORCE_LOGOUT_EVENT, { + detail: { + showLoginModal, + }, + }), + ); +}; + +export const handleUnauthorizedStatus = ( + status: number | undefined, + options?: { showLoginModal?: boolean }, +): boolean => { + if (status !== 401) { + return false; + } + + requestSessionLogout(options); + return true; +}; diff --git a/packages/core/src/features/user/userSliceRTK.ts b/packages/core/src/features/user/userSliceRTK.ts index 6903a882..340b2bad 100644 --- a/packages/core/src/features/user/userSliceRTK.ts +++ b/packages/core/src/features/user/userSliceRTK.ts @@ -38,7 +38,6 @@ export const userAuthApi = createApi({ try { results = await fetchFence({ endpoint, headers }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_e: unknown) { /* Because an "error" response is valid for the auth requests we don't want to diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index dcb5cda7..0e9cd6d4 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6,15 +6,10 @@ import { gen3ServicesReducerMiddleware } from './features/gen3/gen3Api'; import { guppyAPISliceMiddleware } from './features/guppy/guppyApi'; import { userAuthApiMiddleware } from './features/user/userSliceRTK'; import { coreStoreListenerMiddleware } from './listeners'; -import type { PersistConfig, PersistState } from 'redux-persist'; import { - FLUSH, - PAUSE, - PERSIST, persistReducer, - PURGE, - REGISTER, - REHYDRATE, + type PersistConfig, + type PersistState, } from 'redux-persist'; import type { Action, Reducer } from 'redux'; @@ -49,12 +44,8 @@ export const setupCoreStore = (preloadedState?: Partial) => preloadedState, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], - }, - immutableCheck: { - warnAfter: 128, - }, + serializableCheck: false, + immutableCheck: false, }) .concat( gen3ServicesReducerMiddleware, diff --git a/packages/frontend/src/api/auth/sessionLogout.ts b/packages/frontend/src/api/auth/sessionLogout.ts index a9124b3e..08e9eb4c 100644 --- a/packages/frontend/src/api/auth/sessionLogout.ts +++ b/packages/frontend/src/api/auth/sessionLogout.ts @@ -1,7 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { GEN3_FENCE_API } from '@gen3/core'; -import cookie from 'cookie'; +import * as cookie from 'cookie'; import { deleteCookie } from 'cookies-next'; export default async function (req: NextApiRequest, res: NextApiResponse) { diff --git a/packages/frontend/src/components/Modals/LoginModal.tsx b/packages/frontend/src/components/Modals/LoginModal.tsx index 1ac19e6f..8846e1d0 100644 --- a/packages/frontend/src/components/Modals/LoginModal.tsx +++ b/packages/frontend/src/components/Modals/LoginModal.tsx @@ -19,7 +19,7 @@ export const LoginView = ({ }: { redirectPath?: string; }): JSX.Element => ( -
+
{ } return ( -
-
+
+
Protected Content diff --git a/packages/frontend/src/features/Navigation/ProjectWorkspaceTabs.tsx b/packages/frontend/src/features/Navigation/ProjectWorkspaceTabs.tsx index 1296c282..9d67ae32 100644 --- a/packages/frontend/src/features/Navigation/ProjectWorkspaceTabs.tsx +++ b/packages/frontend/src/features/Navigation/ProjectWorkspaceTabs.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Tabs } from '@mantine/core'; +import React, { useMemo } from 'react'; +import Link from 'next/link'; import { useGetAuthzMappingsQuery, userHasMethodForServiceOnResource } from '@gen3/core'; -type ProjectWorkspaceTabKey = 'git' | 'presentation' | 'explorer'; +type ProjectWorkspaceTabKey = 'git' | 'presentation' | 'explorer' | 'storage'; interface ProjectWorkspaceTabsProps { readonly activeTab: ProjectWorkspaceTabKey; @@ -13,91 +13,10 @@ interface ProjectWorkspaceTabsProps { readonly gitHref?: string; readonly presentationHref?: string; readonly explorerHref?: string; + readonly storageHref?: string; + readonly toolbarContent?: React.ReactNode; } -interface EmbeddedRoutePanelProps { - readonly active: boolean; - readonly src: string; - readonly title: string; -} - -const buildEmbeddedHref = (href: string) => - `${href}${href.includes('?') ? '&' : '?'}embed=1`; - -const EmbeddedRoutePanel = ({ - active, - src, - title, -}: EmbeddedRoutePanelProps) => { - const iframeRef = React.useRef(null); - const resizeObserverRef = React.useRef(null); - const [iframeHeight, setIframeHeight] = useState(0); - - const measureIframe = React.useCallback(() => { - const iframe = iframeRef.current; - const doc = iframe?.contentDocument; - if (!doc) { - return; - } - - const bodyHeight = doc.body?.scrollHeight ?? 0; - const documentHeight = doc.documentElement?.scrollHeight ?? 0; - const nextHeight = Math.ceil(Math.max(bodyHeight, documentHeight)); - - setIframeHeight((currentHeight) => - currentHeight !== nextHeight ? nextHeight : currentHeight, - ); - }, []); - - const attachIframeMeasurement = React.useCallback(() => { - const iframe = iframeRef.current; - const doc = iframe?.contentDocument; - if (!doc) { - return; - } - - doc.defaultView?.removeEventListener('resize', measureIframe); - resizeObserverRef.current?.disconnect(); - measureIframe(); - - if (typeof ResizeObserver !== 'undefined') { - const observer = new ResizeObserver(measureIframe); - if (doc.body) { - observer.observe(doc.body); - } - if (doc.documentElement) { - observer.observe(doc.documentElement); - } - resizeObserverRef.current = observer; - } - - doc.defaultView?.addEventListener('resize', measureIframe); - void doc.fonts?.ready.then(measureIframe); - }, [measureIframe]); - - useEffect( - () => () => { - const iframe = iframeRef.current; - const doc = iframe?.contentDocument; - doc?.defaultView?.removeEventListener('resize', measureIframe); - resizeObserverRef.current?.disconnect(); - }, - [measureIframe], - ); - - return ( -