Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/eager-lions-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Only allow members with `org:sys_billing:manage` to manage billing for an Organization
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Protect } from '../../common';
import {
InvoicesContextProvider,
PlansContextProvider,
Expand All @@ -7,6 +8,7 @@ import {
} from '../../contexts';
import { Button, Col, descriptors, Flex, localizationKeys } from '../../customizables';
import {
Alert,
Card,
Header,
Tab,
Expand Down Expand Up @@ -74,28 +76,48 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => {
</TabsList>
<TabPanels>
<TabPanel sx={{ width: '100%', flexDirection: 'column' }}>
{subscriptions.data.length > 0 ? (
<Flex
sx={{ width: '100%', flexDirection: 'column' }}
gap={4}
>
<SubscriptionsList />
<Button
localizationKey='View all plans'
hasArrow
variant='ghost'
onClick={() => navigate('plans')}
sx={{
width: 'fit-content',
}}
/>
<PaymentSources />
</Flex>
) : (
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
<PricingTable />
</PricingTableContext.Provider>
)}
<Flex
sx={{ width: '100%', flexDirection: 'column' }}
gap={4}
>
{subscriptions.data.length > 0 ? (
<>
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
<Alert
variant='info'
colorScheme='info'
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
/>
</Protect>
<SubscriptionsList />
<Button
localizationKey='View all plans'
hasArrow
variant='ghost'
onClick={() => navigate('plans')}
sx={{
width: 'fit-content',
}}
/>
<Protect condition={has => has({ permission: 'org:sys_billing:manage' })}>
<PaymentSources />
</Protect>
</>
) : (
<>
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
<Alert
variant='info'
colorScheme='info'
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
/>
</Protect>
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
<PricingTable />
</PricingTableContext.Provider>
</>
)}
</Flex>
</TabPanel>
<TabPanel sx={{ width: '100%' }}>
<InvoicesContextProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Protect } from '../../common';
import { PlansContextProvider, PricingTableContext, SubscriberTypeContext } from '../../contexts';
import { Header } from '../../elements';
import { Flex } from '../../customizables';
import { Alert, Header } from '../../elements';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
import { PricingTable } from '../PricingTable/PricingTable';

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

return (
<>
<Header.Root sx={t => ({ marginBlockEnd: t.space.$4 })}>
<Header.Root
sx={t => ({
borderBottomWidth: t.borderWidths.$normal,
borderBottomStyle: t.borderStyles.$solid,
borderBottomColor: t.colors.$neutralAlpha100,
marginBlockEnd: t.space.$4,
paddingBlockEnd: t.space.$4,
})}
>
<Header.BackLink onClick={() => void navigate('../', { searchParams: new URLSearchParams('tab=plans') })}>
<Header.Title
localizationKey='Available Plans'
Expand All @@ -17,9 +28,21 @@ const OrganizationPlansPageInternal = () => {
</Header.BackLink>
</Header.Root>

<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
<PricingTable />
</PricingTableContext.Provider>
<Flex
direction='col'
gap={4}
>
<Protect condition={has => !has({ permission: 'org:sys_billing:manage' })}>
<Alert
variant='info'
colorScheme='info'
title={localizationKeys('organizationProfile.billingPage.alerts.noPemissionsToManageBilling')}
/>
</Protect>
<PricingTableContext.Provider value={{ componentName: 'PricingTable', mode: 'modal' }}>
<PricingTable />
</PricingTableContext.Provider>
</Flex>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ export const OrganizationProfileNavbar = (
}) || has({ permission: 'org:sys_memberships:manage' }),
);

const allowBillingRoutes = useProtect(
has =>
has({
permission: 'org:sys_billing:read',
}) || has({ permission: 'org:sys_billing:manage' }),
);

const routes = pages.routes
.filter(
r =>
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS ||
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS && allowMembersRoute),
)
.filter(
r =>
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING ||
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING && allowBillingRoutes),
);
if (!organization) {
return null;
}
Expand All @@ -30,11 +48,7 @@ export const OrganizationProfileNavbar = (
<NavBar
title={localizationKeys('organizationProfile.navbar.title')}
description={localizationKeys('organizationProfile.navbar.description')}
routes={pages.routes.filter(
r =>
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS ||
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS && allowMembersRoute),
)}
routes={routes}
contentRef={props.contentRef}
/>
{props.children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,33 @@ export const OrganizationProfileRoutes = () => {
</Switch>
</Route>
{commerceSettings.billing.enabled && commerceSettings.billing.hasPaidOrgPlans && (
<Route path={isBillingPageRoot ? undefined : 'organization-billing'}>
<Switch>
<Route index>
<Suspense fallback={''}>
<OrganizationBillingPage />
</Suspense>
</Route>
<Route path='plans'>
{/* TODO(@commerce): Should this be lazy loaded ? */}
<Suspense fallback={''}>
<OrganizationPlansPage />
</Suspense>
</Route>
<Route path='invoice/:invoiceId'>
{/* TODO(@commerce): Should this be lazy loaded ? */}
<Suspense fallback={''}>
<OrganizationInvoicePage />
</Suspense>
</Route>
</Switch>
</Route>
<Protect
condition={has =>
has({ permission: 'org:sys_billing:read' }) || has({ permission: 'org:sys_billing:manage' })
}
>
<Route path={isBillingPageRoot ? undefined : 'organization-billing'}>
<Switch>
<Route index>
<Suspense fallback={''}>
<OrganizationBillingPage />
</Suspense>
</Route>
<Route path='plans'>
{/* TODO(@commerce): Should this be lazy loaded ? */}
<Suspense fallback={''}>
<OrganizationPlansPage />
</Suspense>
</Route>
<Route path='invoice/:invoiceId'>
{/* TODO(@commerce): Should this be lazy loaded ? */}
<Suspense fallback={''}>
<OrganizationInvoicePage />
</Suspense>
</Route>
</Switch>
</Route>
</Protect>
)}
</Route>
</Switch>
Expand Down
8 changes: 8 additions & 0 deletions packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import * as React from 'react';
import { useState } from 'react';

import { useProtect } from '../../common';
import { PlansContextProvider, SubscriberTypeContext, usePlansContext, useSubscriberTypeContext } from '../../contexts';
import {
Badge,
Expand Down Expand Up @@ -55,6 +56,9 @@ const PlanDetailsInternal = ({
const { activeOrUpcomingSubscription, revalidate, buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } =
usePlansContext();
const subscriberType = useSubscriberTypeContext();
const canManageBilling = useProtect(
has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user',
);

if (!plan) {
return null;
Expand Down Expand Up @@ -226,6 +230,7 @@ const PlanDetailsInternal = ({
variant='bordered'
colorScheme='secondary'
textVariant='buttonLarge'
isDisabled={!canManageBilling}
onClick={() => openCheckout({ planPeriod: 'annual' })}
localizationKey={localizationKeys('commerce.switchToAnnual')}
/>
Expand All @@ -235,6 +240,7 @@ const PlanDetailsInternal = ({
variant='bordered'
colorScheme='danger'
textVariant='buttonLarge'
isDisabled={!canManageBilling}
onClick={() => setShowConfirmation(true)}
localizationKey={localizationKeys('commerce.cancelSubscription')}
/>
Expand Down Expand Up @@ -262,6 +268,7 @@ const PlanDetailsInternal = ({
variant='ghost'
size='sm'
textVariant='buttonLarge'
isDisabled={!canManageBilling}
onClick={() => {
setCancelError(undefined);
setShowConfirmation(false);
Expand All @@ -275,6 +282,7 @@ const PlanDetailsInternal = ({
size='sm'
textVariant='buttonLarge'
isLoading={isSubmitting}
isDisabled={!canManageBilling}
onClick={() => {
setCancelError(undefined);
setShowConfirmation(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ function Card(props: CardProps) {
borderTopWidth: t.borderWidths.$normal,
borderTopStyle: t.borderStyles.$solid,
borderTopColor: t.colors.$neutralAlpha100,
background: common.mergedColorsBackground(
colors.setAlpha(t.colors.$colorBackground, 0.3),
colors.setAlpha(t.colors.$colorBackground, 0.5),
),
})}
>
{subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CommerceSubscriptionResource } from '@clerk/types';

import { usePlansContext } from '../../contexts';
import { useProtect } from '../../common';
import { usePlansContext, useSubscriberTypeContext } from '../../contexts';
import {
Badge,
Button,
Expand All @@ -21,6 +22,10 @@ import { CogFilled, Plans } from '../../icons';

export function SubscriptionsList() {
const { subscriptions, handleSelectPlan, captionForSubscription, canManageSubscription } = usePlansContext();
const subscriberType = useSubscriberTypeContext();
const canManageBilling = useProtect(
has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user',
);

const handleSelectSubscription = (
subscription: CommerceSubscriptionResource,
Expand Down Expand Up @@ -118,6 +123,7 @@ export function SubscriptionsList() {
onClick={event => handleSelectSubscription(subscription, event)}
variant='bordered'
colorScheme='secondary'
isDisabled={!canManageBilling}
sx={t => ({
width: t.sizes.$6,
height: t.sizes.$6,
Expand Down
20 changes: 19 additions & 1 deletion packages/clerk-js/src/ui/contexts/components/Plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export const usePlansContext = () => {
throw new Error('Clerk: usePlansContext called outside Plans.');
}

const canManageBilling = useMemo(() => {
if (!clerk.session) {
return true;
}

if (clerk?.session?.checkAuthorization({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user') {
return true;
}

return false;
}, [clerk, subscriberType]);

const { componentName, ...ctx } = context;

// return the active or upcoming subscription for a plan if it exists
Expand Down Expand Up @@ -137,7 +149,12 @@ export const usePlansContext = () => {
plan?: CommercePlanResource;
subscription?: CommerceSubscriptionResource;
isCompact?: boolean;
}): { localizationKey: LocalizationKey; variant: 'bordered' | 'solid'; colorScheme: 'secondary' | 'primary' } => {
}): {
localizationKey: LocalizationKey;
variant: 'bordered' | 'solid';
colorScheme: 'secondary' | 'primary';
isDisabled: boolean;
} => {
const subscription = sub ?? (plan ? activeOrUpcomingSubscription(plan) : undefined);

return {
Expand All @@ -151,6 +168,7 @@ export const usePlansContext = () => {
: localizationKeys('commerce.subscribe'),
variant: isCompact || !!subscription ? 'bordered' : 'solid',
colorScheme: isCompact || !!subscription ? 'secondary' : 'primary',
isDisabled: !canManageBilling,
};
},
[activeOrUpcomingSubscription],
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/elements/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Alert as AlertCust, AlertIcon, Col, descriptors, Text } from '../custom
import type { PropsOfComponent } from '../styledSystem';

type _AlertProps = {
variant?: 'danger' | 'warning';
variant?: 'danger' | 'warning' | 'info';
title?: LocalizationKey | string;
subtitle?: LocalizationKey | string;
};
Expand All @@ -24,7 +24,7 @@ export const Alert = (props: AlertProps): JSX.Element | null => {
colorScheme={variant}
align='start'
{...rest}
sx={[t => ({ backgroundColor: t.colors.$warningAlpha100 }), rest.sx]}
sx={[rest.sx]}
>
<AlertIcon
elementId={descriptors.alert.setId(variant)}
Expand Down
Loading