From 13ad44250a1f8f8f98fe912f7b7b414795dd8d56 Mon Sep 17 00:00:00 2001 From: Marcus Robinson Date: Tue, 10 Oct 2023 12:01:43 +0100 Subject: [PATCH] Update UI to better handle different roles (#3733) --- CHANGELOG.md | 2 + api_app/_version.py | 2 +- api_app/db/repositories/base.py | 2 - ui/app/package.json | 2 +- ui/app/src/App.tsx | 3 + ui/app/src/components/root/RootDashboard.tsx | 2 +- ui/app/src/components/root/RootLayout.tsx | 33 +++++---- ui/app/src/components/shared/CostsTag.tsx | 73 +++++++++++++------ ui/app/src/components/shared/ResourceBody.tsx | 46 +++++++++--- ui/app/src/components/shared/ResourceCard.tsx | 61 ++++++++-------- .../components/shared/ResourceContextMenu.tsx | 6 +- .../components/shared/ResourceHistoryList.tsx | 5 +- .../shared/ResourceOperationsList.tsx | 5 +- .../src/components/shared/SecuredByRole.tsx | 60 ++++++++++----- .../src/components/shared/SharedServices.tsx | 2 +- .../shared/notifications/NotificationItem.tsx | 2 +- .../notifications/NotificationPoller.tsx | 18 +++-- .../workspaces/UserResourceItem.tsx | 18 +++-- .../workspaces/WorkspaceLeftNav.tsx | 4 +- .../workspaces/WorkspaceProvider.tsx | 72 ++++++++++++++---- .../workspaces/WorkspaceServiceItem.tsx | 2 +- .../workspaces/WorkspaceServices.tsx | 2 +- ui/app/src/contexts/WorkspaceContext.ts | 3 + 23 files changed, 283 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2912feeda..fe0d7650a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ FEATURES: ENHANCEMENTS: * Reduce logging noise ([#2135](https://github.com/microsoft/AzureTRE/issues/2135)) * Update workspace template to use Terraform's AzureRM 3.73 ([#3715](https://github.com/microsoft/AzureTRE/pull/3715)) +* Enable cost tags for workspace services and user resources ([#2932](https://github.com/microsoft/AzureTRE/issues/2932)) 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)) @@ -13,6 +14,7 @@ BUG FIXES: * 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)) +* Fix issue with cost tags not displaying correctly for some user roles ([#3721](https://github.com/microsoft/AzureTRE/issues/3721)) ## 0.14.1 (September 1, 2023) diff --git a/api_app/_version.py b/api_app/_version.py index bc44bf22fb..a99557a02f 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.15.16" +__version__ = "0.15.17" diff --git a/api_app/db/repositories/base.py b/api_app/db/repositories/base.py index 9100d6f513..631ea58474 100644 --- a/api_app/db/repositories/base.py +++ b/api_app/db/repositories/base.py @@ -25,8 +25,6 @@ async def _get_container(cls, container_name, partition_key_obj) -> ContainerPro try: database = cls._client.get_database_client(config.STATE_STORE_DATABASE) container = await database.create_container_if_not_exists(id=container_name, partition_key=partition_key_obj) - properties = await container.read() - print(properties['partitionKey']) return container except Exception: raise UnableToAccessDatabase diff --git a/ui/app/package.json b/ui/app/package.json index 46b545b9c3..6d01430cd4 100644 --- a/ui/app/package.json +++ b/ui/app/package.json @@ -1,6 +1,6 @@ { "name": "tre-ui", - "version": "0.5.8", + "version": "0.5.9", "private": true, "dependencies": { "@azure/msal-browser": "^2.35.0", diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index b0d4f7a701..8f21c2d260 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -26,6 +26,7 @@ export const App: React.FunctionComponent = () => { const [appRoles, setAppRoles] = useState([] as Array); const [selectedWorkspace, setSelectedWorkspace] = useState({} as Workspace); const [workspaceRoles, setWorkspaceRoles] = useState([] as Array); + const [workspaceCosts, setWorkspaceCosts] = useState([] as Array); const [costs, setCosts] = useState([] as Array); const [costsLoadingState, setCostsLoadingState] = useState(LoadingState.Loading); const [createFormOpen, setCreateFormOpen] = useState(false); @@ -88,6 +89,8 @@ export const App: React.FunctionComponent = () => { ) => {setWorkspaceRoles(roles)}, + costs: workspaceCosts, + setCosts: (costs: Array) => {setWorkspaceCosts(costs)}, workspace: selectedWorkspace, setWorkspace: (w: Workspace) => {setSelectedWorkspace(w)}, workspaceApplicationIdURI: selectedWorkspace.properties?.scope_id diff --git a/ui/app/src/components/root/RootDashboard.tsx b/ui/app/src/components/root/RootDashboard.tsx index 2ed634d6df..0daeaf71c7 100644 --- a/ui/app/src/components/root/RootDashboard.tsx +++ b/ui/app/src/components/root/RootDashboard.tsx @@ -27,7 +27,7 @@ export const RootDashboard: React.FunctionComponent = (props

Workspaces

- { createFormCtx.openCreateForm({ resourceType: ResourceType.Workspace, diff --git a/ui/app/src/components/root/RootLayout.tsx b/ui/app/src/components/root/RootLayout.tsx index 737cc5d00e..fd9599d4f7 100644 --- a/ui/app/src/components/root/RootLayout.tsx +++ b/ui/app/src/components/root/RootLayout.tsx @@ -48,16 +48,21 @@ export const RootLayout: React.FunctionComponent = () => { useEffect(() => { const getCosts = async () => { try { - costsWriteCtx.current.setLoadingState(LoadingState.Loading) - const r = await apiCall(ApiEndpoint.Costs, HttpMethod.Get, undefined, undefined, ResultType.JSON); - - costsWriteCtx.current.setCosts([ - ...r.workspaces, - ...r.shared_services - ]); - - costsWriteCtx.current.setLoadingState(LoadingState.Ok) - setLoadingCostState(LoadingState.Ok); + if (appRolesCtx.roles.includes(RoleName.TREAdmin)) { + costsWriteCtx.current.setLoadingState(LoadingState.Loading) + const r = await apiCall(ApiEndpoint.Costs, HttpMethod.Get, undefined, undefined, ResultType.JSON); + + costsWriteCtx.current.setCosts([ + ...r.workspaces, + ...r.shared_services + ]); + + costsWriteCtx.current.setLoadingState(LoadingState.Ok) + setLoadingCostState(LoadingState.Ok); + } else { + costsWriteCtx.current.setLoadingState(LoadingState.AccessDenied) + setLoadingCostState(LoadingState.AccessDenied); + } } catch (e: any) { if (e instanceof APIError) { @@ -86,9 +91,7 @@ export const RootLayout: React.FunctionComponent = () => { } }; - if (appRolesCtx.roles && appRolesCtx.roles.includes(RoleName.TREAdmin)) { - getCosts(); - } + getCosts(); const ctx = costsWriteCtx.current; @@ -139,8 +142,8 @@ export const RootLayout: React.FunctionComponent = () => { } /> - } allowedRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} /> - } allowedRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} /> + } allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} /> + } allowedAppRoles={[RoleName.TREAdmin]} errorString={"You must be a TRE Admin to access this area"}/>} /> } /> diff --git a/ui/app/src/components/shared/CostsTag.tsx b/ui/app/src/components/shared/CostsTag.tsx index d9c494f760..5c50c6fb82 100644 --- a/ui/app/src/components/shared/CostsTag.tsx +++ b/ui/app/src/components/shared/CostsTag.tsx @@ -1,23 +1,40 @@ import { Stack, Shimmer, TooltipHost, Icon } from "@fluentui/react"; -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { CostsContext } from "../../contexts/CostsContext"; import { LoadingState } from "../../models/loadingState"; +import { WorkspaceContext } from "../../contexts/WorkspaceContext"; +import { CostResource } from "../../models/costs"; +import { useAuthApiCall, HttpMethod, ResultType } from '../../hooks/useAuthApiCall'; +import { ApiEndpoint } from "../../models/apiEndpoints"; interface CostsTagProps { - resourceId: string + resourceId: string; } export const CostsTag: React.FunctionComponent = (props: CostsTagProps) => { const costsCtx = useContext(CostsContext); - const resourceCosts = costsCtx?.costs?.find((resourceCost) => { - return resourceCost.id === props.resourceId; - }); - let costBadge = <>; - switch(costsCtx.loadingState) { - case LoadingState.Loading: - costBadge = - break; - case LoadingState.Ok: + const workspaceCtx = useContext(WorkspaceContext); + const [loadingState, setLoadingState] = useState(LoadingState.Loading); + const apiCall = useAuthApiCall(); + const [formattedCost, setFormattedCost] = useState(undefined); + + useEffect(() => { + async function fetchCostData() { + let costs: CostResource[] = []; + if (workspaceCtx.costs.length > 0) { + costs = workspaceCtx.costs; + } else if (costsCtx.costs.length > 0) { + costs = costsCtx.costs; + } else { + let scopeId = (await apiCall(`${ApiEndpoint.Workspaces}/${props.resourceId}/scopeid`, HttpMethod.Get)).workspaceAuth.scopeId; + const r = await apiCall(`${ApiEndpoint.Workspaces}/${props.resourceId}/${ApiEndpoint.Costs}`, HttpMethod.Get, scopeId, undefined, ResultType.JSON); + costs = [{costs: r.costs, id: r.id, name: r.name }]; + } + + const resourceCosts = costs.find((cost) => { + return cost.id === props.resourceId; + }); + if (resourceCosts && resourceCosts.costs.length > 0) { const formattedCost = new Intl.NumberFormat(undefined, { style: 'currency', @@ -25,19 +42,31 @@ export const CostsTag: React.FunctionComponent = (props: CostsTag currencyDisplay: 'narrowSymbol', minimumFractionDigits: 2, maximumFractionDigits: 2 - }).format(resourceCosts?.costs[0].cost); - costBadge = {formattedCost} - } else { - costBadge = ( - + }).format(resourceCosts.costs[0].cost); + setFormattedCost(formattedCost); + setLoadingState(LoadingState.Ok); + } + } + fetchCostData(); + }, [apiCall, costsCtx.loadingState, props.resourceId, workspaceCtx.costs, costsCtx.costs]); + + const costBadge = ( + + {loadingState === LoadingState.Loading ? ( + + ) : ( + <> + {formattedCost ? ( + formattedCost + ) : ( - - ); - } - break; - } + )} + + )} + + ); return (costBadge); -} +}; diff --git a/ui/app/src/components/shared/ResourceBody.tsx b/ui/app/src/components/shared/ResourceBody.tsx index ac852ed169..cf35e9681e 100644 --- a/ui/app/src/components/shared/ResourceBody.tsx +++ b/ui/app/src/components/shared/ResourceBody.tsx @@ -1,4 +1,4 @@ -import React, { } from 'react'; +import React, { useContext } from 'react'; import { ResourceDebug } from '../shared/ResourceDebug'; import { Pivot, PivotItem } from '@fluentui/react'; import { ResourcePropertyPanel } from '../shared/ResourcePropertyPanel'; @@ -6,15 +6,39 @@ import { Resource } from '../../models/resource'; import { ResourceHistoryList } from '../shared/ResourceHistoryList'; import { ResourceOperationsList } from '../shared/ResourceOperationsList'; import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm' +import remarkGfm from 'remark-gfm'; +import { RoleName, WorkspaceRoleName } from '../../models/roleNames'; +import { ResourceType } from '../../models/resourceType'; +import { SecuredByRole } from './SecuredByRole'; +import { WorkspaceContext } from '../../contexts/WorkspaceContext'; interface ResourceBodyProps { resource: Resource, - readonly?: boolean + readonly?: boolean; } export const ResourceBody: React.FunctionComponent = (props: ResourceBodyProps) => { + const workspaceCtx = useContext(WorkspaceContext); + + const operationsRolesByResourceType = { + [ResourceType.Workspace]: [RoleName.TREAdmin, WorkspaceRoleName.WorkspaceOwner], + [ResourceType.SharedService]: [RoleName.TREAdmin], + [ResourceType.WorkspaceService]: [WorkspaceRoleName.WorkspaceOwner], + [ResourceType.UserResource]: [WorkspaceRoleName.WorkspaceOwner, WorkspaceRoleName.WorkspaceResearcher] + }; + + const historyRolesByResourceType = { + [ResourceType.Workspace]: [RoleName.TREAdmin, WorkspaceRoleName.WorkspaceOwner], + [ResourceType.SharedService]: [RoleName.TREAdmin], + [ResourceType.WorkspaceService]: [WorkspaceRoleName.WorkspaceOwner], + [ResourceType.UserResource]: [WorkspaceRoleName.WorkspaceOwner, WorkspaceRoleName.WorkspaceResearcher] + }; + + const operationsRoles = operationsRolesByResourceType[props.resource.resourceType]; + const historyRoles = historyRolesByResourceType[props.resource.resourceType]; + const workspaceId = workspaceCtx.workspace?.id || ""; + return ( = (props: } { !props.readonly && - - - + + + + } /> } { !props.readonly && - - - + + + + } /> } ); diff --git a/ui/app/src/components/shared/ResourceCard.tsx b/ui/app/src/components/shared/ResourceCard.tsx index b41f5a21f3..0176cbf787 100644 --- a/ui/app/src/components/shared/ResourceCard.tsx +++ b/ui/app/src/components/shared/ResourceCard.tsx @@ -23,8 +23,8 @@ interface ResourceCardProps { selectResource?: (resource: Resource) => void, onUpdate: (resource: Resource) => void, onDelete: (resource: Resource) => void, - readonly?: boolean - isExposedExternally?: boolean + readonly?: boolean; + isExposedExternally?: boolean; } export const ResourceCard: React.FunctionComponent = (props: ResourceCardProps) => { @@ -34,20 +34,19 @@ export const ResourceCard: React.FunctionComponent = (props: const workspaceCtx = useContext(WorkspaceContext); const latestUpdate = useComponentManager( props.resource, - (r: Resource) => { props.onUpdate(r) }, - (r: Resource) => { props.onDelete(r) } + (r: Resource) => { props.onUpdate(r); }, + (r: Resource) => { props.onDelete(r); } ); const navigate = useNavigate(); const costTagRolesByResourceType = { [ResourceType.Workspace]: [RoleName.TREAdmin, WorkspaceRoleName.WorkspaceOwner], [ResourceType.SharedService]: [RoleName.TREAdmin], - [ResourceType.WorkspaceService]: [], // WokspaceRole.WorkspaceOwner when implemented - [ResourceType.UserResource]: [] // WorkspaceRoleName.WorkspaceOwner, WorkspaceRoleName.WorkspaceResearcher when implemented + [ResourceType.WorkspaceService]: [WorkspaceRoleName.WorkspaceOwner], + [ResourceType.UserResource]: [WorkspaceRoleName.WorkspaceOwner] // when implemented WorkspaceRoleName.WorkspaceResearcher] }; const costsTagsRoles = costTagRolesByResourceType[props.resource.resourceType]; - const workspaceAuthContext = workspaceCtx.workspace.id ? true : false; const goToResource = useCallback(() => { const { resource } = props; @@ -65,12 +64,12 @@ export const ResourceCard: React.FunctionComponent = (props: return latestUpdate.componentAction === ComponentAction.Lock || actionsDisabledStates.includes(props.resource.deploymentStatus) || !props.resource.isEnabled - || (props.resource.azureStatus?.powerState && props.resource.azureStatus.powerState !== VMPowerStates.Running) - } + || (props.resource.azureStatus?.powerState && props.resource.azureStatus.powerState !== VMPowerStates.Running); + }; const resourceStatus = latestUpdate.operation?.status ? latestUpdate.operation.status - : props.resource.deploymentStatus + : props.resource.deploymentStatus; // Decide what to show as the top-right header badge let headerBadge = <>; @@ -80,14 +79,15 @@ export const ResourceCard: React.FunctionComponent = (props: successStates.includes(resourceStatus) && props.resource.isEnabled ) { - headerBadge = + headerBadge = ; } else { - headerBadge = + headerBadge = ; } const appRoles = useContext(AppRolesContext); const authNotProvisioned = props.resource.resourceType === ResourceType.Workspace && !props.resource.properties.scope_id; const enableClickOnCard = !authNotProvisioned || appRoles.roles.includes(RoleName.TREAdmin); + const workspaceId = props.resource.resourceType === ResourceType.Workspace ? props.resource.id : ""; const cardStyles = enableClickOnCard ? noNavCardStyles : clickableCardStyles; return ( @@ -109,12 +109,12 @@ export const ResourceCard: React.FunctionComponent = (props:
: {if (enableClickOnCard) goToResource()}} + onClick={() => { if (enableClickOnCard) goToResource(); }} > {props.resource.properties.display_name} @@ -130,7 +130,7 @@ export const ResourceCard: React.FunctionComponent = (props: { // Stop onClick triggering parent handler @@ -149,13 +149,14 @@ export const ResourceCard: React.FunctionComponent = (props: - + {console.log("costTagsToles", costsTagsRoles)} + } /> { connectUri && {e.stopPropagation(); props.isExposedExternally === false ? setShowCopyUrl(true) : window.open(connectUri)}} + onClick={(e) => { e.stopPropagation(); props.isExposedExternally === false ? setShowCopyUrl(true) : window.open(connectUri); }} disabled={shouldDisable()} title={shouldDisable() ? 'Resource must be enabled, successfully deployed & powered on to connect' : 'Connect to resource'} className={styles.button} @@ -164,7 +165,7 @@ export const ResourceCard: React.FunctionComponent = (props: } { - showCopyUrl && setShowCopyUrl(false)} resource={props.resource} /> + showCopyUrl && setShowCopyUrl(false)} resource={props.resource} /> } @@ -187,15 +188,15 @@ export const ResourceCard: React.FunctionComponent = (props: - + Resource Id: {props.resource.id} - + Last Modified By: {props.resource.user.name} - + Last Updated: {moment.unix(props.resource.updatedWhen).toDate().toDateString()} @@ -205,7 +206,7 @@ export const ResourceCard: React.FunctionComponent = (props: } - ) + ); }; const baseCardStyles: IStyle = { @@ -214,11 +215,11 @@ const baseCardStyles: IStyle = { boxShadow: '0 1.6px 3.6px 0 rgba(0,0,0,.132),0 .3px .9px 0 rgba(0,0,0,.108)', backgroundColor: DefaultPalette.white, padding: 10 -} +}; const noNavCardStyles: IStackStyles = { root: { ...baseCardStyles } -} +}; const clickableCardStyles: IStackStyles = { root: { @@ -229,7 +230,7 @@ const clickableCardStyles: IStackStyles = { cursor: 'pointer' } } -} +}; const headerStyles: React.CSSProperties = { padding: '5px 10px', @@ -239,20 +240,20 @@ const headerStyles: React.CSSProperties = { const bodyStyles: React.CSSProperties = { padding: '10px 10px', minHeight: '40px' -} +}; const footerStyles: React.CSSProperties = { minHeight: '30px', alignItems: 'center' -} +}; const calloutKeyStyles: React.CSSProperties = { width: 160 -} +}; const calloutValueStyles: React.CSSProperties = { width: 180 -} +}; const styles = mergeStyleSets({ button: { diff --git a/ui/app/src/components/shared/ResourceContextMenu.tsx b/ui/app/src/components/shared/ResourceContextMenu.tsx index 15ba0b714a..4ddf65f8ce 100644 --- a/ui/app/src/components/shared/ResourceContextMenu.tsx +++ b/ui/app/src/components/shared/ResourceContextMenu.tsx @@ -38,7 +38,6 @@ export const ResourceContextMenu: React.FunctionComponent); - const [wsAuth, setWsAuth] = useState(false); const appRoles = useContext(AppRolesContext); // the user is in these roles which apply across the app const dispatch = useAppDispatch(); @@ -85,7 +84,6 @@ export const ResourceContextMenu: React.FunctionComponent { const action = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.InvokeAction}?action=${actionName}`, HttpMethod.Post, workspaceCtx.workspaceApplicationIdURI); @@ -217,7 +215,7 @@ export const ResourceContextMenu: React.FunctionComponent - { try { // get resource operations - const history = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.History}`, HttpMethod.Get, workspaceCtx.workspaceApplicationIdURI); + const scopeId = workspaceCtx.roles.length > 0 ? workspaceCtx.workspaceApplicationIdURI : ""; + const history = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.History}`, HttpMethod.Get, scopeId); config.debug && console.log(`Got resource history, for resource:${props.resource.id}: ${history.resource_history}`); setResourceHistory(history.resource_history.reverse()); setLoadingState(history ? LoadingState.Ok : LoadingState.Error); @@ -40,7 +41,7 @@ export const ResourceHistoryList: React.FunctionComponent { try { // get resource operations - const ops = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.Operations}`, HttpMethod.Get, workspaceCtx.workspaceApplicationIdURI); + const scopeId = workspaceCtx.roles && workspaceCtx.roles.length > 0 ? workspaceCtx.workspaceApplicationIdURI : ""; + const ops = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.Operations}`, HttpMethod.Get, scopeId); config.debug && console.log(`Got resource operations, for resource:${props.resource.id}: ${ops.operations}`); setResourceOperations(ops.operations.reverse()); setLoadingState(ops && ops.operations.length > 0 ? LoadingState.Ok : LoadingState.Error); @@ -41,7 +42,7 @@ export const ResourceOperationsList: React.FunctionComponent, - errorString?: String + allowedAppRoles?: Array, + allowedWorkspaceRoles?: Array, + workspaceId?: string, + errorString?: String; } // Check if the user roles match any of the roles we are given - if they do, show the element, if not, don't export const SecuredByRole: React.FunctionComponent = (props: SecuredByRoleProps) => { - const appRoles = useContext(AppRolesContext); // the user is in these roles which apply across the app - const workspaceCtx = useContext(WorkspaceContext); // the user is in these roles for the currently selected workspace + const apiCall = useAuthApiCall(); - const userRoles = props.workspaceAuth ? workspaceCtx.roles : appRoles.roles; + const appRoles = useContext(AppRolesContext); + const workspaceCtx = useRef(useContext(WorkspaceContext)); + let [workspaceRoles, setRoles] = useState([] as Array); - if (userRoles && userRoles.length > 0) { - let intersection = props.allowedRoles.filter(x => userRoles.includes(x)); + useEffect(() => { + const getWorkspaceRoles = async () => { + if (!workspaceCtx.current.workspace.id && props.workspaceId !== "") { + let workspaceRoles = [] as Array; - if (intersection.length > 0) { - return props.element + let workspaceAuth = (await apiCall(`${ApiEndpoint.Workspaces}/${props.workspaceId}/scopeid`, HttpMethod.Get)).workspaceAuth; + if (workspaceAuth) { + await apiCall(`${ApiEndpoint.Workspaces}/${props.workspaceId}`, HttpMethod.Get, workspaceAuth.scopeId, + undefined, ResultType.JSON, (roles: Array) => { + workspaceRoles = roles; + }, true); + } + setRoles(workspaceRoles); + } + }; + + if (workspaceCtx.current.roles.length === 0 && props.workspaceId !== undefined){ + getWorkspaceRoles(); + } + else { + setRoles(workspaceCtx.current.roles); } - } - return (props.errorString ? - + }, [apiCall, workspaceCtx.current.workspace.id , props.workspaceId, workspaceCtx.current.roles]); + + if (workspaceRoles.some(x => props.allowedWorkspaceRoles?.includes(x))) return props.element; + + if (appRoles.roles.some(x => props.allowedAppRoles?.includes(x))) return props.element; + + return props.errorString ? ( +

Access Denied

{props.errorString}

- : <>); + ) : ( + <> + ); }; diff --git a/ui/app/src/components/shared/SharedServices.tsx b/ui/app/src/components/shared/SharedServices.tsx index dda4a19d74..74a516cf77 100644 --- a/ui/app/src/components/shared/SharedServices.tsx +++ b/ui/app/src/components/shared/SharedServices.tsx @@ -55,7 +55,7 @@ export const SharedServices: React.FunctionComponent = (prop

Shared Services

{ !props.readonly && - { createFormCtx.openCreateForm({ resourceType: ResourceType.SharedService, diff --git a/ui/app/src/components/shared/notifications/NotificationItem.tsx b/ui/app/src/components/shared/notifications/NotificationItem.tsx index 7eb4ab76ed..39bd45580f 100644 --- a/ui/app/src/components/shared/notifications/NotificationItem.tsx +++ b/ui/app/src/components/shared/notifications/NotificationItem.tsx @@ -49,7 +49,7 @@ export const NotificationItem: React.FunctionComponent = let scopeId = (await apiCall(`${ApiEndpoint.Workspaces}/${wsId}/scopeid`, HttpMethod.Get)).workspaceAuth.scopeId; // is actually a workspace operation or workspace child resource operation - if (op.resourcePath.split('/').length === 3) { + if (op.resourcePath.split('/').length >= 3) { ws = (await apiCall(`${ApiEndpoint.Workspaces}/${wsId}`, HttpMethod.Get, scopeId)).workspace; resource = ws; } else { diff --git a/ui/app/src/components/shared/notifications/NotificationPoller.tsx b/ui/app/src/components/shared/notifications/NotificationPoller.tsx index b077d672bf..9a70c984a2 100644 --- a/ui/app/src/components/shared/notifications/NotificationPoller.tsx +++ b/ui/app/src/components/shared/notifications/NotificationPoller.tsx @@ -15,13 +15,19 @@ export const NotificationPoller: React.FunctionComponent { - let op = (await apiCall(`${props.notification.operation.resourcePath}/${ApiEndpoint.Operations}/${props.notification.operation.id}`, - HttpMethod.Get, props.notification.workspace ? props.notification.workspace.properties.scope_id: null)).operation as Operation; - // check if any fields have changed - ie the json is any different. we don't care _what_ has changed, just that something has - if (JSON.stringify(op) !== JSON.stringify(props.notification.operation)) { - props.notification.operation = op; - props.updateOperation(op); + try { + let op = (await apiCall(`${props.notification.operation.resourcePath}/${ApiEndpoint.Operations}/${props.notification.operation.id}`, + HttpMethod.Get, props.notification.workspace ? props.notification.workspace.properties.scope_id: null)).operation as Operation; + + // check if any fields have changed - ie the json is any different. we don't care _what_ has changed, just that something has + if (JSON.stringify(op) !== JSON.stringify(props.notification.operation)) { + props.notification.operation = op; + props.updateOperation(op); + } + } catch (e: any) { + // likely that the user no longer has access to the operation due to a role change + config.debug && console.log(`Operation ${props.notification.operation.id} for ${props.notification.operation.resourcePath} cqnnot be retrieved`); } }, config.pollingDelayMilliseconds); diff --git a/ui/app/src/components/workspaces/UserResourceItem.tsx b/ui/app/src/components/workspaces/UserResourceItem.tsx index d819e54b5f..f5e85c66f4 100644 --- a/ui/app/src/components/workspaces/UserResourceItem.tsx +++ b/ui/app/src/components/workspaces/UserResourceItem.tsx @@ -10,22 +10,26 @@ import { useComponentManager } from '../../hooks/useComponentManager'; import { ResourceBody } from '../shared/ResourceBody'; interface UserResourceItemProps { - userResource?: UserResource - updateUserResource: (u: UserResource) => void, - removeUserResource: (u: UserResource) => void + userResource?: UserResource; + updateUserResource: (u: UserResource) => void; + removeUserResource: (u: UserResource) => void; } export const UserResourceItem: React.FunctionComponent = (props: UserResourceItemProps) => { const { workspaceServiceId, userResourceId } = useParams(); - const [userResource, setUserResource] = useState({} as UserResource) + const [userResource, setUserResource] = useState({} as UserResource); const apiCall = useAuthApiCall(); const workspaceCtx = useContext(WorkspaceContext); const navigate = useNavigate(); const latestUpdate = useComponentManager( userResource, - (r: Resource) => { props.updateUserResource(r as UserResource); setUserResource(r as UserResource) }, - (r: Resource) => { props.removeUserResource(r as UserResource); navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}`); } + (r: Resource) => { props.updateUserResource(r as UserResource); setUserResource(r as UserResource); }, + (r: Resource) => { + props.removeUserResource(r as UserResource); + if (workspaceCtx.workspace.id) + navigate(`/${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}`); + } ); useEffect(() => { @@ -33,7 +37,7 @@ export const UserResourceItem: React.FunctionComponent = // did we get passed the workspace service, or shall we get it from the api? if (props.userResource && props.userResource.id) { setUserResource(props.userResource); - } else { + } else if (workspaceCtx.workspace.id) { let ur = await apiCall(`${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.WorkspaceServices}/${workspaceServiceId}/${ApiEndpoint.UserResources}/${userResourceId}`, HttpMethod.Get, workspaceCtx.workspaceApplicationIdURI); setUserResource(ur.userResource); } diff --git a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx index 4b30b5eee0..b3c5561538 100644 --- a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx +++ b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx @@ -74,7 +74,7 @@ export const WorkspaceLeftNav: React.FunctionComponent = ]; // Only show airlock link if enabled for workspace - if (workspaceCtx.workspace.properties.enable_airlock !== undefined && workspaceCtx.workspace.properties.enable_airlock) { + if (workspaceCtx.workspace.properties !== undefined && workspaceCtx.workspace.properties.enable_airlock) { serviceNavLinks[0].links.push({ name: 'Airlock', key: ApiEndpoint.AirlockRequests, @@ -85,7 +85,7 @@ export const WorkspaceLeftNav: React.FunctionComponent = setServiceLinks(serviceNavLinks); }; getWorkspaceServices(); - }, [props.workspaceServices, props.sharedServices, workspaceCtx.workspace]); + }, [props.workspaceServices, props.sharedServices, workspaceCtx.workspace.id, workspaceCtx.workspace.properties]); return ( <> diff --git a/ui/app/src/components/workspaces/WorkspaceProvider.tsx b/ui/app/src/components/workspaces/WorkspaceProvider.tsx index b62b74c0c5..f62224aa65 100644 --- a/ui/app/src/components/workspaces/WorkspaceProvider.tsx +++ b/ui/app/src/components/workspaces/WorkspaceProvider.tsx @@ -1,4 +1,4 @@ -import { FontIcon, Icon, Label, Spinner, SpinnerSize, Stack, getTheme, mergeStyles } from '@fluentui/react'; +import { FontIcon, 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'; @@ -19,7 +19,7 @@ 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'; +import { RoleName, WorkspaceRoleName } from '../../models/roleNames'; export const WorkspaceProvider: React.FunctionComponent = () => { const apiCall = useAuthApiCall(); @@ -30,12 +30,49 @@ export const WorkspaceProvider: React.FunctionComponent = () => { const [loadingState, setLoadingState] = useState(LoadingState.Loading); const [apiError, setApiError] = useState({} as APIError); const { workspaceId } = useParams(); + const [costApiError, setCostApiError] = useState({} as APIError); const appRoles = useContext(AppRolesContext); - const refIsTREAdminUser = useRef(false); + const [isTREAdminUser, setIsTREAdminUser] = useState(false); // set workspace context from url useEffect(() => { + const getWorkspaceCosts = async () => { + try { + // TODO: amend when costs enabled in API for WorkspaceRoleName.Researcher + if(workspaceCtx.current.roles.includes(WorkspaceRoleName.WorkspaceOwner)){ + let scopeId = (await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}/scopeid`, HttpMethod.Get)).workspaceAuth.scopeId; + const r = await apiCall(`${ApiEndpoint.Workspaces}/${workspaceId}/${ApiEndpoint.Costs}`, HttpMethod.Get, scopeId, undefined, ResultType.JSON); + const costs = [ + ...r.costs, + ...r.workspace_services, + ...r.workspace_services.flatMap((ws: { user_resources: any; }) => [ + ...ws.user_resources + ]) + ]; + workspaceCtx.current.setCosts(costs); + } + } + catch (e: any) { + if (e instanceof APIError) { + if (e.status === 404 /*subscription not supported*/) { + } + else if (e.status === 429 /*too many requests*/ || e.status === 503 /*service unavaiable*/) { + let msg = JSON.parse(e.message); + let retryAfter = Number(msg.error["retry-after"]); + setTimeout(getWorkspaceCosts, retryAfter * 1000); + } + else { + e.userMessage = 'Error retrieving costs'; + } + } + else { + e.userMessage = 'Error retrieving costs'; + } + setCostApiError(e); + } + }; + const getWorkspace = async () => { try { // get the workspace - first we get the scope_id so we can auth against the right aad app @@ -67,12 +104,12 @@ export const WorkspaceProvider: React.FunctionComponent = () => { // get shared services to pass to nav shared services pages const sharedServices = await apiCall(ApiEndpoint.SharedServices, HttpMethod.Get); setSharedServices(sharedServices.sharedServices); + getWorkspaceCosts(); } 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; + setIsTREAdminUser(true); } else { let e = new APIError(); e.status = 403; @@ -91,18 +128,17 @@ export const WorkspaceProvider: React.FunctionComponent = () => { setLoadingState(LoadingState.Error); } } - }; getWorkspace(); let ctx = workspaceCtx.current; - // run this on onmount - to clear the context - return (() => { + // Return a function to clear the context on unmount + return () => { ctx.setRoles([]); ctx.setWorkspace({} as Workspace); - }); - }, [apiCall, workspaceId, appRoles.roles, loadingState]); + }; + }, [apiCall, workspaceId, isTREAdminUser, appRoles.roles]); const addWorkspaceService = (w: WorkspaceService) => { let ws = [...workspaceServices]; @@ -128,24 +164,28 @@ export const WorkspaceProvider: React.FunctionComponent = () => { case LoadingState.Ok: return ( <> + { + costApiError.message && + + } - {!refIsTREAdminUser.current && ( - + + {!isTREAdminUser && ( setSelectedWorkspaceService(ws)} addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} /> - - )} + )} + - {!refIsTREAdminUser.current ? ( + {!isTREAdminUser ? ( setSelectedWorkspaceService(ws)} addWorkspaceService={(ws: WorkspaceService) => addWorkspaceService(ws)} @@ -163,7 +203,7 @@ export const WorkspaceProvider: React.FunctionComponent = () => { )} } /> - {!refIsTREAdminUser.current && ( + {!isTREAdminUser && ( <>

Resources

-

Workspace Services

- { createFormCtx.openCreateForm({ resourceType: ResourceType.WorkspaceService, diff --git a/ui/app/src/contexts/WorkspaceContext.ts b/ui/app/src/contexts/WorkspaceContext.ts index 9836df88e7..aedddf948d 100644 --- a/ui/app/src/contexts/WorkspaceContext.ts +++ b/ui/app/src/contexts/WorkspaceContext.ts @@ -1,8 +1,11 @@ import React from "react"; import { Workspace } from "../models/workspace"; +import { CostResource } from "../models/costs"; export const WorkspaceContext = React.createContext({ roles: [] as Array, + costs: [] as Array, + setCosts: (costs: Array) => { }, setRoles: (roles: Array) => { }, setWorkspace: (w: Workspace) => { }, workspace: {} as Workspace,