Merge branch '15064-wb2-fed-login' refs #15064
authorPeter Amstutz <pamstutz@veritasgenetics.com>
Wed, 15 May 2019 14:30:57 +0000 (10:30 -0400)
committerPeter Amstutz <pamstutz@veritasgenetics.com>
Wed, 15 May 2019 14:30:57 +0000 (10:30 -0400)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <pamstutz@veritasgenetics.com>

16 files changed:
src/common/config.ts
src/components/data-explorer/data-explorer.tsx
src/index.tsx
src/routes/routes.ts
src/services/auth-service/auth-service.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.ts
src/views-components/api-token/api-token.tsx
src/views-components/main-app-bar/anonymous-menu.tsx
src/views/collection-content-address-panel/collection-content-address-panel.tsx
src/views/login-panel/login-panel.tsx
src/views/search-results-panel/search-results-panel-view.tsx
src/views/search-results-panel/search-results-panel.tsx
src/views/workbench/fed-login.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index 3961d5aa2496fec7fbba912a96738f1bc15b8b5d..71b7774c5fa8d765818b7ce611f6e4bfa1805c11 100644 (file)
@@ -51,6 +51,7 @@ export interface Config {
     version: string;
     websocketUrl: string;
     workbenchUrl: string;
+    workbench2Url?: string;
     vocabularyUrl: string;
     fileViewersConfigUrl: string;
 }
@@ -136,4 +137,4 @@ const getDefaultConfig = (): ConfigJSON => ({
 });
 
 export const DISCOVERY_URL = 'discovery/v1/apis/arvados/v1/rest';
-const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${DISCOVERY_URL}`;
+export const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${DISCOVERY_URL}?nocache=${(new Date()).getTime()}`;
index 5f01957753d0fc5252f5a31b3a63b83b221bd770..7107bd70823526226e45aa16687bdbcb5609b38a 100644 (file)
@@ -14,7 +14,7 @@ import { DataTableFilters } from '~/components/data-table-filters/data-table-fil
 import { MoreOptionsIcon } from '~/components/icon/icon';
 import { PaperProps } from '@material-ui/core/Paper';
 
-type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title';
+type CssRules = 'searchBox' | "toolbar" | "toolbarUnderTitle" | "footer" | "root" | 'moreOptionsButton' | 'title';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     searchBox: {
@@ -23,6 +23,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
         paddingTop: theme.spacing.unit * 2
     },
+    toolbarUnderTitle: {
+        paddingTop: 0
+    },
     footer: {
         overflow: 'auto'
     },
@@ -55,9 +58,9 @@ interface DataExplorerDataProps<T> {
     paperProps?: PaperProps;
     actions?: React.ReactNode;
     hideSearchInput?: boolean;
+    title?: React.ReactNode;
     paperKey?: string;
     currentItemUuid: string;
-    title?: string;
 }
 
 interface DataExplorerActionProps<T> {
@@ -93,8 +96,8 @@ export const DataExplorer = withStyles(styles)(
                 paperKey, fetchMode, currentItemUuid, title
             } = this.props;
             return <Paper className={classes.root} {...paperProps} key={paperKey}>
-                {title ? <div className={classes.title}>Content Address: {title}</div> : null}
-                {(!hideColumnSelector || !hideSearchInput) && <Toolbar className={classes.toolbar}>
+                {title && <div className={classes.title}>{title}</div>}
+                {(!hideColumnSelector || !hideSearchInput) && <Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
                     <Grid container justify="space-between" wrap="nowrap" alignItems="center">
                         <div className={classes.searchBox}>
                             {!hideSearchInput && <SearchInput
index 9c7b39aae4d91dd25fad37633666bf3ccf4358f1..ee174b2c5325866878d6d7012552e64a1ae85478 100644 (file)
@@ -113,7 +113,8 @@ fetchConfig()
         store.dispatch(loadVocabulary);
         store.dispatch(loadFileViewersConfig);
 
-        const TokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} {...props} />;
+        const TokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={true} {...props} />;
+        const FedTokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={false} {...props} />;
         const MainPanelComponent = (props: any) => <MainPanel {...props} />;
 
         const App = () =>
@@ -123,6 +124,7 @@ fetchConfig()
                         <ConnectedRouter history={history}>
                             <Switch>
                                 <Route path={Routes.TOKEN} component={TokenComponent} />
+                                <Route path={Routes.FED_LOGIN} component={FedTokenComponent} />
                                 <Route path={Routes.ROOT} component={MainPanelComponent} />
                             </Switch>
                         </ConnectedRouter>
index dd09d8ece55545546c482bf0b5eb5d5398ce51ae..831c69e58cc62fcd15b09f135b2de66896cf0089 100644 (file)
@@ -10,6 +10,7 @@ import { getCollectionUrl } from '~/models/collection';
 export const Routes = {
     ROOT: '/',
     TOKEN: '/token',
+    FED_LOGIN: '/fedtoken',
     PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`,
     COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`,
     PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`,
index eae219dd0ad2a547883b94047a961d10048f5019..a80d89ba146f374f329ff62f179217997fd34e6f 100644 (file)
@@ -20,6 +20,7 @@ export const USER_IS_ADMIN = 'isAdmin';
 export const USER_IS_ACTIVE = 'isActive';
 export const USER_USERNAME = 'username';
 export const USER_PREFS = 'prefs';
+export const HOME_CLUSTER = 'homeCluster';
 
 export interface UserDetailsResponse {
     email: string;
@@ -42,6 +43,7 @@ export class AuthService {
 
     public saveApiToken(token: string) {
         localStorage.setItem(API_TOKEN_KEY, token);
+        localStorage.setItem(HOME_CLUSTER, token.split('/')[1].substr(0, 5));
     }
 
     public removeApiToken() {
@@ -52,6 +54,10 @@ export class AuthService {
         return localStorage.getItem(API_TOKEN_KEY) || undefined;
     }
 
+    public getHomeCluster() {
+        return localStorage.getItem(HOME_CLUSTER) || undefined;
+    }
+
     public getUuid() {
         return localStorage.getItem(USER_UUID_KEY) || undefined;
     }
@@ -108,9 +114,10 @@ export class AuthService {
         localStorage.removeItem(USER_PREFS);
     }
 
-    public login(uuidPrefix: string, homeCluster: string) {
+    public login(uuidPrefix: string, homeCluster: string, remoteHosts: { [key: string]: string }) {
         const currentUrl = `${window.location.protocol}//${window.location.host}/token`;
-        window.location.assign(`https://${homeCluster}/login?remote=${uuidPrefix}&return_to=${currentUrl}`);
+        const homeClusterHost = remoteHosts[homeCluster];
+        window.location.assign(`https://${homeClusterHost}/login?${uuidPrefix !== homeCluster ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`);
     }
 
     public logout() {
index 5bb192b8816e6c4fc7bff110424ff7ad83617d02..b889e9cf39d7d301a0c5e8e4b8e70859e7d7c3ff 100644 (file)
@@ -68,7 +68,7 @@ const getTokenUuid = async (baseUrl: string, token: string): Promise<string> =>
     return resp.data.items[0].uuid;
 };
 
-const getSaltedToken = (clusterId: string, tokenUuid: string, token: string) => {
+export const getSaltedToken = (clusterId: string, tokenUuid: string, token: string) => {
     const shaObj = new jsSHA("SHA-1", "TEXT");
     let secret = token;
     if (token.startsWith("v2/")) {
index baf80595f300ad9fb7d181aa8ba5441303e71aab..7ebbbaa633ab8fc794be450f6635edf6d691b45e 100644 (file)
@@ -10,8 +10,10 @@ import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
 import { User } from "~/models/user";
 import { Session } from "~/models/session";
-import { Config } from '~/common/config';
+import { getDiscoveryURL, Config } from '~/common/config';
 import { initSessions } from "~/store/auth/auth-action-session";
+import Axios from "axios";
+import { AxiosError } from "axios";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
@@ -28,7 +30,8 @@ export const authActions = unionize({
     SET_SESSIONS: ofType<Session[]>(),
     ADD_SESSION: ofType<Session>(),
     REMOVE_SESSION: ofType<string>(),
-    UPDATE_SESSION: ofType<Session>()
+    UPDATE_SESSION: ofType<Session>(),
+    REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
 });
 
 function setAuthorizationHeader(services: ServiceRepository, token: string) {
@@ -47,17 +50,32 @@ function removeAuthorizationHeader(client: AxiosInstance) {
 export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     const user = services.authService.getUser();
     const token = services.authService.getApiToken();
+    const homeCluster = services.authService.getHomeCluster();
     if (token) {
         setAuthorizationHeader(services, token);
     }
     dispatch(authActions.CONFIG({ config }));
+    dispatch(authActions.SET_HOME_CLUSTER(homeCluster || config.uuidPrefix));
     if (token && user) {
         dispatch(authActions.INIT({ user, token }));
         dispatch<any>(initSessions(services.authService, config, user));
         dispatch<any>(getUserDetails()).then((user: User) => {
             dispatch(authActions.INIT({ user, token }));
+        }).catch((err: AxiosError) => {
+            console.log("error");
+            console.log(err);
+            if (err.response) {
+                // Bad token
+                if (err.response.status === 401) {
+                    logout()(dispatch, getState, services);
+                }
+            }
         });
     }
+    Object.keys(config.remoteHosts).map((k) => {
+        Axios.get<Config>(getDiscoveryURL(config.remoteHosts[k]))
+            .then(response => dispatch(authActions.REMOTE_CLUSTER_CONFIG({ config: response.data })));
+    });
 };
 
 export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
@@ -66,8 +84,8 @@ export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: ()
     dispatch(authActions.SAVE_API_TOKEN(token));
 };
 
-export const login = (uuidPrefix: string, homeCluster: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.login(uuidPrefix, homeCluster);
+export const login = (uuidPrefix: string, homeCluster: string, remoteHosts: { [key: string]: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.login(uuidPrefix, homeCluster, remoteHosts);
     dispatch(authActions.LOGIN());
 };
 
index 0335752678c56421336751dfd79eaef11316db94..e44c81e323297fdfcd8884a4efe08236d911a9f2 100644 (file)
@@ -7,6 +7,7 @@ import { User } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
 import { Session } from "~/models/session";
+import { Config } from '~/common/config';
 
 export interface AuthState {
     user?: User;
@@ -16,6 +17,7 @@ export interface AuthState {
     localCluster: string;
     homeCluster: string;
     remoteHosts: { [key: string]: string };
+    remoteHostsConfig: { [key: string]: Config };
 }
 
 const initialState: AuthState = {
@@ -25,7 +27,8 @@ const initialState: AuthState = {
     sessions: [],
     localCluster: "",
     homeCluster: "",
-    remoteHosts: {}
+    remoteHosts: {},
+    remoteHostsConfig: {}
 };
 
 export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
@@ -41,6 +44,12 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat
                 homeCluster: config.uuidPrefix
             };
         },
+        REMOTE_CLUSTER_CONFIG: ({ config }) => {
+            return {
+                ...state,
+                remoteHostsConfig: { ...state.remoteHostsConfig, [config.uuidPrefix]: config },
+            };
+        },
         INIT: ({ user, token }) => {
             return { ...state, user, apiToken: token, homeCluster: user.uuid.substr(0, 5) };
         },
index 43c55a92c9addbd8031fd537b5c59528b620e7ca..b78e7192dc0bd24f5c1ef14ed1ab6c0629aea564 100644 (file)
@@ -16,6 +16,7 @@ import { initSessions } from "~/store/auth/auth-action-session";
 interface ApiTokenProps {
     authService: AuthService;
     config: Config;
+    loadMainApp: boolean;
 }
 
 export const ApiToken = connect()(
@@ -23,15 +24,18 @@ export const ApiToken = connect()(
         componentDidMount() {
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
+            const loadMainApp = this.props.loadMainApp;
             this.props.dispatch(saveApiToken(apiToken));
             this.props.dispatch<any>(getUserDetails()).then((user: User) => {
                 this.props.dispatch(initSessions(this.props.authService, this.props.config, user));
             }).finally(() => {
-                this.props.dispatch(navigateToRootProject);
+                if (loadMainApp) {
+                    this.props.dispatch(navigateToRootProject);
+                }
             });
         }
         render() {
-            return <div/>;
+            return <div />;
         }
     }
 );
index 5ffc42158acb1c9f5fbc04cd0d1f6aedaa7cfdad..15329a43e9119850a8fce69b759319c7f5f69015 100644 (file)
@@ -11,6 +11,6 @@ export const AnonymousMenu = connect()(
     ({ dispatch }: DispatchProp<any>) =>
         <Button
             color="inherit"
-            onClick={() => dispatch(login("", ""))}>
+            onClick={() => dispatch(login("", "", {}))}>
             Sign in
         </Button>);
index 89b23f7c2fe4b8f2874057e883866ca772b6e33c..b652b502886653bcf43695cf80eeaefd8d9e86bf 100644 (file)
@@ -141,7 +141,7 @@ export const CollectionsContentAddressPanel = withStyles(styles)(
                         onRowDoubleClick={this.props.onItemDoubleClick}
                         onContextMenu={this.props.onContextMenu}
                         contextMenuColumn={true}
-                        title={this.props.match.params.id}
+                        title={`Content address: ${this.props.match.params.id}`}
                         dataTableDefaultView={
                             <DataTableDefaultView
                                 icon={CollectionIcon}
@@ -151,4 +151,4 @@ export const CollectionsContentAddressPanel = withStyles(styles)(
             }
         }
     )
-);
\ No newline at end of file
+);
index b9f3194ab0b2aa12fc375a7ac5f966dc6630db1a..41a17bf9682c1b7fb86d5436097ac24673b19693 100644 (file)
@@ -93,7 +93,7 @@ export const LoginPanel = withStyles(styles)(
 
                 <Typography component="div" align="right">
                     <Button variant="contained" color="primary" style={{ margin: "1em" }} className={classes.button}
-                        onClick={() => dispatch(login(uuidPrefix, remoteHosts[homeCluster]))}>
+                        onClick={() => dispatch(login(uuidPrefix, homeCluster, remoteHosts))}>
                         Log in to {uuidPrefix}
                         {uuidPrefix !== homeCluster &&
                             <span>&nbsp;with user from {homeCluster}</span>}
index 368a0d6449171c5a7efdc592ae5ad365ad83514f..72a9b282824888e1142c5b85edea3b586ef36890 100644 (file)
@@ -21,6 +21,9 @@ import {
 } from '~/views-components/data-explorer/renderers';
 import { createTree } from '~/models/tree';
 import { getInitialResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
+import { User } from "~/models/user";
+import { Config } from '~/common/config';
+import { Session } from "~/models/session";
 
 export enum SearchResultsPanelColumnNames {
     CLUSTER = "Cluster",
@@ -35,6 +38,10 @@ export enum SearchResultsPanelColumnNames {
 
 export interface SearchResultsPanelDataProps {
     data: SearchBarAdvanceFormData;
+    user: User;
+    sessions: Session[];
+    remoteHostsConfig: { [key: string]: Config };
+    localCluster: string;
 }
 
 export interface SearchResultsPanelActionProps {
@@ -112,11 +119,18 @@ export const searchResultsPanelColumns: DataColumns<string> = [
 ];
 
 export const SearchResultsPanelView = (props: SearchResultsPanelProps) => {
+    const homeCluster = props.user.uuid.substr(0, 5);
     return <DataExplorer
         id={SEARCH_RESULTS_PANEL_ID}
         onRowClick={props.onItemClick}
         onRowDoubleClick={props.onItemDoubleClick}
         onContextMenu={props.onContextMenu}
         contextMenuColumn={true}
-        hideSearchInput />;
+        hideSearchInput
+        title={
+            props.localCluster === homeCluster ?
+                <div>Searching clusters: {props.sessions.filter((ss) => ss.loggedIn).map((ss) => <span key={ss.clusterId}> {ss.clusterId}</span>)}</div> :
+                <div>Searching local cluster {props.localCluster} only.  To search multiple clusters, <a href={props.remoteHostsConfig[homeCluster] && props.remoteHostsConfig[homeCluster].workbench2Url}> start from your home Workbench.</a></div>
+        }
+    />;
 };
index 65999a5cbb30aea18b1c7ff66b9227b0cab5e7c0..bde7207a8a8b19f156ba6b87b57d6092d8193060 100644 (file)
@@ -10,6 +10,16 @@ import { openContextMenu, resourceKindToContextMenuKind } from '~/store/context-
 import { ResourceKind } from '~/models/resource';
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { SearchResultsPanelView } from '~/views/search-results-panel/search-results-panel-view';
+import { RootState } from '~/store/store';
+
+const mapStateToProps = (rootState: RootState) => {
+    return {
+        user: rootState.auth.user,
+        sessions: rootState.auth.sessions,
+        remoteHostsConfig: rootState.auth.remoteHostsConfig,
+        localCluster: rootState.auth.localCluster,
+    };
+};
 
 const mapDispatchToProps = (dispatch: Dispatch): SearchResultsPanelActionProps => ({
     onContextMenu: (event, resourceUuid) => {
@@ -34,4 +44,4 @@ const mapDispatchToProps = (dispatch: Dispatch): SearchResultsPanelActionProps =
     }
 });
 
-export const SearchResultsPanel = connect(null, mapDispatchToProps)(SearchResultsPanelView);
\ No newline at end of file
+export const SearchResultsPanel = connect(mapStateToProps, mapDispatchToProps)(SearchResultsPanelView);
diff --git a/src/views/workbench/fed-login.tsx b/src/views/workbench/fed-login.tsx
new file mode 100644 (file)
index 0000000..399b419
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { RootState } from '~/store/store';
+import { AuthState } from '~/store/auth/auth-reducer';
+import { User } from "~/models/user";
+import { getSaltedToken } from '~/store/auth/auth-action-session';
+import { Config } from '~/common/config';
+
+export interface FedLoginProps {
+    user?: User;
+    apiToken?: string;
+    homeCluster: string;
+    remoteHostsConfig: { [key: string]: Config };
+}
+
+const mapStateToProps = ({ auth }: RootState) => ({
+    user: auth.user,
+    apiToken: auth.apiToken,
+    remoteHostsConfig: auth.remoteHostsConfig,
+    homeCluster: auth.homeCluster,
+});
+
+export const FedLogin = connect(mapStateToProps)(
+    class extends React.Component<FedLoginProps> {
+        render() {
+            const { apiToken, user, homeCluster, remoteHostsConfig } = this.props;
+            if (!apiToken || !user || !user.uuid.startsWith(homeCluster)) {
+                return <></>;
+            }
+            const [, tokenUuid, token] = apiToken.split("/");
+            return <div id={"fedtoken-iframe-div"}>
+                {Object.keys(remoteHostsConfig)
+                    .map((k) => k !== homeCluster &&
+                        <iframe key={k} src={`${remoteHostsConfig[k].workbench2Url}/fedtoken?api_token=${getSaltedToken(k, tokenUuid, token)}`} style={{
+                            height: 0,
+                            width: 0,
+                            visibility: "hidden"
+                        }}
+                        />)}
+            </div>;
+        }
+    });
index e3668373ff754ce93bb1dbf978ad346461ff894d..20cbbdea0c9a0b262f976889d5e097243b777c6f 100644 (file)
@@ -92,6 +92,7 @@ import { GroupMemberAttributesDialog } from '~/views-components/groups-dialog/me
 import { AddGroupMembersDialog } from '~/views-components/dialog-forms/add-group-member-dialog';
 import { PartialCopyToCollectionDialog } from '~/views-components/dialog-forms/partial-copy-to-collection-dialog';
 import { PublicFavoritePanel } from '~/views/public-favorites-panel/public-favorites-panel';
+import { FedLogin } from './fed-login';
 import { CollectionsContentAddressPanel } from '~/views/collection-content-address-panel/collection-content-address-panel';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
@@ -177,7 +178,7 @@ export const WorkbenchPanel =
                                 <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
                                 <Route path={Routes.LINKS} component={LinkPanel} />
                                 <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
-                                <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel}/>
+                                <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -242,5 +243,6 @@ export const WorkbenchPanel =
             <UserAttributesDialog />
             <UserManageDialog />
             <VirtualMachineAttributesDialog />
+            <FedLogin />
         </Grid>
     );