Skip to content

Commit 97c88ca

Browse files
author
MargeBot
committed
Merge branch 'refactor/shared-permission-check-components' into 'main'
refactor: shared permission check components/helpers See merge request web/clients!25647
2 parents 4aa882e + 73f15ca commit 97c88ca

12 files changed

Lines changed: 204 additions & 152 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { c } from 'ttag';
2+
3+
import { Banner, type BannerProps } from '@proton/atoms/Banner/Banner';
4+
5+
interface Props extends Omit<BannerProps, 'children'> {
6+
hasPermission: boolean;
7+
}
8+
9+
const PermissionBanner = ({ hasPermission, variant = 'norm', noIcon = true, ...rest }: Props) => {
10+
if (hasPermission) {
11+
return null;
12+
}
13+
14+
return (
15+
<Banner variant={variant} noIcon={noIcon} {...rest}>
16+
{c('Info').t`Editing requires permission`}
17+
</Banner>
18+
);
19+
};
20+
21+
export default PermissionBanner;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ReactNode } from 'react';
2+
3+
import { c } from 'ttag';
4+
5+
import { Tooltip, type Props as TooltipProps } from '@proton/atoms/Tooltip/Tooltip';
6+
7+
export interface Props extends Omit<TooltipProps, 'children' | 'title'> {
8+
hasPermission: boolean;
9+
children: ReactNode;
10+
openDelay?: number;
11+
className?: string;
12+
wrapperClassName?: string;
13+
}
14+
15+
const PermissionTooltip = ({
16+
hasPermission,
17+
children,
18+
openDelay = 100,
19+
className,
20+
wrapperClassName,
21+
...rest
22+
}: Props) => (
23+
<Tooltip
24+
{...rest}
25+
title={hasPermission ? undefined : c('Label').t`You don't have permissions`}
26+
openDelay={openDelay}
27+
tooltipClassName={className}
28+
>
29+
<span className={wrapperClassName}>{children}</span>
30+
</Tooltip>
31+
);
32+
33+
export default PermissionTooltip;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as PermissionBanner } from './PermissionBanner';
2+
export { default as PermissionTooltip } from './PermissionTooltip';
3+
export { default as withPermissionGuard } from './withPermissionGuard';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { ComponentType } from 'react';
2+
3+
import { useOrgPermissions } from '@proton/account/userPermissions/hooks';
4+
import type { ButtonLikeOwnProps } from '@proton/atoms/Button/ButtonLike';
5+
import type { Permission } from '@proton/shared/lib/interfaces/UserPermission';
6+
7+
import PermissionTooltip, { type Props as PermissionTooltipProps } from './PermissionTooltip';
8+
9+
type ExtendedTooltipProps = Omit<PermissionTooltipProps, 'hasPermission' | 'children'>;
10+
11+
/**
12+
* HOC that guards a ButtonLike component behind one or more org permissions.
13+
*
14+
* When the user lacks permission, the component is disabled and a tooltip is shown.
15+
* Multiple permissions use AND semantics — all must be held.
16+
* Defaults to blocked (`hasPermission = false`) while permissions are loading.
17+
*
18+
* Only works with ButtonLike components: `disabled` is required to block interaction,
19+
* and PermissionTooltip's span wrapper is needed to capture mouse events on disabled elements.
20+
*
21+
* @example
22+
* const GuardedButton = withPermissionGuard('account.user.create')(Button);
23+
* const GuardedButton = withPermissionGuard(['account.user.create', 'account.user.update'])(Button);
24+
*/
25+
const withPermissionGuard =
26+
(constraints: Permission | Permission[]) =>
27+
<P extends ButtonLikeOwnProps>(Component: ComponentType<P>) => {
28+
const WithPermissionGuard = (props: P & { tooltip?: ExtendedTooltipProps }) => {
29+
const [permissions] = useOrgPermissions();
30+
const hasPermission = Array.isArray(constraints)
31+
? permissions !== null && constraints.every((permission) => permissions[permission])
32+
: (permissions?.[constraints] ?? false);
33+
34+
const { tooltip, ...rest } = props;
35+
const overrideProps = {
36+
...rest,
37+
disabled: props.disabled || !hasPermission,
38+
} as unknown as P;
39+
40+
return (
41+
<PermissionTooltip {...tooltip} hasPermission={hasPermission}>
42+
<Component {...overrideProps} />
43+
</PermissionTooltip>
44+
);
45+
};
46+
47+
WithPermissionGuard.displayName = `WithPermissionGuard(${Component.displayName ?? Component.name})`;
48+
49+
return WithPermissionGuard;
50+
};
51+
52+
export default withPermissionGuard;

packages/components/containers/members/UsersAndAddressesSection/MembersTableHeader.tsx

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type { ReactNode } from 'react';
33
import { c } from 'ttag';
44

55
import { Button } from '@proton/atoms/Button/Button';
6-
import { Tooltip } from '@proton/atoms/Tooltip/Tooltip';
76
import Info from '@proton/components/components/link/Info';
7+
import withPermissionGuard from '@proton/components/components/orgPermissions/withPermissionGuard';
88
import { SetupOrgSpotlight } from '@proton/components/containers/account/spotlights/passB2bOnboardingSpotlights/PassB2bOnboardingSpotlights';
99
import {
1010
getInvitationAcceptLimit,
@@ -13,6 +13,9 @@ import {
1313
import type { UseUserMemberActions } from '@proton/components/containers/members/UsersAndAddressesSection/useMemberActions';
1414
import type { APP_NAMES } from '@proton/shared/lib/constants';
1515

16+
const GuardedAddUserButton = withPermissionGuard('account.user.create')(Button);
17+
const GuardedAddAddressButton = withPermissionGuard('account.user.update')(Button);
18+
1619
export const MembersTableHeader = ({
1720
app,
1821
searchInput,
@@ -23,13 +26,9 @@ export const MembersTableHeader = ({
2326
membersHook: UseUserMemberActions;
2427
}) => {
2528
const createUserButtonWithTooltip = (
26-
<Tooltip title={!meta.canAddUser ? c('Label').t`You don't have permissions` : undefined} openDelay={100}>
27-
<span>
28-
<Button color="norm" disabled={meta.disableAddUserButton} onClick={actions.handleAddUser}>
29-
{c('Action').t`Add user`}
30-
</Button>
31-
</span>
32-
</Tooltip>
29+
<GuardedAddUserButton color="norm" disabled={meta.disableAddUserButton} onClick={actions.handleAddUser}>
30+
{c('Action').t`Add user`}
31+
</GuardedAddUserButton>
3332
);
3433
return (
3534
<div className="mb-4 flex items-start">
@@ -43,20 +42,13 @@ export const MembersTableHeader = ({
4342
{meta.showAddAddress && (
4443
<>
4544
{meta.isOrgAFamilyPlan ? (
46-
<Tooltip
47-
title={!meta.canAddUser ? c('Label').t`You don't have permissions` : undefined}
48-
openDelay={100}
45+
<GuardedAddUserButton
46+
color="norm"
47+
disabled={meta.disableInviteUserButton}
48+
onClick={actions.handleInviteUser}
4949
>
50-
<span>
51-
<Button
52-
color="norm"
53-
disabled={meta.disableInviteUserButton}
54-
onClick={actions.handleInviteUser}
55-
>
56-
{c('Action').t`Invite user`}
57-
</Button>
58-
</span>
59-
</Tooltip>
50+
{c('Action').t`Invite user`}
51+
</GuardedAddUserButton>
6052
) : (
6153
createUserButtonWithTooltip
6254
)}
@@ -75,20 +67,13 @@ export const MembersTableHeader = ({
7567
))}
7668

7769
{meta.hasMaxAddresses && (
78-
<Tooltip
79-
title={!meta.canUpdateUser ? c('Label').t`You don't have permissions` : undefined}
80-
openDelay={100}
70+
<GuardedAddAddressButton
71+
shape="outline"
72+
disabled={meta.disableAddAddressButton}
73+
onClick={() => actions.handleAddAddress()}
8174
>
82-
<span>
83-
<Button
84-
shape="outline"
85-
disabled={meta.disableAddAddressButton}
86-
onClick={() => actions.handleAddAddress()}
87-
>
88-
{c('Action').t`Add address`}
89-
</Button>
90-
</span>
91-
</Tooltip>
75+
{c('Action').t`Add address`}
76+
</GuardedAddAddressButton>
9277
)}
9378
</>
9479
)}

packages/components/containers/members/UsersAndAddressesSection/useMemberActions.tsx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export const useMemberActions = ({
8484
const { value: memberRolesMap } = useMemberRoles({ members });
8585
const api = useSilentApi();
8686
const dispatch = useDispatch();
87-
const [permissions, loadingPermissions] = useOrgPermissions();
87+
const [, loadingPermissions] = useOrgPermissions();
8888

8989
const {
9090
passOnboardingSpotlights: { setupOrgSpotlight },
@@ -165,29 +165,22 @@ export const useMemberActions = ({
165165
hasPassBusiness(subscription) ||
166166
hasVPNPassProfessional(subscription);
167167

168-
const canAddUser = !!permissions?.['account.user.create'];
169168
const disableAddUserButton =
170169
loadingSubscription ||
171170
loadingOrganization ||
172171
loadingCustomDomains ||
173172
loadingPermissions ||
174-
(organization?.UsedMembers || 0) >= (organization?.MaxMembers || 0) ||
175-
!canAddUser;
173+
(organization?.UsedMembers || 0) >= (organization?.MaxMembers || 0);
176174

177175
const showAddAddress = !hasExternalMemberCapableB2BPlan || hasPassB2BPlan || hasMeetB2BPlanAndVerifiedCustomDomain;
178176

179177
const hasReachedInvitationLimit = organization?.InvitationsRemaining === 0;
180178
const disableInviteUserButton =
181-
loadingSubscription || loadingOrganization || loadingCustomDomains || hasReachedInvitationLimit || !canAddUser;
179+
loadingSubscription || loadingOrganization || loadingCustomDomains || hasReachedInvitationLimit;
182180

183181
const loadingAddAddresses = loadingOrganization || loadingCustomDomains || loadingMembers;
184-
const canUpdateUser = !!permissions?.['account.user.update'];
185182
const disableAddAddressButton =
186-
loadingAddAddresses ||
187-
loadingPermissions ||
188-
organization?.State === ORGANIZATION_STATE.DELINQUENT ||
189-
!canUpdateUser;
190-
const canDeleteUser = !!permissions?.['account.user.delete'];
183+
loadingAddAddresses || loadingPermissions || organization?.State === ORGANIZATION_STATE.DELINQUENT;
191184

192185
const hasSetupActiveOrganizationWithKeys =
193186
organization?.State === ORGANIZATION_STATE.ACTIVE && hasOrganizationSetupWithKeys(organization);
@@ -536,12 +529,9 @@ export const useMemberActions = ({
536529
isOrgAFamilyPlan,
537530
useEmail,
538531
hasExternalMemberCapableB2BPlan,
539-
canAddUser,
540532
disableAddUserButton,
541533
showAddAddress,
542534
canInviteProtonUsers,
543-
canUpdateUser,
544-
canDeleteUser,
545535
disableInviteUserButton,
546536
disableAddAddressButton,
547537
hasSetupActiveOrganizationWithKeys,

packages/components/containers/organization/AuthenticationLogs.tsx

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { c } from 'ttag';
55

66
import { useMembers } from '@proton/account/members/hooks';
77
import { organizationActions } from '@proton/account/organization';
8-
import { useOrgPermissions } from '@proton/account/userPermissions/hooks';
98
import { Banner } from '@proton/atoms/Banner/Banner';
109
import { Button } from '@proton/atoms/Button/Button';
11-
import { Tooltip } from '@proton/atoms/Tooltip/Tooltip';
1210
import AddressesInput, { AddressesInputItem } from '@proton/components/components/addressesInput/AddressesInput';
11+
import withPermissionGuard from '@proton/components/components/orgPermissions/withPermissionGuard';
1312
import DateInput from '@proton/components/components/input/DateInput';
1413
import Label from '@proton/components/components/label/Label';
1514
import Info from '@proton/components/components/link/Info';
@@ -47,6 +46,8 @@ import { convertEnhancedMembersToContactEmails } from './groups/helpers';
4746
import useAuthLogsDateFilter from './useAuthLogsFilter';
4847
import useOrgAuthLogs from './useOrgAuthLogs';
4948

49+
const GuardedExportButton = withPermissionGuard('account.activity_log.export')(Button);
50+
5051
interface Props {
5152
organization?: OrganizationExtended;
5253
activityMonitorSection?: boolean;
@@ -62,8 +63,6 @@ const AuthenticationLogs = ({
6263
}: Props) => {
6364
const api = useApi();
6465
const [members] = useMembers();
65-
const [permissions] = useOrgPermissions();
66-
const canExport = !!permissions?.['account.activity_log.export'];
6766
const [submitting, withSubmitting] = useLoading();
6867
const { createNotification } = useNotifications();
6968
const { page, onNext, onPrevious, onSelect, reset } = usePaginationAsync(1);
@@ -355,25 +354,18 @@ const AuthenticationLogs = ({
355354
<IcArrowRotateRight className="mr-2" />
356355
{c('Action').t`Refresh`}
357356
</Button>
358-
<Tooltip
359-
title={canExport ? null : c('Label').t`You don't have permissions`}
360-
openDelay={100}
357+
<GuardedExportButton
358+
shape="outline"
359+
className="self-end"
360+
onClick={() =>
361+
withLoadingDownload(handleDownload(authLogs, organization))
362+
}
363+
loading={loadingDownload}
364+
tooltip={{ wrapperClassName: 'inline-flex items-center' }}
361365
>
362-
<span className="inline-flex items-center">
363-
<Button
364-
shape="outline"
365-
className="self-end"
366-
onClick={() =>
367-
withLoadingDownload(handleDownload(authLogs, organization))
368-
}
369-
loading={loadingDownload}
370-
disabled={!canExport}
371-
>
372-
<IcArrowDownLine className="mr-2" />
373-
{c('Action').t`Export`}
374-
</Button>
375-
</span>
376-
</Tooltip>
366+
<IcArrowDownLine className="mr-2" />
367+
{c('Action').t`Export`}
368+
</GuardedExportButton>
377369
</div>
378370
</form>
379371
<div className="flex mb-4">{items.slice(0, 50)}</div>

packages/components/containers/organization/OrganizationPasswordPoliciesSection.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import { c } from 'ttag';
55

66
import { organizationActions } from '@proton/account/organization';
77
import { useOrgPermissions } from '@proton/account/userPermissions/hooks';
8-
import { Banner } from '@proton/atoms/Banner/Banner';
98
import { Button } from '@proton/atoms/Button/Button';
10-
import { Tooltip } from '@proton/atoms/Tooltip/Tooltip';
9+
import { PermissionBanner, PermissionTooltip } from '@proton/components/components/orgPermissions';
1110
import RadioGroup from '@proton/components/components/input/RadioGroup';
1211
import Info from '@proton/components/components/link/Info';
1312
import InputFieldTwo from '@proton/components/components/v2/field/InputField';
@@ -262,11 +261,7 @@ const OrganizationPasswordPoliciesSection = ({ organization }: OrganizationProto
262261
{c('Info')
263262
.t`You can enforce the password rules members of your organization will use when they create or reset the password to their ${BRAND_NAME} Account.`}
264263
</SettingsParagraph>
265-
{!canSavePolicy && (
266-
<Banner variant="norm" noIcon className="mb-4 max-w-custom" style={{ '--max-w-custom': '43em' }}>
267-
{c('Info').t`Editing requires permission`}
268-
</Banner>
269-
)}
264+
<PermissionBanner hasPermission={!!canSavePolicy} className="mb-4 max-w-custom" style={{ '--max-w-custom': '43em' }} />
270265

271266
<form
272267
name="org-password-policies"
@@ -321,13 +316,11 @@ const OrganizationPasswordPoliciesSection = ({ organization }: OrganizationProto
321316
/>
322317
))}
323318

324-
<Tooltip title={!canSavePolicy ? c('Label').t`You don't have permissions` : undefined}>
325-
<span>
326-
<Button type="submit" color="norm" disabled={!canSave || !canSavePolicy} loading={loading}>
327-
{c('Action').t`Save`}
328-
</Button>
329-
</span>
330-
</Tooltip>
319+
<PermissionTooltip hasPermission={!!canSavePolicy}>
320+
<Button type="submit" color="norm" disabled={!canSave || !canSavePolicy} loading={loading}>
321+
{c('Action').t`Save`}
322+
</Button>
323+
</PermissionTooltip>
331324
</form>
332325
</>
333326
);

0 commit comments

Comments
 (0)