Flavien modal two
[ProtonMail-WebClient.git] / packages / components / containers / members / SubUserEditModal.tsx
blob54d52e83a5b2218c51c0d02f412eb395edac0814
1 import type { FormEvent } from 'react';
2 import { useEffect, useMemo, useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import {
7     type MemberPromptAction,
8     editMember,
9     getMemberAddresses,
10     getMemberEditPayload,
11     getPrivateAdminError,
12     getPrivateText,
13 } from '@proton/account';
14 import { Button, Card } from '@proton/atoms';
15 import Icon from '@proton/components/components/icon/Icon';
16 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
17 import Modal from '@proton/components/components/modalTwo/Modal';
18 import ModalContent from '@proton/components/components/modalTwo/ModalContent';
19 import ModalFooter from '@proton/components/components/modalTwo/ModalFooter';
20 import ModalHeader from '@proton/components/components/modalTwo/ModalHeader';
21 import useModalState from '@proton/components/components/modalTwo/useModalState';
22 import Prompt from '@proton/components/components/prompt/Prompt';
23 import Toggle from '@proton/components/components/toggle/Toggle';
24 import Tooltip from '@proton/components/components/tooltip/Tooltip';
25 import { useLoading } from '@proton/hooks';
26 import { useDispatch } from '@proton/redux-shared-store';
27 import { getSilentApi } from '@proton/shared/lib/api/helpers/customConfig';
28 import { MEMBER_PRIVATE, MEMBER_ROLE, MEMBER_SUBSCRIBER, NAME_PLACEHOLDER } from '@proton/shared/lib/constants';
29 import { requiredValidator } from '@proton/shared/lib/helpers/formValidators';
30 import { sizeUnits } from '@proton/shared/lib/helpers/size';
31 import type { EnhancedMember, Member } from '@proton/shared/lib/interfaces';
32 import { getIsPasswordless } from '@proton/shared/lib/keys';
33 import { MemberUnprivatizationMode, getMemberUnprivatizationMode } from '@proton/shared/lib/keys/memberHelper';
34 import useFlag from '@proton/unleash/useFlag';
35 import noop from '@proton/utils/noop';
37 import { InputFieldTwo, useFormErrors } from '../../components';
38 import { useApi, useErrorHandler, useNotifications, useOrganization, useOrganizationKey } from '../../hooks';
39 import Addresses from '../addresses/Addresses';
40 import useVerifyOutboundPublicKeys from '../keyTransparency/useVerifyOutboundPublicKeys';
41 import { AssistantUpdateSubscriptionButton } from '../payments';
42 import MemberStorageSelector, { getStorageRange, getTotalStorage } from './MemberStorageSelector';
43 import MemberToggleContainer from './MemberToggleContainer';
44 import { adminTooltipText } from './constants';
45 import { getPrivateLabel } from './helper';
47 interface Props extends ModalProps<'form'> {
48     member: EnhancedMember;
49     allowStorageConfiguration?: boolean;
50     allowVpnAccessConfiguration?: boolean;
51     allowPrivateMemberConfiguration?: boolean;
52     allowAIAssistantConfiguration?: boolean;
53     showAddressesSection?: boolean;
54     aiSeatsRemaining: boolean;
57 interface MemberState {
58     name: string;
59     storage: number;
60     vpn: boolean;
61     private: MEMBER_PRIVATE;
62     ai: boolean;
63     role: MEMBER_ROLE;
66 const getMemberDiff = ({
67     model,
68     initialModel,
69 }: {
70     model: MemberState;
71     initialModel: MemberState;
72     hasVPN: boolean;
73 }): Partial<MemberState> => {
74     return Object.fromEntries(
75         Object.entries({
76             name: initialModel.name !== model.name ? model.name : undefined,
77             storage: initialModel.storage !== model.storage ? model.storage : undefined,
78             // NOTE: These values are not included because they are updated immediately
79             /*
80         vpn: hasVPN && initialModel.vpn !== model.vpn ? model.vpn : undefined,
81         numAI: initialModel.ai !== model.ai ? model.ai : undefined,
82         private: model.private !== initialModel.private ? model.private : undefined,
83         role: model.role !== initialModel.role ? model.role : undefined,
84          */
85         }).filter(([, value]) => {
86             return value !== undefined;
87         })
88     );
91 const getMemberStateFromMember = (member: Member): MemberState => {
92     return {
93         name: member.Name,
94         storage: member.MaxSpace,
95         vpn: !!member.MaxVPN,
96         private: member.Private,
97         ai: !!member.NumAI,
98         role: member.Role,
99     };
102 const getMemberKeyPacketPayload = (memberAction: MemberPromptAction | null) => {
103     return memberAction?.type === 'confirm-promote' ? memberAction.payload : null;
106 const SubUserEditModal = ({
107     member,
108     allowStorageConfiguration,
109     allowVpnAccessConfiguration,
110     allowPrivateMemberConfiguration,
111     allowAIAssistantConfiguration,
112     showAddressesSection,
113     aiSeatsRemaining,
114     ...rest
115 }: Props) => {
116     const [organization] = useOrganization();
117     const [organizationKey] = useOrganizationKey();
118     const unprivatizeMemberEnabled = useFlag('UnprivatizeMember');
119     const dispatch = useDispatch();
120     const storageSizeUnit = sizeUnits.GB;
121     const verifyOutboundPublicKeys = useVerifyOutboundPublicKeys();
122     const { validator, onFormSubmit } = useFormErrors();
123     const [confirmUnprivatizationProps, setConfirmUnprivatizationModal, renderConfirmUnprivatization] = useModalState();
124     const [
125         confirmRemoveUnprivatizationProps,
126         setConfirmRemoveUnprivatizationModal,
127         renderConfirmRemoveUnprivatization,
128     ] = useModalState();
129     const [confirmPrivatizationProps, setConfirmPrivatizationModal, renderConfirmPrivatization] = useModalState();
130     const [confirmDemotionModalProps, setConfirmDemotionModal, renderConfirmDemotion] = useModalState();
131     const [confirmPromotionModalProps, setConfirmPromotionModal, renderConfirmPromotion] = useModalState();
132     const memberPromptActionRef = useRef<MemberPromptAction | null>(null);
133     const passwordlessMode = getIsPasswordless(organizationKey?.Key);
135     // We want to keep AI enabled if all seats are taken but the user already has a seat
136     const disableAI = !organization?.MaxAI || (!aiSeatsRemaining && !member.NumAI);
138     useEffect(() => {
139         dispatch(getMemberAddresses({ member })).catch(noop);
140     }, []);
142     const initialModel = useMemo((): MemberState => {
143         return getMemberStateFromMember(member);
144     }, [member]);
146     const [model, updateModel] = useState<MemberState>(initialModel);
148     const [submitting, withLoading] = useLoading();
149     const [loadingUnprivatization, withLoadingUnprivatization] = useLoading();
150     const [loadingRole, withLoadingRole] = useLoading();
151     const [loadingVPN, withLoadingVPN] = useLoading();
152     const [loadingScribe, withLoadingScribe] = useLoading();
153     const { createNotification } = useNotifications();
154     const normalApi = useApi();
155     const silentApi = getSilentApi(normalApi);
157     const hasVPN = Boolean(organization?.MaxVPN);
158     const unprivatization = getMemberUnprivatizationMode(member);
160     const isSelf = Boolean(member.Self);
162     let canTogglePrivate;
163     if (unprivatizeMemberEnabled) {
164         const organizationHasKeys = Boolean(organization?.HasKeys);
165         const isSelfAndPrivate = Boolean(isSelf && member.Private === MEMBER_PRIVATE.UNREADABLE);
167         canTogglePrivate =
168             // Organization must be keyful, so not family-style organization
169             organizationHasKeys &&
170             // Not yourself, to avoid requesting unprivatization for yourself
171             !isSelfAndPrivate &&
172             // The user does not have an ongoing unprivatization request or an admin request is ongoing (to be able to remove it)
173             (!unprivatization.exists || unprivatization.mode === MemberUnprivatizationMode.AdminAccess);
174     } else {
175         canTogglePrivate = member.Private === MEMBER_PRIVATE.READABLE && !unprivatization.exists;
176     }
178     const canPromoteAdmin =
179         !isSelf &&
180         member.Role === MEMBER_ROLE.ORGANIZATION_MEMBER &&
181         !member.SSO &&
182         unprivatization.mode !== MemberUnprivatizationMode.MagicLinkInvite;
184     const canRevokeAdmin = !isSelf && member.Role === MEMBER_ROLE.ORGANIZATION_ADMIN;
186     const errorHandler = useErrorHandler();
188     const updatePartialModel = (partial: Partial<typeof model>) => {
189         updateModel({ ...model, ...partial });
190     };
192     const handleUpdateMember = async (memberDiff: Parameters<typeof editMember>[0]['memberDiff']) => {
193         const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
195         const result = await dispatch(
196             editMember({
197                 member,
198                 memberDiff,
199                 memberKeyPacketPayload,
200                 api: silentApi,
201             })
202         );
203         if (result.member) {
204             const newValue = getMemberStateFromMember(result.member);
205             const memberDiff = getMemberDiff({ model, initialModel, hasVPN });
206             // Keep the partially updated member diff values if any
207             updateModel({ ...newValue, ...memberDiff });
208             createNotification({ text: c('Success').t`User updated` });
209         }
210         return result.diff;
211     };
213     const handleClose = submitting ? undefined : rest.onClose;
215     const hasToggledPrivate = model.private === MEMBER_PRIVATE.UNREADABLE && !unprivatization.pending;
216     const hasToggledAdmin = model.role === MEMBER_ROLE.ORGANIZATION_ADMIN;
218     return (
219         <>
220             {renderConfirmUnprivatization && (
221                 <Prompt
222                     title={c('unprivatization').t`Request user permission for data access`}
223                     buttons={[
224                         <Button
225                             color="norm"
226                             loading={submitting}
227                             onClick={() => {
228                                 confirmUnprivatizationProps.onClose();
229                                 void withLoadingUnprivatization(
230                                     handleUpdateMember({ private: MEMBER_PRIVATE.READABLE })
231                                 ).catch(errorHandler);
232                             }}
233                         >{c('unprivatization').t`Send request`}</Button>,
234                         <Button
235                             onClick={() => {
236                                 confirmUnprivatizationProps.onClose();
237                             }}
238                         >{c('Action').t`Cancel`}</Button>,
239                     ]}
240                     {...confirmUnprivatizationProps}
241                 >
242                     <div>
243                         {c('unprivatization')
244                             .t`To proceed, you'll need to request the user's permission to access their data or reset their password.`}
245                         <br />
246                         <br />
248                         {c('unprivatization')
249                             .t`Once the user consents, you will be able to help them regain account access or manage their data.`}
250                     </div>
251                 </Prompt>
252             )}
253             {renderConfirmRemoveUnprivatization && (
254                 <Prompt
255                     title={c('unprivatization').t`Delete request to access user data?`}
256                     buttons={[
257                         <Button
258                             color="danger"
259                             loading={submitting}
260                             onClick={() => {
261                                 confirmRemoveUnprivatizationProps.onClose();
262                                 void withLoadingUnprivatization(
263                                     handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
264                                 ).catch(errorHandler);
265                             }}
266                         >{c('unprivatization').t`Delete request`}</Button>,
267                         <Button
268                             onClick={() => {
269                                 confirmRemoveUnprivatizationProps.onClose();
270                             }}
271                         >{c('Action').t`Cancel`}</Button>,
272                     ]}
273                     {...confirmRemoveUnprivatizationProps}
274                 >
275                     <div>
276                         {c('unprivatization')
277                             .t`This will delete the pending request for administrator access to the user’s data or password reset.`}
278                     </div>
279                 </Prompt>
280             )}
281             {renderConfirmPrivatization && (
282                 <Prompt
283                     title={c('unprivatization').t`Revoke administrator access?`}
284                     buttons={[
285                         <Button
286                             color="danger"
287                             loading={submitting}
288                             onClick={() => {
289                                 confirmPrivatizationProps.onClose();
290                                 void withLoadingUnprivatization(
291                                     handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
292                                 ).catch(errorHandler);
293                             }}
294                         >{c('unprivatization').t`Revoke access`}</Button>,
295                         <Button
296                             onClick={() => {
297                                 confirmPrivatizationProps.onClose();
298                             }}
299                         >{c('Action').t`Cancel`}</Button>,
300                     ]}
301                     {...confirmPrivatizationProps}
302                 >
303                     <div>
304                         {c('unprivatization')
305                             .t`This will revoke the administrator permission to manage this user's account.`}
306                         <br />
307                         <br />
309                         {c('unprivatization')
310                             .t`By making the user private you won't be able to help them regain access to their data or account.`}
311                     </div>
312                 </Prompt>
313             )}
314             {renderConfirmDemotion && (
315                 <Prompt
316                     title={c('Title').t`Change role`}
317                     buttons={[
318                         <Button
319                             color="danger"
320                             loading={submitting}
321                             onClick={() => {
322                                 confirmDemotionModalProps.onClose();
323                                 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_MEMBER })).catch(
324                                     errorHandler
325                                 );
326                             }}
327                         >{c('Action').t`Remove`}</Button>,
328                         <Button
329                             onClick={() => {
330                                 confirmDemotionModalProps.onClose();
331                             }}
332                         >{c('Action').t`Cancel`}</Button>,
333                     ]}
334                     {...confirmDemotionModalProps}
335                 >
336                     {member.Subscriber === MEMBER_SUBSCRIBER.PAYER
337                         ? c('Info')
338                               .t`This user is currently responsible for payments for your organization. By demoting this member, you will become responsible for payments for your organization.`
339                         : c('Info').t`Are you sure you want to remove administrative privileges from this user?`}
340                 </Prompt>
341             )}
342             {renderConfirmPromotion && (
343                 <Prompt
344                     title={c('Title').t`Change role`}
345                     buttons={[
346                         <Button
347                             color="norm"
348                             loading={submitting}
349                             onClick={() => {
350                                 confirmPromotionModalProps.onClose();
351                                 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_ADMIN })).catch(
352                                     errorHandler
353                                 );
354                             }}
355                         >{c('Action').t`Make admin`}</Button>,
356                         <Button
357                             onClick={() => {
358                                 confirmPromotionModalProps.onClose();
359                             }}
360                         >{c('Action').t`Cancel`}</Button>,
361                     ]}
362                     {...confirmPromotionModalProps}
363                 >
364                     <div className="mb-2">
365                         {c('Info').t`Are you sure you want to give administrative privileges to this user?`}
366                     </div>
367                     <Card rounded className="text-break">
368                         {(() => {
369                             const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
370                             if (!memberKeyPacketPayload) {
371                                 return '';
372                             }
373                             const { member, email } = memberKeyPacketPayload;
374                             return (
375                                 <>
376                                     <div className="text-bold">{member.Name}</div>
377                                     <div>{email}</div>
378                                 </>
379                             );
380                         })()}
381                     </Card>
382                 </Prompt>
383             )}
384             <Modal
385                 as="form"
386                 size="large"
387                 {...rest}
388                 onSubmit={(event: FormEvent<HTMLFormElement>) => {
389                     event.preventDefault();
390                     event.stopPropagation();
392                     if (!onFormSubmit(event.currentTarget)) {
393                         return;
394                     }
396                     const handleSubmit = async () => {
397                         const memberDiff = getMemberDiff({ model, initialModel, hasVPN });
398                         await handleUpdateMember(memberDiff);
399                         rest.onClose?.();
400                     };
402                     withLoading(handleSubmit()).catch(errorHandler);
403                 }}
404                 onClose={handleClose}
405             >
406                 <ModalHeader title={c('Title').t`Edit user`} />
407                 <ModalContent>
408                     <InputFieldTwo
409                         id="name"
410                         value={model.name}
411                         error={validator([requiredValidator(model.name)])}
412                         onValue={(value: string) => updatePartialModel({ name: value })}
413                         label={c('Label').t`Name`}
414                         placeholder={NAME_PLACEHOLDER}
415                         autoFocus
416                     />
418                     <div className="flex flex-column gap-2 mb-4">
419                         {allowVpnAccessConfiguration && hasVPN ? (
420                             <MemberToggleContainer
421                                 toggle={
422                                     <Toggle
423                                         id="vpn-toggle"
424                                         checked={model.vpn}
425                                         loading={loadingVPN}
426                                         onChange={({ target }) => {
427                                             withLoadingVPN(handleUpdateMember({ vpn: target.checked })).catch(
428                                                 errorHandler
429                                             );
430                                         }}
431                                     />
432                                 }
433                                 label={
434                                     <label className="text-semibold" htmlFor="vpn-toggle">
435                                         {c('Label for new member').t`VPN connections`}
436                                     </label>
437                                 }
438                             />
439                         ) : null}
441                         {allowPrivateMemberConfiguration && canTogglePrivate && (
442                             <MemberToggleContainer
443                                 toggle={
444                                     <Toggle
445                                         id="private-toggle"
446                                         checked={hasToggledPrivate}
447                                         loading={loadingUnprivatization}
448                                         onChange={({ target }) => {
449                                             if (hasToggledPrivate && !target.checked) {
450                                                 setConfirmUnprivatizationModal(true);
451                                                 return;
452                                             }
453                                             if (!hasToggledPrivate && target.checked && unprivatization.pending) {
454                                                 setConfirmRemoveUnprivatizationModal(true);
455                                                 return;
456                                             }
457                                             if (!hasToggledPrivate && target.checked) {
458                                                 setConfirmPrivatizationModal(true);
459                                                 return;
460                                             }
461                                         }}
462                                     />
463                                 }
464                                 label={
465                                     <label className="text-semibold" htmlFor="private-toggle">
466                                         {getPrivateLabel()}
467                                     </label>
468                                 }
469                                 assistiveText={
470                                     <>
471                                         {unprivatization.pending &&
472                                         unprivatization.mode === MemberUnprivatizationMode.AdminAccess
473                                             ? c('unprivatization').t`Pending admin access`
474                                             : getPrivateText()}
475                                     </>
476                                 }
477                             />
478                         )}
480                         {(canPromoteAdmin || canRevokeAdmin) && (
481                             <MemberToggleContainer
482                                 toggle={
483                                     <Toggle
484                                         id="admin-toggle"
485                                         loading={loadingRole}
486                                         checked={hasToggledAdmin}
487                                         onChange={({ target }) => {
488                                             const run = async (memberDiff: { role: MEMBER_ROLE }) => {
489                                                 const result = await dispatch(
490                                                     getMemberEditPayload({
491                                                         verifyOutboundPublicKeys,
492                                                         member,
493                                                         memberDiff,
494                                                         api: silentApi,
495                                                     })
496                                                 );
498                                                 memberPromptActionRef.current = result;
500                                                 if (result?.type === 'confirm-promote') {
501                                                     if (result.prompt) {
502                                                         setConfirmPromotionModal(true);
503                                                         return;
504                                                     }
505                                                 }
507                                                 if (result?.type === 'confirm-demote') {
508                                                     setConfirmDemotionModal(true);
509                                                     return;
510                                                 }
512                                                 await handleUpdateMember(memberDiff);
513                                             };
515                                             const newRole = target.checked
516                                                 ? MEMBER_ROLE.ORGANIZATION_ADMIN
517                                                 : MEMBER_ROLE.ORGANIZATION_MEMBER;
519                                             withLoadingRole(run({ role: newRole })).catch(errorHandler);
520                                         }}
521                                     />
522                                 }
523                                 label={
524                                     <label className="text-semibold" htmlFor="admin-toggle">
525                                         {c('Label for new member').t`Admin`}
526                                     </label>
527                                 }
528                                 assistiveText={
529                                     <div>
530                                         {adminTooltipText()}{' '}
531                                         {passwordlessMode &&
532                                             hasToggledPrivate &&
533                                             hasToggledAdmin &&
534                                             member.addressState === 'full' &&
535                                             !member.Addresses?.[0]?.HasKeys && (
536                                                 <Tooltip title={getPrivateAdminError()} openDelay={0}>
537                                                     <Icon className="color-danger ml-2" name="info-circle-filled" />
538                                                 </Tooltip>
539                                             )}
540                                     </div>
541                                 }
542                             />
543                         )}
545                         {allowAIAssistantConfiguration && (
546                             <MemberToggleContainer
547                                 toggle={
548                                     <Toggle
549                                         id="ai-assistant-toggle"
550                                         checked={model.ai}
551                                         loading={loadingScribe}
552                                         disabled={disableAI}
553                                         onChange={({ target }) => {
554                                             withLoadingScribe(handleUpdateMember({ numAI: target.checked })).catch(
555                                                 errorHandler
556                                             );
557                                         }}
558                                     />
559                                 }
560                                 label={
561                                     <>
562                                         <label className="text-semibold" htmlFor="ai-assistant-toggle">
563                                             {c('Info').t`Writing assistant`}
564                                         </label>
565                                     </>
566                                 }
567                                 assistiveText={
568                                     !aiSeatsRemaining && !model.ai ? <AssistantUpdateSubscriptionButton /> : undefined
569                                 }
570                             />
571                         )}
572                     </div>
574                     {allowStorageConfiguration && (
575                         <MemberStorageSelector
576                             className="mb-5"
577                             value={model.storage}
578                             sizeUnit={storageSizeUnit}
579                             totalStorage={getTotalStorage(member, organization)}
580                             range={getStorageRange(member, organization)}
581                             onChange={(storage) => updatePartialModel({ storage })}
582                         />
583                     )}
584                     {showAddressesSection && (
585                         <div>
586                             <h3 className="text-strong mb-2">{c('Label').t`Addresses`}</h3>
587                             <div>
588                                 <Addresses organization={organization} memberID={member.ID} hasDescription={false} />
589                             </div>
590                         </div>
591                     )}
592                 </ModalContent>
593                 <ModalFooter>
594                     <div>{/* empty div to make it right aligned*/}</div>
595                     <Button loading={submitting} type="submit" color="norm">
596                         {c('Action').t`Save`}
597                     </Button>
598                 </ModalFooter>
599             </Modal>
600         </>
601     );
604 export default SubUserEditModal;