зеркало из https://github.com/microsoft/AzureTRE.git
UI: Error Handling + Bugs (#2570)
* bubbling a custom exception and using it to drive a custom exception layout * shared service api errors * ExceptionLayout in more places * exception handling in create form * error handling in Airlock * Delete + disable popups now showing error messages rather than hanging on a spinner
This commit is contained in:
Родитель
b660f2db5f
Коммит
6fd2e33f33
|
@ -1,4 +1,4 @@
|
|||
import { MessageBar, MessageBarType, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Admin } from '../../App';
|
||||
|
@ -12,21 +12,24 @@ import { SharedServices } from '../shared/SharedServices';
|
|||
import { SharedServiceItem } from '../shared/SharedServiceItem';
|
||||
import { SecuredByRole } from '../shared/SecuredByRole';
|
||||
import { RoleName } from '../../models/roleNames';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { ExceptionLayout } from '../shared/ExceptionLayout';
|
||||
|
||||
export const RootLayout: React.FunctionComponent = () => {
|
||||
const [workspaces, setWorkspaces] = useState([] as Array<Workspace>);
|
||||
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
const apiCall = useAuthApiCall();
|
||||
|
||||
useEffect(() => {
|
||||
const getWorkspaces = async () => {
|
||||
try {
|
||||
const r = await apiCall(ApiEndpoint.Workspaces, HttpMethod.Get, undefined, undefined, ResultType.JSON, (roles: Array<string>) => {
|
||||
setLoadingState(roles && roles.length > 0 ? LoadingState.Ok : LoadingState.AccessDenied);
|
||||
});
|
||||
|
||||
const r = await apiCall(ApiEndpoint.Workspaces, HttpMethod.Get, undefined, undefined, ResultType.JSON);
|
||||
setLoadingState(LoadingState.Ok);
|
||||
r && r.workspaces && setWorkspaces(r.workspaces);
|
||||
} catch {
|
||||
} catch (e:any) {
|
||||
e.userMessage = 'Error retrieving resources';
|
||||
setApiError(e);
|
||||
setLoadingState(LoadingState.Error);
|
||||
}
|
||||
|
||||
|
@ -81,27 +84,9 @@ export const RootLayout: React.FunctionComponent = () => {
|
|||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
case LoadingState.AccessDenied:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Access Denied</h3>
|
||||
<p>
|
||||
You do not have access to this application. If you feel you should have access, please speak to your TRE Administrator. <br />
|
||||
If you have recently been given access, you may need to clear you browser local storage and refresh.</p>
|
||||
</MessageBar>
|
||||
);
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving workspaces</h3>
|
||||
<p>We were unable to fetch the workspace list. Please see browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -5,6 +5,9 @@ import { HttpMethod, ResultType, useAuthApiCall } from '../../hooks/useAuthApiCa
|
|||
import { WorkspaceContext } from '../../contexts/WorkspaceContext';
|
||||
import { OperationsContext } from '../../contexts/OperationsContext';
|
||||
import { ResourceType } from '../../models/resourceType';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { LoadingState } from '../../models/loadingState';
|
||||
import { ExceptionLayout } from './ExceptionLayout';
|
||||
|
||||
interface ConfirmDeleteProps {
|
||||
resource: Resource,
|
||||
|
@ -14,7 +17,8 @@ interface ConfirmDeleteProps {
|
|||
// show a 'are you sure' modal, and then send a patch if the user confirms
|
||||
export const ConfirmDeleteResource: React.FunctionComponent<ConfirmDeleteProps> = (props: ConfirmDeleteProps) => {
|
||||
const apiCall = useAuthApiCall();
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
const [loading, setLoading] = useState(LoadingState.Ok);
|
||||
const workspaceCtx = useContext(WorkspaceContext);
|
||||
const opsCtx = useContext(OperationsContext);
|
||||
|
||||
|
@ -36,11 +40,16 @@ export const ConfirmDeleteResource: React.FunctionComponent<ConfirmDeleteProps>
|
|||
const wsAuth = (props.resource.resourceType === ResourceType.WorkspaceService || props.resource.resourceType === ResourceType.UserResource);
|
||||
|
||||
const deleteCall = async () => {
|
||||
setIsSending(true);
|
||||
let op = await apiCall(props.resource.resourcePath, HttpMethod.Delete, wsAuth ? workspaceCtx.workspaceApplicationIdURI : undefined, undefined, ResultType.JSON);
|
||||
opsCtx.addOperations([op.operation]);
|
||||
setIsSending(false);
|
||||
props.onDismiss();
|
||||
setLoading(LoadingState.Loading);
|
||||
try {
|
||||
let op = await apiCall(props.resource.resourcePath, HttpMethod.Delete, wsAuth ? workspaceCtx.workspaceApplicationIdURI : undefined, undefined, ResultType.JSON);
|
||||
opsCtx.addOperations([op.operation]);
|
||||
props.onDismiss();
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Failed to delete resource';
|
||||
setApiError(err);
|
||||
setLoading(LoadingState.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return (<>
|
||||
|
@ -50,15 +59,21 @@ export const ConfirmDeleteResource: React.FunctionComponent<ConfirmDeleteProps>
|
|||
dialogContentProps={deleteProps}
|
||||
modalProps={modalProps}
|
||||
>
|
||||
{!isSending ?
|
||||
{
|
||||
loading === LoadingState.Ok &&
|
||||
<DialogFooter>
|
||||
<PrimaryButton text="Delete" onClick={() => deleteCall()} />
|
||||
<DefaultButton text="Cancel" onClick={() => props.onDismiss()} />
|
||||
|
||||
</DialogFooter>
|
||||
:
|
||||
}
|
||||
{
|
||||
loading === LoadingState.Loading &&
|
||||
<Spinner label="Sending request..." ariaLive="assertive" labelPosition="right" />
|
||||
}
|
||||
{
|
||||
loading === LoadingState.Error &&
|
||||
<ExceptionLayout e={apiError} />
|
||||
}
|
||||
</Dialog>
|
||||
</>);
|
||||
};
|
||||
|
|
|
@ -5,6 +5,9 @@ import { HttpMethod, ResultType, useAuthApiCall } from '../../hooks/useAuthApiCa
|
|||
import { WorkspaceContext } from '../../contexts/WorkspaceContext';
|
||||
import { OperationsContext } from '../../contexts/OperationsContext';
|
||||
import { ResourceType } from '../../models/resourceType';
|
||||
import { LoadingState } from '../../models/loadingState';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { ExceptionLayout } from './ExceptionLayout';
|
||||
|
||||
interface ConfirmDisableEnableResourceProps {
|
||||
resource: Resource,
|
||||
|
@ -15,7 +18,8 @@ interface ConfirmDisableEnableResourceProps {
|
|||
// show a 'are you sure' modal, and then send a patch if the user confirms
|
||||
export const ConfirmDisableEnableResource: React.FunctionComponent<ConfirmDisableEnableResourceProps> = (props: ConfirmDisableEnableResourceProps) => {
|
||||
const apiCall = useAuthApiCall();
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [loading, setLoading] = useState(LoadingState.Ok);
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
const workspaceCtx = useContext(WorkspaceContext);
|
||||
const opsCtx = useContext(OperationsContext);
|
||||
|
||||
|
@ -44,35 +48,46 @@ export const ConfirmDisableEnableResource: React.FunctionComponent<ConfirmDisabl
|
|||
const wsAuth = (props.resource.resourceType === ResourceType.WorkspaceService || props.resource.resourceType === ResourceType.UserResource);
|
||||
|
||||
const toggleDisableCall = async () => {
|
||||
setIsSending(true);
|
||||
let body = { isEnabled: props.isEnabled }
|
||||
let op = await apiCall(props.resource.resourcePath, HttpMethod.Patch, wsAuth ? workspaceCtx.workspaceApplicationIdURI : undefined, body, ResultType.JSON, undefined, undefined, props.resource._etag);
|
||||
opsCtx.addOperations([op.operation]);
|
||||
setIsSending(false);
|
||||
props.onDismiss();
|
||||
setLoading(LoadingState.Loading);
|
||||
try {
|
||||
let body = { isEnabled: props.isEnabled }
|
||||
let op = await apiCall(props.resource.resourcePath, HttpMethod.Patch, wsAuth ? workspaceCtx.workspaceApplicationIdURI : undefined, body, ResultType.JSON, undefined, undefined, props.resource._etag);
|
||||
opsCtx.addOperations([op.operation]);
|
||||
props.onDismiss();
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Failed to enable/disable resource';
|
||||
setApiError(err);
|
||||
setLoading(LoadingState.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
hidden={false}
|
||||
onDismiss={() => props.onDismiss()}
|
||||
dialogContentProps={props.isEnabled ? enableProps : disableProps}
|
||||
modalProps={modalProps}
|
||||
>
|
||||
{!isSending ?
|
||||
<DialogFooter>
|
||||
{props.isEnabled ?
|
||||
<PrimaryButton text="Enable" onClick={() => toggleDisableCall()} />
|
||||
:
|
||||
<PrimaryButton text="Disable" onClick={() => toggleDisableCall()} />
|
||||
}
|
||||
<DefaultButton text="Cancel" onClick={() => props.onDismiss()} />
|
||||
|
||||
</DialogFooter>
|
||||
:
|
||||
<Spinner label="Sending request..." ariaLive="assertive" labelPosition="right" />
|
||||
}
|
||||
</Dialog>
|
||||
</>);
|
||||
<>
|
||||
<Dialog
|
||||
hidden={false}
|
||||
onDismiss={() => props.onDismiss()}
|
||||
dialogContentProps={props.isEnabled ? enableProps : disableProps}
|
||||
modalProps={modalProps}
|
||||
>
|
||||
{
|
||||
loading === LoadingState.Ok &&
|
||||
<DialogFooter>
|
||||
{props.isEnabled ?
|
||||
<PrimaryButton text="Enable" onClick={() => toggleDisableCall()} />
|
||||
:
|
||||
<PrimaryButton text="Disable" onClick={() => toggleDisableCall()} />
|
||||
}
|
||||
<DefaultButton text="Cancel" onClick={() => props.onDismiss()} />
|
||||
</DialogFooter>
|
||||
}
|
||||
{
|
||||
loading === LoadingState.Loading &&
|
||||
<Spinner label="Sending request..." ariaLive="assertive" labelPosition="right" />
|
||||
}
|
||||
{
|
||||
loading === LoadingState.Error &&
|
||||
<ExceptionLayout e={apiError} />
|
||||
}
|
||||
</Dialog>
|
||||
</>);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import { MessageBar, MessageBarType, Link as FluentLink, Icon, } from '@fluentui/react';
|
||||
import React, { useState } from 'react';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
|
||||
interface ExceptionLayoutProps {
|
||||
e: APIError
|
||||
}
|
||||
|
||||
export const ExceptionLayout: React.FunctionComponent<ExceptionLayoutProps> = (props: ExceptionLayoutProps) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
switch (props.e.status) {
|
||||
case 403:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Access Denied</h3>
|
||||
<h4>{props.e.userMessage}</h4>
|
||||
<p>{props.e.message}</p>
|
||||
<p>Attempted resource: {props.e.endpoint}</p>
|
||||
</MessageBar>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>{props.e.userMessage}</h3>
|
||||
<p>{props.e.message}</p><br />
|
||||
|
||||
<FluentLink title={showDetails ? 'Show less' : 'Show more'} href="#" onClick={() => { setShowDetails(!showDetails) }} style={{ position: 'relative', top: '2px', paddingLeft: 0 }}>
|
||||
{
|
||||
showDetails ?
|
||||
<><Icon iconName='ChevronUp' aria-label='Expand Details' /> {'Hide Details'}</> :
|
||||
<><Icon iconName='ChevronDown' aria-label='Collapse Details' /> {'Show Details'} </>
|
||||
}
|
||||
</FluentLink>
|
||||
{
|
||||
showDetails &&
|
||||
<>
|
||||
<table style={{ border: '1px solid #666', width: '100%', padding: 10, marginTop: 15 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>Endpoint</b></td>
|
||||
<td>{props.e.endpoint}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Status Code</b></td>
|
||||
<td>{props.e.status || '(none)'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Stack Trace</b></td>
|
||||
<td>{props.e.stack}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Exception</b></td>
|
||||
<td>{props.e.exception}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
}
|
||||
|
||||
</MessageBar>
|
||||
)
|
||||
}
|
||||
};
|
|
@ -16,6 +16,7 @@ import { CreateUpdateResourceContext } from '../../contexts/CreateUpdateResource
|
|||
import { Workspace } from '../../models/workspace';
|
||||
import { WorkspaceService } from '../../models/workspaceService';
|
||||
import { actionsDisabledStates } from '../../models/operation';
|
||||
import { AppRolesContext } from '../../contexts/AppRolesContext';
|
||||
|
||||
interface ResourceContextMenuProps {
|
||||
resource: Resource,
|
||||
|
@ -32,6 +33,10 @@ export const ResourceContextMenu: React.FunctionComponent<ResourceContextMenuPro
|
|||
const createFormCtx = useContext(CreateUpdateResourceContext);
|
||||
const opsWriteContext = useRef(useContext(OperationsContext)); // useRef to avoid re-running a hook on context write
|
||||
const [parentResource, setParentResource] = useState({} as WorkspaceService | Workspace);
|
||||
const [roles, setRoles] = useState([] as Array<string>);
|
||||
const [wsAuth, setWsAuth] = useState(false);
|
||||
const appRoles = useContext(AppRolesContext); // the user is in these roles which apply across the app
|
||||
|
||||
|
||||
// get the resource template
|
||||
useEffect(() => {
|
||||
|
@ -57,11 +62,37 @@ export const ResourceContextMenu: React.FunctionComponent<ResourceContextMenuPro
|
|||
default:
|
||||
throw Error('Unsupported resource type.');
|
||||
}
|
||||
const template = await apiCall(`${templatesPath}/${props.resource.templateName}`, HttpMethod.Get);
|
||||
setResourceTemplate(template);
|
||||
|
||||
let r = [] as Array<string>;
|
||||
let wsAuth = false;
|
||||
switch (props.resource.resourceType) {
|
||||
case ResourceType.SharedService:
|
||||
r = [RoleName.TREAdmin, WorkspaceRoleName.WorkspaceOwner];
|
||||
break;
|
||||
case ResourceType.WorkspaceService:
|
||||
r = [WorkspaceRoleName.WorkspaceOwner]
|
||||
wsAuth = true;
|
||||
break;
|
||||
case ResourceType.UserResource:
|
||||
r = [WorkspaceRoleName.WorkspaceOwner, WorkspaceRoleName.WorkspaceResearcher];
|
||||
wsAuth = true;
|
||||
break;
|
||||
case ResourceType.Workspace:
|
||||
r = [RoleName.TREAdmin];
|
||||
break;
|
||||
}
|
||||
setWsAuth(wsAuth);
|
||||
setRoles(r);
|
||||
|
||||
// should we bother getting the template? if the user isn't in the right role they won't see the menu at all.
|
||||
const userRoles = wsAuth ? workspaceCtx.roles : appRoles.roles;
|
||||
if (userRoles && r.filter(x => userRoles.includes(x)).length > 0) {
|
||||
const template = await apiCall(`${templatesPath}/${props.resource.templateName}`, HttpMethod.Get);
|
||||
setResourceTemplate(template);
|
||||
}
|
||||
};
|
||||
getTemplate();
|
||||
}, [apiCall, props.resource, workspaceCtx.workspace.id, workspaceCtx.workspaceApplicationIdURI]);
|
||||
}, [apiCall, props.resource, workspaceCtx.workspace.id, workspaceCtx.workspaceApplicationIdURI, appRoles.roles, workspaceCtx.roles]);
|
||||
|
||||
const doAction = async (actionName: string) => {
|
||||
const action = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.InvokeAction}?action=${actionName}`, HttpMethod.Post, workspaceCtx.workspaceApplicationIdURI);
|
||||
|
@ -70,8 +101,6 @@ export const ResourceContextMenu: React.FunctionComponent<ResourceContextMenuPro
|
|||
|
||||
// context menu
|
||||
let menuItems: Array<any> = [];
|
||||
let roles: Array<string> = [];
|
||||
let wsAuth = false;
|
||||
|
||||
menuItems = [
|
||||
{
|
||||
|
@ -153,18 +182,6 @@ export const ResourceContextMenu: React.FunctionComponent<ResourceContextMenuPro
|
|||
});
|
||||
}
|
||||
|
||||
switch (props.resource.resourceType) {
|
||||
case ResourceType.Workspace:
|
||||
case ResourceType.SharedService:
|
||||
roles = [RoleName.TREAdmin];
|
||||
break;
|
||||
case ResourceType.WorkspaceService:
|
||||
case ResourceType.UserResource:
|
||||
wsAuth = true;
|
||||
roles = [WorkspaceRoleName.WorkspaceOwner];
|
||||
break;
|
||||
}
|
||||
|
||||
const menuProps: IContextualMenuProps = {
|
||||
shouldFocusOnMount: true,
|
||||
items: menuItems
|
||||
|
@ -177,7 +194,7 @@ export const ResourceContextMenu: React.FunctionComponent<ResourceContextMenuPro
|
|||
<CommandBar
|
||||
items={menuItems}
|
||||
ariaLabel="Resource actions"
|
||||
/>
|
||||
/>
|
||||
:
|
||||
<IconButton iconProps={{ iconName: 'More' }} menuProps={menuProps} className="tre-hide-chevron" disabled={props.componentAction === ComponentAction.Lock} />
|
||||
} />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IStackStyles, MessageBar, MessageBarType, Spinner, SpinnerSize, Stack } from "@fluentui/react";
|
||||
import { IStackStyles, Spinner, SpinnerSize, Stack } from "@fluentui/react";
|
||||
import React, { useEffect, useContext, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { HttpMethod, useAuthApiCall } from '../../hooks/useAuthApiCall';
|
||||
|
@ -9,6 +9,9 @@ import { ResourceOperationListItem } from './ResourceOperationListItem';
|
|||
import { WorkspaceContext } from '../../contexts/WorkspaceContext';
|
||||
import config from '../../config.json';
|
||||
import moment from "moment";
|
||||
import { APIError } from "../../models/exceptions";
|
||||
import { LoadingState } from "../../models/loadingState";
|
||||
import { ExceptionLayout } from "./ExceptionLayout";
|
||||
|
||||
|
||||
interface ResourceOperationsListProps {
|
||||
|
@ -17,6 +20,7 @@ interface ResourceOperationsListProps {
|
|||
|
||||
export const ResourceOperationsList: React.FunctionComponent<ResourceOperationsListProps> = (props: ResourceOperationsListProps) => {
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
const workspaceCtx = useContext(WorkspaceContext);
|
||||
const { resourceId } = useParams();
|
||||
const [resourceOperations, setResourceOperations] = useState([] as Array<Operation>)
|
||||
|
@ -29,9 +33,11 @@ export const ResourceOperationsList: React.FunctionComponent<ResourceOperationsL
|
|||
const ops = await apiCall(`${props.resource.resourcePath}/${ApiEndpoint.Operations}`, HttpMethod.Get, workspaceCtx.workspaceApplicationIdURI);
|
||||
config.debug && console.log(`Got resource operations, for resource:${props.resource.id}: ${ops.operations}`);
|
||||
setResourceOperations(ops.operations.reverse());
|
||||
setLoadingState(ops && ops.operations.length > 0 ? 'ok' : 'error');
|
||||
} catch {
|
||||
setLoadingState('error');
|
||||
setLoadingState(ops && ops.operations.length > 0 ? LoadingState.Ok : LoadingState.Error);
|
||||
} catch (err: any) {
|
||||
err.userMessage = "Error retrieving resource operations"
|
||||
setApiError(err);
|
||||
setLoadingState(LoadingState.Error);
|
||||
}
|
||||
};
|
||||
getOperations();
|
||||
|
@ -46,7 +52,7 @@ export const ResourceOperationsList: React.FunctionComponent<ResourceOperationsL
|
|||
};
|
||||
|
||||
switch (loadingState) {
|
||||
case 'ok':
|
||||
case LoadingState.Ok:
|
||||
return (
|
||||
<>
|
||||
{
|
||||
|
@ -70,15 +76,9 @@ export const ResourceOperationsList: React.FunctionComponent<ResourceOperationsL
|
|||
}
|
||||
</>
|
||||
);
|
||||
case 'error':
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving resource operations</h3>
|
||||
<p>There was an error retrieving this resource operations. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -2,13 +2,15 @@ import React, { useEffect, useState } from 'react';
|
|||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ApiEndpoint } from '../../models/apiEndpoints';
|
||||
import { useAuthApiCall, HttpMethod } from '../../hooks/useAuthApiCall';
|
||||
import { MessageBar, MessageBarType, Spinner, SpinnerSize } from '@fluentui/react';
|
||||
import { Spinner, SpinnerSize } from '@fluentui/react';
|
||||
import { LoadingState } from '../../models/loadingState';
|
||||
import { SharedService } from '../../models/sharedService';
|
||||
import { ResourceHeader } from './ResourceHeader';
|
||||
import { useComponentManager } from '../../hooks/useComponentManager';
|
||||
import { Resource } from '../../models/resource';
|
||||
import { ResourceBody } from './ResourceBody';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { ExceptionLayout } from './ExceptionLayout';
|
||||
|
||||
interface SharedServiceItemProps {
|
||||
readonly?: boolean
|
||||
|
@ -19,19 +21,26 @@ export const SharedServiceItem: React.FunctionComponent<SharedServiceItemProps>
|
|||
const [sharedService, setSharedService] = useState({} as SharedService);
|
||||
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
|
||||
const navigate = useNavigate();
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
|
||||
const latestUpdate = useComponentManager(
|
||||
sharedService,
|
||||
(r: Resource) => setSharedService(r as SharedService),
|
||||
(r: Resource) => navigate(`/${ApiEndpoint.SharedServices}`)
|
||||
);
|
||||
const apiCall = useAuthApiCall();
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async () => {
|
||||
let ss = await apiCall(`${ApiEndpoint.SharedServices}/${sharedServiceId}`, HttpMethod.Get);
|
||||
setSharedService(ss.sharedService);
|
||||
setLoadingState(LoadingState.Ok);
|
||||
try {
|
||||
let ss = await apiCall(`${ApiEndpoint.SharedServices}/${sharedServiceId}`, HttpMethod.Get);
|
||||
setSharedService(ss.sharedService);
|
||||
setLoadingState(LoadingState.Ok);
|
||||
} catch (err:any) {
|
||||
err.userMessage = "Error retrieving shared service";
|
||||
setApiError(err);
|
||||
setLoadingState(LoadingState.Error)
|
||||
}
|
||||
};
|
||||
getData();
|
||||
}, [apiCall, sharedServiceId]);
|
||||
|
@ -46,13 +55,7 @@ export const SharedServiceItem: React.FunctionComponent<SharedServiceItemProps>
|
|||
);
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving shared service</h3>
|
||||
<p>There was an error retrieving this shared service. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { CommandBarButton, DetailsList, getTheme, IColumn, MessageBar, MessageBarType, Persona, PersonaSize, SelectionMode, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { CommandBarButton, DetailsList, getTheme, IColumn, Persona, PersonaSize, SelectionMode, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { HttpMethod, useAuthApiCall } from '../../../hooks/useAuthApiCall';
|
||||
import { ApiEndpoint } from '../../../models/apiEndpoints';
|
||||
import { WorkspaceContext } from '../../../contexts/WorkspaceContext';
|
||||
|
@ -8,6 +8,8 @@ import moment from 'moment';
|
|||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { AirlockViewRequest } from './AirlockViewRequest';
|
||||
import { LoadingState } from '../../../models/loadingState';
|
||||
import { APIError } from '../../../models/exceptions';
|
||||
import { ExceptionLayout } from '../ExceptionLayout';
|
||||
|
||||
interface AirlockProps {
|
||||
}
|
||||
|
@ -18,6 +20,7 @@ export const Airlock: React.FunctionComponent<AirlockProps> = (props: AirlockPro
|
|||
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
|
||||
const workspaceCtx = useContext(WorkspaceContext);
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
const theme = getTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -41,7 +44,9 @@ export const Airlock: React.FunctionComponent<AirlockProps> = (props: AirlockPro
|
|||
requests.sort((a, b) => a.updatedWhen < b.updatedWhen ? 1 : -1);
|
||||
setAirlockRequests(requests);
|
||||
setLoadingState(LoadingState.Ok);
|
||||
} catch (error) {
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Error fetching airlock requests';
|
||||
setApiError(err);
|
||||
setLoadingState(LoadingState.Error);
|
||||
}
|
||||
}
|
||||
|
@ -173,13 +178,7 @@ export const Airlock: React.FunctionComponent<AirlockProps> = (props: AirlockPro
|
|||
break;
|
||||
case LoadingState.Error:
|
||||
requestsList = (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error fetching airlock requests</h3>
|
||||
<p>There was an error fetching the airlock requests. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
); break;
|
||||
default:
|
||||
requestsList = (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { DefaultButton, Dialog, DialogFooter, IStackItemStyles, IStackStyles, MessageBar, MessageBarType, Panel, PanelType, Persona, PersonaSize, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from "@fluentui/react";
|
||||
import { DefaultButton, Dialog, DialogFooter, IStackItemStyles, IStackStyles, MessageBar, Panel, PanelType, Persona, PersonaSize, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from "@fluentui/react";
|
||||
import moment from "moment";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
@ -6,6 +6,8 @@ import { WorkspaceContext } from "../../../contexts/WorkspaceContext";
|
|||
import { HttpMethod, useAuthApiCall } from "../../../hooks/useAuthApiCall";
|
||||
import { AirlockRequest, AirlockRequestStatus } from "../../../models/airlock";
|
||||
import { ApiEndpoint } from "../../../models/apiEndpoints";
|
||||
import { APIError } from "../../../models/exceptions";
|
||||
import { ExceptionLayout } from "../ExceptionLayout";
|
||||
|
||||
interface AirlockViewRequestProps {
|
||||
requests: AirlockRequest[];
|
||||
|
@ -40,6 +42,9 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
const [hideCancelDialog, setHideCancelDialog] = useState(true);
|
||||
const workspaceCtx = useContext(WorkspaceContext);
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiFilesLinkError, setApiFilesLinkError] = useState({} as APIError);
|
||||
const [apiSubmitError, setApiSubmitError] = useState({} as APIError);
|
||||
const [apiCancelError, setApiCancelError] = useState({} as APIError);
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
|
@ -68,7 +73,9 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
workspaceCtx.workspaceApplicationIdURI
|
||||
);
|
||||
setFilesLink(linkObject.containerUrl);
|
||||
} catch (error) {
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Error retrieving storage link';
|
||||
setApiFilesLinkError(err);
|
||||
setFilesLinkError(true);
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +96,9 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
);
|
||||
props.updateRequest(response.airlockRequest);
|
||||
setHideSubmitDialog(true);
|
||||
} catch (error) {
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Error submitting airlock request';
|
||||
setApiSubmitError(err);
|
||||
setSubmitError(true);
|
||||
}
|
||||
setSubmitting(false);
|
||||
|
@ -109,8 +118,11 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
);
|
||||
props.updateRequest(response.airlockRequest);
|
||||
setHideCancelDialog(true);
|
||||
} catch (error) {
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Error cancelling airlock request';
|
||||
setApiCancelError(err);
|
||||
setCancelError(true);
|
||||
|
||||
}
|
||||
setCancelling(false);
|
||||
}
|
||||
|
@ -129,10 +141,10 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
}
|
||||
<div style={{textAlign: 'end'}}>
|
||||
{
|
||||
request.status !== AirlockRequestStatus.Cancelled && <DefaultButton onClick={() => setHideCancelDialog(false)} styles={cancelButtonStyles}>Cancel Request</DefaultButton>
|
||||
request.status !== AirlockRequestStatus.Cancelled && <DefaultButton onClick={() => {setCancelError(false); setHideCancelDialog(false)}} styles={cancelButtonStyles}>Cancel Request</DefaultButton>
|
||||
}
|
||||
{
|
||||
request.status === AirlockRequestStatus.Draft && <PrimaryButton onClick={() => setHideSubmitDialog(false)}>Submit</PrimaryButton>
|
||||
request.status === AirlockRequestStatus.Draft && <PrimaryButton onClick={() => {setSubmitError(false); setHideSubmitDialog(false)}}>Submit</PrimaryButton>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
|
@ -236,14 +248,12 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
iconProps={{iconName: 'copy'}}
|
||||
styles={{root: {minWidth: '40px'}}}
|
||||
onClick={() => {navigator.clipboard.writeText(filesLink)}}
|
||||
/> : <PrimaryButton onClick={generateFilesLink}>Generate</PrimaryButton>
|
||||
/> : <PrimaryButton onClick={() => {setFilesLinkError(false); generateFilesLink()}}>Generate</PrimaryButton>
|
||||
}
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
{
|
||||
filesLinkError && <MessageBar messageBarType={MessageBarType.error}>
|
||||
Error retrieving storage link. Check console.
|
||||
</MessageBar>
|
||||
filesLinkError && <ExceptionLayout e={apiFilesLinkError} />
|
||||
}
|
||||
</Stack>
|
||||
</>
|
||||
|
@ -260,7 +270,7 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
}}
|
||||
>
|
||||
{
|
||||
submitError && <MessageBar messageBarType={MessageBarType.error}>Error submitting request. Check the console for details.</MessageBar>
|
||||
submitError && <ExceptionLayout e={apiSubmitError} />
|
||||
}
|
||||
{
|
||||
submitting
|
||||
|
@ -281,7 +291,7 @@ export const AirlockViewRequest: React.FunctionComponent<AirlockViewRequestProps
|
|||
}}
|
||||
>
|
||||
{
|
||||
cancelError && <MessageBar messageBarType={MessageBarType.error}>Error cancelling request. Check the console for details.</MessageBar>
|
||||
cancelError && <ExceptionLayout e={apiCancelError} />
|
||||
}
|
||||
{
|
||||
cancelling
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MessageBar, MessageBarType, Spinner, SpinnerSize } from "@fluentui/react";
|
||||
import { Spinner, SpinnerSize } from "@fluentui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoadingState } from "../../../models/loadingState";
|
||||
import { HttpMethod, ResultType, useAuthApiCall } from "../../../hooks/useAuthApiCall";
|
||||
|
@ -6,6 +6,8 @@ import Form from "@rjsf/fluent-ui";
|
|||
import { Operation } from "../../../models/operation";
|
||||
import { Resource } from "../../../models/resource";
|
||||
import { ResourceType } from "../../../models/resourceType";
|
||||
import { APIError } from "../../../models/exceptions";
|
||||
import { ExceptionLayout } from "../ExceptionLayout";
|
||||
|
||||
interface ResourceFormProps {
|
||||
templateName: string,
|
||||
|
@ -20,9 +22,9 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (props:
|
|||
const [template, setTemplate] = useState<any | null>(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
const [loading, setLoading] = useState(LoadingState.Loading as LoadingState);
|
||||
const [deployError, setDeployError] = useState(false);
|
||||
const [sendingData, setSendingData] = useState(false);
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
|
||||
useEffect(() => {
|
||||
const getFullTemplate = async () => {
|
||||
|
@ -41,7 +43,9 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (props:
|
|||
|
||||
setTemplate(templateResponse);
|
||||
setLoading(LoadingState.Ok);
|
||||
} catch {
|
||||
} catch (err: any){
|
||||
err.userMessage = "Error retrieving resource template";
|
||||
setApiError(err);
|
||||
setLoading(LoadingState.Error);
|
||||
}
|
||||
};
|
||||
|
@ -53,30 +57,32 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (props:
|
|||
}, [apiCall, props.templatePath, template, props.updateResource]);
|
||||
|
||||
const createUpdateResource = async (formData: any) => {
|
||||
setDeployError(false);
|
||||
setSendingData(true);
|
||||
let response;
|
||||
if (props.updateResource) {
|
||||
// only send the properties we're allowed to send
|
||||
let d: any = {}
|
||||
for (let prop in template.properties) {
|
||||
if (!template.properties[prop].readOnly) d[prop] = formData[prop];
|
||||
try
|
||||
{
|
||||
if (props.updateResource) {
|
||||
// only send the properties we're allowed to send
|
||||
let d: any = {}
|
||||
for (let prop in template.properties) {
|
||||
if (!template.properties[prop].readOnly) d[prop] = formData[prop];
|
||||
}
|
||||
console.log("patching resource", d);
|
||||
let wsAuth = props.updateResource.resourceType === ResourceType.WorkspaceService || props.updateResource.resourceType === ResourceType.UserResource;
|
||||
response = await apiCall(props.updateResource.resourcePath, HttpMethod.Patch, wsAuth ? props.workspaceApplicationIdURI : undefined, { properties: d }, ResultType.JSON, undefined, undefined, props.updateResource._etag);
|
||||
} else {
|
||||
const resource = { templateName: props.templateName, properties: formData };
|
||||
console.log(resource);
|
||||
response = await apiCall(props.resourcePath, HttpMethod.Post, props.workspaceApplicationIdURI, resource, ResultType.JSON);
|
||||
}
|
||||
console.log("patching resource", d);
|
||||
let wsAuth = props.updateResource.resourceType === ResourceType.WorkspaceService || props.updateResource.resourceType === ResourceType.UserResource;
|
||||
response = await apiCall(props.updateResource.resourcePath, HttpMethod.Patch, wsAuth ? props.workspaceApplicationIdURI : undefined, { properties: d }, ResultType.JSON, undefined, undefined, props.updateResource._etag);
|
||||
} else {
|
||||
const resource = { templateName: props.templateName, properties: formData };
|
||||
console.log(resource);
|
||||
response = await apiCall(props.resourcePath, HttpMethod.Post, props.workspaceApplicationIdURI, resource, ResultType.JSON);
|
||||
}
|
||||
|
||||
setSendingData(false);
|
||||
if (response) {
|
||||
props.onCreateResource(response.operation);
|
||||
} else {
|
||||
setDeployError(true);
|
||||
} catch (err: any) {
|
||||
err.userMessage = 'Error sending create / update request';
|
||||
setApiError(err);
|
||||
setLoading(LoadingState.Error);
|
||||
}
|
||||
setSendingData(false);
|
||||
}
|
||||
|
||||
// use the supplied uiSchema or create a blank one, and set the overview field to textarea manually.
|
||||
|
@ -106,23 +112,11 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (props:
|
|||
:
|
||||
<Form schema={template} formData={formData} uiSchema={uiSchema} onSubmit={(e: any) => createUpdateResource(e.formData)} />
|
||||
}
|
||||
{
|
||||
deployError &&
|
||||
<MessageBar messageBarType={MessageBarType.error}>
|
||||
<p>The API returned an error. Check the console for details or retry.</p>
|
||||
</MessageBar>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving template</h3>
|
||||
<p>There was an error retrieving the full resource template. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -2,6 +2,8 @@ import { DefaultButton, MessageBar, MessageBarType, Spinner, SpinnerSize, Stack
|
|||
import { useEffect, useState } from "react";
|
||||
import { LoadingState } from "../../../models/loadingState";
|
||||
import { HttpMethod, useAuthApiCall } from "../../../hooks/useAuthApiCall";
|
||||
import { APIError } from "../../../models/exceptions";
|
||||
import { ExceptionLayout } from "../ExceptionLayout";
|
||||
|
||||
interface SelectTemplateProps {
|
||||
templatesPath: string,
|
||||
|
@ -12,6 +14,7 @@ export const SelectTemplate: React.FunctionComponent<SelectTemplateProps> = (pro
|
|||
const [templates, setTemplates] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(LoadingState.Loading as LoadingState);
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
|
||||
useEffect(() => {
|
||||
const getTemplates = async () => {
|
||||
|
@ -20,7 +23,9 @@ export const SelectTemplate: React.FunctionComponent<SelectTemplateProps> = (pro
|
|||
const templatesResponse = await apiCall(props.templatesPath, HttpMethod.Get);
|
||||
setTemplates(templatesResponse.templates);
|
||||
setLoading(LoadingState.Ok);
|
||||
} catch {
|
||||
} catch (err: any){
|
||||
err.userMessage = 'Error retrieving templates';
|
||||
setApiError(err);
|
||||
setLoading(LoadingState.Error);
|
||||
}
|
||||
};
|
||||
|
@ -29,7 +34,7 @@ export const SelectTemplate: React.FunctionComponent<SelectTemplateProps> = (pro
|
|||
if (!templates) {
|
||||
getTemplates();
|
||||
}
|
||||
});
|
||||
}, [apiCall, props.templatesPath, templates]);
|
||||
|
||||
switch (loading) {
|
||||
case LoadingState.Ok:
|
||||
|
@ -56,13 +61,7 @@ export const SelectTemplate: React.FunctionComponent<SelectTemplateProps> = (pro
|
|||
)
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving templates</h3>
|
||||
<p>There was an error retrieving resource templates. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Icon, ProgressIndicator, Link as FluentLink, Stack, DefaultPalette, Shimmer, ShimmerElementType, MessageBar, MessageBarType } from '@fluentui/react';
|
||||
import { Icon, ProgressIndicator, Link as FluentLink, Stack, DefaultPalette, Shimmer, ShimmerElementType } from '@fluentui/react';
|
||||
import { TRENotification } from '../../../models/treNotification';
|
||||
import { awaitingStates, completedStates, failedStates, inProgressStates, Operation, OperationStep } from '../../../models/operation';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
@ -10,6 +10,8 @@ import { ApiEndpoint } from '../../../models/apiEndpoints';
|
|||
import { getResourceFromResult, Resource } from '../../../models/resource';
|
||||
import { NotificationPoller } from './NotificationPoller';
|
||||
import { OperationsContext } from '../../../contexts/OperationsContext';
|
||||
import { APIError } from '../../../models/exceptions';
|
||||
import { ExceptionLayout } from '../ExceptionLayout';
|
||||
|
||||
interface NotificationItemProps {
|
||||
operation: Operation,
|
||||
|
@ -25,6 +27,7 @@ export const NotificationItem: React.FunctionComponent<NotificationItemProps> =
|
|||
const opsCtx = useContext(OperationsContext);
|
||||
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
|
||||
const getRelativeTime = (createdWhen: number) => {
|
||||
return (moment.utc(moment.unix(createdWhen))).from(now);
|
||||
|
@ -60,6 +63,8 @@ export const NotificationItem: React.FunctionComponent<NotificationItemProps> =
|
|||
}
|
||||
setNotification({ operation: op, resource: resource, workspace: ws });
|
||||
} catch (err: any) {
|
||||
err.userMessage = `Error retrieving operation details for ${props.operation.id}`
|
||||
setApiError(err);
|
||||
setErrorNotification(true);
|
||||
}
|
||||
setLoadingNotification(false);
|
||||
|
@ -104,13 +109,7 @@ export const NotificationItem: React.FunctionComponent<NotificationItemProps> =
|
|||
:
|
||||
errorNotification ?
|
||||
<li>
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving operation details</h3>
|
||||
<p>We were unable to get more information about the operation {props.operation.id}. This might be because the associated resource has been deleted. Please investigate with your administrators to get the data cleaned up.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
</li>
|
||||
:
|
||||
<li className="tre-notification-item">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MessageBar, MessageBarType, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Route, Routes, useParams } from 'react-router-dom';
|
||||
import { ApiEndpoint } from '../../models/apiEndpoints';
|
||||
|
@ -16,6 +16,9 @@ import { SharedService } from '../../models/sharedService';
|
|||
import { SharedServices } from '../shared/SharedServices';
|
||||
import { SharedServiceItem } from '../shared/SharedServiceItem';
|
||||
import { Airlock } from '../shared/airlock/Airlock';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { LoadingState } from '../../models/loadingState';
|
||||
import { ExceptionLayout } from '../shared/ExceptionLayout';
|
||||
|
||||
export const WorkspaceProvider: React.FunctionComponent = () => {
|
||||
const apiCall = useAuthApiCall();
|
||||
|
@ -23,9 +26,11 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
|
|||
const [workspaceServices, setWorkspaceServices] = useState([] as Array<WorkspaceService>)
|
||||
const [sharedServices, setSharedServices] = useState([] as Array<SharedService>)
|
||||
const workspaceCtx = useRef(useContext(WorkspaceContext));
|
||||
const [loadingState, setLoadingState] = useState('loading');
|
||||
const [loadingState, setLoadingState] = useState(LoadingState.Loading);
|
||||
const [ apiError, setApiError ] = useState({} as APIError);
|
||||
const { workspaceId } = useParams();
|
||||
|
||||
|
||||
// set workspace context from url
|
||||
useEffect(() => {
|
||||
const getWorkspace = async () => {
|
||||
|
@ -46,14 +51,16 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
|
|||
// 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 ? 'ok' : 'denied');
|
||||
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 {
|
||||
setLoadingState('error');
|
||||
} catch (e: any){
|
||||
e.userMessage = 'Error retrieving workspace';
|
||||
setApiError(e);
|
||||
setLoadingState(LoadingState.Error);
|
||||
}
|
||||
};
|
||||
getWorkspace();
|
||||
|
@ -89,7 +96,7 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
|
|||
}
|
||||
|
||||
switch (loadingState) {
|
||||
case 'ok':
|
||||
case LoadingState.Ok:
|
||||
return (
|
||||
<>
|
||||
<WorkspaceHeader />
|
||||
|
@ -142,27 +149,9 @@ export const WorkspaceProvider: React.FunctionComponent = () => {
|
|||
</Stack>
|
||||
</>
|
||||
);
|
||||
case 'denied':
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.warning}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Access Denied</h3>
|
||||
<p>
|
||||
You do not have access to this Workspace. If you feel you should have access, please speak to your TRE Administrator. <br />
|
||||
If you have recently been given access, you may need to clear you browser local storage and refresh.</p>
|
||||
</MessageBar>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving workspace</h3>
|
||||
<p>There was an error retrieving this workspace. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -4,7 +4,7 @@ import { ApiEndpoint } from '../../models/apiEndpoints';
|
|||
import { useAuthApiCall, HttpMethod } from '../../hooks/useAuthApiCall';
|
||||
import { UserResource } from '../../models/userResource';
|
||||
import { WorkspaceService } from '../../models/workspaceService';
|
||||
import { MessageBar, MessageBarType, PrimaryButton, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { PrimaryButton, Spinner, SpinnerSize, Stack } from '@fluentui/react';
|
||||
import { ComponentAction, Resource } from '../../models/resource';
|
||||
import { ResourceCardList } from '../shared/ResourceCardList';
|
||||
import { LoadingState } from '../../models/loadingState';
|
||||
|
@ -18,6 +18,8 @@ import { UserResourceItem } from './UserResourceItem';
|
|||
import { ResourceBody } from '../shared/ResourceBody';
|
||||
import { SecuredByRole } from '../shared/SecuredByRole';
|
||||
import { WorkspaceRoleName } from '../../models/roleNames';
|
||||
import { APIError } from '../../models/exceptions';
|
||||
import { ExceptionLayout } from '../shared/ExceptionLayout';
|
||||
|
||||
interface WorkspaceServiceItemProps {
|
||||
workspaceService?: WorkspaceService,
|
||||
|
@ -36,6 +38,8 @@ export const WorkspaceServiceItem: React.FunctionComponent<WorkspaceServiceItemP
|
|||
const createFormCtx = useContext(CreateUpdateResourceContext);
|
||||
const navigate = useNavigate();
|
||||
const apiCall = useAuthApiCall();
|
||||
const [apiError, setApiError] = useState({} as APIError);
|
||||
|
||||
const latestUpdate = useComponentManager(
|
||||
workspaceService,
|
||||
(r: Resource) => { props.updateWorkspaceService(r as WorkspaceService); setWorkspaceService(r as WorkspaceService) },
|
||||
|
@ -64,7 +68,9 @@ export const WorkspaceServiceItem: React.FunctionComponent<WorkspaceServiceItemP
|
|||
setHasUserResourceTemplates(ut && ut.templates && ut.templates.length > 0);
|
||||
setUserResources(u.userResources);
|
||||
setLoadingState(LoadingState.Ok);
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
err.userMessage = "Error retrieving resources";
|
||||
setApiError(err);
|
||||
setLoadingState(LoadingState.Error);
|
||||
}
|
||||
};
|
||||
|
@ -100,15 +106,16 @@ export const WorkspaceServiceItem: React.FunctionComponent<WorkspaceServiceItemP
|
|||
<>
|
||||
<ResourceHeader resource={workspaceService} latestUpdate={latestUpdate} />
|
||||
<ResourceBody resource={workspaceService} />
|
||||
|
||||
{
|
||||
hasUserResourceTemplates &&
|
||||
<Stack className="tre-panel">
|
||||
<Stack.Item>
|
||||
<Stack horizontal horizontalAlign="space-between">
|
||||
<h1>User Resources</h1>
|
||||
<h1>Resources</h1>
|
||||
<SecuredByRole allowedRoles={[WorkspaceRoleName.WorkspaceOwner, WorkspaceRoleName.WorkspaceResearcher]} workspaceAuth={true} element={
|
||||
<PrimaryButton iconProps={{ iconName: 'Add' }} text="Create new" disabled={!workspaceService.isEnabled || latestUpdate.componentAction === ComponentAction.Lock || successStates.indexOf(workspaceService.deploymentStatus) === -1} title={!workspaceService.isEnabled ? 'Service must be enabled first' : 'Create a User Resource'}
|
||||
<PrimaryButton iconProps={{ iconName: 'Add' }} text="Create new"
|
||||
disabled={!workspaceService.isEnabled || latestUpdate.componentAction === ComponentAction.Lock || successStates.indexOf(workspaceService.deploymentStatus) === -1}
|
||||
title={(!workspaceService.isEnabled || latestUpdate.componentAction === ComponentAction.Lock || successStates.indexOf(workspaceService.deploymentStatus) === -1) ? 'Service must be enabled, successfully deployed, and not locked' : 'Create a User Resource'}
|
||||
onClick={() => {
|
||||
createFormCtx.openCreateForm({
|
||||
resourceType: ResourceType.UserResource,
|
||||
|
@ -148,13 +155,7 @@ export const WorkspaceServiceItem: React.FunctionComponent<WorkspaceServiceItemP
|
|||
);
|
||||
case LoadingState.Error:
|
||||
return (
|
||||
<MessageBar
|
||||
messageBarType={MessageBarType.error}
|
||||
isMultiline={true}
|
||||
>
|
||||
<h3>Error retrieving workspace</h3>
|
||||
<p>There was an error retrieving this workspace. Please see the browser console for details.</p>
|
||||
</MessageBar>
|
||||
<ExceptionLayout e={apiError} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
|
|
|
@ -1,127 +1,144 @@
|
|||
import { AuthenticationResult, InteractionRequiredAuthError } from "@azure/msal-browser";
|
||||
import { useMsal, useAccount } from "@azure/msal-react";
|
||||
import { useCallback } from "react";
|
||||
import { APIError } from "../models/exceptions";
|
||||
import config from "../config.json";
|
||||
|
||||
export enum ResultType {
|
||||
JSON = "JSON",
|
||||
Text = "Text",
|
||||
None = "None"
|
||||
JSON = "JSON",
|
||||
Text = "Text",
|
||||
None = "None"
|
||||
}
|
||||
|
||||
export enum HttpMethod {
|
||||
Get = "GET",
|
||||
Post = "POST",
|
||||
Patch = "PATCH",
|
||||
Delete = "DELETE"
|
||||
Get = "GET",
|
||||
Post = "POST",
|
||||
Patch = "PATCH",
|
||||
Delete = "DELETE"
|
||||
}
|
||||
|
||||
export const useAuthApiCall = () => {
|
||||
const { instance, accounts } = useMsal();
|
||||
const account = useAccount(accounts[0] || {});
|
||||
const { instance, accounts } = useMsal();
|
||||
const account = useAccount(accounts[0] || {});
|
||||
|
||||
const parseJwt = (token: string) => {
|
||||
var base64Url = token.split('.')[1];
|
||||
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
const parseJwt = (token: string) => {
|
||||
var base64Url = token.split('.')[1];
|
||||
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function (c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
return useCallback(async (
|
||||
endpoint: string,
|
||||
method: HttpMethod,
|
||||
workspaceApplicationIdURI?: string,
|
||||
body?: any,
|
||||
resultType?: ResultType,
|
||||
setRoles?: (roles: Array<string>) => void,
|
||||
tokenOnly?: boolean,
|
||||
etag?: string) => {
|
||||
|
||||
if (!account) {
|
||||
console.error("No account object found, please refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
return useCallback(async (
|
||||
endpoint: string,
|
||||
method: HttpMethod,
|
||||
workspaceApplicationIdURI?: string,
|
||||
body?: any,
|
||||
resultType?: ResultType,
|
||||
setRoles?: (roles: Array<string>) => void,
|
||||
tokenOnly?: boolean,
|
||||
etag?: string) => {
|
||||
const applicationIdURI = workspaceApplicationIdURI || config.treApplicationId;
|
||||
let tokenResponse = {} as AuthenticationResult;
|
||||
let tokenRequest = {
|
||||
scopes: [`${applicationIdURI}/user_impersonation`],
|
||||
account: account
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
console.error("No account object found, please refresh.");
|
||||
return;
|
||||
}
|
||||
// try and get a token silently. at times this might throw an InteractionRequiredAuthError - if so give the user a popup to click
|
||||
try {
|
||||
tokenResponse = await instance.acquireTokenSilent(tokenRequest);
|
||||
} catch (err) {
|
||||
console.warn("Unable to get a token silently", err);
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
tokenResponse = await instance.acquireTokenPopup(tokenRequest);
|
||||
}
|
||||
}
|
||||
|
||||
const applicationIdURI = workspaceApplicationIdURI || config.treApplicationId;
|
||||
let tokenResponse = {} as AuthenticationResult;
|
||||
let tokenRequest = {
|
||||
scopes: [`${applicationIdURI}/user_impersonation`],
|
||||
account: account
|
||||
}
|
||||
config.debug && console.log("Token Response", tokenResponse);
|
||||
|
||||
// try and get a token silently. at times this might throw an InteractionRequiredAuthError - if so give the user a popup to click
|
||||
try {
|
||||
tokenResponse = await instance.acquireTokenSilent(tokenRequest);
|
||||
} catch (err) {
|
||||
console.warn("Unable to get a token silently", err);
|
||||
if (err instanceof InteractionRequiredAuthError) {
|
||||
tokenResponse = await instance.acquireTokenPopup(tokenRequest);
|
||||
}
|
||||
}
|
||||
if (!tokenResponse) {
|
||||
console.error("Token could not be retrieved, please refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
config.debug && console.log("Token Response", tokenResponse);
|
||||
// caller can pass a function to allow us to set the roles to use for RBAC
|
||||
if (setRoles) {
|
||||
let decodedToken = parseJwt(tokenResponse.accessToken);
|
||||
config.debug && console.log("Decoded token", decodedToken);
|
||||
setRoles(decodedToken.roles);
|
||||
}
|
||||
|
||||
if (!tokenResponse) {
|
||||
console.error("Token could not be retrieved, please refresh.");
|
||||
return;
|
||||
}
|
||||
// we might just want the token to get the roles.
|
||||
if (tokenOnly) return;
|
||||
|
||||
// caller can pass a function to allow us to set the roles to use for RBAC
|
||||
if (setRoles) {
|
||||
let decodedToken = parseJwt(tokenResponse.accessToken);
|
||||
config.debug && console.log("Decoded token", decodedToken);
|
||||
setRoles(decodedToken.roles);
|
||||
}
|
||||
// trim first slash if we're given one
|
||||
if (endpoint[0] === "/") endpoint = endpoint.substring(1);
|
||||
|
||||
// we might just want the token to get the roles.
|
||||
if (tokenOnly) return;
|
||||
// default to JSON unless otherwise told
|
||||
resultType = resultType || ResultType.JSON;
|
||||
config.debug && console.log(`Calling ${method} on authenticated api: ${endpoint}`);
|
||||
|
||||
// default to JSON unless otherwise told
|
||||
resultType = resultType || ResultType.JSON;
|
||||
config.debug && console.log(`Calling ${method} on authenticated api: ${endpoint}`);
|
||||
// set the headers for auth + http method
|
||||
const opts: RequestInit = {
|
||||
mode: "cors",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'etag': etag ? etag : ""
|
||||
},
|
||||
method: method
|
||||
}
|
||||
|
||||
// set the headers for auth + http method
|
||||
const opts: RequestInit = {
|
||||
mode: "cors",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'etag': etag ? etag : ""
|
||||
},
|
||||
method: method
|
||||
}
|
||||
// add a body if we're given one
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
// add a body if we're given one
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
let resp;
|
||||
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';
|
||||
e.endpoint = `${config.treUrl}/${endpoint}`;
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
let resp = await fetch(`${config.treUrl}/${endpoint}`, opts);
|
||||
if (!resp.ok) {
|
||||
let e = new APIError();
|
||||
e.message = await resp.text();
|
||||
e.status = resp.status;
|
||||
e.endpoint = endpoint;
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let message = `Error calling ${endpoint}: ${resp.status} - ${resp.statusText}`
|
||||
throw(message);
|
||||
}
|
||||
try {
|
||||
switch (resultType) {
|
||||
case ResultType.Text:
|
||||
let text = await resp.text();
|
||||
config.debug && console.log(text);
|
||||
return text;
|
||||
case ResultType.JSON:
|
||||
let json = await resp.json();
|
||||
config.debug && console.log(json);
|
||||
return json
|
||||
case ResultType.None:
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
let e = err as APIError;
|
||||
e.name = "Error with response data";
|
||||
throw e;
|
||||
}
|
||||
|
||||
switch (resultType) {
|
||||
case ResultType.Text:
|
||||
let text = await resp.text();
|
||||
config.debug && console.log(text);
|
||||
return text;
|
||||
case ResultType.JSON:
|
||||
let json = await resp.json();
|
||||
config.debug && console.log(json);
|
||||
return json
|
||||
case ResultType.None:
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
// TODO: this is currently hiding errors, we should either rethrow to be handled in components
|
||||
// or hook this up to user-facing alerts
|
||||
console.error("Error calling API", err);
|
||||
throw err;
|
||||
}
|
||||
}, [account, instance]);
|
||||
}, [account, instance]);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
class TREError extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class APIError extends TREError {
|
||||
status?: number;
|
||||
exception?: any;
|
||||
userMessage?: string;
|
||||
endpoint?: string;
|
||||
}
|
Загрузка…
Ссылка в новой задаче