Merge branch 'master' into 16848-token-handling-improvements
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Mon, 22 Feb 2021 21:04:57 +0000 (18:04 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Mon, 22 Feb 2021 21:04:57 +0000 (18:04 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas@di-pentima.com.ar>

16 files changed:
src/index.tsx
src/models/api-client-authorization.ts
src/services/common-service/common-service.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.ts
src/store/collections/collection-info-actions.ts
src/store/current-token-dialog/current-token-dialog-actions.tsx [deleted file]
src/store/token-dialog/token-dialog-actions.tsx [new file with mode: 0644]
src/store/users/users-actions.ts
src/views-components/auto-logout/auto-logout.test.tsx
src/views-components/auto-logout/auto-logout.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/token-dialog/token-dialog.test.tsx [moved from src/views-components/current-token-dialog/current-token-dialog.test.tsx with 89% similarity]
src/views-components/token-dialog/token-dialog.tsx [moved from src/views-components/current-token-dialog/current-token-dialog.tsx with 65% similarity]
src/views/workbench/workbench.tsx

index 98281b67d9ff5cfb4bca863ea7b5b5f2dd8ce27d..b32066a46c69c37a9185bece05294319b6453fbc 100644 (file)
@@ -36,7 +36,7 @@ import { ServiceRepository } from '~/services/services';
 import { initWebSocket } from '~/websocket/websocket';
 import { Config } from '~/common/config';
 import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setCurrentTokenDialogApiHost } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { setTokenDialogApiHost } from '~/store/token-dialog/token-dialog-actions';
 import { processResourceActionSet } from '~/views-components/context-menu/action-sets/process-resource-action-set';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
 import { trashedCollectionActionSet } from '~/views-components/context-menu/action-sets/trashed-collection-action-set';
@@ -130,7 +130,7 @@ fetchConfig()
         store.subscribe(initListener(history, store, services, config));
         store.dispatch(initAuth(config));
         store.dispatch(setBuildInfo());
-        store.dispatch(setCurrentTokenDialogApiHost(apiHost));
+        store.dispatch(setTokenDialogApiHost(apiHost));
         store.dispatch(loadVocabulary);
         store.dispatch(loadFileViewersConfig);
 
index 01a92017d54d9ae9b8323d101f549359208323d5..739485c5682aba2209aaf16547e47651a47db2e1 100644 (file)
@@ -18,4 +18,7 @@ export interface ApiClientAuthorization extends Resource {
     ownerUuid: string;
     defaultOwnerUuid: string;
     scopes: string[];
-}
\ No newline at end of file
+}
+
+export const getTokenV2 = (aca: ApiClientAuthorization): string =>
+    `v2/${aca.uuid}/${aca.apiToken}`;
\ No newline at end of file
index 54c0edf6bf2c684346e0309103ad085c4f38a541..e43f9f8f136a7404af40b9ffb0626cffd650d9ad 100644 (file)
@@ -94,11 +94,13 @@ export class CommonService<T> {
             });
     }
 
-    create(data?: Partial<T>) {
+    create(data?: Partial<T>, showErrors?: boolean) {
         return CommonService.defaultResponse(
             this.serverApi
                 .post<T>(this.resourceType, data && CommonService.mapKeys(_.snakeCase)(data)),
-            this.actions
+            this.actions,
+            true, // mapKeys
+            showErrors
         );
     }
 
index 13575d44d5ce1ab65196fd0dab4bbf43ecb464a2..79f93daa66d34d4d5b1695f2cc725205790444b6 100644 (file)
@@ -9,7 +9,7 @@ import 'jest-localstorage-mock';
 import { ServiceRepository, createServices } from "~/services/services";
 import { configureStore, RootStore } from "../store";
 import { createBrowserHistory } from "history";
-import { mockConfig, DISCOVERY_DOC_PATH, } from '~/common/config';
+import { Config, mockConfig } from '~/common/config';
 import { ApiActions } from "~/services/api/api-actions";
 import { ACCOUNT_LINK_STATUS_KEY } from '~/services/link-account-service/link-account-service';
 import Axios from "axios";
@@ -84,6 +84,9 @@ describe('auth-actions', () => {
             uuidPrefix: "zzzzz",
             remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
             apiRevision: 12345678,
+            clusterConfig: {
+                Login: { LoginCluster: "" },
+            },
         };
 
         store.dispatch(initAuth(config));
@@ -100,6 +103,11 @@ describe('auth-actions', () => {
                         apiToken: "token",
                         config: {
                             apiRevision: 12345678,
+                            clusterConfig: {
+                                Login: {
+                                    LoginCluster: "",
+                                },
+                            },
                             remoteHosts: {
                                 "xc59z": "xc59z.arvadosapi.com",
                             },
@@ -107,12 +115,18 @@ describe('auth-actions', () => {
                             uuidPrefix: "zzzzz",
                         },
                         sshKeys: [],
+                        extraApiToken: undefined,
                         homeCluster: "zzzzz",
                         localCluster: "zzzzz",
                         loginCluster: undefined,
                         remoteHostsConfig: {
                             "zzzzz": {
                                 "apiRevision": 12345678,
+                                "clusterConfig": {
+                                    "Login": {
+                                        "LoginCluster": "",
+                                    },
+                                },
                                 "remoteHosts": {
                                     "xc59z": "xc59z.arvadosapi.com",
                                 },
index 15fe3d4d591da9e00ef356ac591726060a8e76a3..fb94746ff983b0a23502c3149d9f91b45bab5a73 100644 (file)
@@ -16,11 +16,13 @@ import { cancelLinking } from '~/store/link-account-panel/link-account-panel-act
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
 import { addRemoteConfig } from './auth-action-session';
+import { getTokenV2 } from '~/models/api-client-authorization';
 
 export const authActions = unionize({
     LOGIN: {},
     LOGOUT: ofType<{ deleteLinkData: boolean }>(),
     SET_CONFIG: ofType<{ config: Config }>(),
+    SET_EXTRA_TOKEN: ofType<{ extraToken: string }>(),
     INIT_USER: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>(),
@@ -86,11 +88,33 @@ export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: ()
     setAuthorizationHeader(svc, token);
     return svc.authService.getUserDetails().then((user: User) => {
         dispatch(authActions.INIT_USER({ user, token }));
+        // Upon user init, request an extra token that won't be expired on logout
+        // for other uses like the "get token" dialog, or S3 URL building.
+        dispatch<any>(getNewExtraToken());
     }).catch(() => {
         dispatch(authActions.LOGOUT({ deleteLinkData: false }));
     });
 };
 
+export const getNewExtraToken = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const user = getState().auth.user;
+        const loginCluster = getState().auth.config.clusterConfig.Login.LoginCluster;
+        if (user === undefined) { return; }
+        if (loginCluster !== "" && getState().auth.homeCluster !== loginCluster) { return; }
+        try {
+            // Do not show errors on the create call, cluster security configuration may not
+            // allow token creation and there's no way to know that from workbench2 side in advance.
+            const client = await services.apiClientAuthorizationService.create(undefined, false);
+            const newExtraToken = getTokenV2(client);
+            dispatch(authActions.SET_EXTRA_TOKEN({ extraToken: newExtraToken }));
+            return newExtraToken;
+        } catch {
+            console.warn("Cannot create new tokens with the current token, probably because of cluster's security settings.");
+            return;
+        }
+    };
+
 export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
     remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         services.authService.login(uuidPrefix, homeCluster, loginCluster, remoteHosts);
index 946407fe24172610fbc3aaf9cff7b95052a43af8..d55e8301df50713625a1cf13861018779661b0db 100644 (file)
@@ -12,6 +12,7 @@ import { Config, mockConfig } from '~/common/config';
 export interface AuthState {
     user?: User;
     apiToken?: string;
+    extraApiToken?: string;
     sshKeys: SshKeyResource[];
     sessions: Session[];
     localCluster: string;
@@ -25,6 +26,7 @@ export interface AuthState {
 const initialState: AuthState = {
     user: undefined,
     apiToken: undefined,
+    extraApiToken: undefined,
     sshKeys: [],
     sessions: [],
     localCluster: "",
@@ -54,6 +56,7 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat
                 remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
             };
         },
+        SET_EXTRA_TOKEN: ({ extraToken }) => ({ ...state, extraApiToken: extraToken }),
         INIT_USER: ({ user, token }) => {
             return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
         },
index 49fe54f67ea6ccacee79135178ea23b912f1607d..b904da705cbf601e45589bbeaa0f09d8267da375 100644 (file)
@@ -26,7 +26,7 @@ export const openWebDavS3InfoDialog = (uuid: string, activeTab?: number) =>
             id: COLLECTION_WEBDAV_S3_DIALOG_NAME,
             data: {
                 title: 'Access Collection using WebDAV or S3',
-                token: getState().auth.apiToken,
+                token: getState().auth.extraApiToken || getState().auth.apiToken,
                 downloadUrl: getState().auth.config.keepWebServiceUrl,
                 collectionsUrl: getState().auth.config.keepWebInlineServiceUrl,
                 localCluster: getState().auth.localCluster,
diff --git a/src/store/current-token-dialog/current-token-dialog-actions.tsx b/src/store/current-token-dialog/current-token-dialog-actions.tsx
deleted file mode 100644 (file)
index fe8186b..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { getProperty } from '~/store/properties/properties';
-import { propertiesActions } from '~/store/properties/properties-actions';
-import { RootState } from '~/store/store';
-
-export const CURRENT_TOKEN_DIALOG_NAME = 'currentTokenDialog';
-const API_HOST_PROPERTY_NAME = 'apiHost';
-
-export interface CurrentTokenDialogData {
-    currentToken: string;
-    apiHost: string;
-}
-
-export const setCurrentTokenDialogApiHost = (apiHost: string) =>
-    propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
-
-export const getCurrentTokenDialogData = (state: RootState): CurrentTokenDialogData => ({
-    apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
-    currentToken: state.auth.apiToken || '',
-});
-
-export const openCurrentTokenDialog = dialogActions.OPEN_DIALOG({ id: CURRENT_TOKEN_DIALOG_NAME, data: {} });
diff --git a/src/store/token-dialog/token-dialog-actions.tsx b/src/store/token-dialog/token-dialog-actions.tsx
new file mode 100644 (file)
index 0000000..2cf573b
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { getProperty } from '~/store/properties/properties';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { RootState } from '~/store/store';
+
+export const TOKEN_DIALOG_NAME = 'tokenDialog';
+const API_HOST_PROPERTY_NAME = 'apiHost';
+
+export interface TokenDialogData {
+    token: string;
+    apiHost: string;
+    canCreateNewTokens: boolean;
+}
+
+export const setTokenDialogApiHost = (apiHost: string) =>
+    propertiesActions.SET_PROPERTY({ key: API_HOST_PROPERTY_NAME, value: apiHost });
+
+export const getTokenDialogData = (state: RootState): TokenDialogData => {
+    const loginCluster = state.auth.config.clusterConfig.Login.LoginCluster;
+    const canCreateNewTokens = !(loginCluster !== "" && state.auth.homeCluster !== loginCluster);
+
+    return {
+        apiHost: getProperty<string>(API_HOST_PROPERTY_NAME)(state.properties) || '',
+        token: state.auth.extraApiToken || state.auth.apiToken || '',
+        canCreateNewTokens,
+    };
+};
+
+export const openTokenDialog = dialogActions.OPEN_DIALOG({ id: TOKEN_DIALOG_NAME, data: {} });
index 8f696fa29ad6e0d9e7122b5cbdc82d04a1d93a3f..26b8810c8485a87ec928e24cc63be320dc8d1c55 100644 (file)
@@ -14,6 +14,7 @@ import { UserResource } from "~/models/user";
 import { getResource } from '~/store/resources/resources';
 import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
 import { authActions } from '~/store/auth/auth-action';
+import { getTokenV2 } from "~/models/api-client-authorization";
 
 export const USERS_PANEL_ID = 'usersPanel';
 export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
@@ -62,7 +63,7 @@ export const loginAs = (uuid: string) =>
         const data = getResource<UserResource>(uuid)(resources);
         const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
         if (data) {
-            dispatch<any>(authActions.INIT_USER({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+            dispatch<any>(authActions.INIT_USER({ user: data, token: getTokenV2(client) }));
             location.reload();
             dispatch<any>(navigateToRootProject);
         }
index f8daa764f86d6068a73c782ce26514c1cd8f9025..4949672437b9530cb36ca6e9cd23058812721b97 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { configure, mount } from "enzyme";
 import * as Adapter from 'enzyme-adapter-react-16';
-import { AutoLogoutComponent, AutoLogoutProps } from './auto-logout';
+import { AutoLogoutComponent, AutoLogoutProps, LAST_ACTIVE_TIMESTAMP } from './auto-logout';
 
 configure({ adapter: new Adapter() });
 
@@ -13,8 +13,15 @@ describe('<AutoLogoutComponent />', () => {
     let props: AutoLogoutProps;
     const sessionIdleTimeout = 300;
     const lastWarningDuration = 60;
+    const eventListeners = {};
     jest.useFakeTimers();
 
+    beforeAll(() => {
+        window.addEventListener = jest.fn((event, cb) => {
+            eventListeners[event] = cb;
+        });
+    });
+
     beforeEach(() => {
         props = {
             sessionIdleTimeout: sessionIdleTimeout,
@@ -39,4 +46,17 @@ describe('<AutoLogoutComponent />', () => {
         jest.runTimersToTime(1*1000);
         expect(props.doWarn).toBeCalled();
     });
+
+    it('should reset the idle timer when activity event is received', () => {
+        jest.runTimersToTime((sessionIdleTimeout-lastWarningDuration-1)*1000);
+        expect(props.doWarn).not.toBeCalled();
+        // Simulate activity from other window/tab
+        eventListeners.storage({
+            key: LAST_ACTIVE_TIMESTAMP,
+            newValue: '42' // value currently doesn't matter
+        })
+        jest.runTimersToTime(1*1000);
+        // Warning should not appear because idle timer was reset
+        expect(props.doWarn).not.toBeCalled();
+    });
 });
\ No newline at end of file
index f1464ce18497f3476017ec2075f430afb3bf339d..f7e6f4b838d0082feff5c2bc8d9ff0f4d14c61cb 100644 (file)
@@ -24,12 +24,10 @@ interface AutoLogoutActionProps {
     doCloseWarn: () => void;
 }
 
-const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => {
-    return {
-        sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
-        lastWarningDuration: ownProps.lastWarningDuration || 60,
-    };
-};
+const mapStateToProps = (state: RootState, ownProps: any): AutoLogoutDataProps => ({
+    sessionIdleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
+    lastWarningDuration: ownProps.lastWarningDuration || 60,
+});
 
 const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
     doLogout: () => dispatch<any>(logout(true)),
@@ -41,9 +39,41 @@ const mapDispatchToProps = (dispatch: Dispatch): AutoLogoutActionProps => ({
 
 export type AutoLogoutProps = AutoLogoutDataProps & AutoLogoutActionProps;
 
+const debounce = (delay: number | undefined, fn: Function) => {
+    let timerId: number | null;
+    return (...args: any[]) => {
+        if (timerId) { clearTimeout(timerId); }
+        timerId = setTimeout(() => {
+            fn(...args);
+            timerId = null;
+        }, delay);
+    };
+};
+
+export const LAST_ACTIVE_TIMESTAMP = 'lastActiveTimestamp';
+
 export const AutoLogoutComponent = (props: AutoLogoutProps) => {
     let logoutTimer: NodeJS.Timer;
-    const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000 ;
+    const lastWarningDuration = min([props.lastWarningDuration, props.sessionIdleTimeout])! * 1000;
+
+    // Runs once after render
+    React.useEffect(() => {
+        window.addEventListener('storage', handleStorageEvents);
+        // Component cleanup
+        return () => {
+            window.removeEventListener('storage', handleStorageEvents);
+        };
+    }, []);
+
+    const handleStorageEvents = (e: StorageEvent) => {
+        if (e.key === LAST_ACTIVE_TIMESTAMP) {
+            // Other tab activity detected by a localStorage change event.
+            debounce(500, () => {
+                handleOnActive();
+                reset();
+            })();
+        }
+    };
 
     const handleOnIdle = () => {
         logoutTimer = setTimeout(
@@ -54,16 +84,23 @@ export const AutoLogoutComponent = (props: AutoLogoutProps) => {
     };
 
     const handleOnActive = () => {
-        clearTimeout(logoutTimer);
+        if (logoutTimer) { clearTimeout(logoutTimer); }
         props.doCloseWarn();
     };
 
-    useIdleTimer({
+    const handleOnAction = () => {
+        // Notify the other tabs there was some activity.
+        const now = (new Date).getTime();
+        localStorage.setItem(LAST_ACTIVE_TIMESTAMP, now.toString());
+    };
+
+    const { reset } = useIdleTimer({
         timeout: (props.lastWarningDuration < props.sessionIdleTimeout)
             ? 1000 * (props.sessionIdleTimeout - props.lastWarningDuration)
             : 1,
         onIdle: handleOnIdle,
         onActive: handleOnActive,
+        onAction: handleOnAction,
         debounce: 500
     });
 
index 6e844cc8e2337001deaa0495eee9d15800de9082..58ed7b849a1def7154c5d0073538115ab45d2dea 100644 (file)
@@ -11,7 +11,7 @@ import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
 import { authActions } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openTokenDialog } from '~/store/token-dialog/token-dialog-actions';
 import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
 import {
     navigateToSiteManager,
@@ -70,7 +70,7 @@ export const AccountMenuComponent =
             {user.isActive ? <>
                 <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
                 {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
-                <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
+                <MenuItem onClick={() => dispatch(openTokenDialog)}>Get API token</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
similarity index 89%
rename from src/views-components/current-token-dialog/current-token-dialog.test.tsx
rename to src/views-components/token-dialog/token-dialog.test.tsx
index eb405e94a0ed6f7bf140f7afe0de4f6179eef0ee..e8df29b8cb4376c7c0b63c764c7a5f1434e7d8d4 100644 (file)
@@ -7,7 +7,7 @@ import { Button } from '@material-ui/core';
 import { mount, configure } from 'enzyme';
 import * as Adapter from 'enzyme-adapter-react-16';
 import * as CopyToClipboard from 'react-copy-to-clipboard';
-import { CurrentTokenDialogComponent } from './current-token-dialog';
+import { TokenDialogComponent } from './token-dialog';
 
 configure({ adapter: new Adapter() });
 
@@ -30,7 +30,7 @@ describe('<CurrentTokenDialog />', () => {
 
   describe('copy to clipboard', () => {
     beforeEach(() => {
-      wrapper = mount(<CurrentTokenDialogComponent {...props} />);
+      wrapper = mount(<TokenDialogComponent {...props} />);
     });
 
     it('should copy API TOKEN to the clipboard', () => {
similarity index 65%
rename from src/views-components/current-token-dialog/current-token-dialog.tsx
rename to src/views-components/token-dialog/token-dialog.tsx
index 9cb08f8b825b49a487a3da86bc87e00176cb56a4..5bbcaf57db5f6aeb3517c6a6ae95c5d6f58d3188 100644 (file)
@@ -3,17 +3,32 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography } from '@material-ui/core';
+import {
+    Dialog,
+    DialogActions,
+    DialogTitle,
+    DialogContent,
+    WithStyles,
+    withStyles,
+    StyleRulesCallback,
+    Button,
+    Typography
+} from '@material-ui/core';
 import * as CopyToClipboard from 'react-copy-to-clipboard';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { withDialog } from '~/store/dialog/with-dialog';
 import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { connect, DispatchProp } from 'react-redux';
-import { CurrentTokenDialogData, getCurrentTokenDialogData, CURRENT_TOKEN_DIALOG_NAME } from '~/store/current-token-dialog/current-token-dialog-actions';
+import {
+    TokenDialogData,
+    getTokenDialogData,
+    TOKEN_DIALOG_NAME,
+} from '~/store/token-dialog/token-dialog-actions';
 import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getNewExtraToken } from '~/store/auth/auth-action';
 
-type CssRules = 'link' | 'paper' | 'button' | 'copyButton';
+type CssRules = 'link' | 'paper' | 'button' | 'actionButton';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     link: {
@@ -31,16 +46,17 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         fontSize: '0.8125rem',
         fontWeight: 600
     },
-    copyButton: {
+    actionButton: {
         boxShadow: 'none',
         marginTop: theme.spacing.unit * 2,
         marginBottom: theme.spacing.unit * 2,
+        marginRight: theme.spacing.unit * 2,
     }
 });
 
-type CurrentTokenProps = CurrentTokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
+type TokenDialogProps = TokenDialogData & WithDialogProps<{}> & WithStyles<CssRules> & DispatchProp;
 
-export class CurrentTokenDialogComponent extends React.Component<CurrentTokenProps> {
+export class TokenDialogComponent extends React.Component<TokenDialogProps> {
     onCopy = (message: string) => {
         this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
             message,
@@ -49,9 +65,26 @@ export class CurrentTokenDialogComponent extends React.Component<CurrentTokenPro
         }));
     }
 
-    getSnippet = ({ apiHost, currentToken }: CurrentTokenDialogData) =>
+    onGetNewToken = async () => {
+        const newToken = await this.props.dispatch<any>(getNewExtraToken());
+        if (newToken) {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'New token retrieved',
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+        } else {
+            this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Creating new tokens is not allowed',
+                hideDuration: 2000,
+                kind: SnackbarKind.WARNING
+            }));
+        }
+    }
+
+    getSnippet = ({ apiHost, token }: TokenDialogData) =>
         `HISTIGNORE=$HISTIGNORE:'export ARVADOS_API_TOKEN=*'
-export ARVADOS_API_TOKEN=${currentToken}
+export ARVADOS_API_TOKEN=${token}
 export ARVADOS_API_HOST=${apiHost}
 unset ARVADOS_API_HOST_INSECURE`
 
@@ -63,7 +96,7 @@ unset ARVADOS_API_HOST_INSECURE`
             onClose={closeDialog}
             fullWidth={true}
             maxWidth='md'>
-            <DialogTitle>Current Token</DialogTitle>
+            <DialogTitle>Get API Token</DialogTitle>
             <DialogContent>
                 <Typography paragraph={true}>
                     The Arvados API token is a secret key that enables the Arvados SDKs to access Arvados with the proper permissions.
@@ -83,11 +116,20 @@ unset ARVADOS_API_HOST_INSECURE`
                         color="primary"
                         size="small"
                         variant="contained"
-                        className={classes.copyButton}
+                        className={classes.actionButton}
                     >
                         COPY TO CLIPBOARD
                     </Button>
                 </CopyToClipboard>
+                { this.props.canCreateNewTokens && <Button
+                    onClick={() => this.onGetNewToken()}
+                    color="primary"
+                    size="small"
+                    variant="contained"
+                    className={classes.actionButton}
+                >
+                    GET NEW TOKEN
+                </Button> }
                 <Typography >
                     Arvados
                             <a href='http://doc.arvados.org/user/reference/api-tokens.html' target='blank' className={classes.link}>virtual machines</a>
@@ -101,8 +143,8 @@ unset ARVADOS_API_HOST_INSECURE`
     }
 }
 
-export const CurrentTokenDialog =
+export const TokenDialog =
     withStyles(styles)(
-        connect(getCurrentTokenDialogData)(
-            withDialog(CURRENT_TOKEN_DIALOG_NAME)(CurrentTokenDialogComponent)));
+        connect(getTokenDialogData)(
+            withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
 
index f5cfda89828e8164c758061ec6c41b2a99c94ea6..9c2a7df8ffd547c7ab8f15f2dc62c875cb91087f 100644 (file)
@@ -10,7 +10,7 @@ import { DetailsPanel } from '~/views-components/details-panel/details-panel';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { ContextMenu } from "~/views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { CurrentTokenDialog } from '~/views-components/current-token-dialog/current-token-dialog';
+import { TokenDialog } from '~/views-components/token-dialog/token-dialog';
 import { RichTextEditorDialog } from '~/views-components/rich-text-editor-dialog/rich-text-editor-dialog';
 import { Snackbar } from '~/views-components/snackbar/snackbar';
 import { CollectionPanel } from '../collection-panel/collection-panel';
@@ -221,7 +221,7 @@ export const WorkbenchPanel =
             <CreateRepositoryDialog />
             <CreateSshKeyDialog />
             <CreateUserDialog />
-            <CurrentTokenDialog />
+            <TokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
             <GroupAttributesDialog />