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