Skip to content

Commit c792f37

Browse files
octoperaelioxalexcarpenterbrkalow
authored
fix(clerk-js): Allow only members with manage billing permission to manage billing in OP (#5835)
Co-authored-by: Keiran Flanigan <keiran@aeliox.com> Co-authored-by: Alex Carpenter <im.alexcarpenter@gmail.com> Co-authored-by: Bryce Kalow <bryce@clerk.dev>
1 parent feebe85 commit c792f37

54 files changed

Lines changed: 394 additions & 62 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/eager-lions-tell.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/clerk-js': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Only allow members with `org:sys_billing:manage` to manage billing for an Organization

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Protect } from '../../common';
12
import {
23
InvoicesContextProvider,
34
PlansContextProvider,
@@ -7,6 +8,7 @@ import {
78
} from '../../contexts';
89
import { Button, Col, descriptors, Flex, localizationKeys } from '../../customizables';
910
import {
11+
Alert,
1012
Card,
1113
Header,
1214
Tab,
@@ -74,28 +76,48 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
7476
</TabsList>
7577
<TabPanels>
7678
<TabPanel sx={{ width: '100%', flexDirection: 'column' }}>
77-
{subscriptions.data.length > 0 ? (
78-
<Flex
79-
sx={{ width: '100%', flexDirection: 'column' }}
80-
gap={4}
81-
>
82-
<SubscriptionsList />
83-
<Button
84-
localizationKey='View all plans'
85-
hasArrow
86-
variant='ghost'
87-
onClick={() => navigate('plans')}
88-
sx={{
89-
width: 'fit-content',
90-
}}
91-
/>
92-
<PaymentSources />
93-
</Flex>
94-
) : (
95-
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
96-
<PricingTable />
97-
</PricingTableContext.Provider>
98-
)}
79+
<Flex
80+
sx={{ width: '100%', flexDirection: 'column' }}
81+
gap={4}
82+
>
83+
{subscriptions.data.length > 0 ? (
84+
<>
85+
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
86+
<Alert
87+
variant='info'
88+
colorScheme='info'
89+
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
90+
/>
91+
</Protect>
92+
<SubscriptionsList />
93+
<Button
94+
localizationKey='View all plans'
95+
hasArrow
96+
variant='ghost'
97+
onClick={() => navigate('plans')}
98+
sx={{
99+
width: 'fit-content',
100+
}}
101+
/>
102+
<Protect condition={has => has({ permission: 'org:sys_billing:manage' })}>
103+
<PaymentSources />
104+
</Protect>
105+
</>
106+
) : (
107+
<>
108+
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
109+
<Alert
110+
variant='info'
111+
colorScheme='info'
112+
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
113+
/>
114+
</Protect>
115+
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
116+
<PricingTable />
117+
</PricingTableContext.Provider>
118+
</>
119+
)}
120+
</Flex>
99121
</TabPanel>
100122
<TabPanel sx={{ width: '100%' }}>
101123
<InvoicesContextProvider>

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationPlansPage.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { Protect } from '../../common';
12
import { PlansContextProvider, PricingTableContext, SubscriberTypeContext } from '../../contexts';
2-
import { Header } from '../../elements';
3+
import { Flex } from '../../customizables';
4+
import { Alert, Header } from '../../elements';
5+
import { localizationKeys } from '../../localization';
36
import { useRouter } from '../../router';
47
import { PricingTable } from '../PricingTable/PricingTable';
58

@@ -8,7 +11,15 @@ const OrganizationPlansPageInternal = () => {
811

912
return (
1013
<>
11-
<Header.Root sx={t => ({ marginBlockEnd: t.space.$4 })}>
14+
<Header.Root
15+
sx={t => ({
16+
borderBottomWidth: t.borderWidths.$normal,
17+
borderBottomStyle: t.borderStyles.$solid,
18+
borderBottomColor: t.colors.$neutralAlpha100,
19+
marginBlockEnd: t.space.$4,
20+
paddingBlockEnd: t.space.$4,
21+
})}
22+
>
1223
<Header.BackLink onClick={() => void navigate('../', { searchParams: new URLSearchParams('tab=plans') })}>
1324
<Header.Title
1425
localizationKey='Available Plans'
@@ -17,9 +28,21 @@ const OrganizationPlansPageInternal = () => {
1728
</Header.BackLink>
1829
</Header.Root>
1930

20-
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
21-
<PricingTable />
22-
</PricingTableContext.Provider>
31+
<Flex
32+
direction='col'
33+
gap={4}
34+
>
35+
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
36+
<Alert
37+
variant='info'
38+
colorScheme='info'
39+
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
40+
/>
41+
</Protect>
42+
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
43+
<PricingTable />
44+
</PricingTableContext.Provider>
45+
</Flex>
2346
</>
2447
);
2548
};

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ export const OrganizationProfileNavbar = (
2121
}) || has({ permission: 'org:sys_memberships:manage' }),
2222
);
2323

24+
const allowBillingRoutes = useProtect(
25+
has =>
26+
has({
27+
permission: 'org:sys_billing:read',
28+
}) || has({ permission: 'org:sys_billing:manage' }),
29+
);
30+
31+
const routes = pages.routes
32+
.filter(
33+
r =>
34+
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS ||
35+
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS && allowMembersRoute),
36+
)
37+
.filter(
38+
r =>
39+
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING ||
40+
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING && allowBillingRoutes),
41+
);
2442
if (!organization) {
2543
return null;
2644
}
@@ -30,11 +48,7 @@ export const OrganizationProfileNavbar = (
3048
<NavBar
3149
title={localizationKeys('organizationProfile.navbar.title')}
3250
description={localizationKeys('organizationProfile.navbar.description')}
33-
routes={pages.routes.filter(
34-
r =>
35-
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS ||
36-
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS && allowMembersRoute),
37-
)}
51+
routes={routes}
3852
contentRef={props.contentRef}
3953
/>
4054
{props.children}

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,27 +61,33 @@ export const OrganizationProfileRoutes = () => {
6161
</Switch>
6262
</Route>
6363
{commerceSettings.billing.enabled && commerceSettings.billing.hasPaidOrgPlans && (
64-
<Route path={isBillingPageRoot ? undefined : 'organization-billing'}>
65-
<Switch>
66-
<Route index>
67-
<Suspense fallback={''}>
68-
<OrganizationBillingPage />
69-
</Suspense>
70-
</Route>
71-
<Route path='plans'>
72-
{/* TODO(@commerce): Should this be lazy loaded ? */}
73-
<Suspense fallback={''}>
74-
<OrganizationPlansPage />
75-
</Suspense>
76-
</Route>
77-
<Route path='invoice/:invoiceId'>
78-
{/* TODO(@commerce): Should this be lazy loaded ? */}
79-
<Suspense fallback={''}>
80-
<OrganizationInvoicePage />
81-
</Suspense>
82-
</Route>
83-
</Switch>
84-
</Route>
64+
<Protect
65+
condition={has =>
66+
has({ permission: 'org:sys_billing:read' }) || has({ permission: 'org:sys_billing:manage' })
67+
}
68+
>
69+
<Route path={isBillingPageRoot ? undefined : 'organization-billing'}>
70+
<Switch>
71+
<Route index>
72+
<Suspense fallback={''}>
73+
<OrganizationBillingPage />
74+
</Suspense>
75+
</Route>
76+
<Route path='plans'>
77+
{/* TODO(@commerce): Should this be lazy loaded ? */}
78+
<Suspense fallback={''}>
79+
<OrganizationPlansPage />
80+
</Suspense>
81+
</Route>
82+
<Route path='invoice/:invoiceId'>
83+
{/* TODO(@commerce): Should this be lazy loaded ? */}
84+
<Suspense fallback={''}>
85+
<OrganizationInvoicePage />
86+
</Suspense>
87+
</Route>
88+
</Switch>
89+
</Route>
90+
</Protect>
8591
)}
8692
</Route>
8793
</Switch>

packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import * as React from 'react';
1111
import { useState } from 'react';
1212

13+
import { useProtect } from '../../common';
1314
import { PlansContextProvider, SubscriberTypeContext, usePlansContext, useSubscriberTypeContext } from '../../contexts';
1415
import {
1516
Badge,
@@ -55,6 +56,9 @@ const PlanDetailsInternal = ({
5556
const { activeOrUpcomingSubscription, revalidate, buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } =
5657
usePlansContext();
5758
const subscriberType = useSubscriberTypeContext();
59+
const canManageBilling = useProtect(
60+
has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user',
61+
);
5862

5963
if (!plan) {
6064
return null;
@@ -226,6 +230,7 @@ const PlanDetailsInternal = ({
226230
variant='bordered'
227231
colorScheme='secondary'
228232
textVariant='buttonLarge'
233+
isDisabled={!canManageBilling}
229234
onClick={() => openCheckout({ planPeriod: 'annual' })}
230235
localizationKey={localizationKeys('commerce.switchToAnnual')}
231236
/>
@@ -235,6 +240,7 @@ const PlanDetailsInternal = ({
235240
variant='bordered'
236241
colorScheme='danger'
237242
textVariant='buttonLarge'
243+
isDisabled={!canManageBilling}
238244
onClick={() => setShowConfirmation(true)}
239245
localizationKey={localizationKeys('commerce.cancelSubscription')}
240246
/>
@@ -262,6 +268,7 @@ const PlanDetailsInternal = ({
262268
variant='ghost'
263269
size='sm'
264270
textVariant='buttonLarge'
271+
isDisabled={!canManageBilling}
265272
onClick={() => {
266273
setCancelError(undefined);
267274
setShowConfirmation(false);
@@ -275,6 +282,7 @@ const PlanDetailsInternal = ({
275282
size='sm'
276283
textVariant='buttonLarge'
277284
isLoading={isSubmitting}
285+
isDisabled={!canManageBilling}
278286
onClick={() => {
279287
setCancelError(undefined);
280288
setShowConfirmation(false);

packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ function Card(props: CardProps) {
192192
borderTopWidth: t.borderWidths.$normal,
193193
borderTopStyle: t.borderStyles.$solid,
194194
borderTopColor: t.colors.$neutralAlpha100,
195+
background: common.mergedColorsBackground(
196+
colors.setAlpha(t.colors.$colorBackground, 0.3),
197+
colors.setAlpha(t.colors.$colorBackground, 0.5),
198+
),
195199
})}
196200
>
197201
{subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? (

packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CommerceSubscriptionResource } from '@clerk/types';
22

3-
import { usePlansContext } from '../../contexts';
3+
import { useProtect } from '../../common';
4+
import { usePlansContext, useSubscriberTypeContext } from '../../contexts';
45
import {
56
Badge,
67
Button,
@@ -21,6 +22,10 @@ import { CogFilled, Plans } from '../../icons';
2122

2223
export function SubscriptionsList() {
2324
const { subscriptions, handleSelectPlan, captionForSubscription, canManageSubscription } = usePlansContext();
25+
const subscriberType = useSubscriberTypeContext();
26+
const canManageBilling = useProtect(
27+
has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user',
28+
);
2429

2530
const handleSelectSubscription = (
2631
subscription: CommerceSubscriptionResource,
@@ -118,6 +123,7 @@ export function SubscriptionsList() {
118123
onClick={event => handleSelectSubscription(subscription, event)}
119124
variant='bordered'
120125
colorScheme='secondary'
126+
isDisabled={!canManageBilling}
121127
sx={t => ({
122128
width: t.sizes.$6,
123129
height: t.sizes.$6,

packages/clerk-js/src/ui/contexts/components/Plans.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ export const usePlansContext = () => {
102102
throw new Error('Clerk: usePlansContext called outside Plans.');
103103
}
104104

105+
const canManageBilling = useMemo(() => {
106+
if (!clerk.session) {
107+
return true;
108+
}
109+
110+
if (clerk?.session?.checkAuthorization({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user') {
111+
return true;
112+
}
113+
114+
return false;
115+
}, [clerk, subscriberType]);
116+
105117
const { componentName, ...ctx } = context;
106118

107119
// return the active or upcoming subscription for a plan if it exists
@@ -137,7 +149,12 @@ export const usePlansContext = () => {
137149
plan?: CommercePlanResource;
138150
subscription?: CommerceSubscriptionResource;
139151
isCompact?: boolean;
140-
}): { localizationKey: LocalizationKey; variant: 'bordered' | 'solid'; colorScheme: 'secondary' | 'primary' } => {
152+
}): {
153+
localizationKey: LocalizationKey;
154+
variant: 'bordered' | 'solid';
155+
colorScheme: 'secondary' | 'primary';
156+
isDisabled: boolean;
157+
} => {
141158
const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined);
142159

143160
return {
@@ -151,6 +168,7 @@ export const usePlansContext = () => {
151168
: localizationKeys('commerce.subscribe'),
152169
variant: isCompact || !!subscription ? 'bordered' : 'solid',
153170
colorScheme: isCompact || !!subscription ? 'secondary' : 'primary',
171+
isDisabled: !canManageBilling,
154172
};
155173
},
156174
[activeOrUpcomingSubscription],

packages/clerk-js/src/ui/elements/Alert.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Alert as AlertCust, AlertIcon, Col, descriptors, Text } from '../custom
33
import type { PropsOfComponent } from '../styledSystem';
44

55
type _AlertProps = {
6-
variant?: 'danger' | 'warning';
6+
variant?: 'danger' | 'warning' | 'info';
77
title?: LocalizationKey | string;
88
subtitle?: LocalizationKey | string;
99
};
@@ -24,7 +24,7 @@ export const Alert = (props: AlertProps): JSX.Element | null => {
2424
colorScheme={variant}
2525
align='start'
2626
{...rest}
27-
sx={[t => ({ backgroundColor: t.colors.$warningAlpha100 }), rest.sx]}
27+
sx={[rest.sx]}
2828
>
2929
<AlertIcon
3030
elementId={descriptors.alert.setId(variant)}

0 commit comments

Comments
 (0)