Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 13 additions & 9 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
description: Settings page layout and SettingsPanel design-system compliance
paths:
- "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}/**"
Expand All @@ -10,14 +11,14 @@ paths:

# Settings Pages

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

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

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

return (
<SettingsPanel
Expand Down Expand Up @@ -228,11 +229,14 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
renders the value paragraph itself; pass a node when the value needs its own
presentation (a control, an icon beside the value, status styling).
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
muted status message, for empty lists, "no results", loading gates, **and
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
available height; `variant='inline'` sits in flow. Never hand-roll
`<div className='flex h-full items-center justify-center …'>` or
`<div className='py-4 text-center …'>`.
muted status message for empty lists, "no results", loading gates, and failed
reads. `variant='fill'` (default) centers in the available height;
`variant='inline'` sits in flow. Never hand-roll its layout.
- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
with an accessible alert and retry control. Show an initial failure when data
is unavailable; preserve cached content, mounted editors, and unsaved drafts
during background failures, using an inline status. Validation and mutation
failures retain their error styling.
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
actions menu for a list row. Pass `label` (aria-label) and
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
Expand Down Expand Up @@ -395,6 +399,6 @@ A settings page is design-system-clean when:
- [ ] 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).
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
- [ ] Decorative trailing content is in `badge`, not `trailing`.
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.
23 changes: 13 additions & 10 deletions .cursor/rules/sim-settings-pages.mdc
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
---
description: "Settings page layout and SettingsPanel design-system compliance"
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/**"]
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/**"]
---

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

# Settings Pages

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

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

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

return (
<SettingsPanel
Expand Down Expand Up @@ -225,11 +225,14 @@ and — on activatable rows only — the hover band. Never hand-roll any of it,
renders the value paragraph itself; pass a node when the value needs its own
presentation (a control, an icon beside the value, status styling).
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
muted status message, for empty lists, "no results", loading gates, **and
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
available height; `variant='inline'` sits in flow. Never hand-roll
`<div className='flex h-full items-center justify-center …'>` or
`<div className='py-4 text-center …'>`.
muted status message for empty lists, "no results", loading gates, and failed
reads. `variant='fill'` (default) centers in the available height;
`variant='inline'` sits in flow. Never hand-roll its layout.
- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
with an accessible alert and retry control. Show an initial failure when data
is unavailable; preserve cached content, mounted editors, and unsaved drafts
during background failures, using an inline status. Validation and mutation
failures retain their error styling.
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
actions menu for a list row. Pass `label` (aria-label) and
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
Expand Down Expand Up @@ -392,6 +395,6 @@ A settings page is design-system-clean when:
- [ ] 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).
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
- [ ] Decorative trailing content is in `badge`, not `trailing`.
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.
9 changes: 9 additions & 0 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getBillingContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import { getSession } from '@/lib/auth'
import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
Expand Down Expand Up @@ -125,6 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
getOrganizationBillingData(organizationId, dbReplica, {
limit: memberLimit,
offset: memberOffset,
includeMemberUsageCounts: false,
}),
getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
dbReplica
Expand Down Expand Up @@ -218,6 +220,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error,
})

const recoverable = internalBillingReadErrorPolicy.project(error)
if (recoverable)
return NextResponse.json(recoverable.body, {
status: recoverable.status,
headers: recoverable.headers,
})

return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/organizations/[id]/billing-plan/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getOrganizationPlanSeatsContract } from '@/lib/api/contracts/organization'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import { getOrganizationPlanSeats } from '@/lib/billing/application/organization-billing/get-organization-plan-seats'
import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getOrganizationPlanSeatsContract,
auth: internalSessionAuth,
operation: organizationBillingOperations.planSeats,
rateLimit: internalRateLimits.none({
reason: 'Small organization plan and seat read restricted to current admins and owners',
}),
errorPolicy: internalBillingReadErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: getOrganizationPlanSeats,
present: (data) => ({ success: true, data }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@ const { mockReadBillingSummary } = vi.hoisted(() => ({
mockReadBillingSummary: vi.fn(),
}))

vi.mock(
'@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
() => ({
getOrganizationBillingSummary: {
operation: { id: 'organization_billing.summary.read' },
execute: mockReadBillingSummary,
},
})
)
vi.mock('@/lib/billing/application/organization-billing/get-organization-billing-summary', () => ({
getOrganizationBillingSummary: {
operation: { id: 'organization_billing.summary.read' },
execute: mockReadBillingSummary,
},
}))

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

Expand Down
10 changes: 5 additions & 5 deletions apps/sim/app/api/organizations/[id]/billing-summary/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing/get-organization-billing-summary'
import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getOrganizationBillingSummaryContract,
auth: internalSessionAuth,
operation: organizationBillingSummaryOperations.read,
operation: organizationBillingOperations.read,
rateLimit: internalRateLimits.none({
reason: 'Authenticated organization billing read, restricted to organization admins and owners',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalBillingReadErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: getOrganizationBillingSummary,
present: (data) => ({ success: true, data }),
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/usage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import {
readUsageLimit,
type UsageLimitResult,
Expand Down Expand Up @@ -48,7 +49,7 @@ export const GET = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: usageLimitOperations.read,
rateLimit,
errorPolicy,
errorPolicy: internalBillingReadErrorPolicy,
mapInput: ({ query }) => query,
useCase: readUsageLimit,
present,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function OrganizationRecentlyDeleted({ organizationId }: OrganizationRece
search={{ value: search, onChange: setSearch, placeholder: 'Search deleted chats' }}
>
{error ? (
<SettingsEmptyState tone='error'>{error.message}</SettingsEmptyState>
<SettingsEmptyState>{error.message}</SettingsEmptyState>
) : isLoading ? null : filtered.length === 0 ? (
<SettingsEmptyState>
{searchTerm && chats.length > 0 ? 'No chats match your search' : 'No deleted chats'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type {
OrganizationSettingsSection,
} from '@/components/settings/navigation'
import {
organizationBillingQueryOptions,
organizationDetailQueryOptions,
organizationRosterQueryOptions,
} from '@/hooks/queries/organization'
import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
import { organizationPlanSeatsOptions } from '@/hooks/queries/organization-plan-seats'
import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'

interface OrganizationSettingsQueryWarmContext {
Expand All @@ -28,7 +28,7 @@ export function warmOrganizationSettingsSectionQuery(
prefetchQueryOnIntent(queryClient, organizationDetailQueryOptions(organizationId))
prefetchQueryOnIntent(queryClient, organizationRosterQueryOptions(organizationId))
if (isAdmin) {
prefetchQueryOnIntent(queryClient, organizationBillingQueryOptions(organizationId))
prefetchQueryOnIntent(queryClient, organizationPlanSeatsOptions(organizationId))
}
}
if (section === 'billing' && isAdmin) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useSendWorkspaceInvitations } from '@/hooks/queries/invitations'
import { useOrganizationBilling } from '@/hooks/queries/organization'
import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
import { useAdminWorkspaces } from '@/hooks/queries/workspace'

const logger = createLogger('InviteModal')
Expand Down Expand Up @@ -193,7 +193,7 @@ export function InviteModal({
)
const canViewOrganizationBilling = canGrantOrganizationAdmin

const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
const { data: organizationBillingData } = useOrganizationPlanSeats(organizationId ?? '', {
enabled: open && billingEnabled && canViewOrganizationBilling,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,9 @@ export function ConnectorDocuments({
</div>
</div>
{mutationError && (
<SettingsEmptyState variant='inline' tone='error'>
<p role='alert' className='py-4 text-center text-[var(--text-error)] text-sm'>
{mutationError.message}
</SettingsEmptyState>
</p>
)}
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
import { MoreHorizontal } from '@sim/emcn/icons'
import { orderHeaderActions, type SettingsAction } from '@/components/settings/settings-header'
import type { ConnectorActionState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'

interface ConnectorActionsProps {
state: ConnectorActionState
Expand Down Expand Up @@ -105,9 +104,9 @@ export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps)
return (
<>
{state.error && (
<SettingsEmptyState variant='inline' tone='error'>
<p role='alert' className='py-4 text-center text-[var(--text-error)] text-sm'>
{state.error.message}
</SettingsEmptyState>
</p>
)}
<ChipConfirmModal
open={removal.open}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,7 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred
{isLoading ? (
<SettingsEmptyState variant='inline'>Loading usage…</SettingsEmptyState>
) : hasBlockingError ? (
<SettingsEmptyState variant='inline' tone='error'>
Couldn't load credit usage.
</SettingsEmptyState>
<SettingsEmptyState variant='inline'>Couldn't load credit usage.</SettingsEmptyState>
) : logs.length === 0 ? (
<SettingsEmptyState variant='inline'>No credit usage in this period.</SettingsEmptyState>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export function Admin() {
</div>

{usersError && (
<p className='text-[var(--text-error)] text-small'>
<p className='text-[var(--text-muted)] text-small'>
{getErrorMessage(usersError, 'Failed to fetch users')}
</p>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
actions={actions}
>
{apiKeysError && apiKeysData === undefined ? (
<SettingsEmptyState tone='error'>
<SettingsEmptyState>
{getErrorMessage(apiKeysError, 'Failed to load API keys')}
</SettingsEmptyState>
) : isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ export function BYOK() {
}
>
{keysError && activeQueryData === undefined ? (
<SettingsEmptyState tone='error'>
<SettingsEmptyState>
{getErrorMessage(keysError, 'Failed to load provider keys')}
</SettingsEmptyState>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ export function CustomTools() {
actions={actions}
>
{error ? (
<SettingsEmptyState tone='error'>
{getErrorMessage(error, 'Failed to load tools')}
</SettingsEmptyState>
<SettingsEmptyState>{getErrorMessage(error, 'Failed to load tools')}</SettingsEmptyState>
) : isLoading ? null : showEmptyState ? (
<SettingsEmptyState>
{canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'}
Expand Down
Loading
Loading