Repository: microsoft/AzureTRE (python)

Issue:
View requests (TRE level)
Ability to view airlock requests across workspaces (at the top TRE-level). 

We should create a new `requests` endpoint in the core TRE API that accepts an optional workspace_id. Return a date-ordered list of all request objects where the requestor.user_id == current user ID. 

We need to add a `Requests` menu item to the core UI similar to the workspace view, showing a users requests across all workspaces

Pull request description:
Add requests endpoint to core TRE API
Introduce a new 'requests' endpoint in the core TRE API to facilitate viewing airlock requests across workspaces, along with corresponding tests for added functionality.

- `api/requests/` takes airlock_manager bool to get requests for workspaces where the user is an airlock manager.
- Updated UI with TRE landing page requests menu. For airlock includes "My Requests", "Awaiting My Review" and "All Requests".
- Added tests
- Fixed local debugging when not using Cosmos Key.

![image](https://github.com/user-attachments/assets/32639b19-99c3-4313-b693-2fbfd41e08c5)

![image](https://github.com/user-attachments/assets/f30dbbee-c10a-4b67-8fc3-d32602f04154)


Fixes #2510

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 879a3f47..d451bec8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 
 ENHANCEMENTS:
 * Allow workspace App Service Plan SKU to be updated ([#4331](https://github.com/microsoft/AzureTRE/issues/4331))
+* Add core requests endpoint and UI to enable requests to be managed TRE wide. ([[#2510](https://github.com/microsoft/AzureTRE/issues/2510)])
 * Remove public IP from TRE's firewall when forced tunneling is configured ([#4346](https://github.com/microsoft/AzureTRE/pull/4346))
 * Upgrade AzureRM Terraform provider from `3.117.0` to `4.14.0`. ([[#4255](https://github.com/microsoft/AzureTRE/pull/4255/)])
 * Subnet definitions are now inline in the `azurerm_virtual_network` resource, and NSG associations are set using `security_group` in each subnet block (no separate `azurerm_subnet_network_security_group_association` needed). ([[#4255](https://github.com/microsoft/AzureTRE/pull/4255/)])
diff --git a/api_app/_version.py b/api_app/_version.py
index 8b8252f4..6a726d85 100644
--- a/api_app/_version.py
+++ b/api_app/_version.py
@@ -1,1 +1,1 @@
-__version__ = "0.20.4"
+__version__ = "0.21.0"
diff --git a/api_app/api/routes/api.py b/api_app/api/routes/api.py
index 0dd1a7d2..6dabc882 100644
--- a/api_app/api/routes/api.py
+++ b/api_app/api/routes/api.py
@@ -8,7 +8,7 @@ from fastapi.openapi.utils import get_openapi
 from api.helpers import get_repository
 from db.repositories.workspaces import WorkspaceRepository
 from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \
-    shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata
+    shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests
 from core import config
 from resources import strings
 
@@ -49,6 +49,7 @@ core_router.include_router(workspaces.workspaces_shared_router, tags=["workspace
 core_router.include_router(migrations.migrations_core_router, tags=["migrations"])
 core_router.include_router(costs.costs_core_router, tags=["costs"])
 core_router.include_router(costs.costs_workspace_router, tags=["costs"])
+core_router.include_router(requests.router, tags=["requests"])
 
 core_swagger_router = APIRouter()
 swagger_disabled_router = APIRouter()
@@ -112,7 +113,7 @@ workspace_swagger_disabled_router = APIRouter()
 
 def get_scope(workspace) -> str:
     # Cope with the fact that scope id can have api:// at the front.
-    return f"api://{workspace.properties['scope_id'].replace('api://','')}/user_impersonation"
+    return f"api://{workspace.properties['scope_id'].replace('api://', '')}/user_impersonation"
 
 
 @workspace_swagger_router.get("/workspaces/{workspace_id}/openapi.json", include_in_schema=False, name="openapi_definitions")
diff --git a/api_app/api/routes/requests.py b/api_app/api/routes/requests.py
new file mode 100644
index 00000000..1fffa6f3
--- /dev/null
+++ b/api_app/api/routes/requests.py
@@ -0,0 +1,38 @@
+from fastapi import APIRouter, Depends, HTTPException, status as status_code
+from typing import List, Optional
+
+from api.helpers import get_repository
+from resources import strings
+from db.repositories.airlock_requests import AirlockRequestRepository
+from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType
+from services.authentication import get_current_tre_user_or_tre_admin
+
+router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)])
+
+
+@router.get("/requests", response_model=List[AirlockRequest], name=strings.API_LIST_REQUESTS)
+async def get_requests(
+    user=Depends(get_current_tre_user_or_tre_admin),
+    airlock_request_repo: AirlockRequestRepository = Depends(get_repository(AirlockRequestRepository)),
+    airlock_manager: bool = False,
+    creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None,
+        order_by: Optional[str] = None, order_ascending: bool = True
+) -> List[AirlockRequest]:
+    try:
+        if not airlock_manager:
+            requests = await airlock_request_repo.get_airlock_requests(
+                creator_user_id=creator_user_id or user.id,
+                type=type,
+                status=status,
+                order_by=order_by,
+                order_ascending=order_ascending,
+            )
+        else:
+            requests = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user)
+
+        return requests
+
+    except ValueError as ve:
+        raise HTTPException(status_code=status_code.HTTP_400_BAD_REQUEST, detail=str(ve))
+    except Exception as e:
+        raise HTTPException(status_code=status_code.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
diff --git a/api_app/db/repositories/airlock_requests.py b/api_app/db/repositories/airlock_requests.py
index f4a19263..8bb8275a 100644
--- a/api_app/db/repositories/airlock_requests.py
+++ b/api_app/db/repositories/airlock_requests.py
@@ -7,6 +7,8 @@ from pydantic import UUID4
 from azure.cosmos.exceptions import CosmosResourceNotFoundError, CosmosAccessConditionFailedError
 from fastapi import HTTPException, status
 from pydantic import parse_obj_as
+from db.repositories.workspaces import WorkspaceRepository
+from services.authentication import get_access_service
 from models.domain.authentication import User
 from db.errors import EntityDoesNotExist
 from models.domain.airlock_request import AirlockFile, AirlockRequest, AirlockRequestStatus, \
@@ -107,27 +109,33 @@ class AirlockRequestRepository(BaseRepository):
 
         return airlock_request
 
-    async def get_airlock_requests(self, workspace_id: str, creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]:
-        query = self.airlock_requests_query() + f' WHERE c.workspaceId = "{workspace_id}"'
+    async def get_airlock_requests(self, workspace_id: Optional[str] = None, creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]:
+        query = self.airlock_requests_query()
 
         # optional filters
+        conditions = []
+        parameters = []
+        if workspace_id:
+            conditions.append('c.workspaceId=@workspace_id')
+            parameters.append({"name": "@workspace_id", "value": workspace_id})
         if creator_user_id:
-            query += ' AND c.createdBy.id=@user_id'
+            conditions.append('c.createdBy.id=@user_id')
+            parameters.append({"name": "@user_id", "value": creator_user_id})
         if status:
-            query += ' AND c.status=@status'
+            conditions.append('c.status=@status')
+            parameters.append({"name": "@status", "value": status})
         if type:
-            query += ' AND c.type=@type'
+            conditions.append('c.type=@type')
+            parameters.append({"name": "@type", "value": type})
+
+        if conditions:
+            query += ' WHERE ' + ' AND '.join(conditions)
 
         # optional sorting
         if order_by:
             query += ' ORDER BY c.' + order_by
             query += ' ASC' if order_ascending else ' DESC'
 
-        parameters = [
-            {"name": "@user_id", "value": creator_user_id},
-            {"name": "@status", "value": status},
-            {"name": "@type", "value": type},
-        ]
         airlock_requests = await self.query(query=query, parameters=parameters)
         return parse_obj_as(List[AirlockRequest], airlock_requests)
 
@@ -138,6 +146,27 @@ class AirlockRequestRepository(BaseRepository):
             raise EntityDoesNotExist
         return parse_obj_as(AirlockRequest, airlock_requests)
 
+    async def get_airlock_requests_for_airlock_manager(self, user: User, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]:
+        workspace_repo = await WorkspaceRepository.create()
+        access_service = get_access_service()
+
+        workspaces = await workspace_repo.get_active_workspaces()
+        user_role_assignments = access_service.get_identity_role_assignments(user.id)
+
+        valid_roles = {ra.role_id for ra in user_role_assignments}
+
+        workspace_ids = [
+            workspace.id
+            for workspace in workspaces
+            if workspace.properties["app_role_id_workspace_airlock_manager"] in valid_roles
+        ]
+        requests = []
+
+        for workspace_id in workspace_ids:
+            requests += await self.get_airlock_requests(workspace_id=workspace_id, type=type, status=status, order_by=order_by, order_ascending=order_ascending)
+
+        return requests
+
     async def update_airlock_request(
             self,
             original_request: AirlockRequest,
diff --git a/api_app/resources/strings.py b/api_app/resources/strings.py
index a9184cf2..896b385f 100644
--- a/api_app/resources/strings.py
+++ b/api_app/resources/strings.py
@@ -34,6 +34,8 @@ API_DELETE_USER_RESOURCE = "Delete user resource"
 API_UPDATE_USER_RESOURCE = "Update an existing user resource"
 API_INVOKE_ACTION_ON_USER_RESOURCE = "Invoke action on a user resource"
 
+API_LIST_REQUESTS = "Get requests"
+
 API_CREATE_AIRLOCK_REQUEST = "Create an airlock request"
 API_GET_AIRLOCK_REQUEST = "Get an airlock request"
 API_LIST_AIRLOCK_REQUESTS = "Get all airlock requests for a workspace"
diff --git a/ui/app/package.json b/ui/app/package.json
index b1a9a669..b3a14712 100644
--- a/ui/app/package.json
+++ b/ui/app/package.json
@@ -1,6 +1,6 @@
 {
   "name": "tre-ui",
-  "version": "0.6.3",
+  "version": "0.7.0",
   "private": true,
   "dependencies": {
     "@azure/msal-browser": "^2.35.0",
diff --git a/ui/app/src/components/root/LeftNav.tsx b/ui/app/src/components/root/LeftNav.tsx
index 568d3261..706cc8c8 100644
--- a/ui/app/src/components/root/LeftNav.tsx
+++ b/ui/app/src/components/root/LeftNav.tsx
@@ -1,13 +1,16 @@
 import React, { useContext } from 'react';
 import { Nav, INavLinkGroup } from '@fluentui/react/lib/Nav';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
 import { AppRolesContext } from '../../contexts/AppRolesContext';
 import { RoleName } from '../../models/roleNames';
 
 export const LeftNav: React.FunctionComponent = () => {
   const navigate = useNavigate();
+  const location = useLocation();
   const appRolesCtx = useContext(AppRolesContext);
 
+  const isRequestsRoute = location.pathname.startsWith('/requests'); // ← True if URL starts with /requests
+
   const navLinkGroups: INavLinkGroup[] = [
     {
       links: [
@@ -32,9 +35,38 @@ export const LeftNav: React.FunctionComponent = () => {
       });
   }
 
+  const requestsLinkArray: { name: string; url: string; key: string; icon: string }[] = [];
+
+  requestsLinkArray.push(
+    {
+      name: 'Airlock',
+      url: '/requests/airlock',
+      key: 'airlock',
+      icon: 'Lock',
+
+    });
+
+  // add Requests link
+  navLinkGroups[0].links.push(
+    {
+      name: 'Requests',
+      url: '/requests',
+      key: 'requests',
+      icon: '',
+      links: requestsLinkArray,
+      isExpanded: isRequestsRoute
+    });
+
   return (
     <Nav
-      onLinkClick={(e, item) => { e?.preventDefault(); item?.url && navigate(item.url) }}
+      onLinkClick={(e, item) => {
+        e?.preventDefault();
+        if (!item || !item.url) return;
+        item.isExpanded = true;
+        if (item.url !== "/requests") {
+          navigate(item.url)
+        }
+      }}
       ariaLabel="TRE Left Navigation"
       groups={navLinkGroups}
     />
diff --git a/ui/app/src/components/root/RootLayout.tsx b/ui/app/src/components/root/RootLayout.tsx
index fd9599d4..2084ce7a 100644
--- a/ui/app/src/components/root/RootLayout.tsx
+++ b/ui/app/src/components/root/RootLayout.tsx
@@ -8,6 +8,7 @@ import { useAuthApiCall, HttpMethod, ResultType } from '../../hooks/useAuthApiCa
 import { RootDashboard } from './RootDashboard';
 import { LeftNav } from './LeftNav';
 import { LoadingState } from '../../models/loadingState';
+import { RequestsList } from '../shared/RequestsList';
 import { SharedServices } from '../shared/SharedServices';
 import { SharedServiceItem } from '../shared/SharedServiceItem';
 import { SecuredByRole } from '../shared/SecuredByRole';
@@ -34,7 +35,7 @@ export const RootLayout: React.FunctionComponent = () => {
         const r = await apiCall(ApiEndpoint.Workspaces, HttpMethod.Get, undefined, undefined, ResultType.JSON);
         setLoadingState(LoadingState.Ok);
         r && r.workspaces && setWorkspaces(r.workspaces);
-      } catch (e:any) {
+      } catch (e: any) {
         e.userMessage = 'Error retrieving resources';
         setApiError(e);
         setLoadingState(LoadingState.Error);
@@ -91,7 +92,7 @@ export const RootLayout: React.FunctionComponent = () => {
       }
     };
 
-   getCosts();
+    getCosts();
 
     const ctx = costsWriteCtx.current;
 
@@ -123,32 +124,38 @@ export const RootLayout: React.FunctionComponent = () => {
     case LoadingState.Ok:
       return (
         <>
-        {
-        loadingCostState === LoadingState.Error &&
-        <ExceptionLayout e={costApiError} />
-        }
-        <Stack horizontal className='tre-body-inner'>
-          <Stack.Item className='tre-left-nav' style={{marginTop:2}}>
-            <LeftNav />
-          </Stack.Item><Stack.Item className='tre-body-content'>
-            <Routes>
-              <Route path="/" element={
-                <RootDashboard
-                  workspaces={workspaces}
-                  addWorkspace={(w: Workspace) => addWorkspace(w)}
-                  updateWorkspace={(w: Workspace) => updateWorkspace(w)}
-                  removeWorkspace={(w: Workspace) => removeWorkspace(w)} />
-              } />
-              <Route path="/admin" element={<Admin />} />
-              <Route path="/shared-services/*" element={
-                <Routes>
-                  <Route path="/" element={<SecuredByRole element={<SharedServices />} allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} />
-                  <Route path=":sharedServiceId" element={<SecuredByRole element={<SharedServiceItem />} allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} />
-                </Routes>
-              } />
-            </Routes>
-          </Stack.Item>
-        </Stack>
+          {
+            loadingCostState === LoadingState.Error &&
+            <ExceptionLayout e={costApiError} />
+          }
+          <Stack horizontal className='tre-body-inner'>
+            <Stack.Item className='tre-left-nav' style={{ marginTop: 2 }}>
+              <LeftNav />
+            </Stack.Item><Stack.Item id="tre-body" className='tre-body-content'>
+              <Routes>
+                <Route path="/" element={
+                  <RootDashboard
+                    workspaces={workspaces}
+                    addWorkspace={(w: Workspace) => addWorkspace(w)}
+                    updateWorkspace={(w: Workspace) => updateWorkspace(w)}
+                    removeWorkspace={(w: Workspace) => removeWorkspace(w)} />
+                } />
+                <Route path="/admin" element={<Admin />} />
+                <Route path="/shared-services/*" element={
+                  <Routes>
+                    <Route path="/" element={<SecuredByRole element={<SharedServices />} allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"} />} />
+                    <Route path=":sharedServiceId" element={<SecuredByRole element={<SharedServiceItem />} allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"} />} />
+                  </Routes>
+                } />
+                <Route path="/requests/*" element={
+                  <Routes>
+                    <Route path="/"  />
+                    <Route path="airlock/*" element={<RequestsList />} />
+                  </Routes>
+                } />
+              </Routes>
+            </Stack.Item>
+          </Stack>
         </>
       );
     case LoadingState.Error:
diff --git a/ui/app/src/components/shared/RequestsList.tsx b/ui/app/src/components/shared/RequestsList.tsx
new file mode 100644
index 00000000..1adc0565
--- /dev/null
+++ b/ui/app/src/components/shared/RequestsList.tsx
@@ -0,0 +1,339 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { ColumnActionsMode, CommandBar, CommandBarButton, ContextualMenu, DirectionalHint, getTheme, IColumn, ICommandBarItemProps, Icon, IContextualMenuItem, IContextualMenuProps, Persona, PersonaSize, SelectionMode, ShimmeredDetailsList, Stack } from '@fluentui/react';
+import { HttpMethod, useAuthApiCall } from '../../hooks/useAuthApiCall';
+import { ApiEndpoint } from '../../models/apiEndpoints';
+import { AirlockRequest, AirlockRequestStatus, AirlockRequestType } from '../../models/airlock';
+import moment from 'moment';
+import { useNavigate } from 'react-router-dom';
+import { LoadingState } from '../../models/loadingState';
+import { APIError } from '../../models/exceptions';
+import { ExceptionLayout } from './ExceptionLayout';
+import { getFileTypeIconProps } from '@fluentui/react-file-type-icons';
+
+interface Workspace {
+  id: string;
+  properties: {
+    display_name: string;
+  };
+}
+
+export const RequestsList: React.FunctionComponent = () => {
+  const [myAirlockRequests, setMyAirlockRequests] = useState([] as AirlockRequest[]);
+  const [airlockManagerRequests, setAirlockManagerRequests] = useState([] as AirlockRequest[]);
+  const [airlockRequests, setAirlockRequests] = useState([] as AirlockRequest[]);
+  const [requestColumns, setRequestColumns] = useState([] as IColumn[]);
+  const [orderBy, setOrderBy] = useState('updatedWhen');
+  const [orderAscending, setOrderAscending] = useState(false);
+  const [filters, setFilters] = useState(new Map<string, string>());
+  const [loadingState, setLoadingState] = useState(LoadingState.Loading);
+  const [contextMenuProps, setContextMenuProps] = useState<IContextualMenuProps>();
+  const [apiError, setApiError] = useState<APIError>();
+  const apiCall = useAuthApiCall();
+  const theme = getTheme();
+  const navigate = useNavigate();
+
+  const mapRequestsToWorkspace = (requests: AirlockRequest[], workspaces: Workspace[]) => {
+    return requests.map(request => {
+      const workspace = workspaces.find(w => w.id === request.workspaceId);
+      return {
+        ...request,
+        workspace: workspace ? workspace.properties.display_name : 'Unknown Workspace'
+      };
+    });
+  };
+
+  const getAirlockRequests = useCallback(async () => {
+    setApiError(undefined);
+    setLoadingState(LoadingState.Loading);
+    try {
+      let query = '?';
+      filters.forEach((value, key) => {
+        query += `${key}=${value}&`;
+      });
+      if (orderBy) {
+        query += `order_by=${orderBy}&order_ascending=${orderAscending}&`;
+      }
+      let fetchedWorkspaces: { workspaces: Workspace[] } = { workspaces: [] };
+      try {
+        fetchedWorkspaces = await apiCall(ApiEndpoint.Workspaces, HttpMethod.Get);
+      } catch (err: any) {
+        setApiError(err);
+        console.error("Failed to fetch workspaces:", err);
+      }
+      let requests: AirlockRequest[];
+      let airlock_manager_requests: AirlockRequest[];
+      requests = await apiCall(`${ApiEndpoint.Requests}${query.slice(0, -1)}`, HttpMethod.Get);
+      requests = mapRequestsToWorkspace(requests, fetchedWorkspaces.workspaces);
+      airlock_manager_requests = await apiCall(`${ApiEndpoint.Requests}?${query.slice(0, -1)}&airlock_manager=true`, HttpMethod.Get);
+      airlock_manager_requests = mapRequestsToWorkspace(airlock_manager_requests, fetchedWorkspaces.workspaces);
+
+      setMyAirlockRequests(requests);
+      setAirlockRequests(requests);
+      setLoadingState(LoadingState.Ok);
+      setAirlockManagerRequests(airlock_manager_requests);
+    } catch (err: any) {
+      err.userMessage = 'Error fetching airlock requests';
+      setApiError(err);
+      setLoadingState(LoadingState.Error);
+    }
+  }, [filters, orderBy, apiCall, orderAscending]);
+
+  useEffect(() => {
+    getAirlockRequests();
+  }, [filters, orderBy, orderAscending, getAirlockRequests]);
+
+  const orderRequests = (column: IColumn) => {
+    setOrderBy((o) => {
+      if (o === column.key) {
+        setOrderAscending((previous) => !previous);
+        return column.key;
+      }
+      return column.key;
+    });
+  };
+
+  const openContextMenu = useCallback((column: IColumn, ev: React.MouseEvent<HTMLElement>, options: Array<string>) => {
+    const filterOptions = options.map(option => {
+      return {
+        key: option,
+        name: option,
+        canCheck: true,
+        checked: filters?.has(column.key) && filters.get(column.key) === option,
+        onClick: () => {
+          setFilters((f) => {
+            if (f.get(column.key) === option) {
+              f.delete(column.key);
+            } else {
+              f.set(column.key, option);
+            }
+            return new Map(f);
+          });
+        }
+      }
+    });
+
+    const items: IContextualMenuItem[] = [
+      {
+        key: 'sort',
+        name: 'Sort',
+        iconProps: { iconName: 'Sort' },
+        onClick: () => orderRequests(column)
+      },
+      {
+        key: 'filter',
+        name: 'Filter',
+        iconProps: { iconName: 'Filter' },
+        subMenuProps: {
+          items: filterOptions,
+        }
+      }
+    ];
+
+    setContextMenuProps({
+      items: items,
+      target: ev.currentTarget as HTMLElement,
+      directionalHint: DirectionalHint.bottomCenter,
+      gapSpace: 0,
+      onDismiss: () => setContextMenuProps(undefined),
+    });
+  }, [filters]);
+
+  useEffect(() => {
+    const orderByColumn = (ev: React.MouseEvent<HTMLElement>, column: IColumn) => {
+      orderRequests(column);
+    };
+
+    const columns: IColumn[] = [
+      {
+        key: 'fileIcon',
+        name: 'fileIcon',
+        minWidth: 16,
+        maxWidth: 16,
+        isIconOnly: true,
+        onRender: (request: AirlockRequest) => {
+          if (request.status === AirlockRequestStatus.Draft) {
+            return <Icon iconName="FolderOpen" style={{ verticalAlign: 'bottom', fontSize: 14 }} />
+          } else if (request.files?.length > 0 && request.files[0].name) {
+            const fileType = request.files[0].name.split('.').pop();
+            return <Icon {...getFileTypeIconProps({ extension: fileType })} style={{ verticalAlign: 'bottom' }} />
+          } else {
+            return <Icon iconName="Page" style={{ verticalAlign: 'bottom', fontSize: 14 }} />
+          }
+        }
+      },
+      {
+        key: 'workspace',
+        name: 'Workspace',
+        ariaLabel: 'Workspace of the airlock request',
+        minWidth: 150,
+        maxWidth: 200,
+        isResizable: true,
+        fieldName: 'workspace'
+      },
+      {
+        key: 'title',
+        name: 'Title',
+        ariaLabel: 'Title of the airlock request',
+        minWidth: 150,
+        maxWidth: 300,
+        isResizable: true,
+        fieldName: 'title'
+      },
+      {
+        key: 'createdBy',
+        name: 'Creator',
+        ariaLabel: 'Creator of the airlock request',
+        minWidth: 150,
+        maxWidth: 200,
+        isResizable: true,
+        onRender: (request: AirlockRequest) => <Persona size={PersonaSize.size24} text={request.createdBy?.name} />,
+        isFiltered: filters.has('creator_user_id')
+      },
+      {
+        key: 'type',
+        name: 'Type',
+        ariaLabel: 'Whether the request is import or export',
+        minWidth: 70,
+        maxWidth: 100,
+        isResizable: true,
+        fieldName: 'type',
+        columnActionsMode: ColumnActionsMode.hasDropdown,
+        isSorted: orderBy === 'type',
+        isSortedDescending: !orderAscending,
+        onColumnClick: (ev, column) => openContextMenu(column, ev, Object.values(AirlockRequestType)),
+        onColumnContextMenu: (column, ev) =>
+          (column && ev) && openContextMenu(column, ev, Object.values(AirlockRequestType)),
+        isFiltered: filters.has('type')
+      },
+      {
+        key: 'status',
+        name: 'Status',
+        ariaLabel: 'Status of the request',
+        minWidth: 70,
+        isResizable: true,
+        fieldName: 'status',
+        columnActionsMode: ColumnActionsMode.hasDropdown,
+        isSorted: orderBy === 'status',
+        isSortedDescending: !orderAscending,
+        onColumnClick: (ev, column) => openContextMenu(column, ev, Object.values(AirlockRequestStatus)),
+        onColumnContextMenu: (column, ev) =>
+          (column && ev) && openContextMenu(column, ev, Object.values(AirlockRequestStatus)),
+        isFiltered: filters.has('status'),
+        onRender: (request: AirlockRequest) => request.status.replace("_", " ")
+      },
+      {
+        key: 'createdTime',
+        name: 'Created',
+        ariaLabel: 'When the request was created',
+        minWidth: 120,
+        data: 'number',
+        isResizable: true,
+        fieldName: 'createdTime',
+        isSorted: orderBy === 'createdTime',
+        isSortedDescending: !orderAscending,
+        onRender: (request: AirlockRequest) => {
+          return <span>{moment.unix(request.createdWhen).format('DD/MM/YYYY')}</span>;
+        },
+        onColumnClick: orderByColumn
+      },
+      {
+        key: 'updatedWhen',
+        name: 'Updated',
+        ariaLabel: 'When the request was last updated',
+        minWidth: 120,
+        data: 'number',
+        isResizable: true,
+        fieldName: 'updatedWhen',
+        isSorted: orderBy === 'updatedWhen',
+        isSortedDescending: !orderAscending,
+        onRender: (request: AirlockRequest) => {
+          return <span>{moment.unix(request.updatedWhen).fromNow()}</span>;
+        },
+        onColumnClick: orderByColumn
+      }
+    ];
+    setRequestColumns(columns);
+  }, [openContextMenu, filters, orderAscending, orderBy]);
+
+  const quickFilters: ICommandBarItemProps[] = [
+    {
+      key: 'reset',
+      text: 'Clear filters',
+      iconProps: { iconName: 'ClearFilter' },
+      onClick: () => setFilters(new Map())
+    }
+  ];
+
+  if (airlockManagerRequests.length > 0) {
+    quickFilters.unshift({
+      key: 'allRequests',
+      text: 'All Requests',
+      iconProps: { iconName: 'BulletedList' },
+      onClick: () => {
+        setFilters(new Map());
+        setAirlockRequests(airlockManagerRequests);
+      }
+    });
+
+    quickFilters.unshift({
+      key: 'awaitingMyReview',
+      text: 'Awaiting my review',
+      iconProps: { iconName: 'TemporaryUser' },
+      onClick: () => {
+        setFilters(new Map([['status', 'in_review']]));
+        setAirlockRequests(airlockManagerRequests);
+      }
+    });
+  }
+
+  quickFilters.unshift({
+    key: 'myRequests',
+    text: 'My Requests',
+    iconProps: { iconName: 'EditContact' },
+    onClick: () => {
+      setFilters(new Map())
+      setAirlockRequests(myAirlockRequests);
+    }
+  });
+
+  return (
+    <>
+      <Stack className="tre-panel">
+        <Stack.Item>
+          <Stack horizontal horizontalAlign="space-between">
+            <h1 style={{ marginBottom: 0, marginRight: 30 }}>Airlock Requests</h1>
+            <Stack.Item grow>
+              <CommandBar items={quickFilters} ariaLabel="Quick filters" />
+            </Stack.Item>
+            <CommandBarButton
+              iconProps={{ iconName: 'refresh' }}
+              text="Refresh"
+              style={{ background: 'none', color: theme.palette.themePrimary }}
+              onClick={() => getAirlockRequests()}
+            />
+          </Stack>
+        </Stack.Item>
+      </Stack>
+      {apiError && <ExceptionLayout e={apiError} />}
+      <div className="tre-resource-panel" style={{ padding: '0px' }}>
+        <ShimmeredDetailsList
+          items={airlockRequests}
+          columns={requestColumns}
+          selectionMode={SelectionMode.none}
+          getKey={(item) => item?.id}
+          onItemInvoked={(item) => navigate(`/${ApiEndpoint.Workspaces}/${item.workspaceId}/${ApiEndpoint.AirlockRequests}/${item.id}`)}
+          className="tre-table"
+          enableShimmer={loadingState === LoadingState.Loading}
+        />
+        {contextMenuProps && <ContextualMenu {...contextMenuProps} />}
+        {airlockRequests.length === 0 && loadingState !== LoadingState.Loading && <div style={{ textAlign: 'center', padding: '50px 10px 100px 10px' }}>
+          <h4>No requests found</h4>
+          {filters.size > 0
+            ? <small>There are no requests matching your selected filter(s).</small>
+            : <small>Looks like there are no airlock requests yet. Create a new request to get started.</small>
+          }
+        </div>}
+      </div>
+    </>
+  );
+};
diff --git a/ui/app/src/models/apiEndpoints.ts b/ui/app/src/models/apiEndpoints.ts
index f5c09c50..85d095de 100644
--- a/ui/app/src/models/apiEndpoints.ts
+++ b/ui/app/src/models/apiEndpoints.ts
@@ -3,6 +3,7 @@ export enum ApiEndpoint {
   WorkspaceServices = 'workspace-services',
   UserResources = 'user-resources',
   SharedServices = 'shared-services',
+  Requests = 'requests',
   AirlockRequests = 'requests',
   AirlockLink = 'link',
   AirlockSubmit = 'submit',


Test patch:
diff --git a/api_app/tests_ma/test_api/test_routes/test_requests.py b/api_app/tests_ma/test_api/test_routes/test_requests.py
new file mode 100644
index 00000000..4a10544c
--- /dev/null
+++ b/api_app/tests_ma/test_api/test_routes/test_requests.py
@@ -0,0 +1,42 @@
+import pytest
+from fastapi import status
+from mock import patch
+
+from resources import strings
+from services.authentication import get_current_tre_user_or_tre_admin
+
+
+pytestmark = pytest.mark.asyncio
+
+
+class TestRequestsThatDontRequireAdminRigths:
+    @pytest.fixture(autouse=True, scope='class')
+    def log_in_with_non_admin_user(self, app, non_admin_user):
+        with patch('services.aad_authentication.AzureADAuthorization._get_user_from_token', return_value=non_admin_user()):
+            app.dependency_overrides[get_current_tre_user_or_tre_admin] = non_admin_user
+            yield
+            app.dependency_overrides = {}
+
+    # [GET] /requests/ - get_requests
+    @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests", return_value=[])
+    async def test_get_all_requests_returns_200(self, _, app, client):
+        response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS))
+        assert response.status_code == status.HTTP_200_OK
+
+    @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests_for_airlock_manager")
+    async def test_get_airlock_manager_requests_returns_200(self, mock_get_airlock_requests_for_airlock_manager, app, client):
+        mock_get_airlock_requests_for_airlock_manager.return_value = []
+        response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True})
+
+        assert response.status_code == status.HTTP_200_OK
+        mock_get_airlock_requests_for_airlock_manager.assert_called_once()
+
+    @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests", side_effect=Exception("Internal Server Error"))
+    async def test_get_all_requests_returns_500(self, _, app, client):
+        response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS))
+        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+
+    @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests_for_airlock_manager", side_effect=Exception("Internal Server Error"))
+    async def test_get_airlock_manager_requests_returns_500(self, _, app, client):
+        response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True})
+        assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
index 4c773db3..92ae8b7a 100644
--- a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
+++ b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py
@@ -3,6 +3,8 @@ from fastapi import HTTPException
 from mock import patch
 import pytest
 import pytest_asyncio
+from models.domain.authentication import RoleAssignment, User
+from models.domain.workspace import Workspace
 from tests_ma.test_api.conftest import create_test_user
 from models.schemas.airlock_request import AirlockRequestInCreate
 from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType
@@ -63,6 +65,18 @@ def verify_dictionary_contains_all_enum_values():
             raise Exception(f"Status '{status}' was not added to the ALLOWED_STATUS_CHANGES dictionary")
 
 
+def sample_workspace(workspace_id=WORKSPACE_ID, workspace_properties: dict = {}) -> Workspace:
+    workspace = Workspace(
+        id=workspace_id,
+        templateName="tre-workspace-base",
+        templateVersion="0.1.0",
+        etag="",
+        properties=workspace_properties,
+        resourcePath=f'/workspaces/{workspace_id}'
+    )
+    return workspace
+
+
 def airlock_request_mock(status=AirlockRequestStatus.Draft):
     airlock_request = AirlockRequest(
         id=AIRLOCK_REQUEST_ID,
@@ -143,12 +157,186 @@ async def test_update_airlock_request_should_retry_update_when_etag_is_not_up_to
 
 async def test_get_airlock_requests_queries_db(airlock_request_repo):
     airlock_request_repo.container.query_items = MagicMock()
-    expected_query = airlock_request_repo.airlock_requests_query() + f' WHERE c.workspaceId = "{WORKSPACE_ID}"'
+    expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.workspaceId=@workspace_id'
     expected_parameters = [
-        {"name": "@user_id", "value": None},
-        {"name": "@status", "value": None},
-        {"name": "@type", "value": None},
+        {"name": "@workspace_id", "value": WORKSPACE_ID},
     ]
 
     await airlock_request_repo.get_airlock_requests(WORKSPACE_ID)
     airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters)
+
+
+async def test_get_airlock_requests_with_user_id(airlock_request_repo):
+    airlock_request_repo.container.query_items = MagicMock()
+    user_id = "test_user_id"
+    expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.createdBy.id=@user_id'
+    expected_parameters = [
+        {"name": "@user_id", "value": user_id},
+    ]
+
+    await airlock_request_repo.get_airlock_requests(creator_user_id=user_id)
+    airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters)
+
+
+async def test_get_airlock_requests_with_status(airlock_request_repo):
+    airlock_request_repo.container.query_items = MagicMock()
+    status = AirlockRequestStatus.Submitted
+    expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.status=@status'
+    expected_parameters = [
+        {"name": "@status", "value": status}
+    ]
+
+    await airlock_request_repo.get_airlock_requests(status=status)
+    airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters)
+
+
+async def test_get_airlock_requests_with_type(airlock_request_repo):
+    airlock_request_repo.container.query_items = MagicMock()
+    request_type = AirlockRequestType.Import
+    expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.type=@type'
+    expected_parameters = [
+        {"name": "@type", "value": request_type},
+    ]
+
+    await airlock_request_repo.get_airlock_requests(type=request_type)
+    airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters)
+
+
+async def test_get_airlock_requests_with_multiple_filters(airlock_request_repo):
+    airlock_request_repo.container.query_items = MagicMock()
+    user_id = "test_user_id"
+    status = AirlockRequestStatus.Submitted
+    request_type = AirlockRequestType.Import
+    expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.createdBy.id=@user_id AND c.status=@status AND c.type=@type'
+    expected_parameters = [
+        {"name": "@user_id", "value": user_id},
+        {"name": "@status", "value": status},
+        {"name": "@type", "value": request_type},
+    ]
+
+    await airlock_request_repo.get_airlock_requests(creator_user_id=user_id, status=status, type=request_type)
+    airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters)
+
+
+@pytest.mark.asyncio
+@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock)
+@patch('db.repositories.airlock_requests.get_access_service', autospec=True)
+@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True)
+async def test_get_airlock_requests_for_airlock_manager_no_roles(
+    mock_workspace_repo,
+    mock_access_service,
+    mock_get_requests,
+    airlock_request_repo
+):
+    # Mock no user roles
+    mock_access_service.return_value.get_identity_role_assignments.return_value = []
+
+    # Mock active workspaces
+    mock_workspace_instance = MagicMock()
+    mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[])
+    mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance)
+
+    # Call function
+    user = User(id="user1", name="TestUser")
+    result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user)
+
+    # validate
+    assert result == []
+    mock_get_requests.assert_not_called()
+
+
+@pytest.mark.asyncio
+@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock)
+@patch('db.repositories.airlock_requests.get_access_service', autospec=True)
+@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True)
+async def test_get_airlock_requests_for_airlock_manager_single_workspace(
+    mock_workspace_repo,
+    mock_access_service,
+    mock_get_requests,
+    airlock_request_repo
+):
+    # Setup workspace and manager role
+    workspace = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"})
+    mock_workspace_instance = MagicMock()
+    mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace])
+    mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance)
+
+    # Setup user roles
+    role_assignment = RoleAssignment(resource_id="resource_id", role_id="manager-role-1")
+    mock_access_service.return_value.get_identity_role_assignments.return_value = [role_assignment]
+
+    # Setup corresponding requests from that workspace
+    request_mock = AirlockRequest(id="request-1", workspaceId=WORKSPACE_ID, type=AirlockRequestType.Import, reviews=[])
+    mock_get_requests.return_value = [request_mock]
+
+    user = User(id="user1", name="TestUser")
+    result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user)
+
+    assert len(result) == 1
+    assert result[0].id == "request-1"
+    mock_get_requests.assert_called_once_with(workspace_id=WORKSPACE_ID, type=None, status=None, order_by=None, order_ascending=True)
+
+
+@pytest.mark.asyncio
+@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock)
+@patch('db.repositories.airlock_requests.get_access_service', autospec=True)
+@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True)
+async def test_get_airlock_requests_for_airlock_manager_multiple_workspaces(
+    mock_workspace_repo,
+    mock_access_service,
+    mock_get_requests,
+    airlock_request_repo
+):
+    # Setup multiple workspaces
+    workspace1 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"})
+    workspace2 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-2"})
+    mock_workspace_instance = MagicMock()
+    mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace1, workspace2])
+    mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance)
+
+    # Setup user roles
+    role_assignment_1 = RoleAssignment(resource_id="resource_id", role_id="manager-role-1")
+    role_assignment_2 = RoleAssignment(resource_id="resource_id", role_id="manager-role-2")
+    mock_access_service.return_value.get_identity_role_assignments.return_value = [role_assignment_1, role_assignment_2]
+
+    # Setup requests for each workspace
+    first_ws_requests = [AirlockRequest(id="request-1", workspaceId="workspace-1", type=AirlockRequestType.Import, reviews=[])]
+    second_ws_requests = [AirlockRequest(id="request-2", workspaceId="workspace-2", type=AirlockRequestType.Import, reviews=[])]
+    mock_get_requests.side_effect = [first_ws_requests, second_ws_requests]
+
+    user = User(id="user1", name="TestUser")
+    result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user)
+
+    # combined requests from both
+    assert len(result) == 2
+    assert result[0].id == "request-1"
+    assert result[1].id == "request-2"
+    assert mock_get_requests.call_count == 2
+
+
+@pytest.mark.asyncio
+@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock)
+@patch('db.repositories.airlock_requests.get_access_service', autospec=True)
+@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True)
+async def test_get_airlock_requests_for_airlock_manager_active_workspaces_but_no_manager_role(
+    mock_workspace_repo,
+    mock_access_service,
+    mock_get_requests,
+    airlock_request_repo
+):
+    # Setup multiple workspaces, but user doesn't have manager roles
+    workspace1 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"})
+    workspace2 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-2"})
+    mock_workspace_instance = MagicMock()
+    mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace1, workspace2])
+    mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance)
+
+    # No matching roles for these workspaces
+    mock_access_service.return_value.get_identity_role_assignments.return_value = [
+        RoleAssignment(resource_id="resource_id", role_id="some-other-role")
+    ]
+
+    user = User(id="user1", name="TestUser")
+    result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user)
+    assert result == []
+    mock_get_requests.assert_not_called()