Skip to content

Commit e396652

Browse files
authored
Enable admins to see workspace details and operations without a workspace role (#3722)
1 parent 6f25650 commit e396652

File tree

5 files changed

+136
-79
lines changed

5 files changed

+136
-79
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ ENHANCEMENTS:
99

1010
BUG FIXES:
1111
* Upgrade unresticted and airlock base template versions due to diagnostic settings retention period being depreciated ([#3704](https://github.com/microsoft/AzureTRE/pull/3704))
12+
* Enable TRE Admins to view workspace details when don't have a workspace role ([#2363](https://github.com/microsoft/AzureTRE/issues/2363))
1213
* Fix shared services list return restricted resource for admins causing issues with updates ([#3716](https://github.com/microsoft/AzureTRE/issues/3716))
1314
* Fix grey box appearing on resource card when costs are not available. ([#3254](https://github.com/microsoft/AzureTRE/issues/3254))
1415
* Fix notification panel not passing the workspace scope id to the API hence UI not updating ([#3353](https://github.com/microsoft/AzureTRE/issues/3353))

ui/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tre-ui",
3-
"version": "0.5.7",
3+
"version": "0.5.8",
44
"private": true,
55
"dependencies": {
66
"@azure/msal-browser": "^2.35.0",

ui/app/src/components/shared/ResourceCard.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { ResourceType } from '../../models/resourceType';
1212
import { WorkspaceContext } from '../../contexts/WorkspaceContext';
1313
import { CostsTag } from './CostsTag';
1414
import { ConfirmCopyUrlToClipboard } from './ConfirmCopyUrlToClipboard';
15+
import { AppRolesContext } from '../../contexts/AppRolesContext';
1516
import { SecuredByRole } from './SecuredByRole';
1617
import { RoleName, WorkspaceRoleName } from '../../models/roleNames';
1718

19+
1820
interface ResourceCardProps {
1921
resource: Resource,
2022
itemId: number,
@@ -83,8 +85,10 @@ export const ResourceCard: React.FunctionComponent<ResourceCardProps> = (props:
8385
headerBadge = <StatusBadge resource={props.resource} status={resourceStatus} />
8486
}
8587

88+
const appRoles = useContext(AppRolesContext);
8689
const authNotProvisioned = props.resource.resourceType === ResourceType.Workspace && !props.resource.properties.scope_id;
87-
const cardStyles = authNotProvisioned ? noNavCardStyles : clickableCardStyles;
90+
const enableClickOnCard = !authNotProvisioned || appRoles.roles.includes(RoleName.TREAdmin);
91+
const cardStyles = enableClickOnCard ? noNavCardStyles : clickableCardStyles;
8892

8993
return (
9094
<>
@@ -110,7 +114,7 @@ export const ResourceCard: React.FunctionComponent<ResourceCardProps> = (props:
110114
<Stack
111115
styles={cardStyles}
112116
aria-labelledby={`card-${props.resource.id}`}
113-
onClick={() => {if (!authNotProvisioned) goToResource()}}
117+
onClick={() => {if (enableClickOnCard) goToResource()}}
114118
>
115119
<Stack horizontal>
116120
<Stack.Item grow={5} style={headerStyles}>{props.resource.properties.display_name}</Stack.Item>
Lines changed: 128 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Spinner, SpinnerSize, Stack } from '@fluentui/react';
1+
import { FontIcon, Icon, Label, Spinner, SpinnerSize, Stack, getTheme, mergeStyles } from '@fluentui/react';
22
import React, { useContext, useEffect, useRef, useState } from 'react';
33
import { Route, Routes, useParams } from 'react-router-dom';
44
import { ApiEndpoint } from '../../models/apiEndpoints';
@@ -18,54 +18,80 @@ import { Airlock } from '../shared/airlock/Airlock';
1818
import { APIError } from '../../models/exceptions';
1919
import { LoadingState } from '../../models/loadingState';
2020
import { ExceptionLayout } from '../shared/ExceptionLayout';
21+
import { AppRolesContext } from '../../contexts/AppRolesContext';
22+
import { RoleName } from '../../models/roleNames';
2123

2224
export const WorkspaceProvider: React.FunctionComponent = () => {
2325
const apiCall = useAuthApiCall();
2426
const [selectedWorkspaceService, setSelectedWorkspaceService] = useState({} as WorkspaceService);
25-
const [workspaceServices, setWorkspaceServices] = useState([] as Array<WorkspaceService>)
26-
const [sharedServices, setSharedServices] = useState([] as Array<SharedService>)
27+
const [workspaceServices, setWorkspaceServices] = useState([] as Array<WorkspaceService>);
28+
const [sharedServices, setSharedServices] = useState([] as Array<SharedService>);
2729
const workspaceCtx = useRef(useContext(WorkspaceContext));
2830
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
29-
const [ apiError, setApiError ] = useState({} as APIError);
31+
const [apiError, setApiError] = useState({} as APIError);
3032
const { workspaceId } = useParams();
3133

34+
const appRoles = useContext(AppRolesContext);
35+
const refIsTREAdminUser = useRef(false);
3236

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

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

47-
// use the client ID to get a token against the workspace (tokenOnly), and set the workspace roles in the context
4846
let wsRoles: Array<string> = [];
49-
console.log('Getting workspace');
50-
await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, ws_application_id_uri, undefined, ResultType.JSON, (roles: Array<string>) => {
51-
workspaceCtx.current.setRoles(roles);
52-
wsRoles = roles;
53-
}, true);
54-
55-
// get workspace services to pass to nav + ws services page
56-
const workspaceServices = await apiCall(`${ApiEndpoint.Workspaces}/${ws.id}/${ApiEndpoint.WorkspaceServices}`, HttpMethod.Get, ws_application_id_uri);
57-
setWorkspaceServices(workspaceServices.workspaceServices);
58-
setLoadingState(wsRoles && wsRoles.length > 0 ? LoadingState.Ok : LoadingState.AccessDenied);
59-
60-
// get shared services to pass to nav shared services pages
61-
const sharedServices = await apiCall(ApiEndpoint.SharedServices, HttpMethod.Get);
62-
setSharedServices(sharedServices.sharedServices);
63-
64-
} catch (e: any){
65-
e.userMessage = 'Error retrieving workspace';
66-
setApiError(e);
67-
setLoadingState(LoadingState.Error);
47+
let ws: Workspace = {} as Workspace;
48+
49+
if (authProvisioned) {
50+
// use the client ID to get a token against the workspace (tokenOnly), and set the workspace roles in the context
51+
await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, scopeId,
52+
undefined, ResultType.JSON, (roles: Array<string>) => {
53+
wsRoles = roles;
54+
}, true);
55+
}
56+
57+
if (wsRoles && wsRoles.length > 0) {
58+
ws = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get, scopeId)).workspace;
59+
workspaceCtx.current.setWorkspace(ws);
60+
workspaceCtx.current.setRoles(wsRoles);
61+
62+
// get workspace services to pass to nav + ws services page
63+
const workspaceServices = await apiCall(`${ApiEndpoint.Workspaces}/${ws.id}/${ApiEndpoint.WorkspaceServices}`,
64+
HttpMethod.Get, ws.properties.scope_id);
65+
setWorkspaceServices(workspaceServices.workspaceServices);
66+
setLoadingState(LoadingState.Ok);
67+
// get shared services to pass to nav shared services pages
68+
const sharedServices = await apiCall(ApiEndpoint.SharedServices, HttpMethod.Get);
69+
setSharedServices(sharedServices.sharedServices);
70+
} else if (appRoles.roles.includes(RoleName.TREAdmin)) {
71+
72+
ws = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}`, HttpMethod.Get)).workspace;
73+
workspaceCtx.current.setWorkspace(ws);
74+
setLoadingState(LoadingState.Ok);
75+
refIsTREAdminUser.current = true;
76+
} else {
77+
let e = new APIError();
78+
e.status = 403;
79+
e.userMessage = "User does not have a role assigned in the workspace or the TRE Admin role assigned";
80+
e.endpoint = `${ApiEndpoint.Workspaces}/${workspaceId}`;
81+
throw e;
82+
}
83+
84+
} catch (e: any) {
85+
if (e.status === 401 || e.status === 403) {
86+
setApiError(e);
87+
setLoadingState(LoadingState.AccessDenied);
88+
} else {
89+
e.userMessage = 'Error retrieving workspace';
90+
setApiError(e);
91+
setLoadingState(LoadingState.Error);
92+
}
6893
}
94+
6995
};
7096
getWorkspace();
7197

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

81107
const addWorkspaceService = (w: WorkspaceService) => {
82-
let ws = [...workspaceServices]
108+
let ws = [...workspaceServices];
83109
ws.push(w);
84110
setWorkspaceServices(ws);
85-
}
111+
};
86112

87113
const updateWorkspaceService = (w: WorkspaceService) => {
88114
let i = workspaceServices.findIndex((f: WorkspaceService) => f.id === w.id);
89-
let ws = [...workspaceServices]
115+
let ws = [...workspaceServices];
90116
ws.splice(i, 1, w);
91117
setWorkspaceServices(ws);
92-
}
118+
};
93119

94120
const removeWorkspaceService = (w: WorkspaceService) => {
95121
let i = workspaceServices.findIndex((f: WorkspaceService) => f.id === w.id);
96122
let ws = [...workspaceServices];
97-
console.log("removing WS...", ws[i]);
98123
ws.splice(i, 1);
99124
setWorkspaceServices(ws);
100-
}
125+
};
101126

102127
switch (loadingState) {
103128
case LoadingState.Ok:
104129
return (
105130
<>
106131
<WorkspaceHeader />
107132
<Stack horizontal className='tre-body-inner'>
108-
<Stack.Item className='tre-left-nav'>
109-
<WorkspaceLeftNav
110-
workspaceServices={workspaceServices}
111-
sharedServices={sharedServices}
112-
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
113-
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} />
114-
</Stack.Item><Stack.Item className='tre-body-content'>
133+
{!refIsTREAdminUser.current && (
134+
<Stack.Item className='tre-left-nav'>
135+
<WorkspaceLeftNav
136+
workspaceServices={workspaceServices}
137+
sharedServices={sharedServices}
138+
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
139+
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} />
140+
</Stack.Item>
141+
)}
142+
<Stack.Item className='tre-body-content'>
115143
<Stack>
116144
<Stack.Item grow={100}>
117145
<Routes>
118146
<Route path="/" element={<>
119147
<WorkspaceItem />
120-
<WorkspaceServices workspaceServices={workspaceServices}
121-
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
122-
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
123-
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
124-
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
125-
</>} />
126-
<Route path="workspace-services" element={
127-
<WorkspaceServices workspaceServices={workspaceServices}
128-
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
129-
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
130-
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
131-
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)}
132-
/>
133-
} />
134-
<Route path="workspace-services/:workspaceServiceId/*" element={
135-
<WorkspaceServiceItem
136-
workspaceService={selectedWorkspaceService}
137-
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
138-
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
139-
} />
140-
<Route path="shared-services" element={
141-
<SharedServices readonly={true} />
142-
} />
143-
<Route path="shared-services/:sharedServiceId/*" element={
144-
<SharedServiceItem readonly={true} />
145-
} />
146-
<Route path="requests/*" element={
147-
<Airlock/>
148-
} />
148+
{!refIsTREAdminUser.current ? (
149+
<WorkspaceServices workspaceServices={workspaceServices}
150+
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
151+
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
152+
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
153+
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
154+
) : (
155+
<Stack className="tre-panel">
156+
<Stack.Item>
157+
<FontIcon iconName="WarningSolid"
158+
className={warningIcon}
159+
/>
160+
You are currently accessing this workspace using a TRE Admin role. Additional funcitonality requires a workspace role, such as Workspace Owner.
161+
</Stack.Item>
162+
</Stack>
163+
)}
164+
</>}
165+
/>
166+
{!refIsTREAdminUser.current && (
167+
<>
168+
<Route path="workspace-services" element={
169+
<WorkspaceServices workspaceServices={workspaceServices}
170+
setWorkspaceService={(ws: WorkspaceService) => setSelectedWorkspaceService(ws)}
171+
addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)}
172+
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
173+
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)}
174+
/>
175+
} />
176+
<Route path="workspace-services/:workspaceServiceId/*" element={
177+
<WorkspaceServiceItem
178+
workspaceService={selectedWorkspaceService}
179+
updateWorkspaceService={(ws: WorkspaceService) => updateWorkspaceService(ws)}
180+
removeWorkspaceService={(ws: WorkspaceService) => removeWorkspaceService(ws)} />
181+
} />
182+
183+
<Route path="shared-services" element={
184+
<SharedServices readonly={true} />
185+
} />
186+
<Route path="shared-services/:sharedServiceId/*" element={
187+
<SharedServiceItem readonly={true} />
188+
} />
189+
<Route path="requests/*" element={
190+
<Airlock />
191+
} />
192+
</>
193+
)}
149194
</Routes>
150195
</Stack.Item>
151196
</Stack>
@@ -154,14 +199,22 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
154199
</>
155200
);
156201
case LoadingState.Error:
202+
case LoadingState.AccessDenied:
157203
return (
158204
<ExceptionLayout e={apiError} />
159-
)
205+
);
160206
default:
161207
return (
162208
<div style={{ marginTop: '20px' }}>
163209
<Spinner label="Loading Workspace" ariaLive="assertive" labelPosition="top" size={SpinnerSize.large} />
164210
</div>
165-
)
211+
);
166212
}
167213
};
214+
215+
const { palette } = getTheme();
216+
const warningIcon = mergeStyles({
217+
color: palette.orangeLight,
218+
fontSize: 18,
219+
marginRight: 8
220+
});

ui/app/src/hooks/useAuthApiCall.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ export const useAuthApiCall = () => {
115115
try {
116116
resp = await fetch(`${config.treUrl}/${endpoint}`, opts);
117117
} catch (err: any) {
118-
console.error(err);
119118
let e = err as APIError;
120119
e.name = 'API call failure';
121120
e.message = 'Unable to make call to API Backend';

0 commit comments

Comments
 (0)