From: Daniel Kos Date: Tue, 18 Dec 2018 09:39:54 +0000 (+0100) Subject: Merge branch 'origin/master' into 14478-log-in-into-clusters X-Git-Tag: 1.4.0~71^2~16^2^2^2~3 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/f9dde5c781766b8be71d43d0f031c201a0edcfbb Merge branch 'origin/master' into 14478-log-in-into-clusters Feature #14478 # Conflicts: # src/common/config.ts # src/components/text-field/text-field.tsx # src/routes/route-change-handlers.ts # src/routes/routes.ts # src/services/auth-service/auth-service.ts # src/services/common-service/common-resource-service.ts # src/services/services.ts # src/store/auth/auth-action.ts # src/store/navigation/navigation-action.ts # src/store/workbench/workbench-actions.ts # src/validators/validators.tsx # src/views-components/main-app-bar/account-menu.tsx # src/views-components/main-content-bar/main-content-bar.tsx # src/views/workbench/workbench.tsx Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- f9dde5c781766b8be71d43d0f031c201a0edcfbb diff --cc src/common/config.ts index d801c5fa,db67ed8d..3961d5aa --- a/src/common/config.ts +++ b/src/common/config.ts @@@ -126,7 -130,7 +132,8 @@@ interface ConfigJSON const getDefaultConfig = (): ConfigJSON => ({ API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "", VOCABULARY_URL: "", + FILE_VIEWERS_CONFIG_URL: "", }); -const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/discovery/v1/apis/arvados/v1/rest`; +export const DISCOVERY_URL = 'discovery/v1/apis/arvados/v1/rest'; +const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${DISCOVERY_URL}`; diff --cc src/components/text-field/text-field.tsx index 0ebb46bc,627e004d..93c4080f --- a/src/components/text-field/text-field.tsx +++ b/src/components/text-field/text-field.tsx @@@ -25,8 -18,8 +25,8 @@@ const styles: StyleRulesCallback; -export const TextField = withStyles(styles)((props: TextFieldProps & { - label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, disabled?: boolean, children: React.ReactNode +export const TextField = withStyles(styles)((props: TextFieldProps & { - label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, children: React.ReactNode, margin?: Margin, placeholder?: string ++ label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, disabled?: boolean, children: React.ReactNode, margin?: Margin, placeholder?: string }) => { @@@ -89,12 -101,15 +102,18 @@@ export const matchAdminVirtualMachineRo export const matchRepositoriesRoute = (route: string) => matchPath(route, { path: Routes.REPOSITORIES }); - export const matchSshKeysRoute = (route: string) => - matchPath(route, { path: Routes.SSH_KEYS }); + export const matchSshKeysUserRoute = (route: string) => + matchPath(route, { path: Routes.SSH_KEYS_USER }); + + export const matchSshKeysAdminRoute = (route: string) => + matchPath(route, { path: Routes.SSH_KEYS_ADMIN }); +export const matchSiteManagerRoute = (route: string) => + matchPath(route, { path: Routes.SITE_MANAGER }); + + export const matchMyAccountRoute = (route: string) => + matchPath(route, { path: Routes.MY_ACCOUNT }); + export const matchKeepServicesRoute = (route: string) => matchPath(route, { path: Routes.KEEP_SERVICES }); diff --cc src/services/auth-service/auth-service.ts index 1492ef1c,22c9dcd6..8601e208 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@@ -2,7 -2,7 +2,7 @@@ // // SPDX-License-Identifier: AGPL-3.0 - import { getUserFullname, User } from "~/models/user"; -import { User, UserPrefs } from "~/models/user"; ++import { getUserFullname, User, UserPrefs } from "~/models/user"; import { AxiosInstance } from "axios"; import { ApiActions } from "~/services/api/api-actions"; import * as uuid from "uuid/v4"; diff --cc src/store/auth/auth-action-session.ts index 4a6f56f9,00000000..83e98e96 mode 100644,000000..100644 --- a/src/store/auth/auth-action-session.ts +++ b/src/store/auth/auth-action-session.ts @@@ -1,206 -1,0 +1,208 @@@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch } from "redux"; +import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions"; +import { RootState } from "~/store/store"; +import { ServiceRepository } from "~/services/services"; +import Axios from "axios"; +import { getUserFullname, User } from "~/models/user"; +import { authActions } from "~/store/auth/auth-action"; +import { Config, DISCOVERY_URL } from "~/common/config"; +import { Session, SessionStatus } from "~/models/session"; +import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions"; +import { UserDetailsResponse } from "~/services/auth-service/auth-service"; +import * as jsSHA from "jssha"; + +const getRemoteHostBaseUrl = async (remoteHost: string): Promise => { + let url = remoteHost; + if (url.indexOf('://') < 0) { + url = 'https://' + url; + } + const origin = new URL(url).origin; + let baseUrl: string | null = null; + + try { + const resp = await Axios.get(`${origin}/${DISCOVERY_URL}`); + baseUrl = resp.data.baseUrl; + } catch (err) { + try { + const resp = await Axios.get(`${origin}/status.json`); + baseUrl = resp.data.apiBaseURL; + } catch (err) { + } + } + + if (baseUrl && baseUrl[baseUrl.length - 1] === '/') { + baseUrl = baseUrl.substr(0, baseUrl.length - 1); + } + + return baseUrl; +}; + +const getUserDetails = async (baseUrl: string, token: string): Promise => { + const resp = await Axios.get(`${baseUrl}/users/current`, { + headers: { + Authorization: `OAuth2 ${token}` + } + }); + return resp.data; +}; + +const getTokenUuid = async (baseUrl: string, token: string): Promise => { + if (token.startsWith("v2/")) { + const uuid = token.split("/")[1]; + return Promise.resolve(uuid); + } + + const resp = await Axios.get(`${baseUrl}/api_client_authorizations`, { + headers: { + Authorization: `OAuth2 ${token}` + }, + data: { + filters: JSON.stringify([['api_token', '=', token]]) + } + }); + + return resp.data.items[0].uuid; +}; + +const getSaltedToken = (clusterId: string, tokenUuid: string, token: string) => { + const shaObj = new jsSHA("SHA-1", "TEXT"); + let secret = token; + if (token.startsWith("v2/")) { + secret = token.split("/")[2]; + } + shaObj.setHMACKey(secret, "TEXT"); + shaObj.update(clusterId); + const hmac = shaObj.getHMAC("HEX"); + return `v2/${tokenUuid}/${hmac}`; +}; + +const clusterLogin = async (clusterId: string, baseUrl: string, activeSession: Session): Promise<{user: User, token: string}> => { + const tokenUuid = await getTokenUuid(activeSession.baseUrl, activeSession.token); + const saltedToken = getSaltedToken(clusterId, tokenUuid, activeSession.token); + const user = await getUserDetails(baseUrl, saltedToken); + return { + user: { + firstName: user.first_name, + lastName: user.last_name, + uuid: user.uuid, + ownerUuid: user.owner_uuid, + email: user.email, - isAdmin: user.is_admin ++ isAdmin: user.is_admin, ++ identityUrl: user.identity_url, ++ prefs: user.prefs + }, + token: saltedToken + }; +}; + +const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active); + +export const validateCluster = async (remoteHost: string, clusterId: string, activeSession: Session): Promise<{ user: User; token: string, baseUrl: string }> => { + const baseUrl = await getRemoteHostBaseUrl(remoteHost); + if (!baseUrl) { + return Promise.reject(`Could not find base url for ${remoteHost}`); + } + const { user, token } = await clusterLogin(clusterId, baseUrl, activeSession); + return { baseUrl, user, token }; +}; + +export const validateSession = (session: Session, activeSession: Session) => + async (dispatch: Dispatch): Promise => { + dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED })); + session.loggedIn = false; + try { + const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.clusterId, activeSession); + session.baseUrl = baseUrl; + session.token = token; + session.email = user.email; + session.username = getUserFullname(user); + session.loggedIn = true; + } catch { + session.loggedIn = false; + } finally { + session.status = SessionStatus.VALIDATED; + dispatch(authActions.UPDATE_SESSION(session)); + } + return session; + }; + +export const validateSessions = () => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const sessions = getState().auth.sessions; + const activeSession = getActiveSession(sessions); + if (activeSession) { + dispatch(progressIndicatorActions.START_WORKING("sessionsValidation")); + for (const session of sessions) { + if (session.status === SessionStatus.INVALIDATED) { + await dispatch(validateSession(session, activeSession)); + } + } + services.authService.saveSessions(sessions); + dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation")); + } + }; + +export const addSession = (remoteHost: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const sessions = getState().auth.sessions; + const activeSession = getActiveSession(sessions); + if (activeSession) { + const clusterId = remoteHost.match(/^(\w+)\./)![1]; + if (sessions.find(s => s.clusterId === clusterId)) { + return Promise.reject("Cluster already exists"); + } + try { + const { baseUrl, user, token } = await validateCluster(remoteHost, clusterId, activeSession); + const session = { + loggedIn: true, + status: SessionStatus.VALIDATED, + active: false, + email: user.email, + username: getUserFullname(user), + remoteHost, + baseUrl, + clusterId, + token + }; + + dispatch(authActions.ADD_SESSION(session)); + services.authService.saveSessions(getState().auth.sessions); + + return session; + } catch (e) { + } + } + return Promise.reject("Could not validate cluster"); + }; + +export const toggleSession = (session: Session) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + let s = { ...session }; + + if (session.loggedIn) { + s.loggedIn = false; + } else { + const sessions = getState().auth.sessions; + const activeSession = getActiveSession(sessions); + if (activeSession) { + s = await dispatch(validateSession(s, activeSession)) as Session; + } + } + + dispatch(authActions.UPDATE_SESSION(s)); + services.authService.saveSessions(getState().auth.sessions); + }; + +export const loadSiteManagerPanel = () => + async (dispatch: Dispatch) => { + try { + dispatch(setBreadcrumbs([{ label: 'Site Manager'}])); + dispatch(validateSessions()); + } catch (e) { + return; + } + }; diff --cc src/store/navigation/navigation-action.ts index 8e3929d9,c53c55e8..f610eb5e --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@@ -66,10 -73,12 +73,14 @@@ export const navigateToAdminVirtualMach export const navigateToRepositories = push(Routes.REPOSITORIES); - export const navigateToSshKeys= push(Routes.SSH_KEYS); + export const navigateToSshKeysAdmin= push(Routes.SSH_KEYS_ADMIN); + + export const navigateToSshKeysUser= push(Routes.SSH_KEYS_USER); +export const navigateToSiteManager= push(Routes.SITE_MANAGER); + + export const navigateToMyAccount = push(Routes.MY_ACCOUNT); + export const navigateToKeepServices = push(Routes.KEEP_SERVICES); export const navigateToComputeNodes = push(Routes.COMPUTE_NODES); diff --cc src/store/workbench/workbench-actions.ts index fdef38c4,5e9dc285..46ab1f59 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@@ -39,8 -39,8 +39,9 @@@ import { sharedWithMePanelActions } fro import { loadSharedWithMePanel } from '~/store/shared-with-me-panel/shared-with-me-panel-actions'; import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog'; import { loadWorkflowPanel, workflowPanelActions } from '~/store/workflow-panel/workflow-panel-actions'; -import { loadSshKeysPanel } from '~/store/auth/auth-action'; +import { loadSshKeysPanel } from '~/store/auth/auth-action-ssh'; + import { loadMyAccountPanel } from '~/store/my-account/my-account-panel-actions'; +import { loadSiteManagerPanel } from '~/store/auth/auth-action-session'; import { workflowPanelColumns } from '~/views/workflow-panel/workflow-panel-view'; import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions'; import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer'; @@@ -413,9 -435,9 +436,14 @@@ export const loadSshKeys = handleFirstT await dispatch(loadSshKeysPanel()); }); +export const loadSiteManager = handleFirstTimeLoad( - async (dispatch: Dispatch) => { - await dispatch(loadSiteManagerPanel()); ++async (dispatch: Dispatch) => { ++ await dispatch(loadSiteManagerPanel()); ++}); ++ + export const loadMyAccount = handleFirstTimeLoad( + (dispatch: Dispatch) => { + dispatch(loadMyAccountPanel()); }); export const loadKeepServices = handleFirstTimeLoad( diff --cc src/validators/validators.tsx index 06f46219,9bc76419..acef9744 --- a/src/validators/validators.tsx +++ b/src/validators/validators.tsx @@@ -28,4 -31,4 +32,6 @@@ export const USER_LENGTH_VALIDATION = [ export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)]; export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)]; +export const SITE_MANAGER_REMOTE_HOST_VALIDATION = [require, isRemoteHost, maxLength(255)]; ++ + export const MY_ACCOUNT_VALIDATION = [require]; diff --cc src/views-components/main-app-bar/account-menu.tsx index b71c92cb,53a5753d..6c1e46c5 --- a/src/views-components/main-app-bar/account-menu.tsx +++ b/src/views-components/main-app-bar/account-menu.tsx @@@ -11,14 -11,9 +11,13 @@@ import { DispatchProp, connect } from ' import { logout } from '~/store/auth/auth-action'; import { RootState } from "~/store/store"; import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions'; -import { navigateToSshKeysUser, navigateToMyAccount } from '~/store/navigation/navigation-action'; +import { openRepositoriesPanel } from "~/store/repositories/repositories-actions"; +import { - navigateToSshKeys, - navigateToKeepServices, - navigateToComputeNodes, - navigateToSiteManager ++ navigateToSiteManager, ++ navigateToSshKeysUser, ++ navigateToMyAccount +} from '~/store/navigation/navigation-action'; - import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions"; + import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions"; -import { openRepositoriesPanel } from '~/store/repositories/repositories-actions'; interface AccountMenuProps { user?: User; @@@ -38,14 -36,11 +40,12 @@@ export const AccountMenu = connect(mapS {getUserFullname(user)} - dispatch(openVirtualMachines())}>Virtual Machines - dispatch(openRepositoriesPanel())}>Repositories + dispatch(openUserVirtualMachines())}>Virtual Machines + {!user.isAdmin && dispatch(openRepositoriesPanel())}>Repositories} dispatch(openCurrentTokenDialog)}>Current token - dispatch(navigateToSshKeys)}>Ssh Keys + dispatch(navigateToSshKeysUser)}>Ssh Keys + dispatch(navigateToSiteManager)}>Site Manager - { user.isAdmin && dispatch(navigateToKeepServices)}>Keep Services } - { user.isAdmin && dispatch(navigateToComputeNodes)}>Compute Nodes } - My account + dispatch(navigateToMyAccount)}>My account dispatch(logout())}>Logout : null); diff --cc src/views-components/main-content-bar/main-content-bar.tsx index 8b8f9891,3806b524..c0014d00 --- a/src/views-components/main-content-bar/main-content-bar.tsx +++ b/src/views-components/main-content-bar/main-content-bar.tsx @@@ -18,10 -18,12 +18,13 @@@ interface MainContentBarProps const isButtonVisible = ({ router }: RootState) => { const pathname = router.location ? router.location.pathname : ''; - return !Routes.matchWorkflowRoute(pathname) && !Routes.matchVirtualMachineRoute(pathname) && - !Routes.matchRepositoriesRoute(pathname) && !Routes.matchSshKeysRoute(pathname) && + return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) && + !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) && + !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) && ++ !Routes.matchSiteManagerRoute(pathname) && !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) && - !Routes.matchSiteManagerRoute(pathname); + !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) && + !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname); }; export const MainContentBar = connect((state: RootState) => ({ diff --cc src/views/workbench/workbench.tsx index af2325bf,bff328e8..90b2dad0 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@@ -45,7 -45,7 +45,8 @@@ import SplitterLayout from 'react-split import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel'; import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel'; import { SshKeyPanel } from '~/views/ssh-key-panel/ssh-key-panel'; +import { SiteManagerPanel } from "~/views/site-manager-panel/site-manager-panel"; + import { MyAccountPanel } from '~/views/my-account-panel/my-account-panel'; import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog'; import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog'; import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog'; @@@ -137,12 -156,19 +157,20 @@@ export const WorkbenchPanel - + + - + + + + + + + + + @@@ -190,6 -228,7 +230,7 @@@ + - ); + );