Merge branch 'master' into 16812-token-appears-in-the-download-URL
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Mon, 12 Oct 2020 19:31:44 +0000 (21:31 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Mon, 12 Oct 2020 19:31:44 +0000 (21:31 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

13 files changed:
src/common/redirect-to.test.ts [new file with mode: 0644]
src/common/redirect-to.ts [new file with mode: 0644]
src/components/icon/icon.tsx
src/index.tsx
src/store/store.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/copy-to-clipboard-action.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/file-viewer-action.test.tsx [new file with mode: 0644]
src/views-components/context-menu/actions/file-viewer-action.tsx
src/views-components/context-menu/actions/helpers.test.ts [new file with mode: 0644]
src/views-components/context-menu/actions/helpers.ts [new file with mode: 0644]

diff --git a/src/common/redirect-to.test.ts b/src/common/redirect-to.test.ts
new file mode 100644 (file)
index 0000000..ad8ac9e
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { storeRedirects, handleRedirects } from './redirect-to';
+
+describe('redirect-to', () => {
+    const { location } = window;
+    const redirectTo = 'http://localhost/test123';
+    const locationTemplate = {
+        hash: '',
+        hostname: '',
+        origin: '',
+        host: '',
+        pathname: '',
+        port: '80',
+        protocol: 'http',
+        search: '',
+        reload: () => {},
+        replace: () => {},
+        assign: () => {},
+        ancestorOrigins: [],
+        href: '',
+    };
+
+    afterAll((): void => {
+        window.location = location;
+    });
+
+    describe('storeRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectTo=${redirectTo}`,
+            } as any;
+            Object.defineProperty(window, 'sessionStorage', {
+                value: {
+                    setItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should store redirectTo in the session storage', () => {
+            // when
+            storeRedirects();
+
+            // then
+            expect(window.sessionStorage.setItem).toHaveBeenCalledWith('redirectTo', redirectTo);
+        });
+    });
+
+    describe('handleRedirects', () => {
+        beforeEach(() => {
+            delete window.location;
+            window.location = {
+                ...locationTemplate,
+                href: `${location.href}?redirectTo=${redirectTo}`,
+            } as any;;
+            Object.defineProperty(window, 'sessionStorage', {
+                value: {
+                    getItem: () => redirectTo,
+                    removeItem: jest.fn(),
+                },
+                writable: true
+            });
+        });
+
+        it('should redirect to page when it is present in session storage', () => {
+            // given
+            const token = 'testToken';
+
+            // when
+            handleRedirects(token);
+
+            // then
+            expect(window.location.href).toBe(`${redirectTo}?api_token=${token}`);
+        });
+    });
+});
\ No newline at end of file
diff --git a/src/common/redirect-to.ts b/src/common/redirect-to.ts
new file mode 100644 (file)
index 0000000..54268c2
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+const REDIRECT_TO_KEY = 'redirectTo';
+
+export const storeRedirects = () => {
+    if (window.location.href.indexOf(REDIRECT_TO_KEY) > -1) {
+        const { location: { href }, sessionStorage } = window;
+        const redirectUrl = href.split(`${REDIRECT_TO_KEY}=`)[1];
+        
+        if (sessionStorage) {
+            sessionStorage.setItem(REDIRECT_TO_KEY, redirectUrl);
+        }
+    }
+};
+
+export const handleRedirects = (token: string) => {
+    const { sessionStorage } = window;
+
+    if (sessionStorage && sessionStorage.getItem(REDIRECT_TO_KEY)) {
+        const redirectUrl = sessionStorage.getItem(REDIRECT_TO_KEY);
+        sessionStorage.removeItem(REDIRECT_TO_KEY);
+        window.location.href = `${redirectUrl}?api_token=${token}`;
+    }
+};
\ No newline at end of file
index b3f44824040cd454853f1d35156b66738774f51a..2573d764d5f79207141db7887caf0edcb1f8561e 100644 (file)
@@ -54,6 +54,7 @@ import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
 import VpnKey from '@material-ui/icons/VpnKey';
+import LinkOutlined from '@material-ui/icons/LinkOutlined';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
@@ -128,3 +129,4 @@ export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
 export const WorkflowIcon: IconType = (props) => <Code {...props} />;
 export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
+export const Link: IconType = (props) => <LinkOutlined {...props} />;
index 0a51ed3c8ab94c9fab61d80325f429428ea098d2..f87ff3843b2f904bc1697d3fd2f1f5521846700c 100644 (file)
@@ -63,6 +63,7 @@ import { processResourceAdminActionSet } from '~/views-components/context-menu/a
 import { projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set';
 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action';
+import { storeRedirects } from './common/redirect-to';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -97,6 +98,8 @@ addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
 
+storeRedirects();
+
 fetchConfig()
     .then(({ config, apiHost }) => {
         const history = createBrowserHistory();
index 030b657662e8b2631f069b18775aa1c97f71f396..0bc351bbe4c6c6aebbc48f22688d78b258e31243 100644 (file)
@@ -6,6 +6,7 @@ import { createStore, applyMiddleware, compose, Middleware, combineReducers, Sto
 import { routerMiddleware, routerReducer } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
+import { handleRedirects } from '../common/redirect-to';
 
 import { authReducer } from "./auth/auth-reducer";
 import { authMiddleware } from "./auth/auth-middleware";
@@ -130,6 +131,16 @@ export function configureStore(history: History, services: ServiceRepository): R
     const subprocessMiddleware = dataExplorerMiddleware(
         new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID)
     );
+    const redirectToMiddleware = (store: any) => (next: any) => (action: any) => {
+        const state = store.getState();
+
+        if (state.auth && state.auth.apiToken) {
+            const { apiToken } = state.auth;
+            handleRedirects(apiToken);
+        }
+
+        return next(action);
+    };
 
     const middlewares: Middleware[] = [
         routerMiddleware(history),
@@ -150,9 +161,9 @@ export function configureStore(history: History, services: ServiceRepository): R
         apiClientAuthorizationMiddlewareService,
         publicFavoritesMiddleware,
         collectionsContentAddress,
-        subprocessMiddleware
+        subprocessMiddleware,
     ];
-    const enhancer = composeEnhancers(applyMiddleware(...middlewares));
+    const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares));
     return createStore(rootReducer, enhancer);
 }
 
index b900d1864dd3490710cb39a061caff1b548417cb..6ce62ca942c55e738f79b2b47a295bd7cc220d1a 100644 (file)
@@ -7,7 +7,7 @@ import { RemoveIcon, RenameIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
 import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 import { CollectionFileViewerAction } from '~/views-components/context-menu/actions/collection-file-viewer-action';
-
+import { CollectionCopyToClipboardAction } from "../actions/collection-copy-to-clipboard-action";
 
 export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
     {
@@ -17,6 +17,10 @@ export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[
     {
         component: CollectionFileViewerAction,
         execute: () => { return; },
+    },
+    {
+        component: CollectionCopyToClipboardAction,
+        execute: () => { return; },
     }
 ]];
 
diff --git a/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx b/src/views-components/context-menu/actions/collection-copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..4ecdef7
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { RootState } from "../../../store/store";
+import { getNodeValue } from "~/models/tree";
+import { CollectionFileType } from "~/models/collection-file";
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { CopyToClipboardAction } from "./copy-to-clipboard-action";
+
+const mapStateToProps = (state: RootState) => {
+    const { resource } = state.contextMenu;
+    const currentCollectionUuid = state.collectionPanel.item ? state.collectionPanel.item.uuid : '';
+    if (resource && resource.menuKind === ContextMenuKind.COLLECTION_FILES_ITEM) {
+        const file = getNodeValue(resource.uuid)(state.collectionPanelFiles);
+        if (file) {
+            return {
+                href: file.url,
+                download: file.type === CollectionFileType.DIRECTORY ? undefined : file.name,
+                kind: 'file',
+                currentCollectionUuid
+            };
+        }
+    } else {
+        return ;
+    }
+    return ;
+};
+
+export const CollectionCopyToClipboardAction = connect(mapStateToProps)(CopyToClipboardAction);
diff --git a/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx b/src/views-components/context-menu/actions/copy-to-clipboard-action.test.tsx
new file mode 100644 (file)
index 0000000..1ada703
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { shallow, configure } from 'enzyme';
+import { ListItem } from "@material-ui/core";
+import * as Adapter from 'enzyme-adapter-react-16';
+import { CopyToClipboardAction } from './copy-to-clipboard-action';
+
+configure({ adapter: new Adapter() });
+
+jest.mock('copy-to-clipboard', () => jest.fn());
+
+describe('CopyToClipboardAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<CopyToClipboardAction {...props} />);
+        wrapper.find(ListItem).simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
diff --git a/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx b/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx
new file mode 100644 (file)
index 0000000..31ef4b9
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import * as copy from 'copy-to-clipboard';
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { Link } from "~/components/icon/icon";
+import { getClipboardUrl } from "./helpers";
+
+export const CopyToClipboardAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
+    const copyToClipboard = () => {
+        if (props.href) {
+            const clipboardUrl = getClipboardUrl(props.href);
+            copy(clipboardUrl);
+        }
+
+        if (props.onClick) {
+            props.onClick();
+        }
+    };
+
+    return props.href
+        ? <ListItem button onClick={copyToClipboard}>
+            <ListItemIcon>
+                <Link />
+            </ListItemIcon>
+            <ListItemText>
+                Copy to clipboard
+                </ListItemText>
+        </ListItem>
+        : null;
+};
\ No newline at end of file
diff --git a/src/views-components/context-menu/actions/file-viewer-action.test.tsx b/src/views-components/context-menu/actions/file-viewer-action.test.tsx
new file mode 100644 (file)
index 0000000..fa455de
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { shallow, configure } from 'enzyme';
+import * as Adapter from 'enzyme-adapter-react-16';
+import { FileViewerAction } from './file-viewer-action';
+
+configure({ adapter: new Adapter() });
+
+describe('FileViewerAction', () => {
+    let props;
+
+    beforeEach(() => {
+        props = {
+            onClick: jest.fn(),
+            href: 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html',
+        };
+    });
+
+    it('should render properly and handle click', () => {
+        // when
+        const wrapper = shallow(<FileViewerAction {...props} />);
+        wrapper.find('a').simulate('click');
+
+        // then
+        expect(wrapper).not.toBeUndefined();
+
+        // and
+        expect(props.onClick).toHaveBeenCalled();
+    });
+});
\ No newline at end of file
index 20dcece400a2955aeaeeb42ebe55280464aa8bac..a2c32bea875e32524be04532dd90957e6da06675 100644 (file)
@@ -5,6 +5,7 @@
 import * as React from "react";
 import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
 import { OpenIcon } from "~/components/icon/icon";
+import { sanitizeToken } from "./helpers";
 
 export const FileViewerAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => {
     const fileProps = props.download ? { download: props.download } : {};
@@ -12,7 +13,7 @@ export const FileViewerAction = (props: { href?: any, download?: any, onClick?:
     return props.href
         ? <a
             style={{ textDecoration: 'none' }}
-            href={props.href}
+            href={sanitizeToken(props.href)}
             target="_blank"
             onClick={props.onClick}
             {...fileProps}>
diff --git a/src/views-components/context-menu/actions/helpers.test.ts b/src/views-components/context-menu/actions/helpers.test.ts
new file mode 100644 (file)
index 0000000..6aaacc2
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { sanitizeToken, getClipboardUrl } from "./helpers";
+
+describe('helpers', () => {
+    // given
+    const url = 'https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/t=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d/LIMS/1.html';
+
+    describe('sanitizeToken', () => {
+        it('should sanitize token from the url', () => {
+            // when
+            const result = sanitizeToken(url);
+
+            // then
+            expect(result).toBe('https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/LIMS/1.html?api_token=1ha4ykd3w14ed19b2gh3uyjrjup38vsx27x1utwdne0bxcfg5d');
+        });
+    });
+
+    describe('getClipboardUrl', () => {
+        it('should add redirectTo query param', () => {
+            // when
+            const result = getClipboardUrl(url);
+
+            // then
+            expect(result).toBe('http://localhost?redirectTo=https://collections.ardev.roche.com/c=ardev-4zz18-k0hamvtwyit6q56/LIMS/1.html');
+        });
+    });
+});
\ No newline at end of file
diff --git a/src/views-components/context-menu/actions/helpers.ts b/src/views-components/context-menu/actions/helpers.ts
new file mode 100644 (file)
index 0000000..a8cc24b
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const sanitizeToken = (href: string, tokenAsQueryParam: boolean = true): string => {
+    const [prefix, suffix] = href.split('/t=');
+    const [token, ...rest] = suffix.split('/');
+
+    return `${[prefix, ...rest].join('/')}${tokenAsQueryParam ? `?api_token=${token}` : ''}`;
+};
+
+export const getClipboardUrl = (href: string): string => {
+    const { origin } = window.location;
+
+    return `${origin}?redirectTo=${sanitizeToken(href, false)}`;
+};
\ No newline at end of file