Skip to content

Commit f20b5b4

Browse files
authored
fix(settings): decouple access settings from usage analytics (#8828)
* fix(settings): decouple access settings from usage analytics * fix(billing): refuse expired reads and recover transaction timeouts * chore(settings): simplify ledger fixture and correct plan documentation * fix(settings): use neutral read-error feedback consistently * refactor(settings): share the named plan and seat response type
1 parent 090ff82 commit f20b5b4

73 files changed

Lines changed: 1020 additions & 697 deletions

File tree

Some content is hidden

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

‎.claude/rules/sim-settings-pages.md‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
description: Settings page layout and SettingsPanel design-system compliance
33
paths:
44
- "apps/sim/app/workspace/*/settings/**"
5+
- "apps/sim/app/o/*/settings/**"
56
- "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**"
67
- "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**"
78
- "apps/sim/components/{settings,permissions}/**"
@@ -10,14 +11,14 @@ paths:
1011

1112
# Settings Pages
1213

13-
The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
14+
Workspace and organization `settings/[section]/layout.tsx` own page chrome via
1415
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
1516
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
1617
by a **title + description from navigation metadata**. The chrome stays mounted
1718
across section navigation. Its routed title and description are available before
1819
the section body resolves. Each section renders through the **`SettingsPanel`**
1920
registrar
20-
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
21+
(`@/components/settings/settings-panel`), which feeds
2122
the shell its header data and renders only the section body. Sections supply
2223
**data**, never chrome.
2324

@@ -34,7 +35,7 @@ shell (fed through `SettingsPanel`):
3435
## Canonical page shape
3536

3637
```tsx
37-
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
38+
import { SettingsPanel } from '@/components/settings/settings-panel'
3839

3940
return (
4041
<SettingsPanel
@@ -228,11 +229,14 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
228229
renders the value paragraph itself; pass a node when the value needs its own
229230
presentation (a control, an icon beside the value, status styling).
230231
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
231-
muted status message, for empty lists, "no results", loading gates, **and
232-
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
233-
available height; `variant='inline'` sits in flow. Never hand-roll
234-
`<div className='flex h-full items-center justify-center …'>` or
235-
`<div className='py-4 text-center …'>`.
232+
muted status message for empty lists, "no results", loading gates, and failed
233+
reads. `variant='fill'` (default) centers in the available height;
234+
`variant='inline'` sits in flow. Never hand-roll its layout.
235+
- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
236+
with an accessible alert and retry control. Show an initial failure when data
237+
is unavailable; preserve cached content, mounted editors, and unsaved drafts
238+
during background failures, using an inline status. Validation and mutation
239+
failures retain their error styling.
236240
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
237241
actions menu for a list row. Pass `label` (aria-label) and
238242
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
@@ -395,6 +399,6 @@ A settings page is design-system-clean when:
395399
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `<button>`/`<Link>`, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke, and draw their own arrow with `RESOURCE_ROW_ARROW_CLASSES`: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), grids (secrets), and the member rows (see the avatar note above).
396400
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
397401
- [ ] Decorative trailing content is in `badge`, not `trailing`.
398-
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
402+
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
399403
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
400404
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.

‎.cursor/rules/sim-settings-pages.mdc‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
---
22
description: "Settings page layout and SettingsPanel design-system compliance"
3-
globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
3+
globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/o/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
44
---
55

66
<!-- Generated from .claude/rules/sim-settings-pages.md by `bun run skills:sync`. Edit the source, not this file. -->
77

88
# Settings Pages
99

10-
The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
10+
Workspace and organization `settings/[section]/layout.tsx` own page chrome via
1111
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
1212
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
1313
by a **title + description from navigation metadata**. The chrome stays mounted
1414
across section navigation. Its routed title and description are available before
1515
the section body resolves. Each section renders through the **`SettingsPanel`**
1616
registrar
17-
(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
17+
(`@/components/settings/settings-panel`), which feeds
1818
the shell its header data and renders only the section body. Sections supply
1919
**data**, never chrome.
2020

@@ -31,7 +31,7 @@ shell (fed through `SettingsPanel`):
3131
## Canonical page shape
3232

3333
```tsx
34-
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
34+
import { SettingsPanel } from '@/components/settings/settings-panel'
3535

3636
return (
3737
<SettingsPanel
@@ -225,11 +225,14 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
225225
renders the value paragraph itself; pass a node when the value needs its own
226226
presentation (a control, an icon beside the value, status styling).
227227
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
228-
muted status message, for empty lists, "no results", loading gates, **and
229-
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
230-
available height; `variant='inline'` sits in flow. Never hand-roll
231-
`<div className='flex h-full items-center justify-center …'>` or
232-
`<div className='py-4 text-center …'>`.
228+
muted status message for empty lists, "no results", loading gates, and failed
229+
reads. `variant='fill'` (default) centers in the available height;
230+
`variant='inline'` sits in flow. Never hand-roll its layout.
231+
- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
232+
with an accessible alert and retry control. Show an initial failure when data
233+
is unavailable; preserve cached content, mounted editors, and unsaved drafts
234+
during background failures, using an inline status. Validation and mutation
235+
failures retain their error styling.
233236
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
234237
actions menu for a list row. Pass `label` (aria-label) and
235238
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
@@ -392,6 +395,6 @@ A settings page is design-system-clean when:
392395
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `<button>`/`<Link>`, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke, and draw their own arrow with `RESOURCE_ROW_ARROW_CLASSES`: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), grids (secrets), and the member rows (see the avatar note above).
393396
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
394397
- [ ] Decorative trailing content is in `badge`, not `trailing`.
395-
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
398+
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
396399
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
397400
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.

‎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/components/organization-recently-deleted.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function OrganizationRecentlyDeleted({ organizationId }: OrganizationRece
6767
search={{ value: search, onChange: setSearch, placeholder: 'Search deleted chats' }}
6868
>
6969
{error ? (
70-
<SettingsEmptyState tone='error'>{error.message}</SettingsEmptyState>
70+
<SettingsEmptyState>{error.message}</SettingsEmptyState>
7171
) : isLoading ? null : filtered.length === 0 ? (
7272
<SettingsEmptyState>
7373
{searchTerm && chats.length > 0 ? 'No chats match your search' : 'No deleted chats'}

‎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

0 commit comments

Comments
 (0)