1 import type { FormEvent } from 'react';
2 import { useEffect, useMemo, useRef, useState } from 'react';
4 import { c } from 'ttag';
7 type MemberPromptAction,
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 {
61 private: MEMBER_PRIVATE;
66 const getMemberDiff = ({
71 initialModel: MemberState;
73 }): Partial<MemberState> => {
74 return Object.fromEntries(
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
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,
85 }).filter(([, value]) => {
86 return value !== undefined;
91 const getMemberStateFromMember = (member: Member): MemberState => {
94 storage: member.MaxSpace,
96 private: member.Private,
102 const getMemberKeyPacketPayload = (memberAction: MemberPromptAction | null) => {
103 return memberAction?.type === 'confirm-promote' ? memberAction.payload : null;
106 const SubUserEditModal = ({
108 allowStorageConfiguration,
109 allowVpnAccessConfiguration,
110 allowPrivateMemberConfiguration,
111 allowAIAssistantConfiguration,
112 showAddressesSection,
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();
125 confirmRemoveUnprivatizationProps,
126 setConfirmRemoveUnprivatizationModal,
127 renderConfirmRemoveUnprivatization,
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);
139 dispatch(getMemberAddresses({ member })).catch(noop);
142 const initialModel = useMemo((): MemberState => {
143 return getMemberStateFromMember(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);
168 // Organization must be keyful, so not family-style organization
169 organizationHasKeys &&
170 // Not yourself, to avoid requesting unprivatization for yourself
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);
175 canTogglePrivate = member.Private === MEMBER_PRIVATE.READABLE && !unprivatization.exists;
178 const canPromoteAdmin =
180 member.Role === MEMBER_ROLE.ORGANIZATION_MEMBER &&
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 });
192 const handleUpdateMember = async (memberDiff: Parameters<typeof editMember>[0]['memberDiff']) => {
193 const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
195 const result = await dispatch(
199 memberKeyPacketPayload,
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` });
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;
220 {renderConfirmUnprivatization && (
222 title={c('unprivatization').t`Request user permission for data access`}
228 confirmUnprivatizationProps.onClose();
229 void withLoadingUnprivatization(
230 handleUpdateMember({ private: MEMBER_PRIVATE.READABLE })
231 ).catch(errorHandler);
233 >{c('unprivatization').t`Send request`}</Button>,
236 confirmUnprivatizationProps.onClose();
238 >{c('Action').t`Cancel`}</Button>,
240 {...confirmUnprivatizationProps}
243 {c('unprivatization')
244 .t`To proceed, you'll need to request the user's permission to access their data or reset their password.`}
248 {c('unprivatization')
249 .t`Once the user consents, you will be able to help them regain account access or manage their data.`}
253 {renderConfirmRemoveUnprivatization && (
255 title={c('unprivatization').t`Delete request to access user data?`}
261 confirmRemoveUnprivatizationProps.onClose();
262 void withLoadingUnprivatization(
263 handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
264 ).catch(errorHandler);
266 >{c('unprivatization').t`Delete request`}</Button>,
269 confirmRemoveUnprivatizationProps.onClose();
271 >{c('Action').t`Cancel`}</Button>,
273 {...confirmRemoveUnprivatizationProps}
276 {c('unprivatization')
277 .t`This will delete the pending request for administrator access to the user’s data or password reset.`}
281 {renderConfirmPrivatization && (
283 title={c('unprivatization').t`Revoke administrator access?`}
289 confirmPrivatizationProps.onClose();
290 void withLoadingUnprivatization(
291 handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
292 ).catch(errorHandler);
294 >{c('unprivatization').t`Revoke access`}</Button>,
297 confirmPrivatizationProps.onClose();
299 >{c('Action').t`Cancel`}</Button>,
301 {...confirmPrivatizationProps}
304 {c('unprivatization')
305 .t`This will revoke the administrator permission to manage this user's account.`}
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.`}
314 {renderConfirmDemotion && (
316 title={c('Title').t`Change role`}
322 confirmDemotionModalProps.onClose();
323 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_MEMBER })).catch(
327 >{c('Action').t`Remove`}</Button>,
330 confirmDemotionModalProps.onClose();
332 >{c('Action').t`Cancel`}</Button>,
334 {...confirmDemotionModalProps}
336 {member.Subscriber === MEMBER_SUBSCRIBER.PAYER
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?`}
342 {renderConfirmPromotion && (
344 title={c('Title').t`Change role`}
350 confirmPromotionModalProps.onClose();
351 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_ADMIN })).catch(
355 >{c('Action').t`Make admin`}</Button>,
358 confirmPromotionModalProps.onClose();
360 >{c('Action').t`Cancel`}</Button>,
362 {...confirmPromotionModalProps}
364 <div className="mb-2">
365 {c('Info').t`Are you sure you want to give administrative privileges to this user?`}
367 <Card rounded className="text-break">
369 const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
370 if (!memberKeyPacketPayload) {
373 const { member, email } = memberKeyPacketPayload;
376 <div className="text-bold">{member.Name}</div>
388 onSubmit={(event: FormEvent<HTMLFormElement>) => {
389 event.preventDefault();
390 event.stopPropagation();
392 if (!onFormSubmit(event.currentTarget)) {
396 const handleSubmit = async () => {
397 const memberDiff = getMemberDiff({ model, initialModel, hasVPN });
398 await handleUpdateMember(memberDiff);
402 withLoading(handleSubmit()).catch(errorHandler);
404 onClose={handleClose}
406 <ModalHeader title={c('Title').t`Edit user`} />
411 error={validator([requiredValidator(model.name)])}
412 onValue={(value: string) => updatePartialModel({ name: value })}
413 label={c('Label').t`Name`}
414 placeholder={NAME_PLACEHOLDER}
418 <div className="flex flex-column gap-2 mb-4">
419 {allowVpnAccessConfiguration && hasVPN ? (
420 <MemberToggleContainer
426 onChange={({ target }) => {
427 withLoadingVPN(handleUpdateMember({ vpn: target.checked })).catch(
434 <label className="text-semibold" htmlFor="vpn-toggle">
435 {c('Label for new member').t`VPN connections`}
441 {allowPrivateMemberConfiguration && canTogglePrivate && (
442 <MemberToggleContainer
446 checked={hasToggledPrivate}
447 loading={loadingUnprivatization}
448 onChange={({ target }) => {
449 if (hasToggledPrivate && !target.checked) {
450 setConfirmUnprivatizationModal(true);
453 if (!hasToggledPrivate && target.checked && unprivatization.pending) {
454 setConfirmRemoveUnprivatizationModal(true);
457 if (!hasToggledPrivate && target.checked) {
458 setConfirmPrivatizationModal(true);
465 <label className="text-semibold" htmlFor="private-toggle">
471 {unprivatization.pending &&
472 unprivatization.mode === MemberUnprivatizationMode.AdminAccess
473 ? c('unprivatization').t`Pending admin access`
480 {(canPromoteAdmin || canRevokeAdmin) && (
481 <MemberToggleContainer
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,
498 memberPromptActionRef.current = result;
500 if (result?.type === 'confirm-promote') {
502 setConfirmPromotionModal(true);
507 if (result?.type === 'confirm-demote') {
508 setConfirmDemotionModal(true);
512 await handleUpdateMember(memberDiff);
515 const newRole = target.checked
516 ? MEMBER_ROLE.ORGANIZATION_ADMIN
517 : MEMBER_ROLE.ORGANIZATION_MEMBER;
519 withLoadingRole(run({ role: newRole })).catch(errorHandler);
524 <label className="text-semibold" htmlFor="admin-toggle">
525 {c('Label for new member').t`Admin`}
530 {adminTooltipText()}{' '}
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" />
545 {allowAIAssistantConfiguration && (
546 <MemberToggleContainer
549 id="ai-assistant-toggle"
551 loading={loadingScribe}
553 onChange={({ target }) => {
554 withLoadingScribe(handleUpdateMember({ numAI: target.checked })).catch(
562 <label className="text-semibold" htmlFor="ai-assistant-toggle">
563 {c('Info').t`Writing assistant`}
568 !aiSeatsRemaining && !model.ai ? <AssistantUpdateSubscriptionButton /> : undefined
574 {allowStorageConfiguration && (
575 <MemberStorageSelector
577 value={model.storage}
578 sizeUnit={storageSizeUnit}
579 totalStorage={getTotalStorage(member, organization)}
580 range={getStorageRange(member, organization)}
581 onChange={(storage) => updatePartialModel({ storage })}
584 {showAddressesSection && (
586 <h3 className="text-strong mb-2">{c('Label').t`Addresses`}</h3>
588 <Addresses organization={organization} memberID={member.ID} hasDescription={false} />
594 <div>{/* empty div to make it right aligned*/}</div>
595 <Button loading={submitting} type="submit" color="norm">
596 {c('Action').t`Save`}
604 export default SubUserEditModal;