Skip to content

Commit 24e5adc

Browse files
committed
fix(settings): decouple access settings from usage analytics
1 parent 146be34 commit 24e5adc

39 files changed

Lines changed: 877 additions & 581 deletions

‎apps/sim/app/api/billing/route.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { and, desc, eq } from 'drizzle-orm'
1010
import { type NextRequest, NextResponse } from 'next/server'
1111
import { getBillingContract } from '@/lib/api/contracts/subscription'
1212
import { parseRequest } from '@/lib/api/server'
13+
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
1314
import { getSession } from '@/lib/auth'
1415
import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
1516
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
@@ -125,6 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
125126
getOrganizationBillingData(organizationId, dbReplica, {
126127
limit: memberLimit,
127128
offset: memberOffset,
129+
includeMemberUsageCounts: false,
128130
}),
129131
getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
130132
dbReplica
@@ -218,6 +220,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
218220
error,
219221
})
220222

223+
const recoverable = internalBillingReadErrorPolicy.project(error)
224+
if (recoverable)
225+
return NextResponse.json(recoverable.body, {
226+
status: recoverable.status,
227+
headers: recoverable.headers,
228+
})
229+
221230
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
222231
}
223232
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getOrganizationPlanSeatsContract } from '@/lib/api/contracts/organization'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
8+
import { getOrganizationPlanSeats } from '@/lib/billing/application/organization-billing/get-organization-plan-seats'
9+
import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getOrganizationPlanSeatsContract,
15+
auth: internalSessionAuth,
16+
operation: organizationBillingOperations.planSeats,
17+
rateLimit: internalRateLimits.none({
18+
reason: 'Small organization plan and seat read restricted to current admins and owners',
19+
}),
20+
errorPolicy: internalBillingReadErrorPolicy,
21+
mapInput: ({ params }) => ({ organizationId: params.id }),
22+
useCase: getOrganizationPlanSeats,
23+
present: (data) => ({ success: true, data }),
24+
})

‎apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@ const { mockReadBillingSummary } = vi.hoisted(() => ({
66
mockReadBillingSummary: vi.fn(),
77
}))
88

9-
vi.mock(
10-
'@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
11-
() => ({
12-
getOrganizationBillingSummary: {
13-
operation: { id: 'organization_billing.summary.read' },
14-
execute: mockReadBillingSummary,
15-
},
16-
})
17-
)
9+
vi.mock('@/lib/billing/application/organization-billing/get-organization-billing-summary', () => ({
10+
getOrganizationBillingSummary: {
11+
operation: { id: 'organization_billing.summary.read' },
12+
execute: mockReadBillingSummary,
13+
},
14+
}))
1815

1916
import { GET } from '@/app/api/organizations/[id]/billing-summary/route'
2017

‎apps/sim/app/api/organizations/[id]/billing-summary/route.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
22
import {
33
defineInternalJsonRoute,
4-
internalOrchestrationErrorPolicy,
54
internalRateLimits,
65
internalSessionAuth,
76
} from '@/lib/api/server/routes'
8-
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
9-
import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
7+
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
8+
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing/get-organization-billing-summary'
9+
import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
1010

1111
export const dynamic = 'force-dynamic'
1212

1313
export const GET = defineInternalJsonRoute({
1414
contract: getOrganizationBillingSummaryContract,
1515
auth: internalSessionAuth,
16-
operation: organizationBillingSummaryOperations.read,
16+
operation: organizationBillingOperations.read,
1717
rateLimit: internalRateLimits.none({
1818
reason: 'Authenticated organization billing read, restricted to organization admins and owners',
1919
}),
20-
errorPolicy: internalOrchestrationErrorPolicy,
20+
errorPolicy: internalBillingReadErrorPolicy,
2121
mapInput: ({ params }) => ({ organizationId: params.id }),
2222
useCase: getOrganizationBillingSummary,
2323
present: (data) => ({ success: true, data }),

‎apps/sim/app/api/usage/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
internalRateLimits,
99
internalSessionAuth,
1010
} from '@/lib/api/server/routes'
11+
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
1112
import {
1213
readUsageLimit,
1314
type UsageLimitResult,
@@ -48,7 +49,7 @@ export const GET = defineInternalJsonRoute({
4849
auth: internalSessionAuth,
4950
operation: usageLimitOperations.read,
5051
rateLimit,
51-
errorPolicy,
52+
errorPolicy: internalBillingReadErrorPolicy,
5253
mapInput: ({ query }) => query,
5354
useCase: readUsageLimit,
5455
present,

‎apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import type {
44
OrganizationSettingsSection,
55
} from '@/components/settings/navigation'
66
import {
7-
organizationBillingQueryOptions,
87
organizationDetailQueryOptions,
98
organizationRosterQueryOptions,
109
} from '@/hooks/queries/organization'
1110
import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
11+
import { organizationPlanSeatsOptions } from '@/hooks/queries/organization-plan-seats'
1212
import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'
1313

1414
interface OrganizationSettingsQueryWarmContext {
@@ -28,7 +28,7 @@ export function warmOrganizationSettingsSectionQuery(
2828
prefetchQueryOnIntent(queryClient, organizationDetailQueryOptions(organizationId))
2929
prefetchQueryOnIntent(queryClient, organizationRosterQueryOptions(organizationId))
3030
if (isAdmin) {
31-
prefetchQueryOnIntent(queryClient, organizationBillingQueryOptions(organizationId))
31+
prefetchQueryOnIntent(queryClient, organizationPlanSeatsOptions(organizationId))
3232
}
3333
}
3434
if (section === 'billing' && isAdmin) {

‎apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
2121
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
2222
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
2323
import { useSendWorkspaceInvitations } from '@/hooks/queries/invitations'
24-
import { useOrganizationBilling } from '@/hooks/queries/organization'
24+
import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
2525
import { useAdminWorkspaces } from '@/hooks/queries/workspace'
2626

2727
const logger = createLogger('InviteModal')
@@ -193,7 +193,7 @@ export function InviteModal({
193193
)
194194
const canViewOrganizationBilling = canGrantOrganizationAdmin
195195

196-
const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
196+
const { data: organizationBillingData } = useOrganizationPlanSeats(organizationId ?? '', {
197197
enabled: open && billingEnabled && canViewOrganizationBilling,
198198
})
199199

‎apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ import {
2929
useCreateOrganization,
3030
useMemberRemovalImpact,
3131
useOrganization,
32-
useOrganizationBilling,
3332
useOrganizationRoster,
3433
useRemoveMember,
3534
useTransferOwnership,
3635
} from '@/hooks/queries/organization'
36+
import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
3737
import { useOpenBillingPortal, useSubscriptionData } from '@/hooks/queries/subscription'
3838
import { usePermissionConfig } from '@/hooks/use-permission-config'
3939

@@ -92,7 +92,7 @@ export function TeamManagement({
9292
isFetchedAfterMount: isOrganizationBillingFetchedAfterMount,
9393
isFetching: isOrganizationBillingFetching,
9494
refetch: refetchOrganizationBilling,
95-
} = useOrganizationBilling(organizationId, { enabled: billingEnabled && adminOrOwner })
95+
} = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled && adminOrOwner })
9696

9797
const {
9898
data: roster,
@@ -175,7 +175,7 @@ export function TeamManagement({
175175
* subscriptions.
176176
*/
177177
const orgBilling = billingEnabled ? (organizationBillingData?.data ?? null) : null
178-
const orgSubscription = orgBilling
178+
const orgSubscription = orgBilling?.subscriptionPlan
179179
? {
180180
id: orgBilling.organizationId,
181181
plan: orgBilling.subscriptionPlan,

‎apps/sim/ee/access-control/components/access-control.tsx‎

Lines changed: 37 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ import { createLogger } from '@sim/logger'
1818
import { getErrorMessage } from '@sim/utils/errors'
1919
import { useParams } from 'next/navigation'
2020
import { useQueryState } from 'nuqs'
21-
import { isEnterprise } from '@/lib/billing/plan-helpers'
22-
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
2321
import {
2422
groupIdParam,
2523
groupIdUrlKeys,
@@ -47,9 +45,7 @@ import {
4745
useCreatePermissionGroup,
4846
useOrganizationWorkspaces,
4947
usePermissionGroups,
50-
useUserPermissionConfig,
5148
} from '@/ee/access-control/hooks/permission-groups'
52-
import { useOrganizationBilling } from '@/hooks/queries/organization'
5349

5450
const logger = createLogger('AccessControl')
5551

@@ -65,57 +61,21 @@ export function AccessControl({
6561
requestsHref,
6662
}: AccessControlProps) {
6763
const params = useParams()
68-
const { features } = useDeploymentShape()
6964
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
7065

71-
/**
72-
* Access control is governed by the workspace's OWNING organization, which may
73-
* differ from the caller's active org (e.g. external members). Resolve the org
74-
* id and the caller's admin status server-side from the workspace so gating is
75-
* never keyed off the session's active org.
76-
*/
77-
const {
78-
data: userPermissionConfig,
79-
isPending: entitlementLoading,
80-
error: entitlementError,
81-
} = useUserPermissionConfig(workspaceId)
82-
const {
83-
data: organizationBillingData,
84-
isPending: organizationBillingLoading,
85-
error: organizationBillingError,
86-
} = useOrganizationBilling(organizationId, {
87-
enabled: !features.accessControl && !userPermissionConfig?.entitled,
88-
})
89-
const currentUserIsOrgAdmin = isOrganizationAdmin
90-
9166
const {
92-
data: permissionGroups = [],
67+
data: permissionGroupsData,
9368
isPending: groupsLoading,
9469
error: groupsError,
9570
isFetching: groupsFetching,
9671
refetch: refetchGroups,
97-
} = usePermissionGroups(organizationId, !!organizationId && currentUserIsOrgAdmin)
72+
} = usePermissionGroups(organizationId, !!organizationId && isOrganizationAdmin)
73+
const permissionGroups = permissionGroupsData ?? []
9874
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
99-
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
75+
useOrganizationWorkspaces(organizationId, !!organizationId && isOrganizationAdmin)
10076

101-
/**
102-
* Must be the resolved flag, not the raw `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`
103-
* read. The settings nav decides visibility from the same resolver, so
104-
* reading the bare var here let a deployment with only `ENTERPRISE_ENABLED`
105-
* set show the section and then refuse to manage it.
106-
*/
107-
const isEntitled =
108-
features.accessControl ||
109-
!!userPermissionConfig?.entitled ||
110-
isEnterprise(organizationBillingData?.data?.subscriptionPlan)
111-
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
112-
const organizationEntitlementLoading =
113-
!features.accessControl && !userPermissionConfig?.entitled && organizationBillingLoading
114-
115-
const isLoading =
116-
(workspaceId ? entitlementLoading : false) ||
117-
organizationEntitlementLoading ||
118-
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)
77+
const canManage = isOrganizationAdmin && !!organizationId
78+
const isLoading = canManage && groupsLoading
11979

12080
const createPermissionGroup = useCreatePermissionGroup()
12181

@@ -225,22 +185,24 @@ export function AccessControl({
225185
},
226186
]
227187

188+
const groupsErrorState = groupsError ? (
189+
<SettingsQueryErrorState
190+
error={groupsError}
191+
fallback={
192+
permissionGroupsData === undefined
193+
? 'Failed to load permission groups'
194+
: 'Failed to refresh permission groups'
195+
}
196+
isRetrying={groupsFetching}
197+
onRetry={() => void refetchGroups()}
198+
variant={permissionGroupsData === undefined ? 'fill' : 'inline'}
199+
/>
200+
) : null
201+
228202
if (isLoading) {
229203
return <SettingsPanel search={listSearch} actions={listActions} />
230204
}
231205

232-
const entitlementLoadError = isEntitled
233-
? null
234-
: ((userPermissionConfig === undefined ? entitlementError : null) ??
235-
(organizationBillingData === undefined ? organizationBillingError : null))
236-
if (entitlementLoadError) {
237-
return (
238-
<SettingsEmptyState tone='error'>
239-
{getErrorMessage(entitlementLoadError, 'Failed to load Access Control access')}
240-
</SettingsEmptyState>
241-
)
242-
}
243-
244206
if (!canManage) {
245207
return (
246208
<SettingsEmptyState>
@@ -251,38 +213,33 @@ export function AccessControl({
251213
)
252214
}
253215

254-
if (groupsError) {
255-
return (
256-
<SettingsPanel search={listSearch}>
257-
<SettingsQueryErrorState
258-
error={groupsError}
259-
fallback='Failed to load permission groups'
260-
isRetrying={groupsFetching}
261-
onRetry={() => void refetchGroups()}
262-
/>
263-
</SettingsPanel>
264-
)
216+
if (groupsError && permissionGroupsData === undefined) {
217+
return <SettingsPanel search={listSearch}>{groupsErrorState}</SettingsPanel>
265218
}
266219

267220
if (selectedGroup && organizationId) {
268221
return (
269-
<GroupDetail
270-
key={selectedGroup.id}
271-
group={selectedGroup}
272-
organizationId={organizationId}
273-
workspaceId={workspaceId}
274-
workspaceOptions={workspaceOptions}
275-
organizationWorkspaces={organizationWorkspaces}
276-
workspacesLoading={workspacesLoading}
277-
onBack={closeGroupDetail}
278-
onDeleted={closeGroupDetail}
279-
/>
222+
<>
223+
{groupsErrorState}
224+
<GroupDetail
225+
key={selectedGroup.id}
226+
group={selectedGroup}
227+
organizationId={organizationId}
228+
workspaceId={workspaceId}
229+
workspaceOptions={workspaceOptions}
230+
organizationWorkspaces={organizationWorkspaces}
231+
workspacesLoading={workspacesLoading}
232+
onBack={closeGroupDetail}
233+
onDeleted={closeGroupDetail}
234+
/>
235+
</>
280236
)
281237
}
282238

283239
return (
284240
<>
285241
<SettingsPanel search={listSearch} actions={listActions}>
242+
{groupsErrorState}
286243
<SettingsSection
287244
label={`Permission groups (${permissionGroups.length})`}
288245
action={<ChipLink href={requestsHref}>Review requests</ChipLink>}

0 commit comments

Comments
 (0)