version: string;
websocketUrl: string;
workbenchUrl: string;
+ workbench2Url?: string;
vocabularyUrl: string;
fileViewersConfigUrl: string;
}
});
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()}`;
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: {
toolbar: {
paddingTop: theme.spacing.unit * 2
},
+ toolbarUnderTitle: {
+ paddingTop: 0
+ },
footer: {
overflow: 'auto'
},
paperProps?: PaperProps;
actions?: React.ReactNode;
hideSearchInput?: boolean;
+ title?: React.ReactNode;
paperKey?: string;
currentItemUuid: string;
- title?: string;
}
interface DataExplorerActionProps<T> {
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
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 = () =>
<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>
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})`,
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;
public saveApiToken(token: string) {
localStorage.setItem(API_TOKEN_KEY, token);
+ localStorage.setItem(HOME_CLUSTER, token.split('/')[1].substr(0, 5));
}
public removeApiToken() {
return localStorage.getItem(API_TOKEN_KEY) || undefined;
}
+ public getHomeCluster() {
+ return localStorage.getItem(HOME_CLUSTER) || undefined;
+ }
+
public getUuid() {
return localStorage.getItem(USER_UUID_KEY) || undefined;
}
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() {
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/")) {
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>(),
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) {
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) => {
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());
};
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;
localCluster: string;
homeCluster: string;
remoteHosts: { [key: string]: string };
+ remoteHostsConfig: { [key: string]: Config };
}
const initialState: AuthState = {
sessions: [],
localCluster: "",
homeCluster: "",
- remoteHosts: {}
+ remoteHosts: {},
+ remoteHostsConfig: {}
};
export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
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) };
},
interface ApiTokenProps {
authService: AuthService;
config: Config;
+ loadMainApp: boolean;
}
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 />;
}
}
);
({ dispatch }: DispatchProp<any>) =>
<Button
color="inherit"
- onClick={() => dispatch(login("", ""))}>
+ onClick={() => dispatch(login("", "", {}))}>
Sign in
</Button>);
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}
}
}
)
-);
\ No newline at end of file
+);
<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> with user from {homeCluster}</span>}
} 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",
export interface SearchResultsPanelDataProps {
data: SearchBarAdvanceFormData;
+ user: User;
+ sessions: Session[];
+ remoteHostsConfig: { [key: string]: Config };
+ localCluster: string;
}
export interface SearchResultsPanelActionProps {
];
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>
+ }
+ />;
};
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) => {
}
});
-export const SearchResultsPanel = connect(null, mapDispatchToProps)(SearchResultsPanelView);
\ No newline at end of file
+export const SearchResultsPanel = connect(mapStateToProps, mapDispatchToProps)(SearchResultsPanelView);
--- /dev/null
+// 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>;
+ }
+ });
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';
<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>
<UserAttributesDialog />
<UserManageDialog />
<VirtualMachineAttributesDialog />
+ <FedLogin />
</Grid>
);