merge 21447: closes #21447
[arvados.git] / services / workbench2 / src / store / sharing-dialog / sharing-dialog-actions.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { dialogActions } from "store/dialog/dialog-actions";
6 import { withDialog } from "store/dialog/with-dialog";
7 import {
8     SHARING_DIALOG_NAME,
9     SHARING_INVITATION_FORM_NAME,
10     SharingManagementFormData,
11     SharingInvitationFormData,
12     getSharingMangementFormData,
13     SharingPublicAccessFormData,
14     VisibilityLevel,
15     SHARING_PUBLIC_ACCESS_FORM_NAME,
16 } from './sharing-dialog-types';
17 import { Dispatch } from 'redux';
18 import { ServiceRepository } from "services/services";
19 import { FilterBuilder } from 'services/api/filter-builder';
20 import { initialize, getFormValues, reset } from 'redux-form';
21 import { SHARING_MANAGEMENT_FORM_NAME } from 'store/sharing-dialog/sharing-dialog-types';
22 import { RootState } from 'store/store';
23 import { getDialog } from 'store/dialog/dialog-reducer';
24 import { PermissionLevel, PermissionResource } from 'models/permission';
25 import { differenceWith } from "lodash";
26 import { withProgress } from "store/progress-indicator/with-progress";
27 import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
28 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
29 import {
30     extractUuidObjectType,
31     ResourceObjectType
32 } from "models/resource";
33 import { resourcesActions } from "store/resources/resources-actions";
34 import { getPublicGroupUuid, getAllUsersGroupUuid } from "store/workflow-panel/workflow-panel-actions";
35 import { getSharingPublicAccessFormData } from './sharing-dialog-types';
36
37 export const openSharingDialog = (resourceUuid: string, refresh?: () => void) =>
38     (dispatch: Dispatch) => {
39         dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: { resourceUuid, refresh } }));
40         dispatch<any>(loadSharingDialog);
41     };
42
43 export const closeSharingDialog = () =>
44     dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME });
45
46 export const connectSharingDialog = withDialog(SHARING_DIALOG_NAME);
47 export const connectSharingDialogProgress = withProgress(SHARING_DIALOG_NAME);
48
49
50 export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: () => RootState) => {
51     dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
52     await dispatch<any>(savePublicPermissionChanges);
53     await dispatch<any>(saveManagementChanges);
54     await dispatch<any>(sendInvitations);
55     dispatch(reset(SHARING_INVITATION_FORM_NAME));
56     await dispatch<any>(loadSharingDialog);
57     dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
58
59     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
60     if (dialog && dialog.data.refresh) {
61         dialog.data.refresh();
62     }
63 };
64
65 export interface SharingDialogData {
66     resourceUuid: string;
67     refresh: () => void;
68 }
69
70 export const createSharingToken = (expDate: Date | undefined) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
71     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
72     if (dialog) {
73         const resourceUuid = dialog.data.resourceUuid;
74         if (extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
75             dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
76             try {
77                 const sharingToken = await apiClientAuthorizationService.createCollectionSharingToken(resourceUuid, expDate);
78                 dispatch(resourcesActions.SET_RESOURCES([sharingToken]));
79                 dispatch(snackbarActions.OPEN_SNACKBAR({
80                     message: 'Sharing URL created',
81                     hideDuration: 2000,
82                     kind: SnackbarKind.SUCCESS,
83                 }));
84             } catch (e) {
85                 dispatch(snackbarActions.OPEN_SNACKBAR({
86                     message: 'Failed to create sharing URL',
87                     hideDuration: 2000,
88                     kind: SnackbarKind.ERROR,
89                 }));
90             } finally {
91                 dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
92             }
93         }
94     }
95 };
96
97 export const deleteSharingToken = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
98     dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
99     try {
100         await apiClientAuthorizationService.delete(uuid);
101         dispatch(resourcesActions.DELETE_RESOURCES([uuid]));
102         dispatch(snackbarActions.OPEN_SNACKBAR({
103             message: 'Sharing URL removed',
104             hideDuration: 2000,
105             kind: SnackbarKind.SUCCESS,
106         }));
107     } catch (e) {
108         dispatch(snackbarActions.OPEN_SNACKBAR({
109             message: 'Failed to remove sharing URL',
110             hideDuration: 2000,
111             kind: SnackbarKind.ERROR,
112         }));
113     } finally {
114         dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
115     }
116 };
117
118 const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { apiClientAuthorizationService }: ServiceRepository) => {
119
120     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
121     const sharingURLsDisabled = getState().auth.config.clusterConfig.Workbench.DisableSharingURLsUI;
122     if (dialog) {
123         dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
124         try {
125             const resourceUuid = dialog.data.resourceUuid;
126             await dispatch<any>(initializeManagementForm);
127             // For collections, we need to load the public sharing tokens
128             if (!sharingURLsDisabled && extractUuidObjectType(resourceUuid) === ResourceObjectType.COLLECTION) {
129                 const sharingTokens = await apiClientAuthorizationService.listCollectionSharingTokens(resourceUuid);
130                 dispatch(resourcesActions.SET_RESOURCES([...sharingTokens.items]));
131             }
132         } catch (e) {
133             dispatch(snackbarActions.OPEN_SNACKBAR({
134                 message: 'You do not have access to share this item',
135                 hideDuration: 2000,
136                 kind: SnackbarKind.ERROR
137             }));
138             dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
139         } finally {
140             dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
141         }
142     }
143 };
144
145 export const initializeManagementForm = async (dispatch: Dispatch, getState: () => RootState, { userService, groupsService, permissionService }: ServiceRepository) => {
146
147     const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
148     if (!dialog) {
149         return;
150     }
151     dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
152     const resourceUuid = dialog?.data.resourceUuid;
153     const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
154     dispatch<any>(initializePublicAccessForm(permissionLinks));
155     const filters = new FilterBuilder()
156         .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
157         .getFilters();
158
159     const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
160     const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
161
162     const getEmail = (tailUuid: string) => {
163         const user = users.find(({ uuid }) => uuid === tailUuid);
164         const group = groups.find(({ uuid }) => uuid === tailUuid);
165         return user
166             ? user.email
167             : group
168                 ? group.name
169                 : tailUuid;
170     };
171
172     const managementPermissions = permissionLinks
173         .map(({ tailUuid, name, uuid }) => ({
174             email: getEmail(tailUuid),
175             permissions: name as PermissionLevel,
176             permissionUuid: uuid,
177         }));
178
179     const managementFormData: SharingManagementFormData = {
180         permissions: managementPermissions,
181         initialPermissions: managementPermissions,
182     };
183
184     dispatch(initialize(SHARING_MANAGEMENT_FORM_NAME, managementFormData));
185     dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
186 };
187
188 const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
189     (dispatch: Dispatch, getState: () => RootState,) => {
190
191         const state = getState();
192
193         const [publicPermission] = permissionLinks
194             .filter(item => item.tailUuid === getPublicGroupUuid(state));
195
196         const [allUsersPermission] = permissionLinks
197             .filter(item => item.tailUuid === getAllUsersGroupUuid(state));
198
199         let publicAccessFormData: SharingPublicAccessFormData;
200
201         if (publicPermission) {
202             publicAccessFormData = {
203                 visibility: VisibilityLevel.PUBLIC,
204                 initialVisibility: VisibilityLevel.PUBLIC,
205                 permissionUuid: publicPermission.uuid
206             };
207         } else if (allUsersPermission) {
208             publicAccessFormData = {
209                 visibility: VisibilityLevel.ALL_USERS,
210                 initialVisibility: VisibilityLevel.ALL_USERS,
211                 permissionUuid: allUsersPermission.uuid
212             };
213         } else if (permissionLinks.length > 0) {
214             publicAccessFormData = {
215                 visibility: VisibilityLevel.SHARED,
216                 initialVisibility: VisibilityLevel.SHARED,
217                 permissionUuid: ''
218             };
219         } else {
220             publicAccessFormData = {
221                 visibility: VisibilityLevel.PRIVATE,
222                 initialVisibility: VisibilityLevel.PRIVATE,
223                 permissionUuid: ''
224             };
225         }
226
227         dispatch(initialize(SHARING_PUBLIC_ACCESS_FORM_NAME, publicAccessFormData));
228     };
229
230 const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
231     const state = getState();
232     const { user } = state.auth;
233     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
234     if (dialog && user) {
235         const { permissionUuid, visibility, initialVisibility } = getSharingPublicAccessFormData(state);
236         // If visibility level changed, delete the previous link to public/all users.
237         // On PRIVATE this link will be deleted by saveManagementChanges
238         // so don't double delete (which would show an error dialog).
239         if (permissionUuid !== "" && visibility !== initialVisibility) {
240             await permissionService.delete(permissionUuid);
241         }
242         if (visibility === VisibilityLevel.ALL_USERS) {
243             await permissionService.create({
244                 ownerUuid: user.uuid,
245                 headUuid: dialog.data.resourceUuid,
246                 tailUuid: getAllUsersGroupUuid(state),
247                 name: PermissionLevel.CAN_READ,
248             });
249         } else if (visibility === VisibilityLevel.PUBLIC) {
250             await permissionService.create({
251                 ownerUuid: user.uuid,
252                 headUuid: dialog.data.resourceUuid,
253                 tailUuid: getPublicGroupUuid(state),
254                 name: PermissionLevel.CAN_READ,
255             });
256         }
257     }
258 };
259
260 const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
261     const state = getState();
262     const { user } = state.auth;
263     const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
264     if (dialog && user) {
265         const { initialPermissions, permissions } = getSharingMangementFormData(state);
266         const { visibility } = getSharingPublicAccessFormData(state);
267         const cancelledPermissions = visibility === VisibilityLevel.PRIVATE
268             ? initialPermissions
269             : differenceWith(
270                 initialPermissions,
271                 permissions,
272                 (a, b) => a.permissionUuid === b.permissionUuid
273             );
274
275         const deletions = cancelledPermissions.map(async ({ permissionUuid }) => {
276             try {
277                 await permissionService.delete(permissionUuid, false);
278             } catch (e) { }
279         });
280         const updates = permissions.map(async update => {
281             try {
282                 await permissionService.update(update.permissionUuid, { name: update.permissions }, false);
283             } catch (e) { }
284         });
285         await Promise.all([...deletions, ...updates]);
286     }
287 };
288
289 const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
290     const state = getState();
291     const { user } = state.auth;
292     const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
293     if (dialog && user) {
294         const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
295         const data = invitations.invitedPeople.map(invitee => ({
296             ownerUuid: user.uuid,
297             headUuid: dialog.data.resourceUuid,
298             tailUuid: invitee.uuid,
299             name: invitations.permissions
300         }));
301         const changes = data.map(invitation => permissionService.create(invitation));
302         await Promise.all(changes);
303     }
304 };