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';
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);
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
});
}
- 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
);
}
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";
uuidPrefix: "zzzzz",
remoteHosts: { xc59z: "xc59z.arvadosapi.com" },
apiRevision: 12345678,
+ clusterConfig: {
+ Login: { LoginCluster: "" },
+ },
};
store.dispatch(initAuth(config));
apiToken: "token",
config: {
apiRevision: 12345678,
+ clusterConfig: {
+ Login: {
+ LoginCluster: "",
+ },
+ },
remoteHosts: {
"xc59z": "xc59z.arvadosapi.com",
},
uuidPrefix: "zzzzz",
},
sshKeys: [],
+ extraApiToken: undefined,
homeCluster: "zzzzz",
localCluster: "zzzzz",
loginCluster: undefined,
remoteHostsConfig: {
"zzzzz": {
"apiRevision": 12345678,
+ "clusterConfig": {
+ "Login": {
+ "LoginCluster": "",
+ },
+ },
"remoteHosts": {
"xc59z": "xc59z.arvadosapi.com",
},
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>(),
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);
export interface AuthState {
user?: User;
apiToken?: string;
+ extraApiToken?: string;
sshKeys: SshKeyResource[];
sessions: Session[];
localCluster: string;
const initialState: AuthState = {
user: undefined,
apiToken: undefined,
+ extraApiToken: undefined,
sshKeys: [],
sessions: [],
localCluster: "",
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) };
},
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,
+++ /dev/null
-// 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: {} });
--- /dev/null
+// 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: {} });
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';
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);
}
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() });
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,
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
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)),
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(
};
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
});
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,
{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>
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() });
describe('copy to clipboard', () => {
beforeEach(() => {
- wrapper = mount(<CurrentTokenDialogComponent {...props} />);
+ wrapper = mount(<TokenDialogComponent {...props} />);
});
it('should copy API TOKEN to the clipboard', () => {
// 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: {
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,
}));
}
- 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`
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.
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>
}
}
-export const CurrentTokenDialog =
+export const TokenDialog =
withStyles(styles)(
- connect(getCurrentTokenDialogData)(
- withDialog(CURRENT_TOKEN_DIALOG_NAME)(CurrentTokenDialogComponent)));
+ connect(getTokenDialogData)(
+ withDialog(TOKEN_DIALOG_NAME)(TokenDialogComponent)));
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';
<CreateRepositoryDialog />
<CreateSshKeyDialog />
<CreateUserDialog />
- <CurrentTokenDialog />
+ <TokenDialog />
<FileRemoveDialog />
<FilesUploadCollectionDialog />
<GroupAttributesDialog />