15088: Adds invalid states and UI and improves action error handling
authorEric Biagiotti <ebiagiotti@veritasgenetics.com>
Thu, 2 May 2019 20:28:51 +0000 (16:28 -0400)
committerEric Biagiotti <ebiagiotti@veritasgenetics.com>
Thu, 2 May 2019 20:28:51 +0000 (16:28 -0400)
Arvados-DCO-1.1-Signed-off-by: Eric Biagiotti <ebiagiotti@veritasgenetics.com>

src/store/link-account-panel/link-account-panel-actions.ts
src/store/link-account-panel/link-account-panel-reducer.ts
src/views/link-account-panel/link-account-panel-root.tsx
src/views/link-account-panel/link-account-panel.tsx

index 4e5052845ad68e28e4fe98fe1db158f4e5b3ca02..47e27f7d9459ae8e5fec61c3a4201bec291f8ce3 100644 (file)
@@ -6,23 +6,41 @@ 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 } from "~/models/link-account";
 import { logout, saveApiToken, saveUser } from "~/store/auth/auth-action";
 import { unionize, ofType, UnionOf } from '~/common/unionize';
-import { UserResource } from "~/models/user";
+import { UserResource, User } from "~/models/user";
 import { navigateToRootProject } from "~/store/navigation/navigation-action";
+import { GroupResource } from "~/models/group";
+import { LinkAccountPanelError } from "./link-account-panel-reducer";
 
 export const linkAccountPanelActions = unionize({
-    LOAD_LINKING: ofType<{
+    INIT: ofType<{ user: UserResource | undefined }>(),
+    LOAD: ofType<{
         user: UserResource | undefined,
         userToken: string | undefined,
         userToLink: UserResource | undefined,
         userToLinkToken: string | undefined }>(),
-    RESET_LINKING: {}
+    RESET: {},
+    INVALID: ofType<{
+        user: UserResource | undefined,
+        userToLink: UserResource | undefined,
+        error: LinkAccountPanelError }>(),
 });
 
 export type LinkAccountPanelAction = UnionOf<typeof linkAccountPanelActions>;
 
+function validateLink(user: UserResource, userToLink: UserResource) {
+    if (user.uuid === userToLink.uuid) {
+        return LinkAccountPanelError.SAME_USER;
+    }
+    else if (!user.isAdmin && userToLink.isAdmin) {
+        return LinkAccountPanelError.NON_ADMIN;
+    }
+    return LinkAccountPanelError.NONE;
+}
+
 export const loadLinkAccountPanel = () =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch(setBreadcrumbs([{ label: 'Link account'}]));
@@ -33,41 +51,49 @@ export const loadLinkAccountPanel = () =>
             const curUserResource = await services.userService.get(curUser.uuid);
             const linkAccountData = services.linkAccountService.getFromSession();
 
-            // If there is link account data, then the user has logged in a second time
+            // If there is link account session data, then the user has logged in a second time
             if (linkAccountData) {
-                // Use the saved token to make the api call to override the current users permissions
                 dispatch<any>(saveApiToken(linkAccountData.token));
                 const savedUserResource = await services.userService.get(linkAccountData.userUuid);
                 dispatch<any>(saveApiToken(curToken));
+
+                let params: any;
                 if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT) {
-                    const params = {
+                    params = {
                         user: savedUserResource,
                         userToken: linkAccountData.token,
                         userToLink: curUserResource,
                         userToLinkToken: curToken
                     };
-                    dispatch<any>(linkAccountPanelActions.LOAD_LINKING(params));
                 }
                 else if (linkAccountData.type === LinkAccountType.ADD_OTHER_LOGIN) {
-                    const params = {
+                    params = {
                         user: curUserResource,
                         userToken: curToken,
                         userToLink: savedUserResource,
                         userToLinkToken: linkAccountData.token
                     };
-                    dispatch<any>(linkAccountPanelActions.LOAD_LINKING(params));
                 }
                 else {
                     throw new Error("Invalid link account type.");
                 }
+
+                const error = validateLink(params.user, params.userToLink);
+                if (error === LinkAccountPanelError.NONE) {
+                    dispatch<any>(linkAccountPanelActions.LOAD(params));
+                }
+                else {
+                    dispatch<any>(linkAccountPanelActions.INVALID({
+                        user: params.user,
+                        userToLink: params.userToLink,
+                        error}));
+                    return;
+                }
             }
             else {
                 // If there is no link account session data, set the state to invoke the initial UI
-                dispatch<any>(linkAccountPanelActions.LOAD_LINKING({
-                    user: curUserResource,
-                    userToken: curToken,
-                    userToLink: undefined,
-                    userToLinkToken: undefined }
+                dispatch<any>(linkAccountPanelActions.INIT({
+                    user: curUserResource }
                 ));
             }
         }
@@ -86,17 +112,19 @@ export const getAccountLinkData = () =>
     };
 
 export const removeAccountLinkData = () =>
-    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const linkAccountData = services.linkAccountService.getFromSession();
-        services.linkAccountService.removeFromSession();
-        dispatch(linkAccountPanelActions.RESET_LINKING());
-        if (linkAccountData) {
-            services.userService.get(linkAccountData.userUuid).then(savedUser => {
-                dispatch(setBreadcrumbs([{ label: ''}]));
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const linkAccountData = services.linkAccountService.getFromSession();
+            if (linkAccountData) {
+                const savedUser = await services.userService.get(linkAccountData.userUuid);
                 dispatch<any>(saveUser(savedUser));
                 dispatch<any>(saveApiToken(linkAccountData.token));
-                dispatch<any>(navigateToRootProject);
-            });
+            }
+        }
+        finally {
+            dispatch<any>(navigateToRootProject);
+            dispatch(linkAccountPanelActions.RESET());
+            services.linkAccountService.removeFromSession();
         }
     };
 
@@ -108,36 +136,50 @@ export const linkAccount = () =>
 
             // First create a project owned by the "userToLink" to accept everything from the current user
             const projectName = `Migrated from ${linkState.user.email} (${linkState.user.uuid})`;
-            dispatch<any>(saveApiToken(linkState.userToLinkToken));
-            const newGroup = await services.projectService.create({
-                name: projectName,
-                ensure_unique_name: true
-            });
-            dispatch<any>(saveApiToken(currentToken));
+            let newGroup: GroupResource;
+            try {
+                dispatch<any>(saveApiToken(linkState.userToLinkToken));
+                newGroup = await services.projectService.create({
+                    name: projectName,
+                    ensure_unique_name: true
+                });
+            }
+            catch (e) {
+                dispatch<any>(saveApiToken(currentToken));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Account link failed.', kind: SnackbarKind.ERROR , hideDuration: 3000
+                }));
+                throw e;
+            }
 
             try {
+                // Use the token of the account that is getting merged to call the merge api
                 dispatch<any>(saveApiToken(linkState.userToken));
                 await services.linkAccountService.linkAccounts(linkState.userToLinkToken, newGroup.uuid);
-                dispatch<any>(saveApiToken(currentToken));
 
                 // If the link was successful, switch to the account that was merged with
-                if (linkState.userToLink && linkState.userToLinkToken) {
-                    dispatch<any>(saveUser(linkState.userToLink));
+                dispatch<any>(saveUser(linkState.userToLink));
+                dispatch<any>(saveApiToken(linkState.userToLinkToken));
+            }
+            catch(e) {
+                // If the link operation fails, delete the previously made project
+                // and stay logged in to the current account.
+                try {
                     dispatch<any>(saveApiToken(linkState.userToLinkToken));
-                    dispatch<any>(navigateToRootProject);
+                    await services.projectService.delete(newGroup.uuid);
                 }
-                services.linkAccountService.removeFromSession();
-                dispatch(linkAccountPanelActions.RESET_LINKING());
+                finally {
+                    dispatch<any>(saveApiToken(currentToken));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: 'Account link failed.', kind: SnackbarKind.ERROR , hideDuration: 3000
+                    }));
+                }
+                throw e;
             }
-            catch(e) {
-                // If the account link operation fails, delete the previously made project
-                // and reset the link account state. The user will have to restart the process.
-                dispatch<any>(saveApiToken(linkState.userToLinkToken));
-                services.projectService.delete(newGroup.uuid);
-                dispatch<any>(saveApiToken(currentToken));
+            finally {
+                dispatch<any>(navigateToRootProject);
                 services.linkAccountService.removeFromSession();
-                dispatch(linkAccountPanelActions.RESET_LINKING());
-                throw e;
+                dispatch(linkAccountPanelActions.RESET());
             }
         }
     };
\ No newline at end of file
index fabef94ed270bcd3c2569f3d5724824cab2a6cd2..7f7e3eb38c53d6776f6955e27e26bbed02f15ce8 100644 (file)
@@ -3,25 +3,51 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { linkAccountPanelActions, LinkAccountPanelAction } from "~/store/link-account-panel/link-account-panel-actions";
-import { UserResource, User } from "~/models/user";
+import { UserResource } from "~/models/user";
+
+export enum LinkAccountPanelStatus {
+    INITIAL,
+    LINKING,
+    ERROR
+}
+
+export enum LinkAccountPanelError {
+    NONE,
+    NON_ADMIN,
+    SAME_USER
+}
 
 export interface LinkAccountPanelState {
     user: UserResource | undefined;
     userToken: string | undefined;
     userToLink: UserResource | undefined;
     userToLinkToken: string | undefined;
+    status: LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
 }
 
 const initialState = {
     user: undefined,
     userToken: undefined,
     userToLink: undefined,
-    userToLinkToken: undefined
+    userToLinkToken: undefined,
+    status: LinkAccountPanelStatus.INITIAL,
+    error: LinkAccountPanelError.NONE
 };
 
 export const linkAccountPanelReducer = (state: LinkAccountPanelState = initialState, action: LinkAccountPanelAction) =>
     linkAccountPanelActions.match(action, {
         default: () => state,
-        LOAD_LINKING: ({ userToLink, user, userToken, userToLinkToken}) => ({ ...state, user, userToken, userToLink, userToLinkToken }),
-        RESET_LINKING: () => ({ ...state, user: undefined, userToken: undefined, userToLink: undefined, userToLinkToken: undefined })
+        INIT: ({ user }) => ({
+            ...state, user, state: LinkAccountPanelStatus.INITIAL, error: LinkAccountPanelError.NONE
+        }),
+        LOAD: ({ userToLink, user, userToken, userToLinkToken}) => ({
+            ...state, user, userToken, userToLink, userToLinkToken, status: LinkAccountPanelStatus.LINKING, error: LinkAccountPanelError.NONE
+        }),
+        RESET: () => ({
+            ...state, userToken: undefined, userToLink: undefined, userToLinkToken: undefined, status: LinkAccountPanelStatus.INITIAL,  error: LinkAccountPanelError.NONE
+        }),
+        INVALID: ({user, userToLink, error}) => ({
+            ...state, user, userToLink, error, status: LinkAccountPanelStatus.ERROR
+        })
     });
\ No newline at end of file
index f4570de2bc924d23847f3014271c0a49b0dcd516..68f407b57044a879f175e5adbfbaa2922f342493 100644 (file)
@@ -17,6 +17,7 @@ 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';// | 'gridItem' | 'label' | 'title' | 'actions';
 
@@ -30,6 +31,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 export interface LinkAccountPanelRootDataProps {
     user?: UserResource;
     userToLink?: UserResource;
+    status : LinkAccountPanelStatus;
+    error: LinkAccountPanelError;
 }
 
 export interface LinkAccountPanelRootActionProps {
@@ -50,10 +53,11 @@ function displayUser(user: UserResource, showCreatedAt: boolean = false) {
 type LinkAccountPanelRootProps = LinkAccountPanelRootDataProps & LinkAccountPanelRootActionProps & WithStyles<CssRules>;
 
 export const LinkAccountPanelRoot = withStyles(styles) (
-    ({classes, user, userToLink, saveAccountLinkData, removeAccountLinkData, linkAccount}: LinkAccountPanelRootProps) => {
+    ({classes, user, userToLink, status, error, saveAccountLinkData, removeAccountLinkData, linkAccount}: LinkAccountPanelRootProps) => {
         return <Card className={classes.root}>
             <CardContent>
-            { user && userToLink===undefined && <Grid container spacing={24}>
+            { status === LinkAccountPanelStatus.INITIAL && user &&
+            <Grid container spacing={24}>
                 <Grid container item direction="column" spacing={24}>
                     <Grid item>
                         You are currently logged in as {displayUser(user, true)}
@@ -74,9 +78,10 @@ export const LinkAccountPanelRoot = withStyles(styles) (
                         </Button>
                     </Grid>
                 </Grid>
-            </Grid>}
-            { userToLink && user && <Grid container spacing={24}>
-                <Grid container item direction="column" spacing={24}>
+            </Grid> }
+            { (status === LinkAccountPanelStatus.LINKING || status === LinkAccountPanelStatus.ERROR) && userToLink && user &&
+            <Grid container spacing={24}>
+                { status === LinkAccountPanelStatus.LINKING && <Grid container item direction="column" spacing={24}>
                     <Grid item>
                         Clicking 'Link accounts' will link {displayUser(user, true)} to {displayUser(userToLink, true)}.
                     </Grid>
@@ -86,7 +91,13 @@ export const LinkAccountPanelRoot = withStyles(styles) (
                     <Grid item>
                        Any object owned by {displayUser(user)} will be transfered to {displayUser(userToLink)}.
                     </Grid>
-                </Grid>
+                </Grid> }
+                { error === LinkAccountPanelError.NON_ADMIN && <Grid item>
+                    Cannot link admin account {displayUser(userToLink)} to non-admin account {displayUser(user)}.
+                </Grid> }
+                { error === LinkAccountPanelError.SAME_USER && <Grid item>
+                    Cannot link {displayUser(userToLink)} to the same account.
+                </Grid> }
                 <Grid container item direction="row" spacing={24}>
                     <Grid item>
                         <Button variant="contained" onClick={() => removeAccountLinkData()}>
@@ -94,7 +105,7 @@ export const LinkAccountPanelRoot = withStyles(styles) (
                         </Button>
                     </Grid>
                     <Grid item>
-                        <Button color="primary" variant="contained" onClick={() => linkAccount()}>
+                        <Button disabled={status === LinkAccountPanelStatus.ERROR} color="primary" variant="contained" onClick={() => linkAccount()}>
                             Link accounts
                         </Button>
                     </Grid>
index 498eff881e1563430dadc70111160f90b477c26b..20727b3967bb01e04244917db7fad4625d2957d8 100644 (file)
@@ -16,7 +16,9 @@ import {
 const mapStateToProps = (state: RootState): LinkAccountPanelRootDataProps => {
     return {
         user: state.linkAccountPanel.user,
-        userToLink: state.linkAccountPanel.userToLink
+        userToLink: state.linkAccountPanel.userToLink,
+        status: state.linkAccountPanel.status,
+        error: state.linkAccountPanel.error
     };
 };