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';
8 checkMemberAddressAvailability,
10 createMember as createMemberConfig,
12 deleteUnprivatizationRequest,
13 getMember as getMemberConfig,
14 privatizeMember as privatizeMemberConfig,
15 requestUnprivatization as requestUnprivatizationConfig,
21 } from '@proton/shared/lib/api/members';
29 } from '@proton/shared/lib/constants';
30 import { validateEmailAddress } from '@proton/shared/lib/helpers/email';
35 KeyTransparencyCommit,
36 KeyTransparencyVerify,
39 VerifyOutboundPublicKeys,
40 } from '@proton/shared/lib/interfaces';
41 import { CreateMemberMode } from '@proton/shared/lib/interfaces';
45 getSignedInvitationData,
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';
57 type MemberKeyPayload,
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 = ({
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))
95 for (const member of success) {
96 dispatch(upsertMember({ member, type: 'delete' }));
98 return { success, failure };
102 export const getMember = (api: Api, memberID: string) =>
105 }>(getMemberConfig(memberID)).then(({ Member }) => Member);
107 export const setAdminRole = ({
113 payload: MemberKeyPayload | null;
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));
125 throw new Error('Missing payload');
128 await dispatch(setAdminRoles({ memberKeyPayloads: [payload], api }));
132 export const requestUnprivatization = ({
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`
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`
154 const invitationData = await getInvitationData({
156 address: primaryEmailAddress,
157 expectRevisionChange: false,
159 const invitationSignature = await getSignedInvitationData(organizationKey.privateKey, invitationData);
161 requestUnprivatizationConfig(member.ID, {
162 InvitationData: invitationData,
163 InvitationSignature: invitationSignature,
167 dispatch(upsertMember({ member: await getMember(api, member.ID) }));
172 export const deleteRequestUnprivatization = ({
180 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
181 return async (dispatch) => {
182 if (member.Unprivatization === null) {
185 await api(deleteUnprivatizationRequest(member.ID));
187 dispatch(upsertMember({ member: await getMember(api, member.ID) }));
192 export const privatizeMember = ({
200 }): ThunkAction<Promise<void>, OrganizationKeyState, ProtonThunkArguments, UnknownAction> => {
201 return async (dispatch) => {
202 await api(privatizeMemberConfig(member.ID));
204 dispatch(upsertMember({ member: await getMember(api, member.ID) }));
209 interface CreateMemberPayload {
211 addresses: { Local: string; Domain: string }[];
212 invitationEmail: string;
213 mode: CreateMemberMode;
214 private: MEMBER_PRIVATE | null;
218 role: MEMBER_ROLE | null;
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 ({
230 organization: Organization;
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));
238 export const editMember = ({
241 memberKeyPacketPayload,
245 memberDiff: Partial<CreateMemberPayload>;
246 memberKeyPacketPayload: MemberKeyPayload | null;
249 Promise<{ diff: true; member: Member } | { diff: false; member: null }>,
250 OrganizationKeyState,
251 ProtonThunkArguments,
254 return async (dispatch) => {
255 if (memberDiff.name !== undefined) {
256 await api(updateName(member.ID, memberDiff.name));
258 if (memberDiff.storage !== undefined) {
259 await api(updateQuota(member.ID, memberDiff.storage));
261 if (memberDiff.vpn !== undefined) {
262 if (memberDiff.vpn) {
263 const [members, organization] = await Promise.all([
264 dispatch(membersThunk()),
265 dispatch(organizationThunk()),
267 await resetAdminVPN({ api, members, organization }).catch(noop);
269 await api(updateVPN(member.ID, memberDiff.vpn ? VPN_CONNECTIONS : 0));
271 if (memberDiff.numAI !== undefined) {
272 await api(updateAI(member.ID, memberDiff.numAI ? 1 : 0));
274 if (memberDiff.role === MEMBER_ROLE.ORGANIZATION_ADMIN) {
275 await dispatch(setAdminRole({ member, payload: memberKeyPacketPayload, api }));
277 if (memberDiff.role === MEMBER_ROLE.ORGANIZATION_MEMBER) {
278 await api(updateRole(member.ID, MEMBER_ROLE.ORGANIZATION_MEMBER));
280 if (memberDiff.private !== undefined) {
281 if (memberDiff.private === MEMBER_PRIVATE.UNREADABLE) {
282 if (member.Unprivatization) {
283 await dispatch(deleteRequestUnprivatization({ member, api, upsert: false }));
285 await dispatch(privatizeMember({ member, api, upsert: false }));
288 if (member.Private === MEMBER_PRIVATE.UNREADABLE && memberDiff.private === MEMBER_PRIVATE.READABLE) {
289 await dispatch(requestUnprivatization({ member, api, upsert: false }));
292 const diff = Object.values(memberDiff).some((value) => value !== undefined);
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 })),
299 dispatch(upsertMember({ member: updatedMember }));
302 member: updatedMember,
312 const createAddressesForMember = async ({
319 addresses: { Local: string; Domain: string }[];
321 const memberAddresses: Address[] = [];
322 for (const { Local, Domain } of addresses) {
323 const { Address } = await api<{ Address: Address }>(
324 createMemberAddress(member.ID, {
329 memberAddresses.push(Address);
331 return memberAddresses;
334 export const createMember = ({
335 member: originalModel,
338 keyTransparencyVerify,
339 keyTransparencyCommit,
340 verifyOutboundPublicKeys,
344 member: CreateMemberPayload;
346 verifiedDomains: Domain[] /* Remove dependency, move to thunk */;
348 disableStorageValidation?: boolean;
349 disableDomainValidation?: boolean;
350 disableAddressValidation?: boolean;
352 keyTransparencyVerify: KeyTransparencyVerify;
353 keyTransparencyCommit: KeyTransparencyCommit;
354 verifyOutboundPublicKeys: VerifyOutboundPublicKeys;
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()),
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;
373 await resetAdminVPN({ api, members, organization }).catch(noop);
376 const error = validateAddUser({
377 privateUser: model.private === MEMBER_PRIVATE.UNREADABLE,
379 organizationKeyInfo: getOrganizationKeyInfo(organization, organizationKey, ownerAddresses),
381 ...validationOptions,
384 throw new MemberCreationValidationError(error);
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);
395 invalidAddresses.push(emailAddress);
397 validAddresses.push(emailAddress);
400 address: emailAddress,
405 if (model.mode === CreateMemberMode.Invitation) {
406 if (!validateEmailAddress(model.invitationEmail)) {
407 invalidInvitationAddresses.push(model.invitationEmail);
411 if (invalidAddresses.length || invalidInvitationAddresses.length) {
413 * Throw if any of the addresses are not valid
415 throw new InvalidAddressesError(invalidAddresses, invalidInvitationAddresses, validAddresses);
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`
428 if (model.role === MEMBER_ROLE.ORGANIZATION_ADMIN) {
429 if (getIsPasswordless(organizationKey?.Key)) {
430 throw new MemberCreationValidationError(getPrivateAdminError());
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`
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}`;
451 checkMemberAddressAvailability({
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 });
469 const [firstAddressParts, ...restAddressParts] = addressParts;
472 * Will prompt password prompt only once
474 await checkAddressAvailability(firstAddressParts);
477 * No more password prompts will be needed
479 await Promise.all(restAddressParts.map(checkAddressAvailability));
481 if (unavailableAddresses.length) {
483 * Throw if any of the addresses are not available
485 throw new UnavailableAddressesError(unavailableAddresses, availableAddresses);
489 Name: model.name || firstAddressParts.Local,
490 MaxSpace: +model.storage,
491 MaxVPN: model.vpn ? VPN_CONNECTIONS : 0,
492 MaxAI: model.numAI ? 1 : 0,
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`
502 const invitationData = await getInvitationData({
504 address: `${firstAddressParts.Local}@${firstAddressParts.Domain}`,
505 expectRevisionChange: true,
507 const invitationSignature = await getSignedInvitationData(organizationKey.privateKey, invitationData);
508 const Member = await api(
512 Email: model.invitationEmail,
513 Data: invitationData,
514 Signature: invitationSignature,
515 PrivateIntent: model.private === MEMBER_PRIVATE.UNREADABLE,
518 ).then(({ Member }) => Member);
521 await createAddressesForMember({
524 addresses: model.addresses,
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,
533 dispatch(upsertMember({ member: updatedMember }));
537 const Member = await srpVerify<{ Member: Member }>({
539 credentials: { password: model.password },
540 config: createMemberConfig({
542 Private: +(model.private === MEMBER_PRIVATE.UNREADABLE),
544 }).then(({ Member }) => Member);
546 const memberAddresses = await createAddressesForMember({
549 addresses: model.addresses,
552 let memberWithKeys: Member | undefined;
553 organizationKey = await dispatch(organizationKeyThunk()); // Ensure latest key
554 if (!model.private && organizationKey?.privateKey) {
555 const result = await setupMemberKeys({
560 organizationKey: organizationKey.privateKey,
561 keyGenConfig: KEYGEN_CONFIGS[DEFAULT_KEYGEN_TYPE],
562 password: model.password,
563 keyTransparencyVerify,
565 memberWithKeys = result.Member;
566 await keyTransparencyCommit(userKeys);
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({
575 member: memberWithKeys,
579 verifyOutboundPublicKeys,
583 await dispatch(setAdminRoles({ api, memberKeyPayloads: [memberKeyPayload] }));
585 // Ignore, can't set non-private users admins on creation
588 await api(updateRole(Member.ID, MEMBER_ROLE.ORGANIZATION_ADMIN));
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,
597 dispatch(upsertMember({ member: updatedMember }));