15088: Fixes auth-action test and simplifies link account cancel snackbar
[arvados-workbench2.git] / src / store / link-account-panel / link-account-panel-actions.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { Dispatch } from "redux";
6 import { RootState } from "~/store/store";
7 import { ServiceRepository } from "~/services/services";
8 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
9 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
10 import { LinkAccountType, AccountToLink, LinkAccountStatus } from "~/models/link-account";
11 import { saveApiToken, saveUser } from "~/store/auth/auth-action";
12 import { unionize, ofType, UnionOf } from '~/common/unionize';
13 import { UserResource } from "~/models/user";
14 import { GroupResource } from "~/models/group";
15 import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
16 import { login, logout, setAuthorizationHeader } from "~/store/auth/auth-action";
17 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
18 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
19
20 export const linkAccountPanelActions = unionize({
21     LINK_INIT: ofType<{
22         targetUser: UserResource | undefined }>(),
23     LINK_LOAD: ofType<{
24         originatingUser: OriginatingUser | undefined,
25         targetUser: UserResource | undefined,
26         targetUserToken: string | undefined,
27         userToLink: UserResource | undefined,
28         userToLinkToken: string | undefined }>(),
29     LINK_INVALID: ofType<{
30         originatingUser: OriginatingUser | undefined,
31         targetUser: UserResource | undefined,
32         userToLink: UserResource | undefined,
33         error: LinkAccountPanelError }>(),
34     SET_SELECTED_CLUSTER: ofType<{
35         selectedCluster: string }>(),
36     SET_IS_PROCESSING: ofType<{
37         isProcessing: boolean}>(),
38     HAS_SESSION_DATA: {}
39 });
40
41 export type LinkAccountPanelAction = UnionOf<typeof linkAccountPanelActions>;
42
43 function validateLink(userToLink: UserResource, targetUser: UserResource) {
44     if (userToLink.uuid === targetUser.uuid) {
45         return LinkAccountPanelError.SAME_USER;
46     }
47     else if (userToLink.isAdmin && !targetUser.isAdmin) {
48         return LinkAccountPanelError.NON_ADMIN;
49     }
50     else if (!targetUser.isActive) {
51         return LinkAccountPanelError.INACTIVE;
52     }
53     return LinkAccountPanelError.NONE;
54 }
55
56 export const checkForLinkStatus = () =>
57     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
58         const status = services.linkAccountService.getLinkOpStatus();
59         if (status !== undefined) {
60             let msg: string;
61             let msgKind: SnackbarKind;
62             if (status.valueOf() === LinkAccountStatus.CANCELLED) {
63                 msg = "Account link cancelled!", msgKind = SnackbarKind.INFO;
64             }
65             else if (status.valueOf() === LinkAccountStatus.FAILED) {
66                 msg = "Account link failed!", msgKind = SnackbarKind.ERROR;
67             }
68             else if (status.valueOf() === LinkAccountStatus.SUCCESS) {
69                 msg = "Account link success!", msgKind = SnackbarKind.SUCCESS;
70             }
71             else {
72                 msg = "Unknown Error!", msgKind = SnackbarKind.ERROR;
73             }
74             dispatch(snackbarActions.OPEN_SNACKBAR({ message: msg, kind: msgKind, hideDuration: 3000 }));
75             services.linkAccountService.removeLinkOpStatus();
76         }
77     };
78
79 export const switchUser = (user: UserResource, token: string) =>
80     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
81         dispatch(saveUser(user));
82         dispatch(saveApiToken(token));
83     };
84
85 export const linkFailed = () =>
86     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
87         // If the link fails, switch to the user account that originated the link operation
88         const linkState = getState().linkAccountPanel;
89         if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
90             if (linkState.originatingUser === OriginatingUser.TARGET_USER) {
91                 dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
92             }
93             else if ((linkState.originatingUser === OriginatingUser.USER_TO_LINK)) {
94                 dispatch(switchUser(linkState.userToLink, linkState.userToLinkToken));
95             }
96         }
97         services.linkAccountService.removeAccountToLink();
98         services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.FAILED);
99         location.reload();
100     };
101
102 export const loadLinkAccountPanel = () =>
103     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
104         try {
105             // If there are remote hosts, set the initial selected cluster by getting the first cluster that isn't the local cluster
106             if (getState().linkAccountPanel.selectedCluster === undefined) {
107                 const localCluster = getState().auth.localCluster;
108                 let selectedCluster = localCluster;
109                 for (const key in getState().auth.remoteHosts) {
110                     if (key !== localCluster) {
111                         selectedCluster = key;
112                         break;
113                     }
114                 }
115                 dispatch(linkAccountPanelActions.SET_SELECTED_CLUSTER({ selectedCluster }));
116             }
117
118             // First check if an account link operation has completed
119             dispatch(checkForLinkStatus());
120
121             // Continue loading the link account panel
122             dispatch(setBreadcrumbs([{ label: 'Link account'}]));
123             const curUser = getState().auth.user;
124             const curToken = getState().auth.apiToken;
125             if (curUser && curToken) {
126
127                 // If there is link account session data, then the user has logged in a second time
128                 const linkAccountData = services.linkAccountService.getAccountToLink();
129                 if (linkAccountData) {
130
131                     dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: true }));
132                     const curUserResource = await services.userService.get(curUser.uuid);
133
134                     // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
135                     // issues since a user will always be able to query the api server for their own user data.
136                     setAuthorizationHeader(services, linkAccountData.token);
137                     const savedUserResource = await services.userService.get(linkAccountData.userUuid);
138                     setAuthorizationHeader(services, curToken);
139
140                     let params: any;
141                     if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
142                         params = {
143                             originatingUser: OriginatingUser.USER_TO_LINK,
144                             targetUser: curUserResource,
145                             targetUserToken: curToken,
146                             userToLink: savedUserResource,
147                             userToLinkToken: linkAccountData.token
148                         };
149                     }
150                     else if (linkAccountData.type === LinkAccountType.ADD_OTHER_LOGIN || linkAccountData.type === LinkAccountType.ADD_LOCAL_TO_REMOTE) {
151                         params = {
152                             originatingUser: OriginatingUser.TARGET_USER,
153                             targetUser: savedUserResource,
154                             targetUserToken: linkAccountData.token,
155                             userToLink: curUserResource,
156                             userToLinkToken: curToken
157                         };
158                     }
159                     else {
160                         throw new Error("Unknown link account type");
161                     }
162
163                     dispatch(switchUser(params.targetUser, params.targetUserToken));
164                     const error = validateLink(params.userToLink, params.targetUser);
165                     if (error === LinkAccountPanelError.NONE) {
166                         dispatch(linkAccountPanelActions.LINK_LOAD(params));
167                     }
168                     else {
169                         dispatch(linkAccountPanelActions.LINK_INVALID({
170                             originatingUser: params.originatingUser,
171                             targetUser: params.targetUser,
172                             userToLink: params.userToLink,
173                             error}));
174                         return;
175                     }
176                 }
177                 else {
178                     // If there is no link account session data, set the state to invoke the initial UI
179                     const curUserResource = await services.userService.get(curUser.uuid);
180                     dispatch(linkAccountPanelActions.LINK_INIT({ targetUser: curUserResource }));
181                     return;
182                 }
183             }
184         }
185         catch (e) {
186             dispatch(linkFailed());
187         }
188         finally {
189             dispatch(linkAccountPanelActions.SET_IS_PROCESSING({ isProcessing: false }));
190         }
191     };
192
193 export const startLinking = (t: LinkAccountType) =>
194     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
195         const accountToLink = {type: t, userUuid: services.authService.getUuid(), token: services.authService.getApiToken()} as AccountToLink;
196         services.linkAccountService.saveAccountToLink(accountToLink);
197
198         const auth = getState().auth;
199         const isLocalUser = auth.user!.uuid.substring(0,5) === auth.localCluster;
200         let homeCluster = auth.localCluster;
201         if (isLocalUser && t === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
202             homeCluster = getState().linkAccountPanel.selectedCluster!;
203         }
204
205         dispatch(logout());
206         dispatch(login(auth.localCluster, homeCluster, auth.remoteHosts));
207     };
208
209 export const getAccountLinkData = () =>
210     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
211         return services.linkAccountService.getAccountToLink();
212     };
213
214 export const cancelLinking = (reload: boolean = false) =>
215     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
216         let user: UserResource | undefined;
217         try {
218             // When linking is cancelled switch to the originating user (i.e. the user saved in session data)
219             dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
220             const linkAccountData = services.linkAccountService.getAccountToLink();
221             if (linkAccountData) {
222                 services.linkAccountService.removeAccountToLink();
223                 setAuthorizationHeader(services, linkAccountData.token);
224                 user = await services.userService.get(linkAccountData.userUuid);
225                 dispatch(switchUser(user, linkAccountData.token));
226                 services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.CANCELLED);
227             }
228         }
229         finally {
230             if (reload) {
231                 location.reload();
232             }
233             else {
234                 dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
235             }
236         }
237     };
238
239 export const linkAccount = () =>
240     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
241         const linkState = getState().linkAccountPanel;
242         if (linkState.userToLink && linkState.userToLinkToken && linkState.targetUser && linkState.targetUserToken) {
243
244             // First create a project owned by the target user
245             const projectName = `Migrated from ${linkState.userToLink.email} (${linkState.userToLink.uuid})`;
246             let newGroup: GroupResource;
247             try {
248                 newGroup = await services.projectService.create({
249                     name: projectName,
250                     ensure_unique_name: true
251                 });
252             }
253             catch (e) {
254                 dispatch(linkFailed());
255                 throw e;
256             }
257
258             try {
259                 // The merge api links the user sending the request into the user
260                 // specified in the request, so change the authorization header accordingly
261                 setAuthorizationHeader(services, linkState.userToLinkToken);
262                 await services.linkAccountService.linkAccounts(linkState.targetUserToken, newGroup.uuid);
263                 dispatch(switchUser(linkState.targetUser, linkState.targetUserToken));
264                 services.linkAccountService.removeAccountToLink();
265                 services.linkAccountService.saveLinkOpStatus(LinkAccountStatus.SUCCESS);
266                 location.reload();
267             }
268             catch(e) {
269                 // If the link operation fails, delete the previously made project
270                 try {
271                     setAuthorizationHeader(services, linkState.targetUserToken);
272                     await services.projectService.delete(newGroup.uuid);
273                 }
274                 finally {
275                     dispatch(linkFailed());
276                 }
277                 throw e;
278             }
279         }
280     };