Merge branch '15088-merge-account'
authorEric Biagiotti <ebiagiotti@veritasgenetics.com>
Wed, 22 May 2019 21:01:20 +0000 (17:01 -0400)
committerEric Biagiotti <ebiagiotti@veritasgenetics.com>
Wed, 22 May 2019 21:01:20 +0000 (17:01 -0400)
refs #15088

Arvados-DCO-1.1-Signed-off-by: Eric Biagiotti <ebiagiotti@veritasgenetics.com>

25 files changed:
src/common/formatters.ts
src/models/group.ts
src/models/link-account.ts [new file with mode: 0644]
src/models/test-utils.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/link-account-service/link-account-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.ts
src/store/link-account-panel/link-account-panel-actions.ts [new file with mode: 0644]
src/store/link-account-panel/link-account-panel-reducer.test.ts [new file with mode: 0644]
src/store/link-account-panel/link-account-panel-reducer.ts [new file with mode: 0644]
src/store/navigation/navigation-action.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/api-token/api-token.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views/inactive-panel/inactive-panel.tsx
src/views/link-account-panel/link-account-panel-root.tsx [new file with mode: 0644]
src/views/link-account-panel/link-account-panel.tsx [new file with mode: 0644]
src/views/main-panel/main-panel-root.tsx
src/views/main-panel/main-panel.tsx
src/views/workbench/workbench.tsx

index 60e6cd59c53e284a929cd6143c618020537ffd3a..377e78e42a8678dae93980549bda9c6a10fc8020 100644 (file)
@@ -4,10 +4,16 @@
 
 import { PropertyValue } from "~/models/search-bar";
 
-export const formatDate = (isoDate?: string | null) => {
+export const formatDate = (isoDate?: string | null, utc: boolean = false) => {
     if (isoDate) {
         const date = new Date(isoDate);
-        const text = date.toLocaleString();
+        let text: string;
+        if (utc) {
+            text = date.toUTCString();
+        }
+        else {
+            text = date.toLocaleString();
+        }
         return text === 'Invalid Date' ? "(none)" : text;
     }
     return "(none)";
index e13fbcbf05da2cb9584e21149020d3447350c0cd..f34ede0afcddaaa5bdbd2e9279b881deb8bb8c8a 100644 (file)
@@ -11,6 +11,7 @@ export interface GroupResource extends TrashableResource {
     description: string;
     properties: any;
     writeableBy: string[];
+    ensure_unique_name: boolean;
 }
 
 export enum GroupClass {
diff --git a/src/models/link-account.ts b/src/models/link-account.ts
new file mode 100644 (file)
index 0000000..f5b6040
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export enum LinkAccountStatus {
+    SUCCESS,
+    CANCELLED,
+    FAILED
+}
+
+export enum LinkAccountType {
+    ADD_OTHER_LOGIN,
+    ADD_LOCAL_TO_REMOTE,
+    ACCESS_OTHER_ACCOUNT,
+    ACCESS_OTHER_REMOTE_ACCOUNT
+}
+
+export interface AccountToLink {
+    type: LinkAccountType;
+    userUuid: string;
+    token: string;
+}
index b08ce5a0525bd913b4d774262f3548b664e8e9ac..22a94f166403d4b1c54c7b1e242c5f684d6e155c 100644 (file)
@@ -24,6 +24,7 @@ export const mockGroupResource = (data: Partial<GroupResource> = {}): GroupResou
     trashAt: "",
     uuid: "",
     writeableBy: [],
+    ensure_unique_name: true,
     ...data
 });
 
index 50ba319ea18315e4e13770cf9f3d36852b5db58f..b43e84bb72147cc240a81a12cffbd223d17b32f0 100644 (file)
@@ -41,6 +41,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const computeNodesMatch = Routes.matchComputeNodesRoute(pathname);
     const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
     const myAccountMatch = Routes.matchMyAccountRoute(pathname);
+    const linkAccountMatch = Routes.matchLinkAccountRoute(pathname);
     const userMatch = Routes.matchUsersRoute(pathname);
     const groupsMatch = Routes.matchGroupsRoute(pathname);
     const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
@@ -95,6 +96,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
     } else if (myAccountMatch) {
         store.dispatch(WorkbenchActions.loadMyAccount);
+    } else if (linkAccountMatch) {
+        store.dispatch(WorkbenchActions.loadLinkAccount);
     } else if (userMatch) {
         store.dispatch(WorkbenchActions.loadUsers);
     } else if (groupsMatch) {
index 37c7a816735a7763b8ef25fb13f731f268277883..08e0a03d058a0030bef3f864e640e8bf40b2a733 100644 (file)
@@ -36,6 +36,7 @@ export const Routes = {
     SSH_KEYS_USER: `/ssh-keys-user`,
     SITE_MANAGER: `/site-manager`,
     MY_ACCOUNT: '/my-account',
+    LINK_ACCOUNT: '/link_account',
     KEEP_SERVICES: `/keep-services`,
     COMPUTE_NODES: `/nodes`,
     USERS: '/users',
@@ -148,9 +149,18 @@ export const matchSiteManagerRoute = (route: string) =>
 export const matchMyAccountRoute = (route: string) =>
     matchPath(route, { path: Routes.MY_ACCOUNT });
 
+export const matchLinkAccountRoute = (route: string) =>
+    matchPath(route, { path: Routes.LINK_ACCOUNT });
+
 export const matchKeepServicesRoute = (route: string) =>
     matchPath(route, { path: Routes.KEEP_SERVICES });
 
+export const matchTokenRoute = (route: string) =>
+    matchPath(route, { path: Routes.TOKEN });
+
+export const matchFedTokenRoute = (route: string) =>
+    matchPath(route, {path: Routes.FED_LOGIN});
+
 export const matchUsersRoute = (route: string) =>
     matchPath(route, { path: Routes.USERS });
 
diff --git a/src/services/link-account-service/link-account-service.ts b/src/services/link-account-service/link-account-service.ts
new file mode 100644 (file)
index 0000000..42fae36
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { ApiActions } from "~/services/api/api-actions";
+import { AccountToLink, LinkAccountStatus } from "~/models/link-account";
+import { CommonService } from "~/services/common-service/common-service";
+
+export const USER_LINK_ACCOUNT_KEY = 'accountToLink';
+export const ACCOUNT_LINK_STATUS_KEY = 'accountLinkStatus';
+
+export class LinkAccountService {
+
+    constructor(
+        protected serverApi: AxiosInstance,
+        protected actions: ApiActions) { }
+
+    public saveAccountToLink(account: AccountToLink) {
+        sessionStorage.setItem(USER_LINK_ACCOUNT_KEY, JSON.stringify(account));
+    }
+
+    public removeAccountToLink() {
+        sessionStorage.removeItem(USER_LINK_ACCOUNT_KEY);
+    }
+
+    public getAccountToLink() {
+        const data = sessionStorage.getItem(USER_LINK_ACCOUNT_KEY);
+        return data ? JSON.parse(data) as AccountToLink : undefined;
+    }
+
+    public saveLinkOpStatus(status: LinkAccountStatus) {
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, JSON.stringify(status));
+    }
+
+    public removeLinkOpStatus() {
+        sessionStorage.removeItem(ACCOUNT_LINK_STATUS_KEY);
+    }
+
+    public getLinkOpStatus() {
+        const data = sessionStorage.getItem(ACCOUNT_LINK_STATUS_KEY);
+        return data ? JSON.parse(data) as LinkAccountStatus : undefined;
+    }
+
+    public linkAccounts(newUserToken: string, newGroupUuid: string) {
+        const params = {
+            new_user_token: newUserToken,
+            new_owner_uuid: newGroupUuid,
+            redirect_to_new_user: true
+        };
+        return CommonService.defaultResponse(
+            this.serverApi.post('/users/merge/', params),
+            this.actions,
+            false
+        );
+    }
+}
\ No newline at end of file
index 78ea714b93cb272afcf8c6518dfd621ed64d9d9e..dd3178790a05bdca84c7456418d85562286f1a66 100644 (file)
@@ -31,6 +31,7 @@ import { AuthorizedKeysService } from '~/services/authorized-keys-service/author
 import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service';
 import { NodeService } from '~/services/node-service/node-service';
 import { FileViewersConfigService } from '~/services/file-viewers-config-service/file-viewers-config-service';
+import { LinkAccountService } from "./link-account-service/link-account-service";
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -56,6 +57,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const userService = new UserService(apiClient, actions);
     const virtualMachineService = new VirtualMachinesService(apiClient, actions);
     const workflowService = new WorkflowService(apiClient, actions);
+    const linkAccountService = new LinkAccountService(apiClient, actions);
 
     const ancestorsService = new AncestorService(groupsService, userService);
     const authService = new AuthService(apiClient, config.rootUrl, actions);
@@ -94,6 +96,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         webdavClient,
         workflowService,
         vocabularyService,
+        linkAccountService
     };
 };
 
index f28ae1790c9739f11eed385203fb04f4006c1cb3..f401d4a77ada98ba05d3282e60554138df6d5d28 100644 (file)
@@ -23,6 +23,7 @@ import { configureStore, RootStore } from "../store";
 import createBrowserHistory from "history/createBrowserHistory";
 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';
 
 describe('auth-actions', () => {
     let reducer: (state: AuthState | undefined, action: AuthAction) => any;
@@ -40,6 +41,8 @@ describe('auth-actions', () => {
 
     it('should initialise state with user and api token from local storage', () => {
 
+        // Only test the case when a link account operation is not being cancelled
+        sessionStorage.setItem(ACCOUNT_LINK_STATUS_KEY, "0");
         localStorage.setItem(API_TOKEN_KEY, "token");
         localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
         localStorage.setItem(USER_FIRST_NAME_KEY, "John");
@@ -64,6 +67,7 @@ describe('auth-actions', () => {
             sshKeys: [],
             homeCluster: "zzzzz",
             localCluster: "zzzzz",
+            remoteHostsConfig: {},
             remoteHosts: {
                 zzzzz: "zzzzz.arvadosapi.com",
                 xc59z: "xc59z.arvadosapi.com"
index c088418a61eaa4b3b4a4c8fdc7b7110fa6ebfa58..1d1ad18cbd8c985218fc7aa433b04056d76d031d 100644 (file)
@@ -8,15 +8,18 @@ import { AxiosInstance } from "axios";
 import { RootState } from "../store";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
-import { User } from "~/models/user";
+import { User, UserResource } from "~/models/user";
 import { Session } from "~/models/session";
 import { getDiscoveryURL, Config } from '~/common/config';
 import { initSessions } from "~/store/auth/auth-action-session";
+import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
+import { matchTokenRoute, matchFedTokenRoute } from '~/routes/routes';
 import Axios from "axios";
 import { AxiosError } from "axios";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
+    SAVE_USER: ofType<UserResource>(),
     LOGIN: {},
     LOGOUT: {},
     CONFIG: ofType<{ config: Config }>(),
@@ -34,7 +37,7 @@ export const authActions = unionize({
     REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
 });
 
-function setAuthorizationHeader(services: ServiceRepository, token: string) {
+export function setAuthorizationHeader(services: ServiceRepository, token: string) {
     services.apiClient.defaults.headers.common = {
         Authorization: `OAuth2 ${token}`
     };
@@ -48,6 +51,20 @@ function removeAuthorizationHeader(client: AxiosInstance) {
 }
 
 export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    // Cancel any link account ops in progress unless the user has
+    // just logged in or there has been a successful link operation
+    const data = services.linkAccountService.getLinkOpStatus();
+    if (!matchTokenRoute(location.pathname) && (!matchFedTokenRoute(location.pathname)) && data === undefined) {
+        dispatch<any>(cancelLinking()).then(() => {
+            dispatch<any>(init(config));
+        });
+    }
+    else {
+        dispatch<any>(init(config));
+    }
+};
+
+const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     const user = services.authService.getUser();
     const token = services.authService.getApiToken();
     const homeCluster = services.authService.getHomeCluster();
@@ -82,12 +99,20 @@ export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: ()
     dispatch(authActions.SAVE_API_TOKEN(token));
 };
 
+export const saveUser = (user: UserResource) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.saveUser(user);
+    dispatch(authActions.SAVE_USER(user));
+};
+
 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());
 };
 
-export const logout = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const logout = (deleteLinkData: boolean = false) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    if (deleteLinkData) {
+        services.linkAccountService.removeAccountToLink();
+    }
     services.authService.removeApiToken();
     services.authService.removeUser();
     removeAuthorizationHeader(services.apiClient);
index e44c81e323297fdfcd8884a4efe08236d911a9f2..cded9f0e71816636ce1876d51c83d3976abb589c 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { authActions, AuthAction } from "./auth-action";
-import { User } from "~/models/user";
+import { User, UserResource } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
 import { Session } from "~/models/session";
@@ -36,6 +36,9 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat
         SAVE_API_TOKEN: (token: string) => {
             return { ...state, apiToken: token };
         },
+        SAVE_USER: (user: UserResource) => {
+            return { ...state, user};
+        },
         CONFIG: ({ config }) => {
             return {
                 ...state,
diff --git a/src/store/link-account-panel/link-account-panel-actions.ts b/src/store/link-account-panel/link-account-panel-actions.ts
new file mode 100644 (file)
index 0000000..cdc9966
--- /dev/null
@@ -0,0 +1,280 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
+import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
+import { LinkAccountType, AccountToLink, LinkAccountStatus } from "~/models/link-account";
+import { saveApiToken, saveUser } from "~/store/auth/auth-action";
+import { unionize, ofType, UnionOf } from '~/common/unionize';
+import { UserResource } from "~/models/user";
+import { GroupResource } from "~/models/group";
+import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
+import { login, logout, setAuthorizationHeader } from "~/store/auth/auth-action";
+import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
+
+export const linkAccountPanelActions = unionize({
+    LINK_INIT: ofType<{
+        targetUser: UserResource | undefined }>(),
+    LINK_LOAD: ofType<{
+        originatingUser: OriginatingUser | undefined,
+        targetUser: UserResource | undefined,
+        targetUserToken: string | undefined,
+        userToLink: UserResource | undefined,
+        userToLinkToken: string | undefined }>(),
+    LINK_INVALID: ofType<{
+        originatingUser: OriginatingUser | undefined,
+        targetUser: UserResource | undefined,
+        userToLink: UserResource | undefined,
+        error: LinkAccountPanelError }>(),
+    SET_SELECTED_CLUSTER: ofType<{
+        selectedCluster: string }>(),
+    SET_IS_PROCESSING: ofType<{
+        isProcessing: boolean}>(),
+    HAS_SESSION_DATA: {}
+});
+
+export type LinkAccountPanelAction = UnionOf<typeof linkAccountPanelActions>;
+
+function validateLink(userToLink: UserResource, targetUser: UserResource) {
+    if (userToLink.uuid === targetUser.uuid) {
+        return LinkAccountPanelError.SAME_USER;
+    }
+    else if (userToLink.isAdmin && !targetUser.isAdmin) {
+        return LinkAccountPanelError.NON_ADMIN;
+    }
+    else if (!targetUser.isActive) {
+        return LinkAccountPanelError.INACTIVE;
+    }
+    return LinkAccountPanelError.NONE;
+}
+
+export const checkForLinkStatus = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const status = services.linkAccountService.getLinkOpStatus();
+        if (status !== undefined) {
+            let msg: string;
+            let msgKind: SnackbarKind;
+            if (status.valueOf() === LinkAccountStatus.CANCELLED) {
+                msg = "Account link cancelled!", msgKind = SnackbarKind.INFO;
+            }
+            else if (status.valueOf() === LinkAccountStatus.FAILED) {
+                msg = "Account link failed!", msgKind = SnackbarKind.ERROR;
+            }
+            else if (status.valueOf() === LinkAccountStatus.SUCCESS) {
+                msg = "Account link success!", msgKind = SnackbarKind.SUCCESS;
+            }
+            else {
+                msg = "Unknown Error!", msgKind = SnackbarKind.ERROR;
+            }
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: msg, kind: msgKind, hideDuration: 3000 }));
+            services.linkAccountService.removeLinkOpStatus();
+        }
+    };
+
+export const switchUser = (user: UserResource, token: string) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(saveUser(user));
+        dispatch(saveApiToken(token));
+    };
+
+export const linkFailed = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        // If the link fails, switch to the user account that originated the link operation
+        const linkState = getState().linkAccountPanel;
+        if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+            if (linkState.originatingUser === OriginatingUser.TARGET_USER) {
+                dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+            }
+            else if ((linkState.originatingUser === OriginatingUser.USER_TO_LINK)) {
+                dispatch(switchUser(linkState.userToLink, linkState.userToLinkToken));
+            }
+        }
+        services.linkAccountService.removeAccountToLink();
+        services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.FAILED);
+        location.reload();
+    };
+
+export const loadLinkAccountPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            // If there are remote hosts, set the initial selected cluster by getting the first cluster that isn't the local cluster
+            if (getState().linkAccountPanel.selectedCluster === undefined) {
+                const localCluster = getState().auth.localCluster;
+                let selectedCluster = localCluster;
+                for (const key in getState().auth.remoteHosts) {
+                    if (key !== localCluster) {
+                        selectedCluster = key;
+                        break;
+                    }
+                }
+                dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }));
+            }
+
+            // First check if an account link operation has completed
+            dispatch(checkForLinkStatus());
+
+            // Continue loading the link account panel
+            dispatch(setBreadcrumbs([{ label: 'Link account'}]));
+            const curUser = getState().auth.user;
+            const curToken = getState().auth.apiToken;
+            if (curUser && curToken) {
+
+                // If there is link account session data, then the user has logged in a second time
+                const linkAccountData = services.linkAccountService.getAccountToLink();
+                if (linkAccountData) {
+
+                    dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: true }));
+                    const curUserResource = await services.userService.get(curUser.uuid);
+
+                    // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
+                    // issues since a user will always be able to query the api server for their own user data.
+                    setAuthorizationHeader(services, linkAccountData.token);
+                    const savedUserResource = await services.userService.get(linkAccountData.userUuid);
+                    setAuthorizationHeader(services, curToken);
+
+                    let params: any;
+                    if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+                        params = {
+                            originatingUser: OriginatingUser.USER_TO_LINK,
+                            targetUser: curUserResource,
+                            targetUserToken: curToken,
+                            userToLink: savedUserResource,
+                            userToLinkToken: linkAccountData.token
+                        };
+                    }
+                    else if (linkAccountData.type === LinkAccountType.ADD_OTHER_LOGIN || linkAccountData.type === LinkAccountType.ADD_LOCAL_TO_REMOTE) {
+                        params = {
+                            originatingUser: OriginatingUser.TARGET_USER,
+                            targetUser: savedUserResource,
+                            targetUserToken: linkAccountData.token,
+                            userToLink: curUserResource,
+                            userToLinkToken: curToken
+                        };
+                    }
+                    else {
+                        throw new Error("Unknown link account type");
+                    }
+
+                    dispatch(switchUser(params.targetUser, params.targetUserToken));
+                    const error = validateLink(params.userToLink, params.targetUser);
+                    if (error === LinkAccountPanelError.NONE) {
+                        dispatch(linkAccountPanelActions.LINK_LOAD(params));
+                    }
+                    else {
+                        dispatch(linkAccountPanelActions.LINK_INVALID({
+                            originatingUser: params.originatingUser,
+                            targetUser: params.targetUser,
+                            userToLink: params.userToLink,
+                            error}));
+                        return;
+                    }
+                }
+                else {
+                    // If there is no link account session data, set the state to invoke the initial UI
+                    const curUserResource = await services.userService.get(curUser.uuid);
+                    dispatch(linkAccountPanelActions.LINK_INIT({ targetUser: curUserResource }));
+                    return;
+                }
+            }
+        }
+        catch (e) {
+            dispatch(linkFailed());
+        }
+        finally {
+            dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: false }));
+        }
+    };
+
+export const startLinking = (t: LinkAccountType) =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const accountToLink = {type: t, userUuid: services.authService.getUuid(), token: services.authService.getApiToken()} as AccountToLink;
+        services.linkAccountService.saveAccountToLink(accountToLink);
+
+        const auth = getState().auth;
+        const isLocalUser = auth.user!.uuid.substring(0,5) === auth.localCluster;
+        let homeCluster = auth.localCluster;
+        if (isLocalUser && t === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
+            homeCluster = getState().linkAccountPanel.selectedCluster!;
+        }
+
+        dispatch(logout());
+        dispatch(login(auth.localCluster, homeCluster, auth.remoteHosts));
+    };
+
+export const getAccountLinkData = () =>
+    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        return services.linkAccountService.getAccountToLink();
+    };
+
+export const cancelLinking = (reload: boolean = false) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        let user: UserResource | undefined;
+        try {
+            // When linking is cancelled switch to the originating user (i.e. the user saved in session data)
+            dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+            const linkAccountData = services.linkAccountService.getAccountToLink();
+            if (linkAccountData) {
+                services.linkAccountService.removeAccountToLink();
+                setAuthorizationHeader(services, linkAccountData.token);
+                user = await services.userService.get(linkAccountData.userUuid);
+                dispatch(switchUser(user, linkAccountData.token));
+                services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.CANCELLED);
+            }
+        }
+        finally {
+            if (reload) {
+                location.reload();
+            }
+            else {
+                dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+            }
+        }
+    };
+
+export const linkAccount = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const linkState = getState().linkAccountPanel;
+        if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
+
+            // First create a project owned by the target user
+            const projectName = `Migrated from ${linkState.userToLink.email} (${linkState.userToLink.uuid})`;
+            let newGroup: GroupResource;
+            try {
+                newGroup = await services.projectService.create({
+                    name: projectName,
+                    ensure_unique_name: true
+                });
+            }
+            catch (e) {
+                dispatch(linkFailed());
+                throw e;
+            }
+
+            try {
+                // The merge api links the user sending the request into the user
+                // specified in the request, so change the authorization header accordingly
+                setAuthorizationHeader(services, linkState.userToLinkToken);
+                await services.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
+                dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
+                services.linkAccountService.removeAccountToLink();
+                services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.SUCCESS);
+                location.reload();
+            }
+            catch(e) {
+                // If the link operation fails, delete the previously made project
+                try {
+                    setAuthorizationHeader(services, linkState.targetUserToken);
+                    await services.projectService.delete(newGroup.uuid);
+                }
+                finally {
+                    dispatch(linkFailed());
+                }
+                throw e;
+            }
+        }
+    };
\ No newline at end of file
diff --git a/src/store/link-account-panel/link-account-panel-reducer.test.ts b/src/store/link-account-panel/link-account-panel-reducer.test.ts
new file mode 100644 (file)
index 0000000..d1bd8df
--- /dev/null
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelReducer, LinkAccountPanelError, LinkAccountPanelStatus, OriginatingUser } from "~/store/link-account-panel/link-account-panel-reducer";
+import { linkAccountPanelActions } from "~/store/link-account-panel/link-account-panel-actions";
+import { UserResource } from "~/models/user";
+
+describe('link-account-panel-reducer', () => {
+    const initialState = undefined;
+
+    it('handles initial link account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INIT({targetUser}));
+        expect(state).toEqual({
+            targetUser,
+            isProcessing: false,
+            selectedCluster: undefined,
+            targetUserToken: undefined,
+            userToLink: undefined,
+            userToLinkToken: undefined,
+            originatingUser: OriginatingUser.NONE,
+            error: LinkAccountPanelError.NONE,
+            status: LinkAccountPanelStatus.INITIAL
+        });
+    });
+
+    it('handles loaded link account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+        const targetUserToken = "targettoken";
+
+        const userToLink = { } as any;
+        userToLink.username = "userToLink";
+        const userToLinkToken = "usertoken";
+
+        const originatingUser = OriginatingUser.TARGET_USER;
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_LOAD({
+            originatingUser, targetUser, targetUserToken, userToLink, userToLinkToken}));
+        expect(state).toEqual({
+            targetUser,
+            targetUserToken,
+            isProcessing: false,
+            selectedCluster: undefined,
+            userToLink,
+            userToLinkToken,
+            originatingUser: OriginatingUser.TARGET_USER,
+            error: LinkAccountPanelError.NONE,
+            status: LinkAccountPanelStatus.LINKING
+        });
+    });
+
+    it('handles loaded invalid account state', () => {
+        const targetUser = { } as any;
+        targetUser.username = "targetUser";
+
+        const userToLink = { } as any;
+        userToLink.username = "userToLink";
+
+        const originatingUser = OriginatingUser.TARGET_USER;
+        const error = LinkAccountPanelError.NON_ADMIN;
+
+        const state = linkAccountPanelReducer(initialState, linkAccountPanelActions.LINK_INVALID({targetUser, userToLink, originatingUser, error}));
+        expect(state).toEqual({
+            targetUser,
+            targetUserToken: undefined,
+            isProcessing: false,
+            selectedCluster: undefined,
+            userToLink,
+            userToLinkToken: undefined,
+            originatingUser: OriginatingUser.TARGET_USER,
+            error: LinkAccountPanelError.NON_ADMIN,
+            status: LinkAccountPanelStatus.ERROR
+        });
+    });
+});
diff --git a/src/store/link-account-panel/link-account-panel-reducer.ts b/src/store/link-account-panel/link-account-panel-reducer.ts
new file mode 100644 (file)
index 0000000..21c2c9e
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { linkAccountPanelActions, LinkAccountPanelAction } from "~/store/link-account-panel/link-account-panel-actions";
+import { UserResource } from "~/models/user";
+
+export enum LinkAccountPanelStatus {
+    NONE,
+    INITIAL,
+    HAS_SESSION_DATA,
+    LINKING,
+    ERROR
+}
+
+export enum LinkAccountPanelError {
+    NONE,
+    INACTIVE,
+    NON_ADMIN,
+    SAME_USER
+}
+
+export enum OriginatingUser {
+    NONE,
+    TARGET_USER,
+    USER_TO_LINK
+}
+
+export interface LinkAccountPanelState {
+    selectedCluster: string | undefined;
+    originatingUser: OriginatingUser | undefined;
+    targetUser: UserResource | undefined;
+    targetUserToken: string | undefined;
+    userToLink: UserResource | undefined;
+    userToLinkToken: string | undefined;
+    status: LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
+    isProcessing: boolean;
+}
+
+const initialState = {
+    selectedCluster: undefined,
+    originatingUser: OriginatingUser.NONE,
+    targetUser: undefined,
+    targetUserToken: undefined,
+    userToLink: undefined,
+    userToLinkToken: undefined,
+    isProcessing: false,
+    status: LinkAccountPanelStatus.NONE,
+    error: LinkAccountPanelError.NONE
+};
+
+export const linkAccountPanelReducer = (state: LinkAccountPanelState = initialState, action: LinkAccountPanelAction) =>
+    linkAccountPanelActions.match(action, {
+        default: () => state,
+        LINK_INIT: ({ targetUser }) => ({
+            ...state,
+            targetUser, targetUserToken: undefined,
+            userToLink: undefined, userToLinkToken: undefined,
+            status: LinkAccountPanelStatus.INITIAL, error: LinkAccountPanelError.NONE, originatingUser: OriginatingUser.NONE
+        }),
+        LINK_LOAD: ({ originatingUser, userToLink, targetUser, targetUserToken, userToLinkToken}) => ({
+            ...state,
+            originatingUser,
+            targetUser, targetUserToken,
+            userToLink, userToLinkToken,
+            status: LinkAccountPanelStatus.LINKING, error: LinkAccountPanelError.NONE
+        }),
+        LINK_INVALID: ({ originatingUser, targetUser, userToLink, error }) => ({
+            ...state,
+            originatingUser,
+            targetUser, targetUserToken: undefined,
+            userToLink, userToLinkToken: undefined,
+            error, status: LinkAccountPanelStatus.ERROR
+        }),
+        SET_SELECTED_CLUSTER: ({ selectedCluster }) => ({
+            ...state, selectedCluster
+        }),
+        SET_IS_PROCESSING: ({ isProcessing }) =>({
+            ...state,
+            isProcessing
+        }),
+        HAS_SESSION_DATA: () => ({
+            ...state, status: LinkAccountPanelStatus.HAS_SESSION_DATA
+        })
+    });
\ No newline at end of file
index f7eeae57c8f53226ae5aa39370cfc80998fbc678..d93e9ab0a08da07096168d84bc8cbbea9d1c0121 100644 (file)
@@ -87,6 +87,8 @@ export const navigateToSiteManager = push(Routes.SITE_MANAGER);
 
 export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
 
+export const navigateToLinkAccount = push(Routes.LINK_ACCOUNT);
+
 export const navigateToKeepServices = push(Routes.KEEP_SERVICES);
 
 export const navigateToComputeNodes = push(Routes.COMPUTE_NODES);
index ff9a495e804b9e30c2f6693008d42e5a002943e5..8a2ca2400cb1cbd3f1229cce96294291e91c8d66 100644 (file)
@@ -62,6 +62,7 @@ import { ApiClientAuthorizationMiddlewareService } from '~/store/api-client-auth
 import { PublicFavoritesMiddlewareService } from '~/store/public-favorites-panel/public-favorites-middleware-service';
 import { PUBLIC_FAVORITE_PANEL_ID } from '~/store/public-favorites-panel/public-favorites-action';
 import { publicFavoritesReducer } from '~/store/public-favorites/public-favorites-reducer';
+import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer';
 import { CollectionsWithSameContentAddressMiddlewareService } from '~/store/collections-content-address-panel/collections-content-address-middleware-service';
 import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from '~/store/collections-content-address-panel/collections-content-address-panel-actions';
 import { ownerNameReducer } from '~/store/owner-name/owner-name-reducer';
@@ -172,5 +173,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     searchBar: searchBarReducer,
     virtualMachines: virtualMachinesReducer,
     repositories: repositoriesReducer,
-    keepServices: keepServicesReducer
+    keepServices: keepServicesReducer,
+    linkAccountPanel: linkAccountPanelReducer
 });
index fb85c0e0c8392b8b6fb2742358b6246389f069b3..868ed5ebfbf2732bebc10a7b0d41120d6bb775d1 100644 (file)
@@ -59,6 +59,7 @@ 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-ssh';
 import { loadMyAccountPanel } from '~/store/my-account/my-account-panel-actions';
+import { loadLinkAccountPanel, linkAccountPanelActions } from '~/store/link-account-panel/link-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';
@@ -115,7 +116,7 @@ const handleFirstTimeLoad = (action: any) =>
     };
 
 export const loadWorkbench = () =>
-    async (dispatch: Dispatch, getState: () => RootState) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
         const { auth, router } = getState();
         const { user } = auth;
@@ -136,6 +137,10 @@ export const loadWorkbench = () =>
             dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns }));
             dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns }));
 
+            if (services.linkAccountService.getAccountToLink()) {
+                dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
+            }
+
             dispatch<any>(initSidePanelTree());
             if (router.location) {
                 const match = matchRootRoute(router.location.pathname);
@@ -492,6 +497,11 @@ export const loadMyAccount = handleFirstTimeLoad(
         dispatch(loadMyAccountPanel());
     });
 
+export const loadLinkAccount = handleFirstTimeLoad(
+    (dispatch: Dispatch<any>) => {
+        dispatch(loadLinkAccountPanel());
+    });
+
 export const loadKeepServices = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadKeepServicesPanel());
index b78e7192dc0bd24f5c1ef14ed1ab6c0629aea564..fae099d5717b3f12bde13763746d26e3f4c9a944 100644 (file)
@@ -8,10 +8,11 @@ import { connect, DispatchProp } from "react-redux";
 import { authActions, getUserDetails, saveApiToken } from "~/store/auth/auth-action";
 import { getUrlParameter } from "~/common/url";
 import { AuthService } from "~/services/auth-service/auth-service";
-import { navigateToRootProject } from "~/store/navigation/navigation-action";
+import { navigateToRootProject, navigateToLinkAccount } from "~/store/navigation/navigation-action";
 import { User } from "~/models/user";
 import { Config } from "~/common/config";
 import { initSessions } from "~/store/auth/auth-action-session";
+import { getAccountLinkData } from "~/store/link-account-panel/link-account-panel-actions";
 
 interface ApiTokenProps {
     authService: AuthService;
@@ -30,7 +31,12 @@ export const ApiToken = connect()(
                 this.props.dispatch(initSessions(this.props.authService, this.props.config, user));
             }).finally(() => {
                 if (loadMainApp) {
-                    this.props.dispatch(navigateToRootProject);
+                    if (this.props.dispatch(getAccountLinkData())) {
+                        this.props.dispatch(navigateToLinkAccount);
+                    }
+                    else {
+                        this.props.dispatch(navigateToRootProject);
+                    }
                 }
             });
         }
index 87bfe0c6db7cd3fe06554e9a66ff3dc789c664e8..1b8424c040b296b43db2b388c21368b8f76b7628 100644 (file)
@@ -17,7 +17,8 @@ import { openRepositoriesPanel } from "~/store/repositories/repositories-actions
 import {
     navigateToSiteManager,
     navigateToSshKeysUser,
-    navigateToMyAccount
+    navigateToMyAccount,
+    navigateToLinkAccount
 } from '~/store/navigation/navigation-action';
 import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
 
@@ -75,12 +76,13 @@ export const AccountMenu = withStyles(styles)(
                         <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                         <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
                         <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+                        <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
                     </> : null}
                     <MenuItem>
                         <a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
                             className={classes.link}>
                             Switch to Workbench v1</a></MenuItem>
                     <Divider />
-                    <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
+                    <MenuItem onClick={() => dispatch(logout(true))}>Logout</MenuItem>
                 </DropdownMenu>
                 : null));
index abfa1f8129aad55e8e7df7351056ea336fac70ec..8d53a21ecaa060fd659d6c92ac7d9e4f83e6b692 100644 (file)
@@ -3,15 +3,15 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { connect, DispatchProp } from 'react-redux';
-import { Grid, Typography, Button, Select } from '@material-ui/core';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { Grid, Typography, Button } from '@material-ui/core';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
-import { login, authActions } from '~/store/auth/auth-action';
 import { ArvadosTheme } from '~/common/custom-theme';
-import { RootState } from '~/store/store';
-import * as classNames from 'classnames';
+import { navigateToLinkAccount } from '~/store/navigation/navigation-action';
 
-type CssRules = 'root' | 'container' | 'title' | 'content' | 'content__bolder' | 'button';
+
+type CssRules = 'root' | 'ontop' | 'title';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -28,51 +28,50 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             opacity: 0.2,
         }
     },
-    container: {
-        width: '560px',
+    ontop: {
         zIndex: 10
     },
     title: {
         marginBottom: theme.spacing.unit * 6,
         color: theme.palette.grey["800"]
-    },
-    content: {
-        marginBottom: theme.spacing.unit * 3,
-        lineHeight: '1.2rem',
-        color: theme.palette.grey["800"]
-    },
-    'content__bolder': {
-        fontWeight: 'bolder'
-    },
-    button: {
-        boxShadow: 'none'
     }
 });
 
-type LoginPanelProps = DispatchProp<any> & WithStyles<CssRules> & {
-    remoteHosts: { [key: string]: string },
-    homeCluster: string,
-    uuidPrefix: string
-};
+export interface InactivePanelActionProps {
+    startLinking: () => void;
+}
 
-export const InactivePanel = withStyles(styles)(
-    connect((state: RootState) => ({
-        remoteHosts: state.auth.remoteHosts,
-        homeCluster: state.auth.homeCluster,
-        uuidPrefix: state.auth.localCluster
-    }))(({ classes, dispatch, remoteHosts, homeCluster, uuidPrefix }: LoginPanelProps) =>
-        <Grid container justify="center" alignItems="center"
+const mapDispatchToProps = (dispatch: Dispatch): InactivePanelActionProps => ({
+    startLinking: () => {
+        dispatch<any>(navigateToLinkAccount);
+    }
+});
+
+type InactivePanelProps =  WithStyles<CssRules> & InactivePanelActionProps;
+
+export const InactivePanel = connect(null, mapDispatchToProps)(withStyles(styles)((({ classes, startLinking }: InactivePanelProps) =>
+        <Grid container justify="center" alignItems="center" direction="column" spacing={24}
             className={classes.root}
-            style={{ marginTop: 56, overflowY: "auto", height: "100%" }}>
-            <Grid item className={classes.container}>
+            style={{ marginTop: 56, height: "100%" }}>
+            <Grid item>
                 <Typography variant='h6' align="center" className={classes.title}>
                     Hi! You're logged in, but...
-               </Typography>
-                <Typography>
-                    Your account is inactive.
-
-                   An administrator must activate your account before you can get any further.
-               </Typography>
+                </Typography>
+            </Grid>
+            <Grid item>
+                <Typography align="center">
+                    Your account is inactive. An administrator must activate your account before you can get any further.
+                </Typography>
+            </Grid>
+            <Grid item>
+                <Typography align="center">
+                    If you would like to use this login to access another account click "Link Account".
+                </Typography>
+            </Grid>
+            <Grid item>
+                <Button className={classes.ontop} color="primary" variant="contained" onClick={() => startLinking()}>
+                    Link Account
+                </Button>
             </Grid>
         </Grid >
-    ));
+    )));
diff --git a/src/views/link-account-panel/link-account-panel-root.tsx b/src/views/link-account-panel/link-account-panel-root.tsx
new file mode 100644 (file)
index 0000000..0eb494e
--- /dev/null
@@ -0,0 +1,182 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardContent,
+    Button,
+    Grid,
+    Select,
+    CircularProgress
+} from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { UserResource } from "~/models/user";
+import { LinkAccountType } from "~/models/link-account";
+import { formatDate } from "~/common/formatters";
+import { LinkAccountPanelStatus, LinkAccountPanelError } from "~/store/link-account-panel/link-account-panel-reducer";
+
+type CssRules = 'root';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+        overflow: 'auto'
+    }
+});
+
+export interface LinkAccountPanelRootDataProps {
+    targetUser?: UserResource;
+    userToLink?: UserResource;
+    remoteHosts:  { [key: string]: string };
+    hasRemoteHosts: boolean;
+    localCluster: string;
+    status : LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
+    selectedCluster?: string;
+    isProcessing: boolean;
+}
+
+export interface LinkAccountPanelRootActionProps {
+    startLinking: (type: LinkAccountType) => void;
+    cancelLinking: () => void;
+    linkAccount: () => void;
+    setSelectedCluster: (cluster: string) => void;
+}
+
+function displayUser(user: UserResource, showCreatedAt: boolean = false, showCluster: boolean = false) {
+    const disp = [];
+    disp.push(<span><b>{user.email}</b> ({user.username}, {user.uuid})</span>);
+    if (showCluster) {
+        const homeCluster = user.uuid.substr(0,5);
+        disp.push(<span> hosted on cluster <b>{homeCluster}</b> and </span>);
+    }
+    if (showCreatedAt) {
+        disp.push(<span> created on <b>{formatDate(user.createdAt)}</b></span>);
+    }
+    return disp;
+}
+
+function isLocalUser(uuid: string, localCluster: string) {
+    return uuid.substring(0, 5) === localCluster;
+}
+
+type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles<CssRules>;
+
+export const LinkAccountPanelRoot = withStyles(styles) (
+    ({classes, targetUser, userToLink, status, isProcessing, error, startLinking, cancelLinking, linkAccount,
+      remoteHosts, hasRemoteHosts, selectedCluster, setSelectedCluster, localCluster}: LinkAccountPanelRootProps) => {
+        return <Card className={classes.root}>
+            <CardContent>
+            { isProcessing && <Grid container item direction="column" alignContent="center" spacing={24}>
+                <Grid item>
+                    Loading user info. Please wait.
+                </Grid>
+                <Grid item style={{ alignSelf: 'center' }}>
+                    <CircularProgress/>
+                </Grid>
+            </Grid> }
+            { !isProcessing && status === LinkAccountPanelStatus.INITIAL && targetUser && <div>
+                { isLocalUser(targetUser.uuid, localCluster) ? <Grid container spacing={24}>
+                    <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You are currently logged in as {displayUser(targetUser, true)}
+                        </Grid>
+                        <Grid item>
+                            You can link Arvados accounts. After linking, either login will take you to the same account.
+                        </Grid >
+                    </Grid>
+                    <Grid container item direction="row" spacing={24}>
+                        <Grid item>
+                            <Button disabled={!targetUser.isActive} color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_OTHER_LOGIN)}>
+                                Add another login to this account
+                            </Button>
+                        </Grid>
+                        <Grid item>
+                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_ACCOUNT)}>
+                                Use this login to access another account
+                            </Button>
+                        </Grid>
+                    </Grid>
+                    { hasRemoteHosts && selectedCluster && <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You can also link {displayUser(targetUser, false)} with an account from a remote cluster.
+                        </Grid>
+                        <Grid item>
+                            Please select the cluster that hosts the account you want to link with:
+                                <Select id="remoteHostsDropdown" native defaultValue={selectedCluster} style={{ marginLeft: "1em" }}
+                                    onChange={(event) => setSelectedCluster(event.target.value)}>
+                                    {Object.keys(remoteHosts).map((k) => k !== localCluster ? <option key={k} value={k}>{k}</option> : null)}
+                                </Select>
+                            </Grid>
+                        <Grid item>
+                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT)}>
+                                Link with an account on&nbsp;{hasRemoteHosts ? <label>{selectedCluster} </label> : null}
+                            </Button>
+                        </Grid>
+                    </Grid> }
+                </Grid> :
+                <Grid container spacing={24}>
+                    <Grid container item direction="column" spacing={24}>
+                        <Grid item>
+                            You are currently logged in as {displayUser(targetUser, true, true)}
+                        </Grid>
+                        {targetUser.isActive ? <> <Grid item>
+                            This a remote account. You can link a local Arvados account to this one. After linking, you can access the local account's data by logging into the <b>{localCluster}</b> cluster as user <b>{targetUser.email}</b> from <b>{targetUser.uuid.substr(0,5)}</b>.
+                        </Grid >
+                        <Grid item>
+                            <Button color="primary" variant="contained" onClick={() => startLinking(LinkAccountType.ADD_LOCAL_TO_REMOTE)}>
+                                Link an account from {localCluster} to this account
+                            </Button>
+                        </Grid> </>
+                        : <Grid item>
+                          This an inactive remote account. An administrator must activate your account before you can proceed. After your accounts is activated, you can link a local Arvados account hosted by the <b>{localCluster}</b> cluster to this one.
+                        </Grid >}
+                    </Grid>
+                </Grid>}
+            </div> }
+            { !isProcessing && (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && targetUser &&
+            <Grid container spacing={24}>
+                { status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
+                    <Grid item>
+                        Clicking 'Link accounts' will link {displayUser(userToLink, true, !isLocalUser(targetUser.uuid, localCluster))} to {displayUser(targetUser, true, !isLocalUser(targetUser.uuid, localCluster))}.
+                    </Grid>
+                    { (isLocalUser(targetUser.uuid, localCluster)) && <Grid item>
+                        After linking, logging in as {displayUser(userToLink)} will log you into the same account as {displayUser(targetUser)}.
+                    </Grid> }
+                    <Grid item>
+                        Any object owned by {displayUser(userToLink)} will be transfered to {displayUser(targetUser)}.
+                    </Grid>
+                    { !isLocalUser(targetUser.uuid, localCluster) && <Grid item>
+                        You can access <b>{userToLink.email}</b> data by logging into <b>{localCluster}</b> with the <b>{targetUser.email}</b> account.
+                    </Grid> }
+                </Grid> }
+                { error === LinkAccountPanelError.NON_ADMIN && <Grid item>
+                    Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(targetUser)}.
+                </Grid> }
+                { error === LinkAccountPanelError.SAME_USER && <Grid item>
+                    Cannot link {displayUser(targetUser)} to the same account.
+                </Grid> }
+                { error === LinkAccountPanelError.INACTIVE && <Grid item>
+                    Cannot link account {displayUser(userToLink)} to inactive account {displayUser(targetUser)}.
+                </Grid> }
+                <Grid container item direction="row" spacing={24}>
+                    <Grid item>
+                        <Button variant="contained" onClick={() => cancelLinking()}>
+                            Cancel
+                        </Button>
+                    </Grid>
+                    <Grid item>
+                        <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
+                            Link accounts
+                        </Button>
+                    </Grid>
+                </Grid>
+            </Grid> }
+        </CardContent>
+    </Card>;
+});
\ No newline at end of file
diff --git a/src/views/link-account-panel/link-account-panel.tsx b/src/views/link-account-panel/link-account-panel.tsx
new file mode 100644 (file)
index 0000000..c3ad51c
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { startLinking, cancelLinking, linkAccount, linkAccountPanelActions } from '~/store/link-account-panel/link-account-panel-actions';
+import { LinkAccountType } from '~/models/link-account';
+import {
+    LinkAccountPanelRoot,
+    LinkAccountPanelRootDataProps,
+    LinkAccountPanelRootActionProps
+} from '~/views/link-account-panel/link-account-panel-root';
+
+const mapStateToProps = (state: RootState): LinkAccountPanelRootDataProps => {
+    return {
+        remoteHosts: state.auth.remoteHosts,
+        hasRemoteHosts: Object.keys(state.auth.remoteHosts).length > 1,
+        selectedCluster: state.linkAccountPanel.selectedCluster,
+        localCluster: state.auth.localCluster,
+        targetUser: state.linkAccountPanel.targetUser,
+        userToLink: state.linkAccountPanel.userToLink,
+        status: state.linkAccountPanel.status,
+        error: state.linkAccountPanel.error,
+        isProcessing: state.linkAccountPanel.isProcessing
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): LinkAccountPanelRootActionProps => ({
+    startLinking: (type: LinkAccountType) => dispatch<any>(startLinking(type)),
+    cancelLinking: () => dispatch<any>(cancelLinking(true)),
+    linkAccount: () => dispatch<any>(linkAccount()),
+    setSelectedCluster: (selectedCluster: string) => dispatch<any>(linkAccountPanelActions.SET_SELECTED_CLUSTER({selectedCluster}))
+});
+
+export const LinkAccountPanel = connect(mapStateToProps, mapDispatchToProps)(LinkAccountPanelRoot);
index 4c64b0b850bc7e2e39e5ef446af61ac5a2a66fa1..43bc7fbc158ba6c891ff3d87b60dd0f8d5deca74 100644 (file)
@@ -11,6 +11,7 @@ import { LoginPanel } from '~/views/login-panel/login-panel';
 import { InactivePanel } from '~/views/inactive-panel/inactive-panel';
 import { WorkbenchLoadingScreen } from '~/views/workbench/workbench-loading-screen';
 import { MainAppBar } from '~/views-components/main-app-bar/main-app-bar';
+import { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel';
 
 type CssRules = 'root';
 
@@ -28,23 +29,25 @@ export interface MainPanelRootDataProps {
     loading: boolean;
     buildInfo: string;
     uuidPrefix: string;
+    isNotLinking: boolean;
+    isLinkingPath: boolean;
 }
 
 type MainPanelRootProps = MainPanelRootDataProps & WithStyles<CssRules>;
 
 export const MainPanelRoot = withStyles(styles)(
-    ({ classes, loading, working, user, buildInfo, uuidPrefix }: MainPanelRootProps) =>
+    ({ classes, loading, working, user, buildInfo, uuidPrefix, isNotLinking, isLinkingPath }: MainPanelRootProps) =>
         loading
             ? <WorkbenchLoadingScreen />
             : <>
-                <MainAppBar
+               { isNotLinking && <MainAppBar
                     user={user}
                     buildInfo={buildInfo}
                     uuidPrefix={uuidPrefix}>
                     {working ? <LinearProgress color="secondary" /> : null}
-                </MainAppBar>
+               </MainAppBar> }
                 <Grid container direction="column" className={classes.root}>
-                    {user ? (user.isActive ? <WorkbenchPanel /> : <InactivePanel />) : <LoginPanel />}
+                    { user ? (user.isActive || (!user.isActive && isLinkingPath) ? <WorkbenchPanel isNotLinking={isNotLinking} isUserActive={user.isActive} /> : <InactivePanel />) : <LoginPanel /> }
                 </Grid>
             </>
 );
index 5009f12987ab59d215a0f697124319ef7bfa5efa..5bf03da3986afbe73c8999c43cd119f438652785 100644 (file)
@@ -7,6 +7,8 @@ import { connect } from 'react-redux';
 import { MainPanelRoot, MainPanelRootDataProps } from '~/views/main-panel/main-panel-root';
 import { isSystemWorking } from '~/store/progress-indicator/progress-indicator-reducer';
 import { isWorkbenchLoading } from '~/store/workbench/workbench-actions';
+import { LinkAccountPanelStatus } from '~/store/link-account-panel/link-account-panel-reducer';
+import { matchLinkAccountRoute } from '~/routes/routes';
 
 const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
     return {
@@ -14,7 +16,9 @@ const mapStateToProps = (state: RootState): MainPanelRootDataProps => {
         working: isSystemWorking(state.progressIndicator),
         loading: isWorkbenchLoading(state),
         buildInfo: state.appInfo.buildInfo,
-        uuidPrefix: state.auth.localCluster
+        uuidPrefix: state.auth.localCluster,
+        isNotLinking: state.linkAccountPanel.status === LinkAccountPanelStatus.NONE || state.linkAccountPanel.status === LinkAccountPanelStatus.INITIAL,
+        isLinkingPath:  state.router.location ? matchLinkAccountRoute(state.router.location.pathname) !== null : false
     };
 };
 
index 20cbbdea0c9a0b262f976889d5e097243b777c6f..d015d4ec363255c5982ce3a8bb12af2393d1b2ec 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
+import { connect } from 'react-redux';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { Route, Switch } from "react-router";
 import { ProjectPanel } from "~/views/project-panel/project-panel";
@@ -92,6 +93,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 { LinkAccountPanel } from '~/views/link-account-panel/link-account-panel';
 import { FedLogin } from './fed-login';
 import { CollectionsContentAddressPanel } from '~/views/collection-content-address-panel/collection-content-address-panel';
 
@@ -125,7 +127,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     }
 });
 
-type WorkbenchPanelProps = WithStyles<CssRules>;
+interface WorkbenchDataProps {
+    isUserActive: boolean;
+    isNotLinking: boolean;
+}
+
+type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
 
 const defaultSplitterSize = 90;
 
@@ -137,21 +144,21 @@ const getSplitterInitialSize = () => {
 const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
 
 export const WorkbenchPanel =
-    withStyles(styles)(({ classes }: WorkbenchPanelProps) =>
-        <Grid container item xs className={classes.root}>
-            <Grid container item xs className={classes.container}>
-                <SplitterLayout customClassName={classes.splitter} percentage={true}
+    withStyles(styles)((props: WorkbenchPanelProps) =>
+        <Grid container item xs className={props.classes.root}>
+            <Grid container item xs className={props.classes.container}>
+                <SplitterLayout customClassName={props.classes.splitter} percentage={true}
                     primaryIndex={0} primaryMinSize={10}
                     secondaryInitialSize={getSplitterInitialSize()} secondaryMinSize={40}
                     onSecondaryPaneSizeChange={saveSplitterSize}>
-                    <Grid container item xs component='aside' direction='column' className={classes.asidePanel}>
+                    { props.isUserActive && props.isNotLinking && <Grid container item xs component='aside' direction='column' className={props.classes.asidePanel}>
                         <SidePanel />
-                    </Grid>
-                    <Grid container item xs component="main" direction="column" className={classes.contentWrapper}>
+                    </Grid> }
+                    <Grid container item xs component="main" direction="column" className={props.classes.contentWrapper}>
                         <Grid item xs>
-                            <MainContentBar />
+                            { props.isNotLinking && <MainContentBar /> }
                         </Grid>
-                        <Grid item xs className={classes.content}>
+                        <Grid item xs className={props.classes.content}>
                             <Switch>
                                 <Route path={Routes.PROJECTS} component={ProjectPanel} />
                                 <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
@@ -178,6 +185,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.LINK_ACCOUNT} component={LinkAccountPanel} />
                                 <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
                             </Switch>
                         </Grid>