Merge branch 'renovate/playwright' into 'main'
[ProtonMail-WebClient.git] / packages / account / members / actions.ts
blob3a8dc05c99ca755844f01e5ae161207353c21b9d
1 import type { ThunkAction, UnknownAction } from '@reduxjs/toolkit';
2 import { c } from 'ttag';
4 import type { ProtonThunkArguments } from '@proton/redux-shared-store-types';
5 import { CacheType } from '@proton/redux-utilities';
6 import { getApiError } from '@proton/shared/lib/api/helpers/apiErrorHelper';
7 import {
8     checkMemberAddressAvailability,
9     createMemberAddress,
10     createMember as createMemberConfig,
11     deleteMember,
12     deleteUnprivatizationRequest,
13     getMember as getMemberConfig,
14     privatizeMember as privatizeMemberConfig,
15     requestUnprivatization as requestUnprivatizationConfig,
16     updateAI,
17     updateName,
18     updateQuota,
19     updateRole,
20     updateVPN,
21 } from '@proton/shared/lib/api/members';
22 import {
23     DEFAULT_KEYGEN_TYPE,
24     HTTP_STATUS_CODE,
25     KEYGEN_CONFIGS,
26     MEMBER_PRIVATE,
27     MEMBER_ROLE,
28     VPN_CONNECTIONS,
29 } from '@proton/shared/lib/constants';
30 import { validateEmailAddress } from '@proton/shared/lib/helpers/email';
31 import type {
32     Address,
33     Api,
34     Domain,
35     KeyTransparencyCommit,
36     KeyTransparencyVerify,
37     Member,
38     Organization,
39     VerifyOutboundPublicKeys,
40 } from '@proton/shared/lib/interfaces';
41 import { CreateMemberMode } from '@proton/shared/lib/interfaces';
42 import {
43     getInvitationData,
44     getIsPasswordless,
45     getSignedInvitationData,
46     setupMemberKeys,
47 } from '@proton/shared/lib/keys';
48 import { getOrganizationKeyInfo } from '@proton/shared/lib/organization/helper';
49 import { srpVerify } from '@proton/shared/lib/srp';
50 import noop from '@proton/utils/noop';
52 import { addressesThunk } from '../addresses';
53 import { organizationThunk } from '../organization';
54 import type { OrganizationKeyState } from '../organizationKey';
55 import { organizationKeyThunk } from '../organizationKey';
56 import {
57     type MemberKeyPayload,
58     getMemberKeyPayload,
59     getPrivateAdminError,
60     setAdminRoles,
61 } from '../organizationKey/actions';
62 import { userKeysThunk } from '../userKeys';
63 import InvalidAddressesError from './errors/InvalidAddressesError';
64 import UnavailableAddressesError from './errors/UnavailableAddressesError';
65 import { MemberCreationValidationError, membersThunk, upsertMember } from './index';
66 import validateAddUser from './validateAddUser';
68 export const deleteMembers = ({
69     members,
70 }: {
71     members: Member[];
72 }): ThunkAction<
73     Promise<{
74         success: Member[];
75         failure: Member[];
76     }>,
77     OrganizationKeyState,
78     ProtonThunkArguments,
79     UnknownAction
80 > => {
81     return async (dispatch, getState, extra) => {
82         const success: Member[] = [];
83         const failure: Member[] = [];
84         for (const member of members) {
85             const deleted = await extra
86                 .api(deleteMember(member.ID))
87                 .then(() => true)
88                 .catch(noop);
89             if (deleted) {
90                 success.push(member);
91             } else {
92                 failure.push(member);
93             }
94         }
95         for (const member of success) {
96             dispatch(upsertMember({ member, type: 'delete' }));
97         }
98         return { success, failure };
99     };
102 export const getMember = (api: Api, memberID: string) =>
103     api<{
104         Member: Member;
105     }>(getMemberConfig(memberID)).then(({ Member }) => Member);
107 export const setAdminRole = ({
108     member,
109     payload,
110     api,
111 }: {
112     member: Member;
113     payload: MemberKeyPayload | null;
114     api: Api;
115 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
116     return async (dispatch) => {
117         const organizationKey = await dispatch(organizationKeyThunk());
119         if (!getIsPasswordless(organizationKey?.Key)) {
120             await api(updateRole(member.ID, MEMBER_ROLE.ORGANIZATION_ADMIN));
121             return;
122         }
124         if (!payload) {
125             throw new Error('Missing payload');
126         }
128         await dispatch(setAdminRoles({ memberKeyPayloads: [payload], api }));
129     };
132 export const requestUnprivatization = ({
133     api,
134     member,
135     upsert,
136 }: {
137     api: Api;
138     member: Member;
139     upsert: boolean;
140 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
141     return async (dispatch) => {
142         const organizationKey = await dispatch(organizationKeyThunk()); // Ensure latest key
143         if (!organizationKey?.privateKey) {
144             throw new MemberCreationValidationError(
145                 c('unprivatization').t`Organization key must be activated to request data access`
146             );
147         }
148         const primaryEmailAddress = member.Addresses?.[0].Email;
149         if (!primaryEmailAddress) {
150             throw new MemberCreationValidationError(
151                 c('unprivatization').t`The user must have an address to request data access`
152             );
153         }
154         const invitationData = await getInvitationData({
155             api,
156             address: primaryEmailAddress,
157             expectRevisionChange: false,
158         });
159         const invitationSignature = await getSignedInvitationData(organizationKey.privateKey, invitationData);
160         await api(
161             requestUnprivatizationConfig(member.ID, {
162                 InvitationData: invitationData,
163                 InvitationSignature: invitationSignature,
164             })
165         );
166         if (upsert) {
167             dispatch(upsertMember({ member: await getMember(api, member.ID) }));
168         }
169     };
172 export const deleteRequestUnprivatization = ({
173     api,
174     member,
175     upsert,
176 }: {
177     api: Api;
178     member: Member;
179     upsert: boolean;
180 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
181     return async (dispatch) => {
182         if (member.Unprivatization === null) {
183             return;
184         }
185         await api(deleteUnprivatizationRequest(member.ID));
186         if (upsert) {
187             dispatch(upsertMember({ member: await getMember(api, member.ID) }));
188         }
189     };
192 export const privatizeMember = ({
193     api,
194     member,
195     upsert,
196 }: {
197     api: Api;
198     member: Member;
199     upsert: boolean;
200 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
201     return async (dispatch) => {
202         await api(privatizeMemberConfig(member.ID));
203         if (upsert) {
204             dispatch(upsertMember({ member: await getMember(api, member.ID) }));
205         }
206     };
209 interface CreateMemberPayload {
210     name: string;
211     addresses: { Local: string; Domain: string }[];
212     invitationEmail: string;
213     mode: CreateMemberMode;
214     private: MEMBER_PRIVATE | null;
215     storage: number;
216     vpn?: boolean;
217     password: string;
218     role: MEMBER_ROLE | null;
219     numAI: boolean;
222 // This resets the VPN connections of the admin to the default value, since this gets reset by the API when changing subscriptions etc
223 export const resetAdminVPN = async ({
224     api,
225     members,
226     organization,
227 }: {
228     api: Api;
229     members: Member[];
230     organization: Organization;
231 }) => {
232     const self = members.find((member) => Boolean(member.Self));
233     if (organization.MaxVPN > 0 && self && self.MaxVPN !== VPN_CONNECTIONS && self.MaxVPN === organization.MaxVPN) {
234         await api(updateVPN(self.ID, VPN_CONNECTIONS));
235     }
238 export const editMember = ({
239     member,
240     memberDiff,
241     memberKeyPacketPayload,
242     api,
243 }: {
244     member: Member;
245     memberDiff: Partial<CreateMemberPayload>;
246     memberKeyPacketPayload: MemberKeyPayload | null;
247     api: Api;
248 }): ThunkAction<
249     Promise<{ diff: true; member: Member } | { diff: false; member: null }>,
250     OrganizationKeyState,
251     ProtonThunkArguments,
252     UnknownAction
253 > => {
254     return async (dispatch) => {
255         if (memberDiff.name !== undefined) {
256             await api(updateName(member.ID, memberDiff.name));
257         }
258         if (memberDiff.storage !== undefined) {
259             await api(updateQuota(member.ID, memberDiff.storage));
260         }
261         if (memberDiff.vpn !== undefined) {
262             if (memberDiff.vpn) {
263                 const [members, organization] = await Promise.all([
264                     dispatch(membersThunk()),
265                     dispatch(organizationThunk()),
266                 ]);
267                 await resetAdminVPN({ api, members, organization }).catch(noop);
268             }
269             await api(updateVPN(member.ID, memberDiff.vpn ? VPN_CONNECTIONS : 0));
270         }
271         if (memberDiff.numAI !== undefined) {
272             await api(updateAI(member.ID, memberDiff.numAI ? 1 : 0));
273         }
274         if (memberDiff.role === MEMBER_ROLE.ORGANIZATION_ADMIN) {
275             await dispatch(setAdminRole({ member, payload: memberKeyPacketPayload, api }));
276         }
277         if (memberDiff.role === MEMBER_ROLE.ORGANIZATION_MEMBER) {
278             await api(updateRole(member.ID, MEMBER_ROLE.ORGANIZATION_MEMBER));
279         }
280         if (memberDiff.private !== undefined) {
281             if (memberDiff.private === MEMBER_PRIVATE.UNREADABLE) {
282                 if (member.Unprivatization) {
283                     await dispatch(deleteRequestUnprivatization({ member, api, upsert: false }));
284                 } else {
285                     await dispatch(privatizeMember({ member, api, upsert: false }));
286                 }
287             }
288             if (member.Private === MEMBER_PRIVATE.UNREADABLE && memberDiff.private === MEMBER_PRIVATE.READABLE) {
289                 await dispatch(requestUnprivatization({ member, api, upsert: false }));
290             }
291         }
292         const diff = Object.values(memberDiff).some((value) => value !== undefined);
293         if (diff) {
294             const [updatedMember] = await Promise.all([
295                 getMember(api, member.ID),
296                 // Upserting the member also has an effect on the org values, so they need to be updated too.
297                 dispatch(organizationThunk({ cache: CacheType.None })),
298             ]);
299             dispatch(upsertMember({ member: updatedMember }));
300             return {
301                 diff: true,
302                 member: updatedMember,
303             };
304         }
305         return {
306             diff: false,
307             member: null,
308         };
309     };
312 const createAddressesForMember = async ({
313     api,
314     addresses,
315     member,
316 }: {
317     member: Member;
318     api: Api;
319     addresses: { Local: string; Domain: string }[];
320 }) => {
321     const memberAddresses: Address[] = [];
322     for (const { Local, Domain } of addresses) {
323         const { Address } = await api<{ Address: Address }>(
324             createMemberAddress(member.ID, {
325                 Local,
326                 Domain,
327             })
328         );
329         memberAddresses.push(Address);
330     }
331     return memberAddresses;
334 export const createMember = ({
335     member: originalModel,
336     single,
337     verifiedDomains,
338     keyTransparencyVerify,
339     keyTransparencyCommit,
340     verifyOutboundPublicKeys,
341     api,
342     validationOptions,
343 }: {
344     member: CreateMemberPayload;
345     single: boolean;
346     verifiedDomains: Domain[] /* Remove dependency, move to thunk */;
347     validationOptions: {
348         disableStorageValidation?: boolean;
349         disableDomainValidation?: boolean;
350         disableAddressValidation?: boolean;
351     };
352     keyTransparencyVerify: KeyTransparencyVerify;
353     keyTransparencyCommit: KeyTransparencyCommit;
354     verifyOutboundPublicKeys: VerifyOutboundPublicKeys;
355     api: Api;
356 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
357     return async (dispatch) => {
358         let [userKeys, ownerAddresses, organizationKey, organization, members] = await Promise.all([
359             dispatch(userKeysThunk()),
360             dispatch(addressesThunk()),
361             dispatch(organizationKeyThunk()),
362             dispatch(organizationThunk()),
363             dispatch(membersThunk()),
364         ]);
366         const model = { ...originalModel };
367         // Force role to member in invitation creation since it can't be created as anything else
368         if (model.mode === CreateMemberMode.Invitation) {
369             model.role = MEMBER_ROLE.ORGANIZATION_MEMBER;
370         }
372         if (model.vpn) {
373             await resetAdminVPN({ api, members, organization }).catch(noop);
374         }
376         const error = validateAddUser({
377             privateUser: model.private === MEMBER_PRIVATE.UNREADABLE,
378             organization,
379             organizationKeyInfo: getOrganizationKeyInfo(organization, organizationKey, ownerAddresses),
380             verifiedDomains,
381             ...validationOptions,
382         });
383         if (error) {
384             throw new MemberCreationValidationError(error);
385         }
387         const invalidAddresses: string[] = [];
388         const invalidInvitationAddresses: string[] = [];
389         const validAddresses: string[] = [];
391         const addressParts = model.addresses.map((parts) => {
392             const emailAddress = `${parts.Local}@${parts.Domain}`;
393             const isValid = validateEmailAddress(emailAddress);
394             if (!isValid) {
395                 invalidAddresses.push(emailAddress);
396             } else {
397                 validAddresses.push(emailAddress);
398             }
399             return {
400                 address: emailAddress,
401                 ...parts,
402             };
403         });
405         if (model.mode === CreateMemberMode.Invitation) {
406             if (!validateEmailAddress(model.invitationEmail)) {
407                 invalidInvitationAddresses.push(model.invitationEmail);
408             }
409         }
411         if (invalidAddresses.length || invalidInvitationAddresses.length) {
412             /**
413              * Throw if any of the addresses are not valid
414              */
415             throw new InvalidAddressesError(invalidAddresses, invalidInvitationAddresses, validAddresses);
416         }
418         if (model.mode === CreateMemberMode.Password) {
419             if (!model.private) {
420                 if (!organizationKey?.privateKey) {
421                     throw new MemberCreationValidationError(
422                         c('Error').t`Organization key must be activated to create non-private users`
423                     );
424                 }
425             }
427             if (model.private) {
428                 if (model.role === MEMBER_ROLE.ORGANIZATION_ADMIN) {
429                     if (getIsPasswordless(organizationKey?.Key)) {
430                         throw new MemberCreationValidationError(getPrivateAdminError());
431                     }
432                 }
433             }
434         }
436         if (model.mode === CreateMemberMode.Invitation) {
437             if (!organizationKey?.privateKey) {
438                 throw new MemberCreationValidationError(
439                     c('Error').t`Organization key must be activated to create invited users`
440                 );
441             }
442         }
444         const unavailableAddresses: { message: string; address: string }[] = [];
445         const availableAddresses: string[] = [];
447         const checkAddressAvailability = async ({ Local, Domain }: { Local: string; Domain: string }) => {
448             const address = `${Local}@${Domain}`;
449             try {
450                 await api(
451                     checkMemberAddressAvailability({
452                         Local,
453                         Domain,
454                     })
455                 );
456                 availableAddresses.push(address);
457             } catch (error: any) {
458                 const { status, message } = getApiError(error);
459                 if (status === HTTP_STATUS_CODE.CONFLICT) {
460                     // Conflict error from address being not available
461                     unavailableAddresses.push({ message, address });
462                     return;
463                 }
465                 throw error;
466             }
467         };
469         const [firstAddressParts, ...restAddressParts] = addressParts;
471         /**
472          * Will prompt password prompt only once
473          */
474         await checkAddressAvailability(firstAddressParts);
476         /**
477          * No more password prompts will be needed
478          */
479         await Promise.all(restAddressParts.map(checkAddressAvailability));
481         if (unavailableAddresses.length) {
482             /**
483              * Throw if any of the addresses are not available
484              */
485             throw new UnavailableAddressesError(unavailableAddresses, availableAddresses);
486         }
488         const payload = {
489             Name: model.name || firstAddressParts.Local,
490             MaxSpace: +model.storage,
491             MaxVPN: model.vpn ? VPN_CONNECTIONS : 0,
492             MaxAI: model.numAI ? 1 : 0,
493         };
495         if (model.mode === CreateMemberMode.Invitation) {
496             organizationKey = await dispatch(organizationKeyThunk()); // Ensure latest key
497             if (!organizationKey?.privateKey) {
498                 throw new MemberCreationValidationError(
499                     c('Error').t`Organization key must be activated to create invited users`
500                 );
501             }
502             const invitationData = await getInvitationData({
503                 api,
504                 address: `${firstAddressParts.Local}@${firstAddressParts.Domain}`,
505                 expectRevisionChange: true,
506             });
507             const invitationSignature = await getSignedInvitationData(organizationKey.privateKey, invitationData);
508             const Member = await api(
509                 createMemberConfig({
510                     ...payload,
511                     Invitation: {
512                         Email: model.invitationEmail,
513                         Data: invitationData,
514                         Signature: invitationSignature,
515                         PrivateIntent: model.private === MEMBER_PRIVATE.UNREADABLE,
516                     },
517                 })
518             ).then(({ Member }) => Member);
520             if (model.private) {
521                 await createAddressesForMember({
522                     api,
523                     member: Member,
524                     addresses: model.addresses,
525                 });
526             }
528             const [updatedMember] = await Promise.all([
529                 getMember(api, Member.ID),
530                 // Upserting the member also has an effect on the org values, so they need to be updated too.
531                 single ? dispatch(organizationThunk({ cache: CacheType.None })) : undefined,
532             ]);
533             dispatch(upsertMember({ member: updatedMember }));
534             return;
535         }
537         const Member = await srpVerify<{ Member: Member }>({
538             api,
539             credentials: { password: model.password },
540             config: createMemberConfig({
541                 ...payload,
542                 Private: +(model.private === MEMBER_PRIVATE.UNREADABLE),
543             }),
544         }).then(({ Member }) => Member);
546         const memberAddresses = await createAddressesForMember({
547             api,
548             member: Member,
549             addresses: model.addresses,
550         });
552         let memberWithKeys: Member | undefined;
553         organizationKey = await dispatch(organizationKeyThunk()); // Ensure latest key
554         if (!model.private && organizationKey?.privateKey) {
555             const result = await setupMemberKeys({
556                 api,
557                 ownerAddresses,
558                 member: Member,
559                 memberAddresses,
560                 organizationKey: organizationKey.privateKey,
561                 keyGenConfig: KEYGEN_CONFIGS[DEFAULT_KEYGEN_TYPE],
562                 password: model.password,
563                 keyTransparencyVerify,
564             });
565             memberWithKeys = result.Member;
566             await keyTransparencyCommit(userKeys);
567         }
569         if (model.role === MEMBER_ROLE.ORGANIZATION_ADMIN) {
570             organizationKey = await dispatch(organizationKeyThunk()); // Ensure latest key
571             if (getIsPasswordless(organizationKey?.Key)) {
572                 if (!model.private && memberWithKeys) {
573                     const memberKeyPayload = await getMemberKeyPayload({
574                         organizationKey,
575                         member: memberWithKeys,
576                         memberAddresses,
577                         mode: {
578                             type: 'email',
579                             verifyOutboundPublicKeys,
580                         },
581                         api,
582                     });
583                     await dispatch(setAdminRoles({ api, memberKeyPayloads: [memberKeyPayload] }));
584                 } else {
585                     // Ignore, can't set non-private users admins on creation
586                 }
587             } else {
588                 await api(updateRole(Member.ID, MEMBER_ROLE.ORGANIZATION_ADMIN));
589             }
590         }
592         const [updatedMember] = await Promise.all([
593             getMember(api, Member.ID),
594             // Upserting the member also has an effect on the org values, so they need to be updated too.
595             single ? dispatch(organizationThunk({ cache: CacheType.None })) : undefined,
596         ]);
597         dispatch(upsertMember({ member: updatedMember }));
598     };