Skip to content

Commit

Permalink
Enable admins to see workspace details and operations without a works…
Browse files Browse the repository at this point in the history
…pace role (#3722)
  • Loading branch information
marrobi authored Oct 5, 2023
1 parent 6f25650 commit e396652
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 79 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ ENHANCEMENTS:

BUG FIXES:
* Upgrade unresticted and airlock base template versions due to diagnostic settings retention period being depreciated ([#3704](https://github.com/microsoft/AzureTRE/pull/3704))
* Enable TRE Admins to view workspace details when don't have a workspace role ([#2363](https://github.com/microsoft/AzureTRE/issues/2363))
* Fix shared services list return restricted resource for admins causing issues with updates ([#3716](https://github.com/microsoft/AzureTRE/issues/3716))
* Fix grey box appearing on resource card when costs are not available. ([#3254](https://github.com/microsoft/AzureTRE/issues/3254))
* Fix notification panel not passing the workspace scope id to the API hence UI not updating ([#3353](https://github.com/microsoft/AzureTRE/issues/3353))
Expand Down
2 changes: 1 addition & 1 deletion ui/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tre-ui",
"version": "0.5.7",
"version": "0.5.8",
"private": true,
"dependencies": {
"@azure/msal-browser": "^2.35.0",
Expand Down
8 changes: 6 additions & 2 deletions ui/app/src/components/shared/ResourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { ResourceType } from '../../models/resourceType';
import { WorkspaceContext } from '../../contexts/WorkspaceContext';
import { CostsTag } from './CostsTag';
import { ConfirmCopyUrlToClipboard } from './ConfirmCopyUrlToClipboard';
import { AppRolesContext } from '../../contexts/AppRolesContext';
import { SecuredByRole } from './SecuredByRole';
import { RoleName, WorkspaceRoleName } from '../../models/roleNames';


interface ResourceCardProps {
resource: Resource,
itemId: number,
Expand Down Expand Up @@ -83,8 +85,10 @@ export const ResourceCard: React.FunctionComponent<ResourceCardProps> = (props:
headerBadge = <StatusBadge resource={props.resource} status={resourceStatus} />
}

const appRoles = useContext(AppRolesContext);
const authNotProvisioned = props.resource.resourceType === ResourceType.Workspace && !props.resource.properties.scope_id;
const cardStyles = authNotProvisioned ? noNavCardStyles : clickableCardStyles;
const enableClickOnCard = !authNotProvisioned || appRoles.roles.includes(RoleName.TREAdmin);
const cardStyles = enableClickOnCard ? noNavCardStyles : clickableCardStyles;

return (
<>
Expand All @@ -110,7 +114,7 @@ export const ResourceCard: React.FunctionComponent<ResourceCardProps> = (props:
<Stack
styles={cardStyles}
aria-labelledby={`card-${props.resource.id}`}
onClick={() => {if (!authNotProvisioned) goToResource()}}
onClick={() => {if (enableClickOnCard) goToResource()}}
>
<Stack horizontal>
<Stack.Item grow={5} style={headerStyles}>{props.resource.properties.display_name}</Stack.Item>
Expand Down
203 changes: 128 additions & 75 deletions ui/app/src/components/workspaces/WorkspaceProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Spinner, SpinnerSize, Stack } from '@fluentui/react';
import { FontIcon, Icon, Label, Spinner, SpinnerSize, Stack, getTheme, mergeStyles } from '@fluentui/react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { Route, Routes, useParams } from 'react-router-dom';
import { ApiEndpoint } from '../../models/apiEndpoints';
Expand All @@ -18,54 +18,80 @@ import { Airlock } from '../shared/airlock/Airlock';
import { APIError } from '../../models/exceptions';
import { LoadingState } from '../../models/loadingState';
import { ExceptionLayout } from '../shared/ExceptionLayout';
import { AppRolesContext } from '../../contexts/AppRolesContext';
import { RoleName } from '../../models/roleNames';

export const WorkspaceProvider: React.FunctionComponent = () => {
const apiCall = useAuthApiCall();
const [selectedWorkspaceService, setSelectedWorkspaceService] = useState({} as WorkspaceService);
const [workspaceServices, setWorkspaceServices] = useState([] as Array<WorkspaceService>)
const [sharedServices, setSharedServices] = useState([] as Array<SharedService>)
const [workspaceServices, setWorkspaceServices] = useState([] as Array<WorkspaceService>);
const [sharedServices, setSharedServices] = useState([] as Array<SharedService>);
const workspaceCtx = useRef(useContext(WorkspaceContext));
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
const [ apiError, setApiError ] = useState({} as APIError);
const [apiError, setApiError] = useState({} as APIError);
const { workspaceId } = useParams();

const appRoles = useContext(AppRolesContext);
const refIsTREAdminUser = useRef(false);

// set workspace context from url
useEffect(() => {
const getWorkspace = async () => {
try {
// get the workspace - first we get the scope_id so we can auth against the right aad app
let scopeId = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}/scopeid`, HttpMethod.Get)).workspaceAuth.scopeId;
if (scopeId === "") {
console.error("Unable to get scope_id from workspace - authentication not set up.");
}

const ws = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, scopeId)).workspace;
workspaceCtx.current.setWorkspace(ws);
const ws_application_id_uri = ws.properties.scope_id;
const authProvisioned = scopeId !== "";

// use the client ID to get a token against the workspace (tokenOnly), and set the workspace roles in the context
let wsRoles: Array<string> = [];
console.log('Getting workspace');
await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, ws_application_id_uri, undefined, ResultType.JSON, (roles: Array<string>) => {
workspaceCtx.current.setRoles(roles);
wsRoles = roles;
}, true);

// get workspace services to pass to nav + ws services page
const workspaceServices = await apiCall(`${ApiEndpoint.Workspaces}/${ws.id}/${ApiEndpoint.WorkspaceServices}`, HttpMethod.Get, ws_application_id_uri);
setWorkspaceServices(workspaceServices.workspaceServices);
setLoadingState(wsRoles && wsRoles.length > 0 ? LoadingState.Ok : LoadingState.AccessDenied);

// get shared services to pass to nav shared services pages
const sharedServices = await apiCall(ApiEndpoint.SharedServices, HttpMethod.Get);
setSharedServices(sharedServices.sharedServices);

} catch (e: any){
e.userMessage = 'Error retrieving workspace';
setApiError(e);
setLoadingState(LoadingState.Error);
let ws: Workspace = {} as Workspace;

if (authProvisioned) {
// use the client ID to get a token against the workspace (tokenOnly), and set the workspace roles in the context
await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, scopeId,
undefined, ResultType.JSON, (roles: Array<string>) => {
wsRoles = roles;
}, true);
}

if (wsRoles && wsRoles.length > 0) {
ws = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, scopeId)).workspace;
workspaceCtx.current.setWorkspace(ws);
workspaceCtx.current.setRoles(wsRoles);

// get workspace services to pass to nav + ws services page
const workspaceServices = await apiCall(`${ApiEndpoint.Workspaces}/${ws.id}/${ApiEndpoint.WorkspaceServices}`,
HttpMethod.Get, ws.properties.scope_id);
setWorkspaceServices(workspaceServices.workspaceServices);
setLoadingState(LoadingState.Ok);
// get shared services to pass to nav shared services pages
const sharedServices = await apiCall(ApiEndpoint.SharedServices, HttpMethod.Get);
setSharedServices(sharedServices.sharedServices);
} else if (appRoles.roles.includes(RoleName.TREAdmin)) {

ws = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get)).workspace;
workspaceCtx.current.setWorkspace(ws);
setLoadingState(LoadingState.Ok);
refIsTREAdminUser.current = true;
} else {
let e = new APIError();
e.status = 403;
e.userMessage = "User does not have a role assigned in the workspace or the TRE Admin role assigned";
e.endpoint = `${ApiEndpoint.Workspaces}/${workspaceId}`;
throw e;
}

} catch (e: any) {
if (e.status === 401 || e.status === 403) {
setApiError(e);
setLoadingState(LoadingState.AccessDenied);
} else {
e.userMessage = 'Error retrieving workspace';
setApiError(e);
setLoadingState(LoadingState.Error);
}
}

};
getWorkspace();

Expand All @@ -76,76 +102,95 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
ctx.setRoles([]);
ctx.setWorkspace({} as Workspace);
});
}, [apiCall, workspaceId]);
}, [apiCall, workspaceId, appRoles.roles, loadingState]);

const addWorkspaceService = (w: WorkspaceService) => {
let ws = [...workspaceServices]
let ws = [...workspaceServices];
ws.push(w);
setWorkspaceServices(ws);
}
};

const updateWorkspaceService = (w: WorkspaceService) => {
let i = workspaceServices.findIndex((f: WorkspaceService) => f.id === w.id);
let ws = [...workspaceServices]
let ws = [...workspaceServices];
ws.splice(i, 1, w);
setWorkspaceServices(ws);
}
};

const removeWorkspaceService = (w: WorkspaceService) => {
let i = workspaceServices.findIndex((f: WorkspaceService) => f.id === w.id);
let ws = [...workspaceServices];
console.log("removing WS...", ws[i]);
ws.splice(i, 1);
setWorkspaceServices(ws);
}
};

switch (loadingState) {
case LoadingState.Ok:
return (
<>
<WorkspaceHeader />
<Stack horizontal className='tre-body-inner'>
<Stack.Item className='tre-left-nav'>
<WorkspaceLeftNav
workspaceServices={workspaceServices}
sharedServices={sharedServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} />
</Stack.Item><Stack.Item className='tre-body-content'>
{!refIsTREAdminUser.current && (
<Stack.Item className='tre-left-nav'>
<WorkspaceLeftNav
workspaceServices={workspaceServices}
sharedServices={sharedServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} />
</Stack.Item>
)}
<Stack.Item className='tre-body-content'>
<Stack>
<Stack.Item grow={100}>
<Routes>
<Route path="/" element={<>
<WorkspaceItem />
<WorkspaceServices workspaceServices={workspaceServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
</>} />
<Route path="workspace-services" element={
<WorkspaceServices workspaceServices={workspaceServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)}
/>
} />
<Route path="workspace-services/:workspaceServiceId/*" element={
<WorkspaceServiceItem
workspaceService={selectedWorkspaceService}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
} />
<Route path="shared-services" element={
<SharedServices readonly={true} />
} />
<Route path="shared-services/:sharedServiceId/*" element={
<SharedServiceItem readonly={true} />
} />
<Route path="requests/*" element={
<Airlock/>
} />
{!refIsTREAdminUser.current ? (
<WorkspaceServices workspaceServices={workspaceServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
) : (
<Stack className="tre-panel">
<Stack.Item>
<FontIcon iconName="WarningSolid"
className={warningIcon}
/>
You are currently accessing this workspace using a TRE Admin role. Additional funcitonality requires a workspace role, such as Workspace Owner.
</Stack.Item>
</Stack>
)}
</>}
/>
{!refIsTREAdminUser.current && (
<>
<Route path="workspace-services" element={
<WorkspaceServices workspaceServices={workspaceServices}
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)}
/>
} />
<Route path="workspace-services/:workspaceServiceId/*" element={
<WorkspaceServiceItem
workspaceService={selectedWorkspaceService}
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
} />

<Route path="shared-services" element={
<SharedServices readonly={true} />
} />
<Route path="shared-services/:sharedServiceId/*" element={
<SharedServiceItem readonly={true} />
} />
<Route path="requests/*" element={
<Airlock />
} />
</>
)}
</Routes>
</Stack.Item>
</Stack>
Expand All @@ -154,14 +199,22 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
</>
);
case LoadingState.Error:
case LoadingState.AccessDenied:
return (
<ExceptionLayout e={apiError} />
)
);
default:
return (
<div style={{ marginTop: '20px' }}>
<Spinner label="Loading Workspace" ariaLive="assertive" labelPosition="top" size={SpinnerSize.large} />
</div>
)
);
}
};

const { palette } = getTheme();
const warningIcon = mergeStyles({
color: palette.orangeLight,
fontSize: 18,
marginRight: 8
});
1 change: 0 additions & 1 deletion ui/app/src/hooks/useAuthApiCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export const useAuthApiCall = () => {
try {
resp = await fetch(`${config.treUrl}/${endpoint}`, opts);
} catch (err: any) {
console.error(err);
let e = err as APIError;
e.name = 'API call failure';
e.message = 'Unable to make call to API Backend';
Expand Down

0 comments on commit e396652

Please sign in to comment.