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 { 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 {
68 private: MEMBER_PRIVATE;
73 const getMemberDiff = ({
78 initialModel: MemberState;
80 }): Partial<MemberState> => {
81 return Object.fromEntries(
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
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,
92 }).filter(([, value]) => {
93 return value !== undefined;
98 const getMemberStateFromMember = (member: Member): MemberState => {
101 storage: member.MaxSpace,
102 vpn: !!member.MaxVPN,
103 private: member.Private,
109 const getMemberKeyPacketPayload = (memberAction: MemberPromptAction | null) => {
110 return memberAction?.type === 'confirm-promote' ? memberAction.payload : null;
113 const SubUserEditModal = ({
115 allowStorageConfiguration,
116 allowVpnAccessConfiguration,
117 allowPrivateMemberConfiguration,
118 allowAIAssistantConfiguration,
119 showAddressesSection,
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();
132 confirmRemoveUnprivatizationProps,
133 setConfirmRemoveUnprivatizationModal,
134 renderConfirmRemoveUnprivatization,
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);
146 dispatch(getMemberAddresses({ member })).catch(noop);
149 const initialModel = useMemo((): MemberState => {
150 return getMemberStateFromMember(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);
175 // Organization must be keyful, so not family-style organization
176 organizationHasKeys &&
177 // Not yourself, to avoid requesting unprivatization for yourself
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);
182 canTogglePrivate = member.Private === MEMBER_PRIVATE.READABLE && !unprivatization.exists;
185 const canPromoteAdmin =
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 });
199 const handleUpdateMember = async (memberDiff: Parameters<typeof editMember>[0]['memberDiff']) => {
200 const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
202 const result = await dispatch(
206 memberKeyPacketPayload,
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` });
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;
229 {renderConfirmUnprivatization && (
231 title={c('unprivatization').t`Request user permission for data access`}
237 confirmUnprivatizationProps.onClose();
238 void withLoadingUnprivatization(
239 handleUpdateMember({ private: MEMBER_PRIVATE.READABLE })
240 ).catch(errorHandler);
242 >{c('unprivatization').t`Send request`}</Button>,
245 confirmUnprivatizationProps.onClose();
247 >{c('Action').t`Cancel`}</Button>,
249 {...confirmUnprivatizationProps}
252 {c('unprivatization')
253 .t`To proceed, you'll need to request the user's permission to access their data or reset their password.`}
257 {c('unprivatization')
258 .t`Once the user consents, you will be able to help them regain account access or manage their data.`}
262 {renderConfirmRemoveUnprivatization && (
264 title={c('unprivatization').t`Cancel request to access user data?`}
270 confirmRemoveUnprivatizationProps.onClose();
271 void withLoadingUnprivatization(
272 handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
273 ).catch(errorHandler);
275 >{c('unprivatization').t`Cancel request`}</Button>,
278 confirmRemoveUnprivatizationProps.onClose();
280 >{c('unprivatization').t`Keep request`}</Button>,
282 {...confirmRemoveUnprivatizationProps}
285 {c('unprivatization')
286 .t`This will cancel the pending request for administrator access to the user’s data or password reset.`}
290 {renderConfirmPrivatization && (
292 title={c('unprivatization').t`Revoke administrator access?`}
298 confirmPrivatizationProps.onClose();
299 void withLoadingUnprivatization(
300 handleUpdateMember({ private: MEMBER_PRIVATE.UNREADABLE })
301 ).catch(errorHandler);
303 >{c('unprivatization').t`Revoke access`}</Button>,
306 confirmPrivatizationProps.onClose();
308 >{c('Action').t`Cancel`}</Button>,
310 {...confirmPrivatizationProps}
313 {c('unprivatization')
314 .t`This will revoke the administrator permission to manage this user's account.`}
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.`}
323 {renderConfirmDemotion && (
325 title={c('Title').t`Change role`}
331 confirmDemotionModalProps.onClose();
332 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_MEMBER })).catch(
336 >{c('Action').t`Remove`}</Button>,
339 confirmDemotionModalProps.onClose();
341 >{c('Action').t`Cancel`}</Button>,
343 {...confirmDemotionModalProps}
345 {member.Subscriber === MEMBER_SUBSCRIBER.PAYER
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?`}
351 {renderConfirmPromotion && (
353 title={c('Title').t`Change role`}
359 confirmPromotionModalProps.onClose();
360 withLoadingRole(handleUpdateMember({ role: MEMBER_ROLE.ORGANIZATION_ADMIN })).catch(
364 >{c('Action').t`Make admin`}</Button>,
367 confirmPromotionModalProps.onClose();
369 >{c('Action').t`Cancel`}</Button>,
371 {...confirmPromotionModalProps}
373 <div className="mb-2">
374 {c('Info').t`Are you sure you want to give administrative privileges to this user?`}
376 <Card rounded className="text-break">
378 const memberKeyPacketPayload = getMemberKeyPacketPayload(memberPromptActionRef.current);
379 if (!memberKeyPacketPayload) {
382 const { member, email } = memberKeyPacketPayload;
385 <div className="text-bold">{member.Name}</div>
397 onSubmit={(event: FormEvent<HTMLFormElement>) => {
398 event.preventDefault();
399 event.stopPropagation();
401 if (!onFormSubmit(event.currentTarget)) {
405 const handleSubmit = async () => {
406 const memberDiff = getMemberDiff({ model, initialModel, hasVPN });
407 await handleUpdateMember(memberDiff);
411 withLoading(handleSubmit()).catch(errorHandler);
413 onClose={handleClose}
415 <ModalHeader title={c('Title').t`Edit user`} />
420 error={validator([requiredValidator(model.name)])}
421 onValue={(value: string) => updatePartialModel({ name: value })}
422 label={c('Label').t`Name`}
423 placeholder={NAME_PLACEHOLDER}
427 <div className="flex flex-column gap-2 mb-4">
428 {allowPrivateMemberConfiguration && canTogglePrivate && (
430 <MemberToggleContainer
433 disabled={isPendingAdminAccess}
435 checked={hasToggledPrivate}
436 loading={!isPendingAdminAccess && loadingUnprivatization}
437 onChange={({ target }) => {
438 if (hasToggledPrivate && !target.checked) {
439 setConfirmUnprivatizationModal(true);
442 if (!hasToggledPrivate && target.checked && unprivatization.pending) {
443 setConfirmRemoveUnprivatizationModal(true);
446 if (!hasToggledPrivate && target.checked) {
447 setConfirmPrivatizationModal(true);
454 <label className="text-semibold" htmlFor="private-toggle">
458 assistiveText={getPrivateText()}
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(
466 .t`**Pending admin access:** We sent a request to this user to allow any administrator to manage their account.`
469 <div className="md:shrink-0">
471 loading={loadingUnprivatization}
474 setConfirmRemoveUnprivatizationModal(true);
477 {c('unprivatization').t`Cancel request`}
486 {(canPromoteAdmin || canRevokeAdmin) && (
487 <MemberToggleContainer
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,
504 memberPromptActionRef.current = result;
506 if (result?.type === 'confirm-promote') {
508 setConfirmPromotionModal(true);
513 if (result?.type === 'confirm-demote') {
514 setConfirmDemotionModal(true);
518 await handleUpdateMember(memberDiff);
521 const newRole = target.checked
522 ? MEMBER_ROLE.ORGANIZATION_ADMIN
523 : MEMBER_ROLE.ORGANIZATION_MEMBER;
525 withLoadingRole(run({ role: newRole })).catch(errorHandler);
530 <label className="text-semibold" htmlFor="admin-toggle">
531 {c('Label for new member').t`Admin`}
536 {adminTooltipText()}{' '}
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" />
551 {allowVpnAccessConfiguration && hasVPN ? (
552 <MemberToggleContainer
558 onChange={({ target }) => {
559 withLoadingVPN(handleUpdateMember({ vpn: target.checked })).catch(
566 <label className="text-semibold" htmlFor="vpn-toggle">
567 {c('Label for new member').t`VPN connections`}
573 {allowAIAssistantConfiguration && (
574 <MemberToggleContainer
577 id="ai-assistant-toggle"
579 loading={loadingScribe}
581 onChange={({ target }) => {
582 withLoadingScribe(handleUpdateMember({ numAI: target.checked })).catch(
590 <label className="text-semibold" htmlFor="ai-assistant-toggle">
591 {c('Info').t`Writing assistant`}
596 !aiSeatsRemaining && !model.ai ? <AssistantUpdateSubscriptionButton /> : undefined
602 {allowStorageConfiguration && (
603 <MemberStorageSelector
605 value={model.storage}
606 sizeUnit={storageSizeUnit}
607 totalStorage={getTotalStorage(member, organization)}
608 range={getStorageRange(member, organization)}
609 onChange={(storage) => updatePartialModel({ storage })}
612 {showAddressesSection && (
614 <h3 className="text-strong mb-2">{c('Label').t`Addresses`}</h3>
616 <Addresses organization={organization} memberID={member.ID} hasDescription={false} />
622 <div>{/* empty div to make it right aligned*/}</div>
623 <Button loading={submitting} type="submit" color="norm">
624 {c('Action').t`Save`}
632 export default SubUserEditModal;