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