From 92217ad60a833af014f1d5b2c4d48cc7aed7a331 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 12:59:51 -0700 Subject: [PATCH 01/11] improvement(settings): accelerate navigation and data loading --- .claude/rules/sim-react-performance.md | 6 + .../app/account/settings/[section]/page.tsx | 20 +- apps/sim/app/api/billing/route.test.ts | 3 +- apps/sim/app/api/billing/route.ts | 78 +---- .../[id]/billing-summary/route.ts | 24 ++ .../app/api/users/me/profile/route.test.ts | 67 ++++ apps/sim/app/api/users/me/profile/route.ts | 49 ++- .../app/api/users/me/settings/route.test.ts | 18 +- apps/sim/app/api/users/me/settings/route.ts | 11 +- .../app/selfhost/settings/[section]/page.tsx | 18 +- .../settings/[section]/page.test.tsx | 301 +++--------------- .../[workspaceId]/settings/[section]/page.tsx | 166 +--------- .../credit-usage/credit-usage-view.test.tsx | 150 +++++++++ .../credit-usage/credit-usage-view.tsx | 20 +- .../settings/components/api-keys/api-keys.tsx | 55 ++-- .../components/billing/billing.test.tsx | 18 +- .../settings/components/billing/billing.tsx | 34 +- .../settings/components/byok/byok.test.tsx | 13 + .../settings/components/byok/byok.tsx | 102 +++--- .../settings/components/inbox/inbox.test.tsx | 96 ++++++ .../settings/components/inbox/inbox.tsx | 14 +- .../components/sandboxes/sandboxes.tsx | 12 +- .../team-management/team-management.test.tsx | 100 +++++- .../team-management/team-management.tsx | 66 ++-- .../panel/components/deploy/deploy.tsx | 2 +- .../settings-query-warmers.test.ts | 101 ++++++ .../settings-query-warmers.ts | 50 +++ .../settings-sidebar/settings-sidebar.tsx | 28 +- .../components/settings/navigation.test.ts | 20 ++ apps/sim/components/settings/navigation.ts | 10 + .../prefetch-standalone-general.test.ts | 94 ++++++ .../settings/prefetch-standalone-general.ts | 56 ++++ .../settings/settings-intent-link.test.tsx | 149 +++++++-- .../settings/settings-intent-link.tsx | 157 +++++++-- .../components/access-control.test.tsx | 114 +++++++ .../components/access-control.tsx | 30 +- .../components/custom-blocks.test.tsx | 99 ++++++ .../components/custom-blocks.tsx | 15 +- .../ee/sso/components/sso-settings.test.tsx | 15 + apps/sim/ee/sso/components/sso-settings.tsx | 28 +- .../whitelabeling-settings.test.tsx | 99 ++++++ .../components/whitelabeling-settings.tsx | 15 +- apps/sim/hooks/queries/api-key-list.ts | 76 +++++ apps/sim/hooks/queries/api-keys.test.ts | 2 +- apps/sim/hooks/queries/api-keys.ts | 119 ++----- apps/sim/hooks/queries/byok-key-list.ts | 34 ++ apps/sim/hooks/queries/byok-keys.ts | 40 +-- .../hooks/queries/general-settings.test.tsx | 82 +++++ apps/sim/hooks/queries/general-settings.ts | 21 +- apps/sim/hooks/queries/mcp-server-list.ts | 47 +++ apps/sim/hooks/queries/mcp.ts | 55 +--- .../navigation-request-gating.test.tsx | 3 +- .../queries/organization-billing-summary.ts | 27 ++ apps/sim/hooks/queries/organization.test.tsx | 43 +++ apps/sim/hooks/queries/organization.ts | 130 +++++--- apps/sim/hooks/queries/sandbox-list.ts | 27 ++ apps/sim/hooks/queries/sandboxes.ts | 33 +- apps/sim/hooks/queries/subscription-data.ts | 31 ++ apps/sim/hooks/queries/subscription.ts | 36 +-- .../hooks/queries/utils/organization-keys.ts | 16 + .../utils/prefetch-query-on-intent.test.ts | 113 +++++++ .../queries/utils/prefetch-query-on-intent.ts | 24 ++ .../hooks/queries/workflow-mcp-server-list.ts | 51 +++ .../queries/workflow-mcp-servers.test.tsx | 114 +++++++ .../sim/hooks/queries/workflow-mcp-servers.ts | 70 +--- apps/sim/lib/api/contracts/organization.ts | 36 +++ apps/sim/lib/api/contracts/workspaces.ts | 2 + ...anization-billing-summary-use-case.test.ts | 92 ++++++ ...d-organization-billing-summary-use-case.ts | 88 +++++ .../get-organization-billing-summary.ts | 169 ++++++++++ .../operations.ts | 28 ++ apps/sim/lib/billing/core/payer-context.ts | 59 ++++ apps/sim/lib/core/utils/theme.test.ts | 39 +++ apps/sim/lib/core/utils/theme.ts | 39 ++- .../workspace-section-access.test.ts | 221 +++++++++++++ .../application/workspace-section-access.ts | 138 ++++++++ .../lib/users/application/authorization.ts | 16 + .../lib/users/application/delete-account.ts | 12 +- apps/sim/lib/users/application/operations.ts | 16 +- .../application/read-current-user.test.ts | 74 +++++ .../users/application/read-current-user.ts | 32 ++ apps/sim/lib/workspaces/host-context.test.ts | 2 + apps/sim/lib/workspaces/host-context.ts | 1 + apps/sim/stores/index.test.ts | 89 ++++++ apps/sim/stores/index.ts | 54 +--- apps/sim/stores/reset-all-stores.ts | 36 +++ 86 files changed, 3805 insertions(+), 1155 deletions(-) create mode 100644 apps/sim/app/api/organizations/[id]/billing-summary/route.ts create mode 100644 apps/sim/app/api/users/me/profile/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts create mode 100644 apps/sim/components/settings/prefetch-standalone-general.test.ts create mode 100644 apps/sim/components/settings/prefetch-standalone-general.ts create mode 100644 apps/sim/ee/access-control/components/access-control.test.tsx create mode 100644 apps/sim/ee/custom-blocks/components/custom-blocks.test.tsx create mode 100644 apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx create mode 100644 apps/sim/hooks/queries/api-key-list.ts create mode 100644 apps/sim/hooks/queries/byok-key-list.ts create mode 100644 apps/sim/hooks/queries/general-settings.test.tsx create mode 100644 apps/sim/hooks/queries/mcp-server-list.ts create mode 100644 apps/sim/hooks/queries/organization-billing-summary.ts create mode 100644 apps/sim/hooks/queries/sandbox-list.ts create mode 100644 apps/sim/hooks/queries/subscription-data.ts create mode 100644 apps/sim/hooks/queries/utils/organization-keys.ts create mode 100644 apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts create mode 100644 apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts create mode 100644 apps/sim/hooks/queries/workflow-mcp-server-list.ts create mode 100644 apps/sim/hooks/queries/workflow-mcp-servers.test.tsx create mode 100644 apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts create mode 100644 apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts create mode 100644 apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts create mode 100644 apps/sim/lib/billing/application/organization-billing-summary/operations.ts create mode 100644 apps/sim/lib/billing/core/payer-context.ts create mode 100644 apps/sim/lib/core/utils/theme.test.ts create mode 100644 apps/sim/lib/settings/application/workspace-section-access.test.ts create mode 100644 apps/sim/lib/settings/application/workspace-section-access.ts create mode 100644 apps/sim/lib/users/application/authorization.ts create mode 100644 apps/sim/lib/users/application/read-current-user.test.ts create mode 100644 apps/sim/lib/users/application/read-current-user.ts create mode 100644 apps/sim/stores/index.test.ts create mode 100644 apps/sim/stores/reset-all-stores.ts diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index 2d77b324f24..932e1e1f4da 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -99,6 +99,12 @@ server state with the consumer's shared React Query options. A short, cancelable avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling; let the actual unmodified click start the data request. +A speculative failure must not poison a later visit when the app default disables +`retryOnMount`: remove only that exact failed query while it is inactive, keep failures visible +to mounted consumers, and set the shared options to `retryOnMount: true` so a quick-click failure +can recover after the user leaves and returns. Never carry placeholder data between protected +resource keys (for example, workspace A to workspace B); an explicit loading state is truthful. + If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains mounted until its peer is ready, the intent path must warm both the full route and its critical data. Otherwise keep the loading boundary so dynamic navigation remains responsive. diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 71f10cbea0e..424a05612cd 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer' @@ -9,9 +10,11 @@ import { getSettingsSectionMeta, parseSettingsPathSection, } from '@/components/settings/navigation' +import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general' import { getSession } from '@/lib/auth' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' interface AccountSettingsSectionPageProps { params: Promise<{ section: string }> @@ -52,14 +55,21 @@ export default async function AccountSettingsSectionPage({ } /** - * Sections read URL query params via nuqs (which uses `useSearchParams` - * internally), so the renderer must sit under a Suspense boundary. The - * `null` fallback matches the existing visual behavior — the sections are - * `next/dynamic` components that render nothing while their chunk loads. + * Sections read URL query params via nuqs, so the renderer must sit under a + * Suspense boundary. The null fallback preserves the existing chunk-loading UI. */ - return ( + const content = ( ) + + if (parsed === 'general') { + const queryClient = getQueryClient() + await prefetchStandaloneGeneral(queryClient) + + return {content} + } + + return content } diff --git a/apps/sim/app/api/billing/route.test.ts b/apps/sim/app/api/billing/route.test.ts index 68d52a9c13e..b516914badc 100644 --- a/apps/sim/app/api/billing/route.test.ts +++ b/apps/sim/app/api/billing/route.test.ts @@ -150,9 +150,8 @@ function mockOrganizationDbRows({ .mockResolvedValueOnce([{ role }]) .mockResolvedValueOnce([{ id: 'org-target', name: 'Target organization' }]) .mockResolvedValueOnce(latestSubscription ? [latestSubscription] : []) - .mockResolvedValueOnce([{ userId: ownerId }]) + .mockResolvedValueOnce([{ userId: ownerId, billingBlocked, billingBlockedReason }]) .mockResolvedValueOnce(upgradeWorkspaceId ? [{ id: upgradeWorkspaceId }] : []) - .mockResolvedValueOnce([{ billingBlocked, billingBlockedReason }]) } describe('GET /api/billing', () => { diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index ff0b352af2f..2223cd89c70 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -3,18 +3,20 @@ import { member, organization as organizationTable, subscription as subscriptionTable, - userStats, - workspace as workspaceTable, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, asc, desc, eq, isNull } from 'drizzle-orm' +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 { getSession } from '@/lib/auth' import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' +import { + getOrganizationBillingBlockState, + getUpgradeWorkspaceId, +} from '@/lib/billing/core/payer-context' import { resolveBillingInterval } from '@/lib/billing/core/subscription' import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance' import { isPaid } from '@/lib/billing/plan-helpers' @@ -22,76 +24,6 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UnifiedBillingAPI') -interface BillingBlockState { - billingBlocked: boolean - billingBlockedReason: 'payment_failed' | 'dispute' | null - blockedByOrgOwner: boolean -} - -/** - * Finds an active workspace whose host billing identity is the requested payer. - */ -async function getUpgradeWorkspaceId( - target: { type: 'user'; id: string } | { type: 'organization'; id: string } -): Promise { - const targetPredicate = - target.type === 'organization' - ? eq(workspaceTable.organizationId, target.id) - : and( - eq(workspaceTable.ownerId, target.id), - eq(workspaceTable.billedAccountUserId, target.id), - isNull(workspaceTable.organizationId) - ) - - const [workspace] = await dbReplica - .select({ id: workspaceTable.id }) - .from(workspaceTable) - .where(and(targetPredicate, isNull(workspaceTable.archivedAt))) - .orderBy(asc(workspaceTable.createdAt), asc(workspaceTable.id)) - .limit(1) - - return workspace?.id ?? null -} - -/** - * Reads the exact organization's payer block from its owner, without allowing - * the viewer's personal status or another organization membership to leak in. - */ -async function getOrganizationBillingBlockState( - organizationId: string, - viewerUserId: string -): Promise { - const [owner] = await dbReplica - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) - .limit(1) - - if (!owner) { - return { - billingBlocked: false, - billingBlockedReason: null, - blockedByOrgOwner: false, - } - } - - const [stats] = await dbReplica - .select({ - billingBlocked: userStats.billingBlocked, - billingBlockedReason: userStats.billingBlockedReason, - }) - .from(userStats) - .where(eq(userStats.userId, owner.userId)) - .limit(1) - - const billingBlocked = Boolean(stats?.billingBlocked) - return { - billingBlocked, - billingBlockedReason: billingBlocked ? (stats?.billingBlockedReason ?? null) : null, - blockedByOrgOwner: billingBlocked && owner.userId !== viewerUserId, - } -} - /** * Unified Billing Endpoint */ diff --git a/apps/sim/app/api/organizations/[id]/billing-summary/route.ts b/apps/sim/app/api/organizations/[id]/billing-summary/route.ts new file mode 100644 index 00000000000..778387f37d8 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/billing-summary/route.ts @@ -0,0 +1,24 @@ +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' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationBillingSummaryContract, + auth: internalSessionAuth, + operation: organizationBillingSummaryOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization billing read, restricted to organization admins and owners', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationBillingSummary, + present: (data) => ({ success: true, data }), +}) diff --git a/apps/sim/app/api/users/me/profile/route.test.ts b/apps/sim/app/api/users/me/profile/route.test.ts new file mode 100644 index 00000000000..3f8b1691fe9 --- /dev/null +++ b/apps/sim/app/api/users/me/profile/route.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockReadProfile } = vi.hoisted(() => ({ + mockReadProfile: vi.fn(), +})) + +vi.mock('@/lib/users/application/read-current-user', () => ({ + getCurrentUserProfileUseCase: { + operation: { id: 'users.account.profile.read', principalKinds: ['session'] }, + execute: mockReadProfile, + }, +})) + +import { GET } from '@/app/api/users/me/profile/route' + +describe('GET /api/users/me/profile', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockReadProfile.mockResolvedValue({ + id: 'user-1', + name: 'User', + email: 'user@example.com', + image: null, + }) + }) + + it('reads the authenticated account through the semantic use case', async () => { + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + user: { + id: 'user-1', + name: 'User', + email: 'user@example.com', + image: null, + }, + }) + expect(mockReadProfile).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: {}, + }) + ) + }) + + it('rejects an unauthenticated request before the use case runs', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + expect(mockReadProfile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts index b71fa183f21..260678ab8c7 100644 --- a/apps/sim/app/api/users/me/profile/route.ts +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -3,12 +3,19 @@ import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateUserProfileContract } from '@/lib/api/contracts' +import { getUserProfileContract, updateUserProfileContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserProfile } from '@/lib/users/queries' +import { userAccountOperations } from '@/lib/users/application/operations' +import { getCurrentUserProfileUseCase } from '@/lib/users/application/read-current-user' const logger = createLogger('UpdateUserProfileAPI') @@ -71,31 +78,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { } }) -// GET endpoint to fetch current user profile -export const GET = withRouteHandler(async () => { - const requestId = generateRequestId() - - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized profile fetch attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - const userRecord = await getUserProfile(userId) - - if (!userRecord) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }) - } - - return NextResponse.json({ - user: userRecord, - }) - } catch (error: any) { - logger.error(`[${requestId}] Profile fetch error`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const GET = defineInternalJsonRoute({ + contract: getUserProfileContract, + auth: internalSessionAuth, + operation: userAccountOperations.readProfile, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated current-user profile read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => ({}), + useCase: getCurrentUserProfileUseCase, + present: (userRecord) => ({ user: userRecord }), }) diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts index f11f71ff96f..0ef5a21ab9a 100644 --- a/apps/sim/app/api/users/me/settings/route.test.ts +++ b/apps/sim/app/api/users/me/settings/route.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) -import { PATCH } from '@/app/api/users/me/settings/route' +import { GET, PATCH } from '@/app/api/users/me/settings/route' describe('PATCH /api/users/me/settings', () => { beforeEach(() => { @@ -46,3 +46,19 @@ describe('PATCH /api/users/me/settings', () => { expect(await response.json()).not.toMatchObject({ success: true }) }) }) + +describe('GET /api/users/me/settings', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + it('preserves anonymous defaults without entering the protected current-user read', async () => { + const response = await GET() + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + data: { theme: 'system', autoConnect: true }, + }) + }) +}) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 69e06dd689c..3bafb97aaf4 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -5,10 +5,12 @@ import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { updateUserSettingsContract } from '@/lib/api/contracts' import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { defaultUserSettings, getUserSettings } from '@/lib/users/queries' +import { getCurrentUserSettingsUseCase } from '@/lib/users/application/read-current-user' +import { defaultUserSettings } from '@/lib/users/queries' const logger = createLogger('UserSettingsAPI') @@ -16,10 +18,13 @@ export const GET = withRouteHandler(async () => { const requestId = generateRequestId() try { - const session = await getSession() - const data = await getUserSettings(session?.user?.id ?? null) + const principal = await internalSessionAuth.authenticate() + const data = await getCurrentUserSettingsUseCase.execute({ principal, input: {} }) return NextResponse.json({ data }, { status: 200 }) } catch (error: any) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ data: defaultUserSettings }, { status: 200 }) + } logger.error(`[${requestId}] Settings fetch error`, error) return NextResponse.json({ data: defaultUserSettings }, { status: 200 }) } diff --git a/apps/sim/app/selfhost/settings/[section]/page.tsx b/apps/sim/app/selfhost/settings/[section]/page.tsx index eacc4d54c24..933b783a082 100644 --- a/apps/sim/app/selfhost/settings/[section]/page.tsx +++ b/apps/sim/app/selfhost/settings/[section]/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { @@ -7,9 +8,11 @@ import { parseSettingsPathSection, SELFHOST_SETTINGS_ITEMS, } from '@/components/settings/navigation' +import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general' import { SelfHostSettingsRenderer } from '@/components/settings/selfhost-settings-renderer' import { getSession } from '@/lib/auth' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' interface SelfHostSettingsSectionPageProps { params: Promise<{ section: string }> @@ -45,12 +48,21 @@ export default async function SelfHostSettingsSectionPage({ if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general')) /** - * Sections read URL query params via nuqs (which uses `useSearchParams` - * internally), so the renderer must sit under a Suspense boundary. + * Sections read URL query params via nuqs, so the renderer must sit under a + * Suspense boundary. The null fallback preserves the existing chunk-loading UI. */ - return ( + const content = ( ) + + if (parsed === 'general') { + const queryClient = getQueryClient() + await prefetchStandaloneGeneral(queryClient) + + return {content} + } + + return content } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 205c416042e..9814b45a8ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -1,329 +1,116 @@ /** * @vitest-environment node */ +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockCanOpenOrganizationSettingsSection, + mockAuthorizeSection, + mockGetQueryClient, mockGetSession, - mockGetWorkspaceHostContext, - mockIsForkingAvailable, - mockIsCustomBlocksEligibleForOrganization, - mockIsOrganizationOnEnterprisePlan, - mockIsOrganizationSettingsSectionAvailable, mockNotFound, mockRedirect, - mockResolveWorkspaceGroup, - mockResolveWorkspaceNavigation, + mockSectionPrefetch, } = vi.hoisted(() => ({ - mockCanOpenOrganizationSettingsSection: vi.fn(), + mockAuthorizeSection: vi.fn(), + mockGetQueryClient: vi.fn(), mockGetSession: vi.fn(), - mockGetWorkspaceHostContext: vi.fn(), - mockIsForkingAvailable: vi.fn(), - mockIsCustomBlocksEligibleForOrganization: vi.fn(), - mockIsOrganizationOnEnterprisePlan: vi.fn(), - mockIsOrganizationSettingsSectionAvailable: vi.fn(), mockNotFound: vi.fn(() => { throw new Error('NEXT_NOT_FOUND') }), mockRedirect: vi.fn((href: string) => { throw new Error(`NEXT_REDIRECT:${href}`) }), - mockResolveWorkspaceGroup: vi.fn(), - mockResolveWorkspaceNavigation: vi.fn(), -})) - -vi.mock('next/navigation', () => ({ - notFound: mockNotFound, - redirect: mockRedirect, -})) - -vi.mock('@/components/settings/navigation', () => ({ - getOrganizationSettingsFeatures: vi.fn(() => ({})), - isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable, - resolveWorkspaceNavigation: mockResolveWorkspaceNavigation, - /** Mirrors the registry-derived map; a section absent here gets no organization gate. */ - UNIFIED_TO_ORGANIZATION_SECTION: { - organization: 'members', - billing: 'billing', - 'access-control': 'access-control', - 'audit-logs': 'audit-logs', - sso: 'sso', - sessions: 'sessions', - 'data-retention': 'data-retention', - 'data-drains': 'data-drains', - usage: 'usage', - whitelabeling: 'whitelabeling', - }, - workspaceSectionUsesPermissionConfig: vi.fn((section: string) => - ['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section) - ), -})) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - -vi.mock('@/lib/billing', () => ({ - isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, -})) - -vi.mock('@/lib/core/config/env', () => ({ - env: {}, - getEnv: vi.fn(), - isTruthy: vi.fn(() => false), -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - isAppConfigEnabled: false, - isBillingEnabled: true, - isHosted: true, -})) - -vi.mock('@/lib/organizations/settings-access', () => ({ - canOpenOrganizationSettingsSection: mockCanOpenOrganizationSettingsSection, -})) - -vi.mock('@/lib/permissions/super-user', () => ({ - isPlatformAdmin: vi.fn(() => false), -})) - -vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ - isCustomBlocksEligibleForOrganization: mockIsCustomBlocksEligibleForOrganization, + mockSectionPrefetch: vi.fn(), })) -vi.mock('@/lib/workspaces/host-context', () => ({ - getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext, +vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect })) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/settings/application/workspace-section-access', () => ({ + authorizeWorkspaceSettingsSection: mockAuthorizeSection, })) - vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: mockGetQueryClient, })) - -const { mockGetQueryClient, mockSectionPrefetch } = vi.hoisted(() => ({ - mockGetQueryClient: vi.fn(), - mockSectionPrefetch: vi.fn(), -})) - -const { mockSections, mockAliases } = vi.hoisted(() => ({ - mockSections: [ - 'general', - 'billing', - 'secrets', - 'sessions', - 'admin', - 'teammates', - 'custom-blocks', - ], - /** Mirrors the real alias table so a legacy segment behaves here as it does in production. */ - mockAliases: { - subscription: 'billing', - team: 'organization', - 'api-keys': 'apikeys', - domains: 'sso', - } as Record, -})) - vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ resolveSettingsSection: vi.fn((section: string) => { - const id = mockAliases[section] ?? section - return mockSections.includes(id) ? { id, meta: { title: id } } : null + const aliases: Record = { subscription: 'billing' } + const id = aliases[section] ?? section + return ['general', 'billing', 'secrets'].includes(id) ? { id, meta: { title: id } } : null }), - getSettingsSectionMeta: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - resolveWorkspaceGroup: mockResolveWorkspaceGroup, -})) - -vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - isForkingAvailableForWorkspace: mockIsForkingAvailable, })) - vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({ - /** Mirrors the real registry's keys so a section absent from it prefetches nothing. */ - SECTION_PREFETCHERS: { - general: mockSectionPrefetch, - billing: mockSectionPrefetch, - admin: mockSectionPrefetch, - 'credential-groups': mockSectionPrefetch, - }, + SECTION_PREFETCHERS: { general: mockSectionPrefetch, billing: mockSectionPrefetch }, })) - vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({ SettingsPage: vi.fn(() => null), })) -import { QueryClient } from '@tanstack/react-query' import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page' -const PERSONAL_HOST_CONTEXT = { - workspace: { - id: 'workspace-b', - billedAccountUserId: 'owner-b', - }, - hostOrganizationId: null, - ownerBilling: { - isEnterprise: false, - }, - viewer: { - permission: 'admin', - isHostOrganizationAdmin: false, - }, -} - -const ORGANIZATION_HOST_CONTEXT = { - workspace: { - id: 'workspace-b', - billedAccountUserId: 'owner-b', - }, - hostOrganizationId: 'organization-b', - ownerBilling: { - isEnterprise: true, - }, - viewer: { - permission: 'admin', - isHostOrganizationAdmin: true, - }, -} - function pageProps(section: string) { - return { - params: Promise.resolve({ workspaceId: 'workspace-b', section }), - } + return { params: Promise.resolve({ workspaceId: 'workspace-b', section }) } } -describe('WorkspaceSettingsSectionPage unavailable sections', () => { +describe('WorkspaceSettingsSectionPage', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } }) - mockGetWorkspaceHostContext.mockResolvedValue(PERSONAL_HOST_CONTEXT) - mockResolveWorkspaceNavigation.mockReturnValue([]) - mockResolveWorkspaceGroup.mockResolvedValue(null) - mockIsForkingAvailable.mockResolvedValue(false) - mockIsCustomBlocksEligibleForOrganization.mockResolvedValue(false) - mockCanOpenOrganizationSettingsSection.mockResolvedValue(false) - mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false) - mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true) + mockAuthorizeSection.mockResolvedValue({ allowed: true }) mockGetQueryClient.mockReturnValue(new QueryClient()) + mockSectionPrefetch.mockResolvedValue(undefined) }) - it('redirects an unavailable subscription section to General', async () => { - await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow( - 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' - ) - }) + it('authenticates before authorizing the resolved section', async () => { + await WorkspaceSettingsSectionPage(pageProps('subscription')) - it('redirects a workspace section hidden in the destination workspace to General', async () => { - await expect(WorkspaceSettingsSectionPage(pageProps('secrets'))).rejects.toThrow( - 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' - ) + expect(mockAuthorizeSection).toHaveBeenCalledWith({ + workspaceId: 'workspace-b', + userId: 'viewer-a', + section: 'billing', + }) + expect(mockSectionPrefetch).toHaveBeenCalledTimes(1) }) - it('redirects Custom Blocks for a personal workspace without resolving an org entitlement', async () => { - mockResolveWorkspaceNavigation.mockImplementation(({ entitlements }) => - entitlements.customBlocks ? [{ id: 'custom-blocks' }] : [] - ) + it('conceals inaccessible workspaces and platform-only sections', async () => { + mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'not-found' }) - await expect(WorkspaceSettingsSectionPage(pageProps('custom-blocks'))).rejects.toThrow( - 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' + await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow( + 'NEXT_NOT_FOUND' ) - expect(mockIsCustomBlocksEligibleForOrganization).not.toHaveBeenCalled() + expect(mockSectionPrefetch).not.toHaveBeenCalled() }) - it('uses the shared Custom Blocks entitlement for an organization workspace', async () => { - mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT) - mockIsCustomBlocksEligibleForOrganization.mockResolvedValue(true) - mockResolveWorkspaceNavigation.mockImplementation(({ entitlements }) => - entitlements.customBlocks ? [{ id: 'custom-blocks' }] : [] - ) + it('redirects unavailable visible-catalog sections to General', async () => { + mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' }) - await WorkspaceSettingsSectionPage(pageProps('custom-blocks')) - - expect(mockIsCustomBlocksEligibleForOrganization).toHaveBeenCalledWith('organization-b') - }) - - it('redirects an organization section when the destination has no organization', async () => { - await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow( + await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow( 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' ) + expect(mockSectionPrefetch).not.toHaveBeenCalled() }) - it('keeps unknown settings sections fail-fast', async () => { + it('rejects unknown sections before protected authorization', async () => { await expect(WorkspaceSettingsSectionPage(pageProps('unknown'))).rejects.toThrow( 'NEXT_NOT_FOUND' ) - expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled() + expect(mockAuthorizeSection).not.toHaveBeenCalled() }) - it('prefetches only for the sections that declare a prefetcher', async () => { - // The saving the registry exists for: a section with no entry blocks on nothing. - mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }]) - - await WorkspaceSettingsSectionPage(pageProps('general')) - expect(mockSectionPrefetch).toHaveBeenCalledTimes(1) - - mockSectionPrefetch.mockClear() + it('prefetches only sections with an explicit prefetcher after authorization', async () => { await WorkspaceSettingsSectionPage(pageProps('secrets')) expect(mockSectionPrefetch).not.toHaveBeenCalled() - }) - - it('resolves a permission group only when its config can hide the requested section', async () => { - mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT) - mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'teammates' }]) - - await WorkspaceSettingsSectionPage(pageProps('teammates')) - - expect(mockResolveWorkspaceGroup).not.toHaveBeenCalled() - - mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }]) - await WorkspaceSettingsSectionPage(pageProps('secrets')) - - expect(mockResolveWorkspaceGroup).toHaveBeenCalledTimes(1) - expect(mockResolveWorkspaceGroup).toHaveBeenCalledWith( - 'viewer-a', - 'organization-b', - 'workspace-b' - ) - }) - - it('overlaps the section prefetch with the organization section gate', async () => { - let resolveCanOpenSection: ((value: boolean) => void) | undefined - mockGetWorkspaceHostContext.mockResolvedValue(ORGANIZATION_HOST_CONTEXT) - mockCanOpenOrganizationSettingsSection.mockReturnValue( - new Promise((resolve) => { - resolveCanOpenSection = resolve - }) - ) - - const render = WorkspaceSettingsSectionPage(pageProps('billing')) - await vi.waitFor(() => expect(mockCanOpenOrganizationSettingsSection).toHaveBeenCalledTimes(1)) - - expect(mockSectionPrefetch).toHaveBeenCalledWith( - expect.any(QueryClient), - expect.objectContaining({ userId: 'viewer-a', workspaceId: 'workspace-b' }) - ) - - resolveCanOpenSection?.(true) - await render - }) - - it('selects the prefetcher by resolved section, not the raw segment', async () => { - // `/settings/subscription` is a legacy link for billing, which does read the key. Billing on - // a personal workspace is only reachable by the billed account owner. - mockGetSession.mockResolvedValue({ user: { id: 'owner-b' } }) - - await WorkspaceSettingsSectionPage(pageProps('subscription')) + await WorkspaceSettingsSectionPage(pageProps('general')) expect(mockSectionPrefetch).toHaveBeenCalledTimes(1) }) - it('keeps inaccessible workspaces fail-fast', async () => { - mockGetWorkspaceHostContext.mockResolvedValue(null) + it('redirects unauthenticated viewers without authorizing', async () => { + mockGetSession.mockResolvedValue(null) await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow( - 'NEXT_NOT_FOUND' + 'NEXT_REDIRECT:/login' ) - expect(mockSectionPrefetch).not.toHaveBeenCalled() + expect(mockAuthorizeSection).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index ca0c7426125..e58cafd30b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,28 +2,10 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' -import { - getOrganizationSettingsFeatures, - isOrganizationSettingsSectionAvailable, - resolveWorkspaceNavigation, - UNIFIED_TO_ORGANIZATION_SECTION, - type WorkspaceSettingsSection, - workspaceSectionUsesPermissionConfig, -} from '@/components/settings/navigation' import { getSession } from '@/lib/auth' -import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' -import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' -import { isPlatformAdmin } from '@/lib/permissions/super-user' -import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' -import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/workspace-section-access' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { - resolveSettingsSection, - type SettingsSection, -} from '@/app/workspace/[workspaceId]/settings/navigation' -import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' -import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { SECTION_PREFETCHERS } from './prefetch' import { SettingsPage } from './settings' @@ -31,23 +13,6 @@ interface WorkspaceSettingsSectionPageProps { params: Promise<{ workspaceId: string; section: string }> } -const WORKSPACE_SECTION_MAP: Partial> = { - teammates: 'teammates', - secrets: 'secrets', - 'credential-groups': 'credential-groups', - byok: 'byok', - sandboxes: 'sandboxes', - 'custom-tools': 'custom-tools', - mcp: 'mcp', - 'workflow-mcp-servers': 'workflow-mcp-servers', - apikeys: 'api-keys', - inbox: 'inbox', - 'recently-deleted': 'recently-deleted', - forks: 'forks', - 'custom-blocks': 'custom-blocks', - 'self-host': 'self-host', -} - /** * Settings availability varies across workspaces, so a preserved section may * need to land on the destination workspace's universally available page. @@ -75,24 +40,20 @@ export default async function WorkspaceSettingsSectionPage({ if (!resolved) notFound() const parsed = resolved.id - /** - * Independent given the session, and both gate the same render, so they overlap rather than - * queue. Every await here sits in front of the section's body, so it is the length of this - * chain that the user waits out. - */ - const requiresPlatformAdmin = parsed === 'admin' || parsed === 'mothership' - const [hostContext, isViewerPlatformAdmin] = await Promise.all([ - getWorkspaceHostContextForViewer(workspaceId, session.user.id), - requiresPlatformAdmin ? isPlatformAdmin(session.user.id) : Promise.resolve(false), - ]) - if (!hostContext) notFound() - if (requiresPlatformAdmin && !isViewerPlatformAdmin) notFound() + const access = await authorizeWorkspaceSettingsSection({ + workspaceId, + userId: session.user.id, + section: parsed, + }) + if (!access.allowed) { + if (access.disposition === 'not-found') notFound() + redirectToGeneralSettings(workspaceId) + } const queryClient = getQueryClient() /** - * Start the viewer-scoped prefetch as soon as workspace access is established. Organization - * and section-entitlement gates remain authoritative, but their independent reads no longer - * serialize in front of this data. The promise is still awaited before dehydration below. + * Protected section data starts only after the current server-side section gate succeeds. + * The promise remains awaited because unsettled queries are omitted from dehydration. */ const sectionPrefetch = SECTION_PREFETCHERS[parsed]?.(queryClient, { @@ -100,107 +61,6 @@ export default async function WorkspaceSettingsSectionPage({ userId: session.user.id, }) ?? Promise.resolve() - const workspaceSection = WORKSPACE_SECTION_MAP[parsed] - if (workspaceSection) { - /** - * The gate asks one question — is this section in the viewer's navigation — so it resolves - * only the entitlements that can answer it, and only for the section being opened. - * - * `credentialGroups` is already on the host context, derived from the same owner billing one - * await earlier, so asking again is a second feature-flag lookup for an answer in hand. - * - * `inbox` and `sandboxes` feed only `locked`, which marks a section as needing an upgrade - * rather than hiding it. This gate reads membership alone, so their two billing round-trips - * could not change the outcome for any section. - * - * `forks` is read only by the `forks` entry, so every other section resolved a lineage - * check it could not act on. Passing `false` elsewhere is safe in the one direction that - * matters: it can only remove `forks` from a list this gate is not asking about. - * - * Permission-group config is narrowed by the same policy map that hides navigation items. - * Every other section is independent of that config, so resolving the viewer's group for it - * can never change this gate's answer. - */ - const [permissionGroup, forksAvailable, customBlocksAvailable] = await Promise.all([ - hostContext.hostOrganizationId && - hostContext.ownerBilling.isEnterprise && - workspaceSectionUsesPermissionConfig(workspaceSection) - ? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId) - : null, - workspaceSection === 'forks' - ? isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id) - : Promise.resolve(false), - workspaceSection === 'custom-blocks' && hostContext.hostOrganizationId - ? isCustomBlocksEligibleForOrganization(hostContext.hostOrganizationId) - : Promise.resolve(false), - ]) - const navigation = resolveWorkspaceNavigation({ - permission: hostContext.viewer.permission, - permissionConfig: permissionGroup?.config ?? {}, - entitlements: { - byok: isHosted, - credentialGroups: hostContext.features?.credentialGroups ?? false, - inbox: true, - customBlocks: customBlocksAvailable, - forks: forksAvailable, - sandboxes: true, - }, - }) - if (!navigation.some((item) => item.id === workspaceSection)) { - redirectToGeneralSettings(workspaceId) - } - } - - const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[parsed] - if (organizationSection) { - if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) { - redirectToGeneralSettings(workspaceId) - } - if (!hostContext.hostOrganizationId) { - if (parsed !== 'billing' || hostContext.workspace.billedAccountUserId !== session.user.id) { - redirectToGeneralSettings(workspaceId) - } - } else { - /** - * The roster is the one organization section a plain member may open, read-only - * (`resolveOrganizationSectionAccess` returns `'view'` for it below). Everything - * else acts on the organization and stays admin-only. - */ - if (organizationSection !== 'members' && !hostContext.viewer.isHostOrganizationAdmin) { - redirectToGeneralSettings(workspaceId) - } - /** - * Overlapped for the same reason: neither reads the other's result. The plan lookup is - * skipped for the two sections that do not gate on it, so the only case that pays for a - * lookup it does not use is one that was about to redirect anyway. - */ - const needsEnterprisePlan = - organizationSection !== 'members' && organizationSection !== 'billing' - const [canOpenSection, isEnterpriseOrganization] = await Promise.all([ - canOpenOrganizationSettingsSection( - hostContext.hostOrganizationId, - session.user.id, - organizationSection - ), - needsEnterprisePlan - ? isOrganizationOnEnterprisePlan(hostContext.hostOrganizationId) - : Promise.resolve(false), - ]) - if (!canOpenSection) { - redirectToGeneralSettings(workspaceId) - } - if ( - !isOrganizationSettingsSectionAvailable( - organizationSection, - getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization) - ) - ) { - redirectToGeneralSettings(workspaceId) - } - } - } - - /** Awaiting is required because unsettled queries are omitted from dehydration. */ await sectionPrefetch return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.test.tsx new file mode 100644 index 00000000000..cd566675ab0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.test.tsx @@ -0,0 +1,150 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseUsageLogs } = vi.hoisted(() => ({ + mockUseUsageLogs: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Calendar: () => null, + ChipCombobox: () =>
, + Popover: ({ children }: { children?: ReactNode }) => <>{children}, + PopoverAnchor: () => null, + PopoverContent: ({ children }: { children?: ReactNode }) => <>{children}, + chipVariants: () => '', + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + toast: { error: vi.fn(), info: vi.fn() }, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('nuqs', () => ({ + useQueryStates: () => [{ period: '30d', startDate: null, endDate: null }, vi.fn()], +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/hooks/queries/usage-logs', () => ({ + useUsageLogs: mockUseUsageLogs, +})) + +import { CreditUsageView } from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +function renderView() { + act(() => root.render()) +} + +describe('CreditUsageView summary states', () => { + it('does not present zero as the total while the defining query is pending', () => { + mockUseUsageLogs.mockReturnValue({ + data: undefined, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: false, + isFetchingNextPage: false, + isLoading: true, + isPlaceholderData: false, + }) + + renderView() + + expect(container.textContent).toContain('Total: Loading…') + expect(container.textContent).not.toContain('Total: 0') + }) + + it('does not present the prior period total while the next period is pending', () => { + mockUseUsageLogs.mockReturnValue({ + data: { pages: [{ logs: [], summary: { totalCredits: 987_654 } }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: true, + }) + + renderView() + + expect(container.textContent).toContain('Total: Updating…') + expect(container.textContent).not.toContain('987,654') + }) + + it('marks the total unavailable when the query fails', () => { + mockUseUsageLogs.mockReturnValue({ + data: undefined, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: true, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + }) + + renderView() + + expect(container.textContent).toContain('Total: Unavailable') + expect(container.textContent).toContain("Couldn't load credit usage.") + }) + + it('keeps cached usage visible when a background refresh fails', () => { + mockUseUsageLogs.mockReturnValue({ + data: { + pages: [ + { + logs: [ + { + id: 'usage-1', + createdAt: '2026-08-31T12:00:00.000Z', + source: 'workflow', + workflowName: 'Cached workflow', + creditCost: 42, + hasCost: true, + }, + ], + summary: { totalCredits: 42 }, + }, + ], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: true, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + }) + + renderView() + + expect(container.textContent).toContain('Total: 42') + expect(container.textContent).not.toContain('Unavailable') + expect(container.textContent).not.toContain("Couldn't load credit usage.") + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx index 617707ea924..f476317d649 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -146,7 +146,15 @@ export function CreditUsageView({ backHref = '/account/settings/billing' }: Cred }) const logs = data?.pages.flatMap((page) => page.logs) ?? [] - const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0 + const totalCredits = data?.pages[0]?.summary.totalCredits + const hasBlockingError = isError && data === undefined + const totalCreditsLabel = isLoading + ? 'Loading…' + : hasBlockingError + ? 'Unavailable' + : isPlaceholderData + ? 'Updating…' + : formatCreditsLabel(totalCredits ?? 0) return (
- - Total: {formatCreditsLabel(totalCredits)} - + Total: {totalCreditsLabel}
{isLoading ? ( Loading usage… - ) : isError ? ( - Couldn't load credit usage. + ) : hasBlockingError ? ( + + Couldn't load credit usage. + ) : logs.length === 0 ? ( No credit usage in this period. ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx index 656f1d225d5..9c7f21a0d89 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx @@ -4,10 +4,13 @@ import { useMemo, useState } from 'react' import { ChipConfirmModal, Label, Switch, Tooltip, toast } from '@sim/emcn' import { CircleInfo, Plus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { useParams } from 'next/navigation' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' +import type { ApiKey } from '@/lib/api/contracts/api-keys' import { useSession } from '@/lib/auth/auth-client' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' @@ -19,14 +22,12 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' +import type { ApiKeyScope } from '@/hooks/queries/api-key-list' import { - type ApiKey, - type ApiKeyScope, useApiKeys, useDeleteApiKey, useUpdateWorkspaceApiKeySettings, } from '@/hooks/queries/api-keys' -import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { CreateApiKeyModal } from './components' const logger = createLogger('ApiKeys') @@ -81,6 +82,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const userId = session?.user?.id const params = useParams<{ workspaceId?: string }>() const workspaceId = (params?.workspaceId as string) || '' + const hostContext = useOptionalWorkspaceHostContext() const workspacePermissions = useUserPermissionsContext() const isWorkspaceScope = scope === 'workspace' const isPersonalScope = scope === 'personal' @@ -92,10 +94,9 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const { data: apiKeysData, isLoading: isLoadingKeys, + error: apiKeysError, refetch: refetchApiKeys, } = useApiKeys(workspaceId, scope) - const { data: workspaceSettingsData, isLoading: isLoadingSettings } = - useWorkspaceSettings(workspaceId) const deleteApiKeyMutation = useDeleteApiKey() const updateSettingsMutation = useUpdateWorkspaceApiKeySettings() @@ -103,10 +104,9 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { const personalKeys = apiKeysData?.personalKeys ?? EMPTY_KEYS const conflicts = apiKeysData?.conflicts ?? EMPTY_KEY_NAMES const conflictNames = useMemo(() => new Set(conflicts), [conflicts]) - const isLoading = isLoadingKeys || (showsWorkspaceKeys && isLoadingSettings) + const isLoading = isLoadingKeys - const allowPersonalApiKeys = - workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true + const allowPersonalApiKeys = hostContext?.workspace.allowPersonalApiKeys ?? true const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [deleteKey, setDeleteKey] = useState(null) @@ -120,6 +120,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { : 'workspace' const createButtonDisabled = isLoading || + (Boolean(apiKeysError) && apiKeysData === undefined) || (isWorkspaceScope && !canManageWorkspaceKeys) || (isCombinedScope && !allowPersonalApiKeys && !canManageWorkspaceKeys) @@ -195,7 +196,11 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) { }} actions={actions} > - {isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( + {apiKeysError && apiKeysData === undefined ? ( + + {getErrorMessage(apiKeysError, 'Failed to load API keys')} + + ) : isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? ( Click "Create API key" above to get started ) : (
@@ -333,23 +338,21 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
- {isLoadingSettings ? null : ( - { - try { - await updateSettingsMutation.mutateAsync({ - workspaceId, - allowPersonalApiKeys: checked, - }) - } catch (error) { - logger.error('Error updating workspace settings:', { error }) - } - }} - /> - )} + { + try { + await updateSettingsMutation.mutateAsync({ + workspaceId, + allowPersonalApiKeys: checked, + }) + } catch (error) { + logger.error('Error updating workspace settings:', { error }) + } + }} + />
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index e6a32a8d2f4..c879d3fc76e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -100,16 +100,19 @@ vi.mock('@/hooks/queries/general-settings', () => ({ })) vi.mock('@/hooks/queries/organization', () => ({ - useOrganizationBilling: (...args: unknown[]) => { - mockUseOrganizationBilling(...args) - return mockOrganizationQuery.current - }, useUpdateOrganizationUsageLimit: () => ({ isPending: false, mutateAsync: mockUpdateOrganizationLimit, }), })) +vi.mock('@/hooks/queries/organization-billing-summary', () => ({ + useOrganizationBillingSummary: (...args: unknown[]) => { + mockUseOrganizationBilling(...args) + return mockOrganizationQuery.current + }, +})) + vi.mock('@/hooks/queries/subscription', () => ({ useInvoices: (...args: unknown[]) => { mockUseInvoices(...args) @@ -216,14 +219,8 @@ const PERSONAL_DATA = { function organizationResponse(overrides: Record = {}): Record { return { success: true, - context: 'organization', - userRole: 'owner', - billingBlocked: false, - billingBlockedReason: null, - blockedByOrgOwner: false, data: { organizationId: 'org-target', - organizationName: 'Target organization', subscriptionState: 'active', hasSubscription: true, subscriptionPlan: 'team_25000', @@ -245,6 +242,7 @@ function organizationResponse(overrides: Record = {}): Record = { paid: { variant: 'green', label: 'Paid' }, @@ -80,7 +81,12 @@ const INVOICE_STATUS_BADGES: Record = { /** Resolve a Stripe invoice status to its badge presentation. */ function getInvoiceStatusBadge(status: string | null): InvoiceStatusBadge { - return INVOICE_STATUS_BADGES[status ?? ''] ?? { variant: 'gray', label: status ?? 'Unknown' } + return ( + INVOICE_STATUS_BADGES[status ?? ''] ?? { + variant: 'gray', + label: status ?? 'Unknown', + } + ) } /** Cached currency formatters, keyed by upper-cased ISO currency code. */ @@ -91,7 +97,10 @@ function getInvoiceAmountFormatter(currency: string): Intl.NumberFormat { const code = currency.toUpperCase() let formatter = invoiceAmountFormatters.get(code) if (!formatter) { - formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: code }) + formatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code, + }) invoiceAmountFormatters.set(code, formatter) } return formatter @@ -128,7 +137,9 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps error: organizationBillingError, isLoading: isOrgBillingLoading, refetch: refetchOrganizationBilling, - } = useOrganizationBilling(billingOrganizationId || '', { enabled: isOrganizationScope }) + } = useOrganizationBillingSummary(billingOrganizationId || '', { + enabled: isOrganizationScope, + }) const updateUserLimit = useUpdateUsageLimit() const updateOrgLimit = useUpdateOrganizationUsageLimit() @@ -195,7 +206,7 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps ? Boolean(organizationBilling?.billingBlocked) : Boolean(subscriptionData?.data?.billingBlocked) - const userRole = isOrganizationScope ? (organizationBillingData?.userRole ?? 'member') : 'owner' + const userRole = isOrganizationScope ? (organizationBilling?.userRole ?? 'member') : 'owner' const isTeamAdmin = isOrgAdminRole(userRole) const shouldUseOrganizationBillingContext = isOrganizationScope @@ -367,7 +378,10 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps } const referenceId = subscription.isOrgScoped ? billingOrganizationId : session?.user?.id const returnUrl = getBaseUrl() + window.location.pathname - await betterAuthSubscription.cancel({ returnUrl, referenceId: referenceId || '' }) + await betterAuthSubscription.cancel({ + returnUrl, + referenceId: referenceId || '', + }) } catch (error) { logger.error('Failed to cancel subscription', { error }) toast.error("Couldn't cancel subscription", { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.test.tsx index 586e3a342da..3c8cd9c0db7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.test.tsx @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ entitled: true, }, isLoading: false, + error: undefined as Error | undefined, }, }, inheritedStatusError: { current: false }, @@ -184,6 +185,7 @@ describe('BYOK scope access', () => { mocks.hostContext.current.viewer.isHostOrganizationAdmin = true mocks.canManageWorkspace.current = true mocks.organizationResult.current.data.entitled = true + mocks.organizationResult.current.error = undefined mocks.inheritedStatusError.current = false container = document.createElement('div') @@ -264,4 +266,15 @@ describe('BYOK scope access', () => { 'true:true:true' ) }) + + it('keeps cached organization keys visible when a background refresh fails', () => { + mocks.scope.current = 'organization' + mocks.organizationResult.current.error = new Error('Temporary failure') + + act(() => root.render()) + + expect(container.textContent).toContain('Sensitive organization key sk-org-secret') + expect(container.querySelector('[aria-label="BYOK manager"]')).not.toBeNull() + expect(container.textContent).not.toContain('Failed to load provider keys') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 286b941bc43..b4bf221de6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { ChipTag } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { @@ -59,6 +60,7 @@ import { type BYOKManagerProvider, type BYOKProviderSection, } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { @@ -425,8 +427,10 @@ export function BYOK() { const upsertOrganizationKey = useUpsertOrganizationBYOKKey() const deleteOrganizationKey = useDeleteOrganizationBYOKKey() - const activeKeys = isOrganizationScope ? organizationKeys.data?.keys : workspaceKeys.data?.keys + const activeQueryData = isOrganizationScope ? organizationKeys.data : workspaceKeys.data + const activeKeys = activeQueryData?.keys const isLoading = isOrganizationScope ? organizationKeys.isLoading : workspaceKeys.isLoading + const keysError = isOrganizationScope ? organizationKeys.error : workspaceKeys.error const isSaving = isOrganizationScope ? upsertOrganizationKey.isPending : upsertWorkspaceKey.isPending @@ -515,60 +519,66 @@ export function BYOK() { : undefined } > - { - if (isOrganizationScope && organizationQueryId) { - await upsertOrganizationKey.mutateAsync({ - organizationId: organizationQueryId, + {keysError && activeQueryData === undefined ? ( + + {getErrorMessage(keysError, 'Failed to load provider keys')} + + ) : ( + { + if (isOrganizationScope && organizationQueryId) { + await upsertOrganizationKey.mutateAsync({ + organizationId: organizationQueryId, + providerId: providerId as BYOKProviderId, + apiKey, + keyId, + name, + }) + return + } + + await upsertWorkspaceKey.mutateAsync({ + workspaceId, providerId: providerId as BYOKProviderId, apiKey, keyId, name, }) - return - } + }} + onDeleteKey={async (providerId, keyId) => { + if (isOrganizationScope && organizationQueryId) { + await deleteOrganizationKey.mutateAsync({ + organizationId: organizationQueryId, + providerId: providerId as BYOKProviderId, + keyId, + }) + return + } - await upsertWorkspaceKey.mutateAsync({ - workspaceId, - providerId: providerId as BYOKProviderId, - apiKey, - keyId, - name, - }) - }} - onDeleteKey={async (providerId, keyId) => { - if (isOrganizationScope && organizationQueryId) { - await deleteOrganizationKey.mutateAsync({ - organizationId: organizationQueryId, + await deleteWorkspaceKey.mutateAsync({ + workspaceId, providerId: providerId as BYOKProviderId, keyId, }) - return - } - - await deleteWorkspaceKey.mutateAsync({ - workspaceId, - providerId: providerId as BYOKProviderId, - keyId, - }) - }} - /> + }} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.test.tsx new file mode 100644 index 00000000000..29cc47b6d4d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.test.tsx @@ -0,0 +1,96 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseInboxConfig } = vi.hoisted(() => ({ + mockUseInboxConfig: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) + +vi.mock('@/components/settings/navigation', () => ({ + canMutateWorkspaceSettingsSection: () => true, +})) + +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ + useUserPermissionsContext: () => ({}), +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/inbox/components', () => ({ + InboxEnableToggle: () =>
inbox-toggle
, + InboxSettingsTab: () =>
inbox-settings
, + InboxTaskList: () =>
inbox-tasks
, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) =>
{children}
, + }) +) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice', () => ({ + SettingsUpgradeNotice: () =>
inbox-upgrade
, +})) + +vi.mock('@/hooks/queries/inbox', () => ({ + useInboxConfig: mockUseInboxConfig, +})) + +import { Inbox } from '@/app/workspace/[workspaceId]/settings/components/inbox/inbox' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('Inbox entitlement states', () => { + it('shows a load failure instead of an upgrade notice when entitlement is unknown', () => { + mockUseInboxConfig.mockReturnValue({ + data: undefined, + error: new Error('Inbox request failed'), + isLoading: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('Inbox request failed') + expect(container.textContent).not.toContain('inbox-upgrade') + }) + + it('preserves the upgrade notice for a successful non-entitled response', () => { + mockUseInboxConfig.mockReturnValue({ + data: { enabled: false, entitled: false }, + error: null, + isLoading: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('inbox-upgrade') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx index e1ff2ee7904..fdbac9319a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx @@ -1,5 +1,6 @@ 'use client' +import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -8,6 +9,7 @@ import { InboxSettingsTab, InboxTaskList, } from '@/app/workspace/[workspaceId]/settings/components/inbox/components' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { SettingsUpgradeNotice } from '@/app/workspace/[workspaceId]/settings/components/settings-upgrade-notice' @@ -17,7 +19,7 @@ export function Inbox() { const params = useParams() const workspaceId = params.workspaceId as string - const { data: config, isLoading } = useInboxConfig(workspaceId) + const { data: config, isLoading, error } = useInboxConfig(workspaceId) const workspacePermissions = useUserPermissionsContext() const canAdmin = canMutateWorkspaceSettingsSection('inbox', workspacePermissions) @@ -25,6 +27,16 @@ export function Inbox() { return null } + if (error && config === undefined) { + return ( + + + {getErrorMessage(error, 'Failed to load Inbox settings')} + + + ) + } + if (!config?.entitled) { if (config?.enabled && canAdmin) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx index e3fbbc30d54..249416267f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx @@ -58,7 +58,7 @@ export function Sandboxes() { ...sandboxIdUrlKeys, }) - const { data, isLoading } = useSandboxes(workspaceId) + const { data, isLoading, error } = useSandboxes(workspaceId) const createSandbox = useCreateSandbox() const updateSandbox = useUpdateSandbox() const deleteSandbox = useDeleteSandbox() @@ -227,6 +227,16 @@ export function Sandboxes() { ) } + if (error && data === undefined) { + return ( + + + {getErrorMessage(error, 'Failed to load sandboxes')} + + + ) + } + if (!entitled) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx index ffa5a03c2ff..120263937c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx @@ -5,12 +5,20 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseOrganization } = vi.hoisted(() => ({ +const { + mockIsAdminOrOwner, + mockUseOrganization, + mockUseOrganizationBilling, + mockUseOrganizationRoster, +} = vi.hoisted(() => ({ + mockIsAdminOrOwner: vi.fn(), mockUseOrganization: vi.fn(), + mockUseOrganizationBilling: vi.fn(), + mockUseOrganizationRoster: vi.fn(), })) vi.mock('@/lib/auth/auth-client', () => ({ - useSession: () => ({ data: { user: { email: 'viewer' } } }), + useSession: () => ({ data: { user: { id: 'viewer-1', email: 'viewer' } } }), })) vi.mock('@/lib/billing/client/utils', () => ({ @@ -22,7 +30,7 @@ vi.mock('@/lib/billing/client/utils', () => ({ vi.mock('@/lib/workspaces/organization', () => ({ generateSlug: (value: string) => value.toLowerCase(), - isAdminOrOwner: () => false, + isAdminOrOwner: mockIsAdminOrOwner, })) vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({ @@ -39,9 +47,9 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () = vi.mock('@/app/workspace/[workspaceId]/settings/components/team-management/components', () => ({ NoOrganizationView: () =>
no-organization-view
, - OrganizationMemberLists: () => null, + OrganizationMemberLists: () =>
organization-member-lists
, RemoveMemberDialog: () => null, - TeamSeatsOverview: () => null, + TeamSeatsOverview: () =>
team-seats-overview
, TransferOwnershipDialog: () => null, })) @@ -62,8 +70,8 @@ vi.mock('@/hooks/queries/organization', () => ({ useCreateOrganization: () => ({ error: null, isPending: false, mutateAsync: vi.fn() }), useMemberRemovalImpact: () => ({ data: [], isError: false, isFetching: false }), useOrganization: mockUseOrganization, - useOrganizationBilling: () => ({ data: undefined, isLoading: false }), - useOrganizationRoster: () => ({ data: undefined, isLoading: false }), + useOrganizationBilling: mockUseOrganizationBilling, + useOrganizationRoster: mockUseOrganizationRoster, useRemoveMember: () => ({ isPending: false, mutateAsync: vi.fn() }), useTransferOwnership: () => ({ isPending: false, mutateAsync: vi.fn() }), })) @@ -78,6 +86,17 @@ beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) + mockIsAdminOrOwner.mockReturnValue(false) + mockUseOrganizationBilling.mockReturnValue({ + data: undefined, + error: null, + isLoading: false, + }) + mockUseOrganizationRoster.mockReturnValue({ + data: { members: [], pendingInvitations: [], workspaces: [] }, + error: null, + isLoading: false, + }) }) afterEach(() => { @@ -103,4 +122,71 @@ describe('TeamManagement organization errors', () => { expect(container.textContent).toContain('Organization request failed') expect(container.textContent).not.toContain('no-organization-view') }) + + it('does not render a false member count while the roster is pending', () => { + mockUseOrganization.mockReturnValue({ + data: { id: 'org-1' }, + error: null, + isLoading: false, + }) + mockUseOrganizationRoster.mockReturnValue({ + data: undefined, + error: null, + isLoading: true, + }) + + act(() => + root.render( + + ) + ) + + expect(container.textContent).toContain('Loading members…') + expect(container.textContent).not.toContain('organization-member-lists') + }) + + it('shows a roster failure instead of an empty member list', () => { + mockUseOrganization.mockReturnValue({ + data: { id: 'org-1' }, + error: null, + isLoading: false, + }) + mockUseOrganizationRoster.mockReturnValue({ + data: undefined, + error: new Error('Roster request failed'), + isLoading: false, + }) + + act(() => + root.render( + + ) + ) + + expect(container.textContent).toContain('Roster request failed') + expect(container.textContent).not.toContain('organization-member-lists') + }) + + it('shows a billing failure instead of a subscription upsell', () => { + mockIsAdminOrOwner.mockReturnValue(true) + mockUseOrganization.mockReturnValue({ + data: { id: 'org-1' }, + error: null, + isLoading: false, + }) + mockUseOrganizationBilling.mockReturnValue({ + data: undefined, + error: new Error('Billing request failed'), + isLoading: false, + }) + + act(() => + root.render( + + ) + ) + + expect(container.textContent).toContain('Billing request failed') + expect(container.textContent).not.toContain('team-seats-overview') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 8efc23f7ba5..5520e9b52ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -64,12 +64,17 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) - const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling( - organizationId, - { enabled: adminOrOwner } - ) + const { + data: organizationBillingData, + isLoading: isOrgBillingLoading, + error: organizationBillingError, + } = useOrganizationBilling(organizationId, { enabled: adminOrOwner }) - const { data: roster, isLoading: isLoadingRoster } = useOrganizationRoster(organizationId) + const { + data: roster, + isLoading: isLoadingRoster, + error: rosterError, + } = useOrganizationRoster(organizationId) const removeMemberMutation = useRemoveMember() const transferOwnershipMutation = useTransferOwnership() @@ -347,27 +352,40 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr : [] } > - {adminOrOwner && ( - + {getErrorMessage(organizationBillingError, 'Failed to load seat information')} + + ) : ( + + ))} + + {isLoadingRoster ? ( + Loading members… + ) : rosterError && roster === undefined ? ( + + {getErrorMessage(rosterError, 'Failed to load organization members')} + + ) : ( + )} - -
{adminOrOwner && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 53ed7c47dea..b3f25c5778c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -12,7 +12,7 @@ import { useDeployReadiness, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' -import { apiKeysQueryOptions } from '@/hooks/queries/api-keys' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-servers' import { workspaceSettingsQueryOptions } from '@/hooks/queries/workspace' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts new file mode 100644 index 00000000000..5fe5a7c33c4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' + +let queryClient: QueryClient +const personalContext = { workspaceId: 'workspace-1', billingOrganizationId: null } + +describe('settings query warmers', () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryOnMount: false } }, + }) + mockRequestJson.mockImplementation((contract: { path: string }) => { + if (contract.path === '/api/mcp/servers' || contract.path === '/api/mcp/workflow-servers') { + return Promise.resolve({ data: { servers: [] } }) + } + if (contract.path === '/api/workspaces/[id]/sandboxes') { + return Promise.resolve({ sandboxes: [], entitled: true, strategy: 'prebuilt' }) + } + return Promise.resolve({ keys: [] }) + }) + }) + + afterEach(() => { + queryClient.clear() + vi.clearAllMocks() + }) + + it('warms only the approved first-content list for each section', async () => { + expect(warmSettingsSectionQuery(queryClient, personalContext, 'apikeys')).toBe(true) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'sandboxes')).toBe(true) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'byok')).toBe(true) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'mcp')).toBe(true) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'workflow-mcp-servers')).toBe( + true + ) + + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(6)) + expect(mockRequestJson.mock.calls.map(([contract]) => contract.path)).toEqual( + expect.arrayContaining([ + '/api/workspaces/[id]/api-keys', + '/api/users/me/api-keys', + '/api/workspaces/[id]/sandboxes', + '/api/workspaces/[id]/byok-keys', + '/api/mcp/servers', + '/api/mcp/workflow-servers', + ]) + ) + }) + + it('does not warm sensitive or broad settings data', () => { + expect(warmSettingsSectionQuery(queryClient, personalContext, 'secrets')).toBe(false) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'custom-tools')).toBe(false) + + expect(mockRequestJson).not.toHaveBeenCalled() + }) + + it('warms only the exact payer summary needed by Billing', async () => { + expect(warmSettingsSectionQuery(queryClient, personalContext, 'billing')).toBe(true) + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(1)) + expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/billing') + + queryClient.clear() + mockRequestJson.mockClear() + + expect( + warmSettingsSectionQuery( + queryClient, + { workspaceId: 'workspace-1', billingOrganizationId: 'org-1' }, + 'billing' + ) + ).toBe(true) + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(1)) + expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/organizations/[id]/billing-summary') + expect(mockRequestJson.mock.calls[0][1]).toEqual( + expect.objectContaining({ params: { id: 'org-1' } }) + ) + }) + + it('deduplicates a successful API-key warm with the eventual consumer', async () => { + warmSettingsSectionQuery(queryClient, personalContext, 'apikeys') + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(2)) + + await queryClient.fetchQuery(apiKeysQueryOptions('workspace-1', 'combined')) + + expect(mockRequestJson).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts new file mode 100644 index 00000000000..99fb01326fd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts @@ -0,0 +1,50 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { byokKeysQueryOptions } from '@/hooks/queries/byok-key-list' +import { mcpServersQueryOptions } from '@/hooks/queries/mcp-server-list' +import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' +import { getSandboxListQueryOptions } from '@/hooks/queries/sandbox-list' +import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' +import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' +import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-server-list' + +const SETTINGS_QUERY_WARMERS: Partial< + Record void> +> = { + apikeys: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), + sandboxes: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, getSandboxListQueryOptions(workspaceId)), + byok: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, byokKeysQueryOptions(workspaceId)), + mcp: (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), + 'workflow-mcp-servers': (queryClient, { workspaceId }) => + prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), + billing: (queryClient, { billingOrganizationId }) => { + if (billingOrganizationId) { + prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) + return + } + prefetchQueryOnIntent(queryClient, subscriptionDataQueryOptions(false)) + }, +} + +export interface SettingsQueryWarmContext { + workspaceId: string + billingOrganizationId: string | null +} + +/** Starts only the first-content query explicitly approved for a settings section. */ +export function warmSettingsSectionQuery( + queryClient: QueryClient, + context: SettingsQueryWarmContext, + section: SettingsSection +): boolean { + const warmer = SETTINGS_QUERY_WARMERS[section] + if (!warmer) return false + + warmer(queryClient, context) + return true +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 60c26715a5b..c3ef9162f20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -10,7 +10,7 @@ import { OverflowText, } from '@sim/emcn' import { ChevronLeft } from '@sim/emcn/icons' -import { type QueryClient, useQueryClient } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' import { useParams, usePathname, useRouter } from 'next/navigation' import type { DesktopSettingsSurface } from '@/components/settings/navigation' import { ORGANIZATION_PLANE_UNIFIED_SECTIONS } from '@/components/settings/navigation' @@ -28,6 +28,7 @@ import { isBillingEnabled, sectionConfig, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, @@ -39,7 +40,6 @@ import { import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { useSSOProviders } from '@/ee/sso/hooks/sso' import { useForkingAvailable } from '@/ee/workspace-forking/hooks/use-forking-available' -import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials' import { useGeneralSettings } from '@/hooks/queries/general-settings' import { useInboxConfig } from '@/hooks/queries/inbox' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -71,24 +71,6 @@ const SECTION_CHUNK_WARMERS: Partial Promise import('@/app/workspace/[workspaceId]/settings/components/terminal/terminal'), } -/** - * Sections whose first paint waits on a query the sidebar is able to start early. - * - * `general` is absent because a warm cannot help here: this sidebar only renders inside the - * workspace layout, whose `SettingsLoader` holds a live observer on that key with an hour-long - * stale time, so `prefetchQuery` short-circuits on every hover. - * - * The type argument is load-bearing: workspace credentials are cached per type and the secrets - * panel subscribes to `env_workspace`, so warming the unfiltered list writes a different cache - * entry and leaves the panel to fetch cold anyway. - */ -const SECTION_QUERY_WARMERS: Partial< - Record void> -> = { - secrets: (queryClient, workspaceId) => - prefetchWorkspaceCredentials(queryClient, workspaceId, 'env_workspace'), -} - interface SettingsSidebarProps { isCollapsed?: boolean showCollapsedTooltips?: boolean @@ -278,7 +260,11 @@ export function SettingsSidebar({ const handleIntent = (section: SettingsSection) => { void SECTION_CHUNK_WARMERS[section]?.() - SECTION_QUERY_WARMERS[section]?.(queryClient, workspaceId) + warmSettingsSectionQuery( + queryClient, + { workspaceId, billingOrganizationId: hostContext.hostOrganizationId }, + section + ) } const handleBack = useCallback(() => { diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index df3a5f0d5cd..7bbb4e0aa2c 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -20,6 +20,7 @@ import { SELFHOST_SETTINGS_ITEMS, SETTINGS_SECTION_REGISTRY, UNIFIED_TO_ORGANIZATION_SECTION, + UNIFIED_TO_WORKSPACE_SECTION, WORKSPACE_SETTINGS_ITEMS, WORKSPACE_SETTINGS_PATH_ALIASES, } from '@/components/settings/navigation' @@ -244,6 +245,25 @@ describe('settings navigation boundaries', () => { ) }) + it('maps every workspace projection from its unified section', () => { + expect(UNIFIED_TO_WORKSPACE_SECTION).toEqual({ + teammates: 'teammates', + secrets: 'secrets', + byok: 'byok', + sandboxes: 'sandboxes', + 'credential-groups': 'credential-groups', + 'custom-tools': 'custom-tools', + mcp: 'mcp', + 'workflow-mcp-servers': 'workflow-mcp-servers', + apikeys: 'api-keys', + inbox: 'inbox', + 'recently-deleted': 'recently-deleted', + forks: 'forks', + 'custom-blocks': 'custom-blocks', + 'self-host': 'self-host', + }) + }) + it('labels the members section consistently', () => { const unifiedOrganization = buildUnifiedSettingsNavigation().find( ({ id }) => id === 'organization' diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index dcb92ef1beb..bfeecdb187b 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -908,6 +908,16 @@ export const UNIFIED_TO_ORGANIZATION_SECTION: Readonly< ) ) +export const UNIFIED_TO_WORKSPACE_SECTION: Readonly< + Partial> +> = Object.fromEntries( + SETTINGS_SECTION_REGISTRY.flatMap((entry) => { + const unifiedSection = entry.unified?.id + const workspaceSection = entry.planes?.workspace?.id + return unifiedSection && workspaceSection ? [[unifiedSection, workspaceSection] as const] : [] + }) +) + export type OrganizationSectionAccess = 'unavailable' | 'view' | 'manage' interface ResolveOrganizationSectionAccessOptions { diff --git a/apps/sim/components/settings/prefetch-standalone-general.test.ts b/apps/sim/components/settings/prefetch-standalone-general.test.ts new file mode 100644 index 00000000000..380378f6837 --- /dev/null +++ b/apps/sim/components/settings/prefetch-standalone-general.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticate, mockGetUserProfile, mockGetUserSettings } = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockGetUserProfile: vi.fn(), + mockGetUserSettings: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes', () => ({ + internalSessionAuth: { authenticate: mockAuthenticate }, +})) + +vi.mock('@/lib/users/application/read-current-user', () => ({ + getCurrentUserProfileUseCase: { execute: mockGetUserProfile }, + getCurrentUserSettingsUseCase: { execute: mockGetUserSettings }, +})) + +import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general' +import { generalSettingsKeys } from '@/hooks/queries/general-settings' +import { userProfileKeys } from '@/hooks/queries/user-profile' + +describe('prefetchStandaloneGeneral', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticate.mockResolvedValue({ + kind: 'session', + userId: 'viewer-a', + sessionId: 'session-a', + }) + mockGetUserProfile.mockResolvedValue({ + id: 'viewer-a', + name: 'Viewer', + email: 'viewer@example.com', + image: null, + emailVerified: true, + }) + mockGetUserSettings.mockResolvedValue({ + autoConnect: true, + superUserModeEnabled: false, + mothershipEnvironment: 'default', + theme: 'dark', + telemetryEnabled: true, + billingUsageNotificationsEnabled: true, + errorNotificationsEnabled: true, + snapToGridSize: 0, + showActionBar: true, + autoFocusOnClick: true, + copilotAutoAllowedTools: [], + timezone: null, + }) + }) + + it('hydrates both exact General query entries for the authenticated viewer', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + await prefetchStandaloneGeneral(queryClient) + + expect(mockAuthenticate).toHaveBeenCalledTimes(1) + expect(mockGetUserProfile).toHaveBeenCalledWith({ + principal: expect.objectContaining({ userId: 'viewer-a' }), + input: {}, + }) + expect(mockGetUserSettings).toHaveBeenCalledWith({ + principal: expect.objectContaining({ userId: 'viewer-a' }), + input: {}, + }) + expect(queryClient.getQueryData(userProfileKeys.profile())).toEqual({ + id: 'viewer-a', + name: 'Viewer', + email: 'viewer@example.com', + image: null, + }) + expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({ + theme: 'dark', + telemetryEnabled: true, + }) + }) + + it('keeps successful settings hydration when the profile is unavailable', async () => { + mockGetUserProfile.mockResolvedValue(null) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + await prefetchStandaloneGeneral(queryClient) + + expect(queryClient.getQueryData(userProfileKeys.profile())).toBeUndefined() + expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({ + theme: 'dark', + }) + }) +}) diff --git a/apps/sim/components/settings/prefetch-standalone-general.ts b/apps/sim/components/settings/prefetch-standalone-general.ts new file mode 100644 index 00000000000..463d2ebfa53 --- /dev/null +++ b/apps/sim/components/settings/prefetch-standalone-general.ts @@ -0,0 +1,56 @@ +import type { QueryClient } from '@tanstack/react-query' +import { getUserProfileContract, getUserSettingsContract } from '@/lib/api/contracts/user' +import { internalSessionAuth } from '@/lib/api/server/routes' +import { + getCurrentUserProfileUseCase, + getCurrentUserSettingsUseCase, +} from '@/lib/users/application/read-current-user' +import { + GENERAL_SETTINGS_STALE_TIME, + generalSettingsKeys, + mapGeneralSettingsResponse, +} from '@/hooks/queries/general-settings' +import { + mapUserProfileResponse, + USER_PROFILE_STALE_TIME, + userProfileKeys, +} from '@/hooks/queries/user-profile' + +/** + * Hydrates the authenticated viewer's standalone General page with the exact + * keys, values, and freshness windows consumed by its client queries. + */ +export async function prefetchStandaloneGeneral(queryClient: QueryClient): Promise { + let principalPromise: ReturnType | undefined + const getPrincipal = () => { + principalPromise ??= internalSessionAuth.authenticate() + return principalPromise + } + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: userProfileKeys.profile(), + queryFn: async () => { + const profile = await getCurrentUserProfileUseCase.execute({ + principal: await getPrincipal(), + input: {}, + }) + const response = getUserProfileContract.response.schema.parse({ user: profile }) + return mapUserProfileResponse(response.user) + }, + staleTime: USER_PROFILE_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: generalSettingsKeys.settings(), + queryFn: async () => { + const settings = await getCurrentUserSettingsUseCase.execute({ + principal: await getPrincipal(), + input: {}, + }) + const response = getUserSettingsContract.response.schema.parse({ data: settings }) + return mapGeneralSettingsResponse(response.data) + }, + staleTime: GENERAL_SETTINGS_STALE_TIME, + }), + ]) +} diff --git a/apps/sim/components/settings/settings-intent-link.test.tsx b/apps/sim/components/settings/settings-intent-link.test.tsx index 87ed7b046e2..6cb0c932089 100644 --- a/apps/sim/components/settings/settings-intent-link.test.tsx +++ b/apps/sim/components/settings/settings-intent-link.test.tsx @@ -5,13 +5,16 @@ import { act, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { mockPathname } = vi.hoisted(() => ({ mockPathname: vi.fn(() => '/settings/billing') })) + +vi.mock('next/navigation', () => ({ usePathname: mockPathname })) vi.mock('next/link', () => ({ default: ({ prefetch, onNavigate: _onNavigate, ...props }: ComponentProps<'a'> & { - prefetch: boolean | null + prefetch: boolean onNavigate?: unknown }) => , })) @@ -23,6 +26,8 @@ let root: Root beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + mockPathname.mockReturnValue('/settings/billing') container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -31,49 +36,135 @@ beforeEach(() => { afterEach(() => { act(() => root.unmount()) container.remove() + vi.useRealTimers() }) +function renderLink( + props: Partial> = {}, + onIntent = vi.fn() +) { + act(() => { + root.render( + + General + + ) + }) + const link = container.querySelector('a') + if (!link) throw new Error('settings link not rendered') + return { link, onIntent } +} + +function pointerEvent(type: string, pointerType: 'mouse' | 'touch', init?: MouseEventInit) { + const event = new MouseEvent(type, { bubbles: true, ...init }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + return event +} + describe('SettingsIntentLink', () => { - it('promotes prefetch from disabled to the Next.js default once the user signals intent', () => { - const onIntent = vi.fn() + it('enables full route prefetch after deliberate hover dwell', () => { + const { link, onIntent } = renderLink() act(() => { - root.render( - - General - - ) + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + vi.advanceTimersByTime(79) }) + expect(link).toHaveAttribute('data-prefetch', 'false') + expect(onIntent).not.toHaveBeenCalled() + + act(() => vi.advanceTimersByTime(1)) + expect(link).toHaveAttribute('data-prefetch', 'true') + expect(onIntent).toHaveBeenCalledTimes(1) + }) - const link = container.querySelector('a') + it('cancels drive-by hover intent and cleans up pending timers', () => { + const { link, onIntent } = renderLink() + act(() => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + link.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) + vi.runAllTimers() + }) expect(link).toHaveAttribute('data-prefetch', 'false') act(() => { - link?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })) - link?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) - link?.dispatchEvent(new Event('touchstart', { bubbles: true })) + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + root.unmount() + vi.runAllTimers() }) + expect(onIntent).not.toHaveBeenCalled() + root = createRoot(container) + }) - expect(link).toHaveAttribute('data-prefetch', 'null') - expect(onIntent).toHaveBeenCalledTimes(1) + it('prefetches immediately for keyboard focus and respects cancellation', () => { + const canceled = renderLink({ onFocus: (event) => event.preventDefault() }) + act(() => + canceled.link.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: true })) + ) + expect(canceled.link).toHaveAttribute('data-prefetch', 'false') + expect(canceled.onIntent).not.toHaveBeenCalled() + + const focused = renderLink() + act(() => focused.link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))) + expect(focused.link).toHaveAttribute('data-prefetch', 'true') + expect(focused.onIntent).toHaveBeenCalledTimes(1) }) - it('honors a consumer preventing an intent event', () => { - const onIntent = vi.fn() - act(() => { - root.render( - event.preventDefault()} - > - General - - ) - }) + it('does not treat touchstart scrolling as navigation intent', () => { + const onTouchStart = vi.fn() + const { link, onIntent } = renderLink({ onTouchStart }) + act(() => link.dispatchEvent(new TouchEvent('touchstart', { bubbles: true }))) + expect(onTouchStart).toHaveBeenCalledTimes(1) + expect(link).toHaveAttribute('data-prefetch', 'false') + expect(onIntent).not.toHaveBeenCalled() + }) - const link = container.querySelector('a') - act(() => link?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true }))) + it('covers quick mouse clicks, completed touch taps, and click fallback', () => { + const mouse = renderLink() + act(() => mouse.link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 }))) + expect(mouse.link).toHaveAttribute('data-prefetch', 'true') + expect(mouse.onIntent).toHaveBeenCalledTimes(1) + act(() => mouse.link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + const touch = renderLink() + act(() => touch.link.dispatchEvent(pointerEvent('pointerup', 'touch', { button: 0 }))) + expect(touch.link).toHaveAttribute('data-prefetch', 'true') + expect(touch.onIntent).toHaveBeenCalledTimes(1) + act(() => touch.link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))) + + const fallback = renderLink({ href: '#general' }) + act(() => + fallback.link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + ) + expect(fallback.link).toHaveAttribute('data-prefetch', 'true') + expect(fallback.onIntent).toHaveBeenCalledTimes(1) + }) + + it('ignores canceled and modified clicks', () => { + const canceled = renderLink({ onClick: (event) => event.preventDefault() }) + act(() => + canceled.link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + ) + expect(canceled.link).toHaveAttribute('data-prefetch', 'false') + expect(canceled.onIntent).not.toHaveBeenCalled() + + const modified = renderLink() + act(() => + modified.link.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true }) + ) + ) + expect(modified.link).toHaveAttribute('data-prefetch', 'false') + expect(modified.onIntent).not.toHaveBeenCalled() + }) + + it('never prefetches or warms data for the current route', () => { + mockPathname.mockReturnValue('/settings/general') + const { link, onIntent } = renderLink() + act(() => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + link.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + link.dispatchEvent(pointerEvent('pointerdown', 'mouse', { button: 0 })) + vi.runAllTimers() + }) expect(link).toHaveAttribute('data-prefetch', 'false') expect(onIntent).not.toHaveBeenCalled() }) diff --git a/apps/sim/components/settings/settings-intent-link.tsx b/apps/sim/components/settings/settings-intent-link.tsx index dac4ad08891..28637d3e4ca 100644 --- a/apps/sim/components/settings/settings-intent-link.tsx +++ b/apps/sim/components/settings/settings-intent-link.tsx @@ -1,49 +1,156 @@ 'use client' -import { type ComponentProps, useRef, useState } from 'react' +import { + type ComponentProps, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import Link from 'next/link' +import { usePathname } from 'next/navigation' + +const SETTINGS_PREFETCH_DWELL_MS = 80 interface SettingsIntentLinkProps extends Omit, 'prefetch'> { - /** Runs once when pointer, keyboard, or touch interaction signals likely navigation. */ + /** Runs when deliberate interaction signals likely navigation. */ onIntent?: () => void } -/** - * A settings navigation link that avoids eager route work until interaction - * lets Next.js apply its normal destination-aware prefetch behavior. - */ -export function SettingsIntentLink({ - onIntent, - onPointerEnter, +function hrefPathname(href: ComponentProps['href']): string | null { + if (typeof href === 'string') return href.split(/[?#]/, 1)[0] || null + return typeof href.pathname === 'string' ? href.pathname : null +} + +function isUnmodifiedPrimaryPointer(event: ReactPointerEvent): boolean { + return ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) +} + +export function SettingsIntentLink(props: SettingsIntentLinkProps) { + const pathname = usePathname() + const destinationPathname = hrefPathname(props.href) + const isCurrentRoute = destinationPathname !== null && destinationPathname === pathname + const routeRole = isCurrentRoute ? 'current' : 'destination' + + return ( + + ) +} + +interface IntentAwareSettingsLinkProps extends SettingsIntentLinkProps { + isCurrentRoute: boolean +} + +function IntentAwareSettingsLink({ + isCurrentRoute, + onBlur, + onClick, onFocus, + onIntent, + onMouseEnter, + onMouseLeave, + onPointerCancel, + onPointerDown, + onPointerUp, onTouchStart, ...props -}: SettingsIntentLinkProps) { - const intentHandledRef = useRef(false) - const [hasIntent, setHasIntent] = useState(false) - - const handleIntent = () => { - if (intentHandledRef.current) return - intentHandledRef.current = true - setHasIntent(true) +}: IntentAwareSettingsLinkProps) { + const prefetchTimerRef = useRef | null>(null) + const navigationIntentRef = useRef(false) + const [shouldPrefetchRoute, setShouldPrefetchRoute] = useState(false) + + const cancelScheduledPrefetch = useCallback(() => { + if (prefetchTimerRef.current === null) return + clearTimeout(prefetchTimerRef.current) + prefetchTimerRef.current = null + }, []) + + const prefetchForIntent = () => { + cancelScheduledPrefetch() + if (isCurrentRoute || navigationIntentRef.current) return + navigationIntentRef.current = true + setShouldPrefetchRoute(true) onIntent?.() } + const schedulePrefetch = () => { + cancelScheduledPrefetch() + prefetchTimerRef.current = setTimeout(() => { + prefetchTimerRef.current = null + prefetchForIntent() + }, SETTINGS_PREFETCH_DWELL_MS) + } + + const clearIntent = () => { + cancelScheduledPrefetch() + navigationIntentRef.current = false + setShouldPrefetchRoute(false) + } + + useEffect(() => cancelScheduledPrefetch, [cancelScheduledPrefetch]) + return ( { - onPointerEnter?.(event) - if (!event.defaultPrevented) handleIntent() + prefetch={!isCurrentRoute && shouldPrefetchRoute} + onMouseEnter={(event) => { + onMouseEnter?.(event) + if (!event.defaultPrevented) schedulePrefetch() + }} + onMouseLeave={(event) => { + onMouseLeave?.(event) + clearIntent() }} onFocus={(event) => { onFocus?.(event) - if (!event.defaultPrevented) handleIntent() + if (!event.defaultPrevented) prefetchForIntent() + }} + onBlur={(event) => { + onBlur?.(event) + clearIntent() + }} + onPointerDown={(event) => { + onPointerDown?.(event) + if (event.pointerType === 'mouse' && isUnmodifiedPrimaryPointer(event)) { + prefetchForIntent() + } + }} + onPointerUp={(event) => { + onPointerUp?.(event) + if (event.pointerType !== 'mouse' && isUnmodifiedPrimaryPointer(event)) { + prefetchForIntent() + } + }} + onPointerCancel={(event) => { + onPointerCancel?.(event) + clearIntent() }} - onTouchStart={(event) => { - onTouchStart?.(event) - if (!event.defaultPrevented) handleIntent() + onTouchStart={onTouchStart} + onClick={(event) => { + onClick?.(event) + if ( + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + !isCurrentRoute && + !navigationIntentRef.current + ) { + prefetchForIntent() + } }} /> ) diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx new file mode 100644 index 00000000000..4057b4db8d5 --- /dev/null +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseOrganizationBilling, mockUseUserPermissionConfig } = vi.hoisted(() => ({ + mockUseOrganizationBilling: vi.fn(), + mockUseUserPermissionConfig: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Checkbox: () => null, + ChipModal: ({ children }: { children?: ReactNode }) => <>{children}, + ChipModalBody: ({ children }: { children?: ReactNode }) => <>{children}, + ChipModalError: () => null, + ChipModalField: ({ children }: { children?: ReactNode }) => <>{children}, + ChipModalFooter: () => null, + ChipModalHeader: ({ children }: { children?: ReactNode }) => <>{children}, + ChipTag: () => null, + Label: ({ children }: { children?: ReactNode }) => <>{children}, +})) +vi.mock('@sim/emcn/icons', () => ({ Plus: () => null })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) +vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) +vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false })) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-resource-row', () => ({ + RESOURCE_LIST_STACK: '', + SettingsResourceRow: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) =>
{children}
, + }) +) +vi.mock('@/app/workspace/[workspaceId]/settings/components/use-settings-search', () => ({ + useSettingsSearch: () => ['', vi.fn()], +})) +vi.mock('@/ee/access-control/components/group-detail', () => ({ GroupDetail: () => null })) +vi.mock('@/ee/access-control/components/workspace-select', () => ({ WorkspaceSelect: () => null })) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useCreatePermissionGroup: () => ({ isPending: false, mutateAsync: vi.fn() }), + useOrganizationWorkspaces: () => ({ data: [], isPending: false }), + usePermissionGroups: () => ({ data: [], isPending: false }), + useUserPermissionConfig: mockUseUserPermissionConfig, +})) +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizationBilling: mockUseOrganizationBilling, +})) + +import { AccessControl } from '@/ee/access-control/components/access-control' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('AccessControl entitlement states', () => { + it('shows a billing failure instead of a plan notice when access is unknown', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: { entitled: false }, + error: null, + isPending: false, + }) + mockUseOrganizationBilling.mockReturnValue({ + data: undefined, + error: new Error('Access Control billing failed'), + isPending: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('Access Control billing failed') + expect(container.textContent).not.toContain('Only organization admins on Enterprise plans') + }) + + it('preserves the plan notice when both entitlement reads succeed', () => { + mockUseUserPermissionConfig.mockReturnValue({ + data: { entitled: false }, + error: null, + isPending: false, + }) + mockUseOrganizationBilling.mockReturnValue({ + data: { data: { subscriptionPlan: 'free' } }, + error: null, + isPending: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('Only organization admins on Enterprise plans') + }) +}) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index b7e0fe6ea31..adf546a7847 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -64,12 +64,18 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon * id and the caller's admin status server-side from the workspace so gating is * never keyed off the session's active org. */ - const { data: userPermissionConfig, isPending: entitlementLoading } = - useUserPermissionConfig(workspaceId) - const { data: organizationBillingData, isPending: organizationBillingLoading } = - useOrganizationBilling(organizationId, { - enabled: !isAccessControlEnabled && !userPermissionConfig?.entitled, - }) + const { + data: userPermissionConfig, + isPending: entitlementLoading, + error: entitlementError, + } = useUserPermissionConfig(workspaceId) + const { + data: organizationBillingData, + isPending: organizationBillingLoading, + error: organizationBillingError, + } = useOrganizationBilling(organizationId, { + enabled: !isAccessControlEnabled && !userPermissionConfig?.entitled, + }) const currentUserIsOrgAdmin = isOrganizationAdmin const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups( @@ -226,6 +232,18 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon return } + const entitlementLoadError = isEntitled + ? null + : ((userPermissionConfig === undefined ? entitlementError : null) ?? + (organizationBillingData === undefined ? organizationBillingError : null)) + if (entitlementLoadError) { + return ( + + {getErrorMessage(entitlementLoadError, 'Failed to load Access Control access')} + + ) + } + if (!canManage) { return ( diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.test.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.test.tsx new file mode 100644 index 00000000000..58da27dbcab --- /dev/null +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.test.tsx @@ -0,0 +1,99 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseCanPublishCustomBlock } = vi.hoisted(() => ({ + mockUseCanPublishCustomBlock: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ ChipTag: () => null })) +vi.mock('@sim/emcn/icons', () => ({ Plus: () => null })) +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1' }), +})) +vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] })) +vi.mock('@/components/settings/navigation', () => ({ + canMutateWorkspaceSettingsSection: () => true, +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ + useUserPermissionsContext: () => ({ isLoading: false }), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-resource-row', () => ({ + RESOURCE_LIST_STACK: '', + SettingsResourceRow: () => null, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) =>
{children}
, + }) +) +vi.mock('@/app/workspace/[workspaceId]/settings/components/use-settings-search', () => ({ + useSettingsSearch: () => ['', vi.fn()], +})) +vi.mock('@/blocks/custom/custom-block-icon', () => ({ + getCustomBlockIcon: () => () => null, +})) +vi.mock('@/ee/custom-blocks/components/custom-block-detail', () => ({ + CustomBlockDetail: () => null, +})) +vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({ + useOrgBrandConfig: () => ({}), +})) +vi.mock('@/hooks/queries/custom-blocks', () => ({ + useCanPublishCustomBlock: mockUseCanPublishCustomBlock, + useCustomBlocks: () => ({ data: [] }), +})) +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesQuery: () => ({ data: [] }), +})) + +import { CustomBlocks } from '@/ee/custom-blocks/components/custom-blocks' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('CustomBlocks entitlement states', () => { + it('shows a load failure instead of an Enterprise notice when access is unknown', () => { + mockUseCanPublishCustomBlock.mockReturnValue({ + data: undefined, + error: new Error('Custom block access failed'), + isLoading: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('Custom block access failed') + expect(container.textContent).not.toContain('require an Enterprise plan') + }) + + it('preserves the Enterprise notice for a successful non-entitled response', () => { + mockUseCanPublishCustomBlock.mockReturnValue({ data: false, error: null, isLoading: false }) + + act(() => root.render()) + + expect(container.textContent).toContain('require an Enterprise plan') + }) +}) diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index d21c047e725..97970396f1d 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react' import { ChipTag } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import { useQueryState } from 'nuqs' import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation' @@ -32,7 +33,11 @@ export function CustomBlocks() { const canAdmin = canMutateWorkspaceSettingsSection('custom-blocks', workspacePermissions) const permissionsLoading = workspacePermissions.isLoading - const { data: canManage = false, isLoading } = useCanPublishCustomBlock(workspaceId) + const { + data: canManage, + isLoading, + error: entitlementError, + } = useCanPublishCustomBlock(workspaceId) const { data: blocks = [] } = useCustomBlocks(workspaceId) const { data: workspaces = [] } = useWorkspacesQuery() @@ -75,6 +80,14 @@ export function CustomBlocks() { */ if (isLoading || (selectedBlockId !== null && permissionsLoading)) return null + if (entitlementError && canManage === undefined) { + return ( + + {getErrorMessage(entitlementError, 'Failed to load custom block access')} + + ) + } + if (!canManage) { return ( diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index 71dfac60ffa..259c0dd6d66 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -197,6 +197,7 @@ beforeEach(() => { mockUseSession.mockReturnValue({ data: { user: { id: 'user-1' } } }) mockUseOrganizationBilling.mockReturnValue({ data: { data: { subscriptionPlan: 'enterprise' } }, + error: null, isLoading: false, }) mockUseConfigureSSO.mockReturnValue({ @@ -205,6 +206,7 @@ beforeEach(() => { }) mockUseSSOProviders.mockImplementation(({ organizationId }: { organizationId: string }) => ({ data: { providers: [provider(organizationId)] }, + error: null, isLoading: false, })) }) @@ -230,6 +232,19 @@ describe('SSO organization transitions', () => { expect(container).not.toHaveTextContent('org-a.example.com') expect(container.querySelector('input[value="client-a"]')).toBeNull() }) + + it('shows a billing failure instead of an Enterprise upsell', () => { + mockUseOrganizationBilling.mockReturnValue({ + data: undefined, + error: new Error('Billing entitlement failed'), + isLoading: false, + }) + + renderSso('org-a') + + expect(container).toHaveTextContent('Billing entitlement failed') + expect(container).not.toHaveTextContent('available on Enterprise plans only') + }) }) describe('SSO member provisioning', () => { diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 8d3438bf0ee..11c21257c63 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -230,12 +230,17 @@ export function SSO({ organizationId }: SSOProps) { function OrganizationSsoSettings({ organizationId }: SSOProps) { const { data: session } = useSession() - const { data: organizationBillingData, isLoading: isLoadingOrganizationBilling } = - useOrganizationBilling(organizationId) - - const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders({ - organizationId, - }) + const { + data: organizationBillingData, + isLoading: isLoadingOrganizationBilling, + error: organizationBillingError, + } = useOrganizationBilling(organizationId) + + const { + data: providersData, + isLoading: isLoadingProviders, + error: providersError, + } = useSSOProviders({ organizationId }) const providers = providersData?.providers || [] const existingProvider = providers[0] as SSOProvider | undefined @@ -281,6 +286,17 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { return null } + const loadingError = + (providersData === undefined ? providersError : null) ?? + (isBillingEnabled && organizationBillingData === undefined ? organizationBillingError : null) + if (loadingError) { + return ( + + {getErrorMessage(loadingError, 'Failed to load Single Sign-On settings')} + + ) + } + if (isBillingEnabled) { if (!hasEnterprisePlan) { return ( diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx new file mode 100644 index 00000000000..7e8f9280f5d --- /dev/null +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.test.tsx @@ -0,0 +1,99 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseOrganizationBilling } = vi.hoisted(() => ({ + mockUseOrganizationBilling: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isBillingEnabled: true, + isOrganizationsEnabled: false, + isSsoEnabled: false, +})) +vi.mock('@/components/settings/save-discard-actions', () => ({ + saveDiscardActions: () => [], +})) +vi.mock('@/app/workspace/[workspaceId]/components/credential-detail', () => ({ + CHIP_FIELD_INPUT: '', + CHIP_FIELD_SHELL: '', +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', + () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) =>
{children}
, + }) +) +vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload', () => ({ + useProfilePictureUpload: () => ({ isUploading: false, uploadProfilePicture: vi.fn() }), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard', () => ({ + useSettingsUnsavedGuard: vi.fn(), +})) +vi.mock('@/ee/components/setting-row', () => ({ + SettingRow: ({ children }: { children?: ReactNode }) =>
{children}
, +})) +vi.mock('@/ee/whitelabeling/hooks/whitelabel', () => ({ + useUpdateWhitelabelSettings: () => ({ isPending: false, mutateAsync: vi.fn() }), + useWhitelabelSettings: () => ({ data: {}, error: null, isLoading: false }), +})) +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizationBilling: mockUseOrganizationBilling, +})) +vi.mock('@/hooks/queries/workspace', () => ({ + useWorkspacesQuery: () => ({ data: [] }), +})) + +import { WhitelabelingSettings } from '@/ee/whitelabeling/components/whitelabeling-settings' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('WhitelabelingSettings entitlement states', () => { + it('shows a billing failure instead of an Enterprise notice when access is unknown', () => { + mockUseOrganizationBilling.mockReturnValue({ + data: undefined, + error: new Error('Whitelabel billing failed'), + isPending: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('Whitelabel billing failed') + expect(container.textContent).not.toContain('available on Enterprise plans only') + }) + + it('preserves the Enterprise notice for a successful non-entitled response', () => { + mockUseOrganizationBilling.mockReturnValue({ + data: { data: { subscriptionPlan: 'free' } }, + error: null, + isPending: false, + }) + + act(() => root.render()) + + expect(container.textContent).toContain('available on Enterprise plans only') + }) +}) diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx index 393bc2b29f9..0db76100a28 100644 --- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx +++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx @@ -457,8 +457,11 @@ function WhitelabelingForm({ initialSettings, orgId, uploadWorkspaceId }: Whitel } export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSettingsProps) { - const { data: organizationBillingData, isPending: organizationBillingLoading } = - useOrganizationBilling(orgId, { enabled: isBillingEnabled }) + const { + data: organizationBillingData, + isPending: organizationBillingLoading, + error: organizationBillingError, + } = useOrganizationBilling(orgId, { enabled: isBillingEnabled }) const { data: workspaces } = useWorkspacesQuery(true) const uploadWorkspaceId = workspaces?.find((workspace) => workspace.organizationId === orgId)?.id const { data: savedSettings, error: settingsError, isLoading } = useWhitelabelSettings(orgId) @@ -485,6 +488,14 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe ) } + if (isBillingEnabled && organizationBillingData === undefined && organizationBillingError) { + return ( + + {getErrorMessage(organizationBillingError, 'Failed to load organization billing')} + + ) + } + if (isBillingEnabled && !isEnterprise(organizationBillingData?.data?.subscriptionPlan)) { return ( Whitelabeling is available on Enterprise plans only. diff --git a/apps/sim/hooks/queries/api-key-list.ts b/apps/sim/hooks/queries/api-key-list.ts new file mode 100644 index 00000000000..bffbf64b212 --- /dev/null +++ b/apps/sim/hooks/queries/api-key-list.ts @@ -0,0 +1,76 @@ +import { queryOptions } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type ApiKey, + listPersonalApiKeysContract, + listWorkspaceApiKeysContract, +} from '@/lib/api/contracts/api-keys' + +export type ApiKeyScope = 'combined' | 'personal' | 'workspace' + +export interface CombinedApiKeysData { + workspaceKeys: ApiKey[] + personalKeys: ApiKey[] + conflicts: string[] +} + +export const apiKeysKeys = { + all: ['apiKeys'] as const, + workspaces: () => [...apiKeysKeys.all, 'workspace'] as const, + workspace: (workspaceId: string) => [...apiKeysKeys.workspaces(), workspaceId] as const, + personal: () => [...apiKeysKeys.all, 'personal'] as const, + combineds: () => [...apiKeysKeys.all, 'combined'] as const, + combined: (workspaceId: string) => [...apiKeysKeys.combineds(), workspaceId] as const, +} + +export const API_KEYS_COMBINED_STALE_TIME = 60 * 1000 + +export async function fetchApiKeys( + workspaceId: string, + scope: ApiKeyScope, + signal?: AbortSignal +): Promise { + if (scope === 'personal') { + const data = await requestJson(listPersonalApiKeysContract, { signal }) + return { workspaceKeys: [], personalKeys: data.keys, conflicts: [] } + } + if (scope === 'workspace') { + const data = await requestJson(listWorkspaceApiKeysContract, { + params: { id: workspaceId }, + signal, + }) + return { workspaceKeys: data.keys, personalKeys: [], conflicts: [] } + } + + const [workspaceData, personalData] = await Promise.all([ + requestJson(listWorkspaceApiKeysContract, { params: { id: workspaceId }, signal }), + requestJson(listPersonalApiKeysContract, { signal }), + ]) + const workspaceKeys = workspaceData.keys + const personalKeys = personalData.keys + const workspaceKeyNames = new Set(workspaceKeys.map((key) => key.name)) + const conflicts: string[] = [] + for (const key of personalKeys) { + if (workspaceKeyNames.has(key.name)) conflicts.push(key.name) + } + + return { + workspaceKeys, + personalKeys, + conflicts, + } +} + +export function apiKeysQueryOptions(workspaceId: string, scope: ApiKeyScope = 'combined') { + return queryOptions({ + queryKey: + scope === 'personal' + ? apiKeysKeys.personal() + : scope === 'workspace' + ? apiKeysKeys.workspace(workspaceId) + : apiKeysKeys.combined(workspaceId), + queryFn: ({ signal }) => fetchApiKeys(workspaceId, scope, signal), + retryOnMount: true, + staleTime: API_KEYS_COMBINED_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/api-keys.test.ts b/apps/sim/hooks/queries/api-keys.test.ts index 4cf28b1ba4e..6297f703eda 100644 --- a/apps/sim/hooks/queries/api-keys.test.ts +++ b/apps/sim/hooks/queries/api-keys.test.ts @@ -12,7 +12,7 @@ vi.mock('@/lib/api/client/request', () => ({ })) import { listPersonalApiKeysContract, listWorkspaceApiKeysContract } from '@/lib/api/contracts' -import { fetchApiKeys } from '@/hooks/queries/api-keys' +import { fetchApiKeys } from '@/hooks/queries/api-key-list' describe('API key settings scopes', () => { beforeEach(() => { diff --git a/apps/sim/hooks/queries/api-keys.ts b/apps/sim/hooks/queries/api-keys.ts index 6ea84cea7c8..576b1354ca8 100644 --- a/apps/sim/hooks/queries/api-keys.ts +++ b/apps/sim/hooks/queries/api-keys.ts @@ -1,106 +1,25 @@ -import { - keepPreviousData, - queryOptions, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { - type ApiKey, type CreatedApiKey, createPersonalApiKeyContract, createWorkspaceApiKeyContract, deletePersonalApiKeyContract, deleteWorkspaceApiKeyContract, - listPersonalApiKeysContract, - listWorkspaceApiKeysContract, updateWorkspaceContract, } from '@/lib/api/contracts' +import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' +import { type ApiKeyScope, apiKeysKeys, apiKeysQueryOptions } from '@/hooks/queries/api-key-list' import { workspaceKeys } from '@/hooks/queries/workspace' +import { workspaceHostKeys } from '@/hooks/queries/workspace-host' -export type { ApiKey, CreatedApiKey } - -/** - * Query key factories for API keys-related queries - */ -export const apiKeysKeys = { - all: ['apiKeys'] as const, - workspaces: () => [...apiKeysKeys.all, 'workspace'] as const, - workspace: (workspaceId: string) => [...apiKeysKeys.workspaces(), workspaceId] as const, - personal: () => [...apiKeysKeys.all, 'personal'] as const, - combineds: () => [...apiKeysKeys.all, 'combined'] as const, - combined: (workspaceId: string) => [...apiKeysKeys.combineds(), workspaceId] as const, -} - -export const API_KEYS_COMBINED_STALE_TIME = 60 * 1000 - -type CombinedApiKeysData = { - workspaceKeys: ApiKey[] - personalKeys: ApiKey[] - conflicts: string[] -} - -export type ApiKeyScope = 'combined' | 'personal' | 'workspace' +export type { CreatedApiKey } interface UseApiKeysOptions { enabled?: boolean } -/** - * Fetch API keys for one settings plane, or both for compatibility callers. - */ -export async function fetchApiKeys( - workspaceId: string, - scope: ApiKeyScope, - signal?: AbortSignal -): Promise { - if (scope === 'personal') { - const data = await requestJson(listPersonalApiKeysContract, { signal }) - return { workspaceKeys: [], personalKeys: data.keys, conflicts: [] } - } - if (scope === 'workspace') { - const data = await requestJson(listWorkspaceApiKeysContract, { - params: { id: workspaceId }, - signal, - }) - return { workspaceKeys: data.keys, personalKeys: [], conflicts: [] } - } - - const [workspaceData, personalData] = await Promise.all([ - requestJson(listWorkspaceApiKeysContract, { params: { id: workspaceId }, signal }), - requestJson(listPersonalApiKeysContract, { signal }), - ]) - const workspaceKeys: ApiKey[] = workspaceData.keys - const personalKeys: ApiKey[] = personalData.keys - - const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name)) - const conflicts = personalKeys - .filter((key) => workspaceKeyNames.has(key.name)) - .map((key) => key.name) - - return { - workspaceKeys, - personalKeys, - conflicts, - } -} - -export function apiKeysQueryOptions(workspaceId: string, scope: ApiKeyScope = 'combined') { - return queryOptions({ - queryKey: - scope === 'personal' - ? apiKeysKeys.personal() - : scope === 'workspace' - ? apiKeysKeys.workspace(workspaceId) - : apiKeysKeys.combined(workspaceId), - queryFn: ({ signal }) => fetchApiKeys(workspaceId, scope, signal), - staleTime: API_KEYS_COMBINED_STALE_TIME, - placeholderData: scope === 'personal' ? undefined : keepPreviousData, - }) -} - /** * Hook to fetch API keys for the requested settings plane. */ @@ -215,10 +134,30 @@ export function useUpdateWorkspaceApiKeySettings() { body: { allowPersonalApiKeys }, }) }, - onSettled: (_data, _error, variables) => { - return queryClient.invalidateQueries({ - queryKey: workspaceKeys.settings(variables.workspaceId), - }) + onSuccess: (_data, variables) => { + queryClient.setQueryData( + workspaceHostKeys.detail(variables.workspaceId), + (current) => + current + ? { + ...current, + workspace: { + ...current.workspace, + allowPersonalApiKeys: variables.allowPersonalApiKeys, + }, + } + : current + ) + }, + onSettled: async (_data, _error, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: workspaceKeys.settings(variables.workspaceId), + }), + queryClient.invalidateQueries({ + queryKey: workspaceHostKeys.detail(variables.workspaceId), + }), + ]) }, }) } diff --git a/apps/sim/hooks/queries/byok-key-list.ts b/apps/sim/hooks/queries/byok-key-list.ts new file mode 100644 index 00000000000..f935141cbb5 --- /dev/null +++ b/apps/sim/hooks/queries/byok-key-list.ts @@ -0,0 +1,34 @@ +import { queryOptions } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { type BYOKKeysResponse, listByokKeysContract } from '@/lib/api/contracts/byok-keys' + +export const byokKeysKeys = { + all: ['byok-keys'] as const, + lists: () => [...byokKeysKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const, + organizationLists: () => [...byokKeysKeys.all, 'organization-list'] as const, + organizationList: (organizationId?: string) => + [...byokKeysKeys.organizationLists(), organizationId ?? ''] as const, + inheritedStatuses: () => [...byokKeysKeys.all, 'inherited-status'] as const, + inheritedStatus: (workspaceId?: string) => + [...byokKeysKeys.inheritedStatuses(), workspaceId ?? ''] as const, +} + +export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000 + +async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listByokKeysContract, { + params: { id: workspaceId }, + signal, + }) + return { keys: data.keys ?? [] } +} + +export function byokKeysQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: byokKeysKeys.list(workspaceId), + queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal), + retryOnMount: true, + staleTime: BYOK_KEY_LIST_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 5dfa9be7afe..06ce3bfbb69 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -1,48 +1,27 @@ import { createLogger } from '@sim/logger' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { type BYOKKey, - type BYOKKeysResponse, deleteByokKeyContract, deleteOrganizationByokKeyContract, getInheritedByokStatusContract, type InheritedBYOKStatusResponse, - listByokKeysContract, listOrganizationByokKeysContract, type OrganizationBYOKKeysResponse, upsertByokKeyContract, upsertOrganizationByokKeyContract, } from '@/lib/api/contracts' +import { + BYOK_KEY_LIST_STALE_TIME, + byokKeysKeys, + byokKeysQueryOptions, +} from '@/hooks/queries/byok-key-list' const logger = createLogger('BYOKKeysQueries') -export type { BYOKKey, BYOKKeysResponse } - -export const byokKeysKeys = { - all: ['byok-keys'] as const, - lists: () => [...byokKeysKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const, - organizationLists: () => [...byokKeysKeys.all, 'organization-list'] as const, - organizationList: (organizationId?: string) => - [...byokKeysKeys.organizationLists(), organizationId ?? ''] as const, - inheritedStatuses: () => [...byokKeysKeys.all, 'inherited-status'] as const, - inheritedStatus: (workspaceId?: string) => - [...byokKeysKeys.inheritedStatuses(), workspaceId ?? ''] as const, -} - -export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000 - -async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { - const data = await requestJson(listByokKeysContract, { - params: { id: workspaceId }, - signal, - }) - return { - keys: data.keys ?? [], - } -} +export type { BYOKKey } async function fetchOrganizationBYOKKeys( organizationId: string, @@ -66,11 +45,8 @@ async function fetchInheritedBYOKStatus( export function useBYOKKeys(workspaceId: string) { return useQuery({ - queryKey: byokKeysKeys.list(workspaceId), - queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal), + ...byokKeysQueryOptions(workspaceId), enabled: !!workspaceId, - staleTime: BYOK_KEY_LIST_STALE_TIME, - placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/general-settings.test.tsx b/apps/sim/hooks/queries/general-settings.test.tsx new file mode 100644 index 00000000000..a70ef87de5f --- /dev/null +++ b/apps/sim/hooks/queries/general-settings.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson, mockSyncTheme } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + mockSyncTheme: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +vi.mock('@/lib/core/utils/theme', () => ({ + syncThemeToNextThemes: mockSyncTheme, +})) + +import { + type GeneralSettings, + generalSettingsKeys, + useGeneralSettings, +} from '@/hooks/queries/general-settings' + +const HYDRATED_SETTINGS: GeneralSettings = { + autoConnect: true, + superUserModeEnabled: false, + mothershipEnvironment: 'prod', + theme: 'dark', + telemetryEnabled: true, + billingUsageNotificationsEnabled: true, + errorNotificationsEnabled: true, + snapToGridSize: 0, + showActionBar: true, + autoFocusOnClick: true, + copilotAutoAllowedTools: [], + timezone: null, +} + +function Probe() { + useGeneralSettings() + return null +} + +describe('useGeneralSettings', () => { + let container: HTMLDivElement + let root: Root + let queryClient: QueryClient + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + }) + + afterEach(() => { + act(() => root.unmount()) + queryClient.clear() + container.remove() + vi.clearAllMocks() + }) + + it('synchronizes the browser theme from server-hydrated settings without refetching', () => { + queryClient.setQueryData(generalSettingsKeys.settings(), HYDRATED_SETTINGS) + + act(() => { + root.render( + + + + ) + }) + + expect(mockSyncTheme).toHaveBeenCalledWith('dark') + expect(mockRequestJson).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 57dfa16780e..6eb12286f22 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -78,15 +79,17 @@ async function fetchGeneralSettings(signal?: AbortSignal): Promise { - const settings = await fetchGeneralSettings(signal) - syncThemeToNextThemes(settings.theme) - return settings - }, + queryFn: ({ signal }) => fetchGeneralSettings(signal), staleTime: GENERAL_SETTINGS_STALE_TIME, }) + + useEffect(() => { + if (query.data?.theme) syncThemeToNextThemes(query.data.theme) + }, [query.data?.theme]) + + return query } /** @@ -96,11 +99,7 @@ export function useGeneralSettings() { export function prefetchGeneralSettings(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: generalSettingsKeys.settings(), - queryFn: async ({ signal }) => { - const settings = await fetchGeneralSettings(signal) - syncThemeToNextThemes(settings.theme) - return settings - }, + queryFn: ({ signal }) => fetchGeneralSettings(signal), staleTime: GENERAL_SETTINGS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/mcp-server-list.ts b/apps/sim/hooks/queries/mcp-server-list.ts new file mode 100644 index 00000000000..c1b783aba38 --- /dev/null +++ b/apps/sim/hooks/queries/mcp-server-list.ts @@ -0,0 +1,47 @@ +import { queryOptions } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { listMcpServersContract, type McpServer } from '@/lib/api/contracts/mcp' + +export type { McpServer } + +export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 + +export const mcpKeys = { + all: ['mcp'] as const, + servers: () => [...mcpKeys.all, 'servers'] as const, + serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, + serverTools: () => [...mcpKeys.all, 'serverTools'] as const, + serverToolsWorkspace: (workspaceId?: string) => + [...mcpKeys.serverTools(), workspaceId ?? ''] as const, + serverToolsList: (workspaceId?: string, serverId?: string) => + [...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const, + storedTools: () => [...mcpKeys.all, 'storedTools'] as const, + storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const, + allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const, +} + +async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise { + try { + const data = await requestJson(listMcpServersContract, { + query: { workspaceId }, + signal, + }) + return data.data.servers + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return [] + } + throw error + } +} + +export function mcpServersQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: mcpKeys.serversList(workspaceId), + queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), + retry: false, + retryOnMount: true, + staleTime: MCP_SERVER_LIST_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 8cb44396489..94c010f2f70 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -2,13 +2,7 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/hostnames' import { getErrorMessage } from '@sim/utils/errors' -import { - keepPreviousData, - useMutation, - useQueries, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { @@ -16,9 +10,7 @@ import { deleteMcpServerContract, discoverMcpToolsContract, getAllowedMcpDomainsContract, - listMcpServersContract, listStoredMcpToolsContract, - type McpServer, type McpServerTestBody, type McpServerTestResult, type RefreshMcpServerResult, @@ -39,13 +31,19 @@ import type { McpTransport, StoredMcpTool, } from '@/lib/mcp/types' +import { type McpServer, mcpKeys, mcpServersQueryOptions } from '@/hooks/queries/mcp-server-list' import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers' const logger = createLogger('McpQueries') export type { McpServerStatusConfig, McpTool, StoredMcpTool } -export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 +export { + MCP_SERVER_LIST_STALE_TIME, + type McpServer, + mcpKeys, + mcpServersQueryOptions, +} from '@/hooks/queries/mcp-server-list' /** * Tool discovery is kept fresh by the `list_changed` → SSE push (see `useMcpToolsEvents`), * so the query only needs a re-probe-on-visit fallback for servers without push. Matches the @@ -56,22 +54,6 @@ export const MCP_SERVER_TOOLS_STALE_TIME = 5 * 60 * 1000 export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000 export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000 -export const mcpKeys = { - all: ['mcp'] as const, - servers: () => [...mcpKeys.all, 'servers'] as const, - serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, - serverTools: () => [...mcpKeys.all, 'serverTools'] as const, - serverToolsWorkspace: (workspaceId?: string) => - [...mcpKeys.serverTools(), workspaceId ?? ''] as const, - serverToolsList: (workspaceId?: string, serverId?: string) => - [...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const, - storedTools: () => [...mcpKeys.all, 'storedTools'] as const, - storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const, - allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const, -} - -export type { McpServer } - /** Wire shape for create/update; distinct from runtime McpServerConfig. */ export interface McpServerInput { name: string @@ -85,29 +67,10 @@ export interface McpServerInput { authType?: McpAuthType } -async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise { - try { - const data = await requestJson(listMcpServersContract, { - query: { workspaceId }, - signal, - }) - return data.data.servers - } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { - return [] - } - throw error - } -} - export function useMcpServers(workspaceId: string) { return useQuery({ - queryKey: mcpKeys.serversList(workspaceId), - queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), + ...mcpServersQueryOptions(workspaceId), enabled: !!workspaceId, - retry: false, - staleTime: MCP_SERVER_LIST_STALE_TIME, - placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/navigation-request-gating.test.tsx b/apps/sim/hooks/queries/navigation-request-gating.test.tsx index be703cc218e..909eb1bf4f1 100644 --- a/apps/sim/hooks/queries/navigation-request-gating.test.tsx +++ b/apps/sim/hooks/queries/navigation-request-gating.test.tsx @@ -22,7 +22,8 @@ import { listWorkspaceApiKeysContract, } from '@/lib/api/contracts' import { listWorkflowMcpServersContract } from '@/lib/api/contracts/workflow-mcp-servers' -import { apiKeysQueryOptions, useApiKeys } from '@/hooks/queries/api-keys' +import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { useApiKeys } from '@/hooks/queries/api-keys' import { useWorkflowMcpServers, workflowMcpServersQueryOptions, diff --git a/apps/sim/hooks/queries/organization-billing-summary.ts b/apps/sim/hooks/queries/organization-billing-summary.ts new file mode 100644 index 00000000000..81eca4aef14 --- /dev/null +++ b/apps/sim/hooks/queries/organization-billing-summary.ts @@ -0,0 +1,27 @@ +import { queryOptions, useQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' + +export const ORGANIZATION_BILLING_SUMMARY_STALE_TIME = 30 * 1000 + +export function organizationBillingSummaryOptions(orgId: string) { + return queryOptions({ + queryKey: organizationKeys.billingSummary(orgId), + queryFn: ({ signal }) => + requestJson(getOrganizationBillingSummaryContract, { + params: { id: orgId }, + signal, + }), + retry: false, + retryOnMount: true, + staleTime: ORGANIZATION_BILLING_SUMMARY_STALE_TIME, + }) +} + +export function useOrganizationBillingSummary(orgId: string, options?: { enabled?: boolean }) { + return useQuery({ + ...organizationBillingSummaryOptions(orgId), + enabled: !!orgId && (options?.enabled ?? true), + }) +} diff --git a/apps/sim/hooks/queries/organization.test.tsx b/apps/sim/hooks/queries/organization.test.tsx index 011a61f6401..9e9cb3cd0d8 100644 --- a/apps/sim/hooks/queries/organization.test.tsx +++ b/apps/sim/hooks/queries/organization.test.tsx @@ -28,6 +28,7 @@ vi.mock('@/lib/auth/auth-client', () => ({ })) import { + getOrganizationBillingSummaryContract, getOrganizationRosterContract, type OrganizationRoster, } from '@/lib/api/contracts/organization' @@ -36,10 +37,12 @@ import { type OrganizationBillingApiResponse, } from '@/lib/api/contracts/subscription' import { + organizationKeys, useOrganization, useOrganizationBilling, useOrganizationRoster, } from '@/hooks/queries/organization' +import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' interface Deferred { promise: Promise @@ -204,4 +207,44 @@ describe('organization identity transitions', () => { const [args] = mockGetFullOrganization.mock.calls[0] expect(args.fetchOptions?.signal).toBeInstanceOf(AbortSignal) }) + + it('uses a shape-specific recoverable cache entry for the navigation billing summary', async () => { + const summary = { + success: true as const, + data: { + organizationId: 'org-a', + subscriptionState: 'active' as const, + subscriptionPlan: 'team_25000', + subscriptionStatus: 'active', + creditBalance: 0, + billingInterval: 'month' as const, + cancelAtPeriodEnd: false, + totalSeats: 2, + totalCurrentUsage: 3, + totalUsageLimit: 125, + minimumBillingAmount: 125, + billingPeriodEnd: '2026-09-01T00:00:00.000Z', + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + upgradeWorkspaceId: 'workspace-a', + userRole: 'owner' as const, + }, + } + mockRequestJson.mockResolvedValue(summary) + + const options = organizationBillingSummaryOptions('org-a') + expect(options.queryKey).toEqual(organizationKeys.billingSummary('org-a')) + expect(options.queryKey).not.toEqual(organizationKeys.billing('org-a')) + expect(options.retryOnMount).toBe(true) + + await expect(queryClient.fetchQuery(options)).resolves.toEqual(summary) + expect(mockRequestJson).toHaveBeenCalledWith( + getOrganizationBillingSummaryContract, + expect.objectContaining({ + params: { id: 'org-a' }, + signal: expect.any(AbortSignal), + }) + ) + }) }) diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 684ecc67c49..5b3c650deb6 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -14,6 +14,7 @@ import { getMemberRemovalImpactContract, getOrganizationMemberUsageLimitContract, getOrganizationRosterContract, + type OrganizationBillingSummary, type OrganizationMemberUsageLimitData, type OrganizationRoster, type RemovalImpactCredential, @@ -32,6 +33,7 @@ import { } from '@/lib/api/contracts/subscription' import { client } from '@/lib/auth/auth-client' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' import { workspaceKeys } from '@/hooks/queries/workspace' @@ -63,25 +65,7 @@ function readNumber(value: unknown): number | undefined { return undefined } -/** - * Query key factories for organization-related queries - * This ensures consistent cache invalidation across the app - */ -export const organizationKeys = { - all: ['organizations'] as const, - lists: () => [...organizationKeys.all, 'list'] as const, - details: () => [...organizationKeys.all, 'detail'] as const, - detail: (id: string) => [...organizationKeys.details(), id] as const, - subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const, - billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const, - members: (id: string) => [...organizationKeys.detail(id), 'members'] as const, - memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const, - memberUsageLimit: (id: string, userId: string) => - [...organizationKeys.detail(id), 'member-usage-limit', userId] as const, - roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const, - removalImpact: (id: string, userId: string) => - [...organizationKeys.detail(id), 'removal-impact', userId] as const, -} +export { organizationKeys } export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess } @@ -228,10 +212,17 @@ export function useUpdateOrganizationUsageLimit() { }) }, onMutate: async ({ organizationId, limit }) => { - await queryClient.cancelQueries({ queryKey: organizationKeys.billing(organizationId) }) - await queryClient.cancelQueries({ queryKey: organizationKeys.subscription(organizationId) }) + await queryClient.cancelQueries({ + queryKey: organizationKeys.billing(organizationId), + }) + await queryClient.cancelQueries({ + queryKey: organizationKeys.subscription(organizationId), + }) const previousBillingData = queryClient.getQueryData(organizationKeys.billing(organizationId)) + const previousBillingSummary = queryClient.getQueryData( + organizationKeys.billingSummary(organizationId) + ) const previousSubscriptionData = queryClient.getQueryData( organizationKeys.subscription(organizationId) ) @@ -264,7 +255,24 @@ export function useUpdateOrganizationUsageLimit() { } ) - return { previousBillingData, previousSubscriptionData, organizationId } + queryClient.setQueryData<{ + success: true + data: OrganizationBillingSummary + }>(organizationKeys.billingSummary(organizationId), (old) => + old + ? { + ...old, + data: { ...old.data, totalUsageLimit: limit }, + } + : old + ) + + return { + previousBillingData, + previousBillingSummary, + previousSubscriptionData, + organizationId, + } }, onError: (_err, _variables, context) => { if (context?.previousBillingData && context?.organizationId) { @@ -279,6 +287,12 @@ export function useUpdateOrganizationUsageLimit() { context.previousSubscriptionData ) } + if (context?.previousBillingSummary && context?.organizationId) { + queryClient.setQueryData( + organizationKeys.billingSummary(context.organizationId), + context.previousBillingSummary + ) + } }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ @@ -309,11 +323,21 @@ export function useRemoveMember() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.memberUsage(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.subscription(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) queryClient.invalidateQueries({ queryKey: workspaceKeys.all }) @@ -340,8 +364,12 @@ export function useUpdateOrganizationMemberRole() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) }, }) } @@ -411,10 +439,18 @@ export function useTransferOwnership() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.subscription(variables.orgId), + }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }) queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) @@ -438,8 +474,12 @@ export function useUpdateInvitation() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) }, }) } @@ -467,9 +507,15 @@ export function useCancelInvitation() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.billing(variables.orgId), + }) queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }) queryClient.invalidateQueries({ queryKey: invitationListsKey }) }, @@ -494,8 +540,12 @@ export function useResendInvitation() { }) }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) }) - queryClient.invalidateQueries({ queryKey: organizationKeys.roster(variables.orgId) }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.detail(variables.orgId), + }) + queryClient.invalidateQueries({ + queryKey: organizationKeys.roster(variables.orgId), + }) }, }) } diff --git a/apps/sim/hooks/queries/sandbox-list.ts b/apps/sim/hooks/queries/sandbox-list.ts new file mode 100644 index 00000000000..07a6cdbcceb --- /dev/null +++ b/apps/sim/hooks/queries/sandbox-list.ts @@ -0,0 +1,27 @@ +import { queryOptions } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { listSandboxesContract, type SandboxListResponse } from '@/lib/api/contracts/sandboxes' + +export const sandboxKeys = { + all: ['sandboxes'] as const, + lists: () => [...sandboxKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...sandboxKeys.lists(), workspaceId ?? ''] as const, +} + +export const SANDBOX_LIST_STALE_TIME = 30 * 1000 + +async function fetchSandboxes( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listSandboxesContract, { params: { id: workspaceId }, signal }) +} + +export function getSandboxListQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: sandboxKeys.list(workspaceId), + queryFn: ({ signal }) => fetchSandboxes(workspaceId, signal), + retryOnMount: true, + staleTime: SANDBOX_LIST_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/sandboxes.ts b/apps/sim/hooks/queries/sandboxes.ts index 006132274f3..3092adfcde9 100644 --- a/apps/sim/hooks/queries/sandboxes.ts +++ b/apps/sim/hooks/queries/sandboxes.ts @@ -5,24 +5,16 @@ import type { ContractBodyInput } from '@/lib/api/contracts' import { createSandboxContract, deleteSandboxContract, - listSandboxesContract, type Sandbox, type SandboxListResponse, updateSandboxContract, } from '@/lib/api/contracts' +import { getSandboxListQueryOptions, sandboxKeys } from '@/hooks/queries/sandbox-list' const logger = createLogger('SandboxQueries') export type { Sandbox, SandboxListResponse } -export const sandboxKeys = { - all: ['sandboxes'] as const, - lists: () => [...sandboxKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...sandboxKeys.lists(), workspaceId ?? ''] as const, -} - -export const SANDBOX_LIST_STALE_TIME = 30 * 1000 - /** Poll cadence while any sandbox is still building; see {@link useSandboxes}. */ export const SANDBOX_BUILD_POLL_INTERVAL = 3 * 1000 @@ -33,13 +25,6 @@ export const SANDBOX_BUILD_POLL_INTERVAL = 3 * 1000 */ const MAX_BUILD_POLLS = 350 -async function fetchSandboxes( - workspaceId: string, - signal?: AbortSignal -): Promise { - return requestJson(listSandboxesContract, { params: { id: workspaceId }, signal }) -} - /** True while at least one sandbox has a build that has not reached a terminal state. */ export function hasPendingBuild(sandboxes: readonly Sandbox[]): boolean { return sandboxes.some( @@ -47,24 +32,10 @@ export function hasPendingBuild(sandboxes: readonly Sandbox[]): boolean { ) } -/** - * Query options shared by the hook and the Function block's sandbox picker - * (`fetchWorkspaceSandboxOptions`), so both read one cache entry rather than two. - */ -export function getSandboxListQueryOptions(workspaceId: string) { - return { - queryKey: sandboxKeys.list(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => fetchSandboxes(workspaceId, signal), - staleTime: SANDBOX_LIST_STALE_TIME, - } -} - export function useSandboxes(workspaceId?: string) { return useQuery({ - queryKey: sandboxKeys.list(workspaceId), - queryFn: ({ signal }) => fetchSandboxes(workspaceId as string, signal), + ...getSandboxListQueryOptions(workspaceId ?? ''), enabled: Boolean(workspaceId), - staleTime: SANDBOX_LIST_STALE_TIME, // Builds are the only thing that changes without a user action, so the poll // runs only while one is in flight and stops on the first terminal read. refetchInterval: (query) => diff --git a/apps/sim/hooks/queries/subscription-data.ts b/apps/sim/hooks/queries/subscription-data.ts new file mode 100644 index 00000000000..e992a2525fc --- /dev/null +++ b/apps/sim/hooks/queries/subscription-data.ts @@ -0,0 +1,31 @@ +import { queryOptions } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getUserBillingContract, + type SubscriptionApiResponse, +} from '@/lib/api/contracts/subscription' +import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' + +export const SUBSCRIPTION_DATA_STALE_TIME = 5 * 60 * 1000 + +async function fetchSubscriptionData( + includeOrg = false, + signal?: AbortSignal +): Promise { + return requestJson(getUserBillingContract, { + query: { context: 'user', includeOrg }, + signal, + }) +} + +export function subscriptionDataQueryOptions( + includeOrg = false, + staleTime = SUBSCRIPTION_DATA_STALE_TIME +) { + return queryOptions({ + queryKey: subscriptionKeys.user(includeOrg), + queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal), + retryOnMount: true, + staleTime, + }) +} diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index ad4b38dcf45..84c1ee0c599 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -1,39 +1,27 @@ import type { QueryClient } from '@tanstack/react-query' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { createBillingPortalContract, getInvoicesContract, - getUserBillingContract, getUserUsageLimitContract, type InvoicesApiResponse, type SubscriptionApiResponse, updateUsageLimitContract, } from '@/lib/api/contracts/subscription' +import { + SUBSCRIPTION_DATA_STALE_TIME, + subscriptionDataQueryOptions, +} from '@/hooks/queries/subscription-data' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { subscriptionKeys } from '@/hooks/queries/utils/subscription-keys' export type { SubscriptionApiResponse } -export const SUBSCRIPTION_DATA_STALE_TIME = 5 * 60 * 1000 export const USAGE_LIMIT_STALE_TIME = 30 * 1000 export const INVOICES_STALE_TIME = 5 * 60 * 1000 -/** - * Fetch user subscription data - * @param includeOrg - Whether to include organization role data - */ -async function fetchSubscriptionData( - includeOrg = false, - signal?: AbortSignal -): Promise { - return requestJson(getUserBillingContract, { - query: { context: 'user', includeOrg }, - signal, - }) -} - interface UseSubscriptionDataOptions { /** Include organization membership and role data */ includeOrg?: boolean @@ -50,13 +38,7 @@ interface UseSubscriptionDataOptions { export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { const { includeOrg = false, enabled = true, staleTime = SUBSCRIPTION_DATA_STALE_TIME } = options - return useQuery({ - queryKey: subscriptionKeys.user(includeOrg), - queryFn: ({ signal }) => fetchSubscriptionData(includeOrg, signal), - staleTime, - placeholderData: keepPreviousData, - enabled, - }) + return useQuery({ ...subscriptionDataQueryOptions(includeOrg, staleTime), enabled }) } /** @@ -71,11 +53,7 @@ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { * workspace queries land, so it cannot be warmed at hover time. */ export function prefetchUpgradeBillingData(queryClient: QueryClient) { - queryClient.prefetchQuery({ - queryKey: subscriptionKeys.user(true), - queryFn: ({ signal }) => fetchSubscriptionData(true, signal), - staleTime: SUBSCRIPTION_DATA_STALE_TIME, - }) + queryClient.prefetchQuery(subscriptionDataQueryOptions(true)) queryClient.prefetchQuery({ queryKey: subscriptionKeys.usage(), queryFn: ({ signal }) => fetchUsageLimitData(signal), diff --git a/apps/sim/hooks/queries/utils/organization-keys.ts b/apps/sim/hooks/queries/utils/organization-keys.ts new file mode 100644 index 00000000000..2873410cf8b --- /dev/null +++ b/apps/sim/hooks/queries/utils/organization-keys.ts @@ -0,0 +1,16 @@ +export const organizationKeys = { + all: ['organizations'] as const, + lists: () => [...organizationKeys.all, 'list'] as const, + details: () => [...organizationKeys.all, 'detail'] as const, + detail: (id: string) => [...organizationKeys.details(), id] as const, + subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const, + billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const, + billingSummary: (id: string) => [...organizationKeys.billing(id), 'summary'] as const, + members: (id: string) => [...organizationKeys.detail(id), 'members'] as const, + memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const, + memberUsageLimit: (id: string, userId: string) => + [...organizationKeys.detail(id), 'member-usage-limit', userId] as const, + roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const, + removalImpact: (id: string, userId: string) => + [...organizationKeys.detail(id), 'removal-impact', userId] as const, +} diff --git a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts new file mode 100644 index 00000000000..d822be1d2fb --- /dev/null +++ b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { QueryClient, QueryObserver, queryOptions } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' +import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + retryOnMount: false, + }, + }, + }) +} + +describe('prefetchQueryOnIntent', () => { + it('shares successful work with the eventual consumer', async () => { + const queryClient = createQueryClient() + const queryFn = vi.fn().mockResolvedValue(['ready']) + const options = queryOptions({ + queryKey: ['intent', 'success'] as const, + queryFn, + staleTime: 60_000, + }) + + prefetchQueryOnIntent(queryClient, options) + await vi.waitFor(() => expect(queryClient.getQueryData(options.queryKey)).toEqual(['ready'])) + await queryClient.fetchQuery(options) + + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('removes an inactive speculative failure so a later mount can recover', async () => { + const queryClient = createQueryClient() + const queryFn = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce('recovered') + const options = queryOptions({ + queryKey: ['intent', 'recover'] as const, + queryFn, + staleTime: 60_000, + }) + + prefetchQueryOnIntent(queryClient, options) + await vi.waitFor(() => expect(queryClient.getQueryState(options.queryKey)).toBeUndefined()) + + await expect(queryClient.fetchQuery(options)).resolves.toBe('recovered') + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('preserves a failure once a real observer is mounted', async () => { + const queryClient = createQueryClient() + let rejectQuery: ((error: Error) => void) | undefined + const queryFn = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectQuery = reject + }) + ) + .mockResolvedValueOnce('recovered') + const options = queryOptions({ + queryKey: ['intent', 'observed-error'] as const, + queryFn, + retryOnMount: true, + staleTime: 60_000, + }) + + prefetchQueryOnIntent(queryClient, options) + const observer = new QueryObserver(queryClient, options) + const unsubscribe = observer.subscribe(() => undefined) + rejectQuery?.(new Error('visible failure')) + + await vi.waitFor(() => + expect(queryClient.getQueryState(options.queryKey)?.status).toBe('error') + ) + expect(observer.getCurrentResult().error?.message).toBe('visible failure') + + unsubscribe() + + const remountedObserver = new QueryObserver(queryClient, options) + const unsubscribeRemount = remountedObserver.subscribe(() => undefined) + await vi.waitFor(() => expect(remountedObserver.getCurrentResult().data).toBe('recovered')) + expect(queryFn).toHaveBeenCalledTimes(2) + unsubscribeRemount() + }) + + it('forwards cancellation through the query function signal', async () => { + const queryClient = createQueryClient() + let wasAborted = false + const options = queryOptions({ + queryKey: ['intent', 'cancel'] as const, + queryFn: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + wasAborted = true + reject(signal.reason) + }) + }), + staleTime: 60_000, + }) + + prefetchQueryOnIntent(queryClient, options) + await queryClient.cancelQueries({ queryKey: options.queryKey, exact: true }) + + expect(wasAborted).toBe(true) + }) +}) diff --git a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts new file mode 100644 index 00000000000..2ce32f7d460 --- /dev/null +++ b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts @@ -0,0 +1,24 @@ +import type { FetchQueryOptions, QueryClient, QueryKey } from '@tanstack/react-query' + +type QueryFilterKey = NonNullable< + NonNullable[0]>['queryKey'] +> + +/** + * Starts a speculative query without allowing an inactive failure to poison a later mount. + * Mounted observers retain the error so their component can render truthful feedback. + */ +export function prefetchQueryOnIntent( + queryClient: QueryClient, + options: FetchQueryOptions +): void { + void queryClient.prefetchQuery(options).then(() => { + if (queryClient.getQueryState(options.queryKey)?.status !== 'error') return + + queryClient.removeQueries({ + queryKey: options.queryKey as QueryFilterKey, + exact: true, + type: 'inactive', + }) + }) +} diff --git a/apps/sim/hooks/queries/workflow-mcp-server-list.ts b/apps/sim/hooks/queries/workflow-mcp-server-list.ts new file mode 100644 index 00000000000..61909713a00 --- /dev/null +++ b/apps/sim/hooks/queries/workflow-mcp-server-list.ts @@ -0,0 +1,51 @@ +import { queryOptions } from '@tanstack/react-query' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + listWorkflowMcpServersContract, + type WorkflowMcpServer, +} from '@/lib/api/contracts/workflow-mcp-servers' + +export const workflowMcpServerKeys = { + all: ['workflow-mcp-servers'] as const, + serverLists: () => [...workflowMcpServerKeys.all, 'server-list'] as const, + servers: (workspaceId: string) => [...workflowMcpServerKeys.serverLists(), workspaceId] as const, + details: () => [...workflowMcpServerKeys.all, 'detail'] as const, + server: (workspaceId: string, serverId: string) => + [...workflowMcpServerKeys.details(), workspaceId, serverId] as const, + tools: (workspaceId: string, serverId: string) => + [...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const, + deployedWorkflowLists: () => [...workflowMcpServerKeys.all, 'deployed-workflow-list'] as const, + deployedWorkflows: (workspaceId: string) => + [...workflowMcpServerKeys.deployedWorkflowLists(), workspaceId] as const, +} + +export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000 + +async function fetchWorkflowMcpServers( + workspaceId: string, + signal?: AbortSignal +): Promise { + try { + const data = await requestJson(listWorkflowMcpServersContract, { + query: { workspaceId }, + signal, + }) + return data.data.servers + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) { + return [] + } + throw error + } +} + +export function workflowMcpServersQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: workflowMcpServerKeys.servers(workspaceId), + queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), + retry: false, + retryOnMount: true, + staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx b/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx new file mode 100644 index 00000000000..413729d97fd --- /dev/null +++ b/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), +})) + +vi.mock('@/lib/api/client/request', () => ({ + requestJson: mockRequestJson, +})) + +import { + deleteWorkflowMcpServerContract, + listWorkflowMcpToolsContract, +} from '@/lib/api/contracts/workflow-mcp-servers' +import { + useDeleteWorkflowMcpServer, + useWorkflowMcpTools, + workflowMcpServerKeys, +} from '@/hooks/queries/workflow-mcp-servers' + +let container: HTMLDivElement +let root: Root +let queryClient: QueryClient + +function Wrapper({ children }: { children: ReactNode }) { + return {children} +} + +async function flushQueries() { + await act(async () => { + for (let index = 0; index < 5; index++) { + await Promise.resolve() + await sleep(1) + } + }) +} + +describe('workflow MCP server queries', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + root = createRoot(container) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + }) + + afterEach(() => { + act(() => root.unmount()) + queryClient.clear() + vi.clearAllMocks() + }) + + it('does not carry protected tool data between server keys', async () => { + mockRequestJson.mockImplementation((contract, input) => { + if (contract !== listWorkflowMcpToolsContract) throw new Error('Unexpected request') + const serverId = input.params.id + if (serverId === 'server-a') { + return Promise.resolve({ data: { tools: [{ id: 'tool-a', name: 'Tool A' }] } }) + } + return new Promise(() => undefined) + }) + + function Probe({ serverId }: { serverId: string }) { + const query = useWorkflowMcpTools('workspace-1', serverId) + return {query.data?.[0]?.name ?? 'loading'} + } + + act(() => root.render({})) + await flushQueries() + expect(container.textContent).toBe('Tool A') + + act(() => root.render({})) + expect(container.textContent).toBe('loading') + }) + + it('removes a deleted server detail subtree and invalidates its list', async () => { + mockRequestJson.mockImplementation((contract) => { + if (contract === deleteWorkflowMcpServerContract) return Promise.resolve({ success: true }) + throw new Error('Unexpected request') + }) + queryClient.setQueryData(workflowMcpServerKeys.servers('workspace-1'), [{ id: 'server-1' }]) + queryClient.setQueryData(workflowMcpServerKeys.server('workspace-1', 'server-1'), { + server: { id: 'server-1' }, + tools: [], + }) + queryClient.setQueryData(workflowMcpServerKeys.tools('workspace-1', 'server-1'), []) + let mutation: ReturnType | undefined + + function Probe() { + mutation = useDeleteWorkflowMcpServer() + return null + } + + act(() => root.render({})) + await act(async () => { + await mutation?.mutateAsync({ workspaceId: 'workspace-1', serverId: 'server-1' }) + }) + + expect( + queryClient.getQueriesData({ + queryKey: workflowMcpServerKeys.server('workspace-1', 'server-1'), + }) + ).toHaveLength(0) + expect( + queryClient.getQueryState(workflowMcpServerKeys.servers('workspace-1'))?.isInvalidated + ).toBe(true) + }) +}) diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index 849169d2398..39c1b877bef 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sim/logger' -import { - keepPreviousData, - queryOptions, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { @@ -16,35 +10,29 @@ import { deleteWorkflowMcpToolContract, getWorkflowMcpServerContract, listWorkflowMcpDeployedWorkflowsContract, - listWorkflowMcpServersContract, listWorkflowMcpToolsContract, updateWorkflowMcpServerContract, updateWorkflowMcpToolContract, type WorkflowMcpServer, type WorkflowMcpTool, } from '@/lib/api/contracts/workflow-mcp-servers' +import { + workflowMcpServerKeys, + workflowMcpServersQueryOptions, +} from '@/hooks/queries/workflow-mcp-server-list' const logger = createLogger('WorkflowMcpServerQueries') export type { DeployedWorkflow } -/** - * Query key factories for Workflow MCP Server queries - */ -export const workflowMcpServerKeys = { - all: ['workflow-mcp-servers'] as const, - servers: (workspaceId: string) => [...workflowMcpServerKeys.all, 'servers', workspaceId] as const, - server: (workspaceId: string, serverId: string) => - [...workflowMcpServerKeys.servers(workspaceId), serverId] as const, - tools: (workspaceId: string, serverId: string) => - [...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const, - deployedWorkflows: (workspaceId: string) => - [...workflowMcpServerKeys.all, 'deployed-workflows', workspaceId] as const, -} +export { + WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, + workflowMcpServerKeys, + workflowMcpServersQueryOptions, +} from '@/hooks/queries/workflow-mcp-server-list' export type { WorkflowMcpServer, WorkflowMcpTool } -export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000 export const WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME = 30 * 1000 export const WORKFLOW_MCP_TOOLS_STALE_TIME = 30 * 1000 export const WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME = 30 * 1000 @@ -53,37 +41,6 @@ interface UseWorkflowMcpServersOptions { enabled?: boolean } -/** - * Fetch workflow MCP servers for a workspace - */ -async function fetchWorkflowMcpServers( - workspaceId: string, - signal?: AbortSignal -): Promise { - try { - const data = await requestJson(listWorkflowMcpServersContract, { - query: { workspaceId }, - signal, - }) - return data.data.servers - } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { - return [] - } - throw error - } -} - -export function workflowMcpServersQueryOptions(workspaceId: string) { - return queryOptions({ - queryKey: workflowMcpServerKeys.servers(workspaceId), - queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), - retry: false, - staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - /** * Hook to fetch workflow MCP servers */ @@ -160,7 +117,6 @@ export function useWorkflowMcpTools(workspaceId: string, serverId: string | null enabled: !!workspaceId && !!serverId, retry: false, staleTime: WORKFLOW_MCP_TOOLS_STALE_TIME, - placeholderData: keepPreviousData, }) } @@ -267,9 +223,12 @@ export function useDeleteWorkflowMcpServer() { return data }, onSettled: (_data, _error, variables) => { - return queryClient.invalidateQueries({ + queryClient.invalidateQueries({ queryKey: workflowMcpServerKeys.servers(variables.workspaceId), }) + queryClient.removeQueries({ + queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId), + }) }, }) } @@ -436,6 +395,5 @@ export function useDeployedWorkflows(workspaceId: string) { queryFn: ({ signal }) => fetchDeployedWorkflows(workspaceId, signal), enabled: !!workspaceId, staleTime: WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME, - placeholderData: keepPreviousData, }) } diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index 6497733ffd9..f8cf72bed90 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -4,6 +4,7 @@ import { type PiiRedactionSettings, piiRedactionSettingsSchema, retentionOverridesSchema, + workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -665,6 +666,41 @@ export const createOrganizationContract = defineRouteContract({ }, }) +export const organizationBillingSummarySchema = z.object({ + organizationId: z.string().min(1), + subscriptionState: z.enum(['active', 'free', 'lapsed']), + subscriptionPlan: z.string().min(1), + subscriptionStatus: z.string().nullable(), + creditBalance: z.number(), + billingInterval: z.enum(['month', 'year']), + cancelAtPeriodEnd: z.boolean(), + totalSeats: z.number().int().min(0), + totalCurrentUsage: z.number().min(0), + totalUsageLimit: z.number().min(0), + minimumBillingAmount: z.number().min(0), + billingPeriodEnd: z.string().nullable(), + billingBlocked: z.boolean(), + billingBlockedReason: z.enum(['payment_failed', 'dispute']).nullable(), + blockedByOrgOwner: z.boolean(), + upgradeWorkspaceId: workspaceIdSchema.nullable(), + userRole: z.enum(['admin', 'owner']), +}) + +export type OrganizationBillingSummary = z.output + +export const getOrganizationBillingSummaryContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/billing-summary', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + data: organizationBillingSummarySchema, + }), + }, +}) + export const updateOrganizationUsageLimitContract = defineRouteContract({ method: 'PUT', path: '/api/usage', diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index beac7ce329f..12b0747856b 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -259,6 +259,8 @@ export const workspaceHostContextSchema = z.object({ name: z.string().min(1), workspaceMode: workspaceModeSchema, billedAccountUserId: nonEmptyIdSchema, + /** Optional for rolling compatibility with app versions that predate API-key policy projection. */ + allowPersonalApiKeys: z.boolean().optional(), }), hostOrganizationId: nonEmptyIdSchema.nullable(), ownerBilling: workspaceOwnerBillingSchema, diff --git a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts b/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts new file mode 100644 index 00000000000..a171160308d --- /dev/null +++ b/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + membership: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: mocks.membership }), + }), + }), + }, +})) + +import { defineAuthorizedOrganizationBillingSummaryUseCase } from '@/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case' +import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations' +import { ForbiddenOperationError } from '@/lib/core/application' + +const session: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} +const personalKey: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} + +const useCase = defineAuthorizedOrganizationBillingSummaryUseCase({ + operation: organizationBillingSummaryOperations.read, + organizationId: (input: { organizationId: string }) => input.organizationId, + execute: mocks.execute, +}) + +function run(principal: SessionPrincipal | PersonalApiKeyPrincipal = session) { + return useCase.execute({ principal, input: { organizationId: 'org-1' } }) +} + +async function refusalCode(promise: Promise) { + try { + await promise + throw new Error('Expected the billing summary read to be refused') + } catch (error) { + expect(error).toBeInstanceOf(ForbiddenOperationError) + return (error as ForbiddenOperationError).detailCode + } +} + +describe('organization billing summary authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.membership.mockResolvedValue([{ role: 'owner' }]) + mocks.execute.mockResolvedValue({ ok: true }) + }) + + it('rejects API keys before loading protected organization membership', async () => { + expect(await refusalCode(run(personalKey))).toBe('PRINCIPAL_KIND_NOT_PERMITTED') + expect(mocks.membership).not.toHaveBeenCalled() + }) + + it('distinguishes a non-member from a member without billing authority', async () => { + mocks.membership.mockResolvedValueOnce([]) + expect(await refusalCode(run())).toBe('ORGANIZATION_MEMBERSHIP_REQUIRED') + + mocks.membership.mockResolvedValueOnce([{ role: 'member' }]) + expect(await refusalCode(run())).toBe('ORGANIZATION_ADMIN_REQUIRED') + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it.each(['admin', 'owner'] as const)('authorizes an organization %s', async (role) => { + mocks.membership.mockResolvedValue([{ role }]) + + await expect(run()).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: session, + input: { organizationId: 'org-1' }, + context: { + organizationId: 'org-1', + actorUserId: 'user-1', + userRole: role, + }, + }) + }) +}) diff --git a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts b/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts new file mode 100644 index 00000000000..3da11fdb6c0 --- /dev/null +++ b/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts @@ -0,0 +1,88 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import type { + OrganizationBillingSummaryOperation, + OrganizationBillingSummaryPrincipal, +} from '@/lib/billing/application/organization-billing-summary/operations' +import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application' + +export interface AuthorizedOrganizationBillingSummaryContext { + organizationId: string + actorUserId: string + userRole: 'admin' | 'owner' +} + +interface AuthorizedOrganizationBillingSummaryDefinition< + O extends OrganizationBillingSummaryOperation, + I, + R, +> { + operation: O + organizationId(input: I): string + execute(args: { + principal: OrganizationBillingSummaryPrincipal + input: I + context: AuthorizedOrganizationBillingSummaryContext + }): Promise +} + +function requireSessionPrincipal( + principal: Principal, + operation: OrganizationBillingSummaryOperation +): asserts principal is OrganizationBillingSummaryPrincipal { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new ForbiddenOperationError( + 'PRINCIPAL_KIND_NOT_PERMITTED', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +/** + * Authorizes the organization payer read once and carries the canonical role into + * presentation. Membership alone is insufficient because the summary includes the + * organization's pooled spend, payment state, and configurable usage ceiling. + */ +export function defineAuthorizedOrganizationBillingSummaryUseCase< + const O extends OrganizationBillingSummaryOperation, + I, + R, +>(definition: AuthorizedOrganizationBillingSummaryDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input }) { + requireSessionPrincipal(principal, definition.operation) + const organizationId = definition.organizationId(input) + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, principal.userId))) + .limit(1) + + if (!membership) { + throw new ForbiddenOperationError( + 'ORGANIZATION_MEMBERSHIP_REQUIRED', + 'Organization membership is required to read billing information' + ) + } + if (membership.role !== 'admin' && membership.role !== 'owner') { + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization admin or owner authority is required to read billing information' + ) + } + + return definition.execute({ + principal, + input, + context: { + organizationId, + actorUserId: principal.userId, + userRole: membership.role, + }, + }) + }, + } +} diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts new file mode 100644 index 00000000000..cce1c07245f --- /dev/null +++ b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts @@ -0,0 +1,169 @@ +import { dbReplica } from '@sim/db' +import { organization, subscription as subscriptionTable } from '@sim/db/schema' +import { desc, eq } from 'drizzle-orm' +import { defineAuthorizedOrganizationBillingSummaryUseCase } from '@/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case' +import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations' +import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing' +import { + getOrganizationBillingBlockState, + getUpgradeWorkspaceId, +} from '@/lib/billing/core/payer-context' +import { resolveSubscriptionUsagePeriodOrDefault } from '@/lib/billing/core/reporting-period' +import { resolveBillingInterval } from '@/lib/billing/core/subscription' +import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh' +import { getPlanWeeklyRefreshDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' +import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface OrganizationBillingSummaryInput { + organizationId: string +} + +export interface OrganizationBillingSummaryResult { + organizationId: string + subscriptionState: 'active' | 'free' | 'lapsed' + subscriptionPlan: string + subscriptionStatus: string | null + creditBalance: number + billingInterval: 'month' | 'year' + cancelAtPeriodEnd: boolean + totalSeats: number + totalCurrentUsage: number + totalUsageLimit: number + minimumBillingAmount: number + billingPeriodEnd: string | null + billingBlocked: boolean + billingBlockedReason: 'payment_failed' | 'dispute' | null + blockedByOrgOwner: boolean + upgradeWorkspaceId: string | null + userRole: 'admin' | 'owner' +} + +function roundCurrency(value: number): number { + return Math.round(value * 100) / 100 +} + +/** + * Returns only the payer state rendered above the fold on Organization Billing. + * Member pages, invitation counts, member ledgers, and limit aggregates remain on + * their dedicated surfaces instead of delaying this navigation-critical response. + */ +export const getOrganizationBillingSummary = defineAuthorizedOrganizationBillingSummaryUseCase({ + operation: organizationBillingSummaryOperations.read, + organizationId: (input: OrganizationBillingSummaryInput) => input.organizationId, + async execute({ context }): Promise { + const { organizationId, actorUserId, userRole } = context + const [ + organizationRows, + entitledSubscription, + latestSubscriptionRows, + billingStatus, + upgradeWorkspaceId, + ] = await Promise.all([ + dbReplica + .select({ + id: organization.id, + orgUsageLimit: organization.orgUsageLimit, + creditBalance: organization.creditBalance, + }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1), + getOrganizationSubscription(organizationId, { + executor: dbReplica, + onError: 'throw', + }), + dbReplica + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.referenceId, organizationId)) + .orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id)) + .limit(1), + getOrganizationBillingBlockState(organizationId, actorUserId, dbReplica), + getUpgradeWorkspaceId({ type: 'organization', id: organizationId }, dbReplica), + ]) + + const organizationRecord = organizationRows[0] + if (!organizationRecord) { + throw new OrchestrationError('not_found', 'Organization not found') + } + + const latestSubscription = latestSubscriptionRows[0] ?? null + const activeSubscription = + entitledSubscription && isPaid(entitledSubscription.plan) ? entitledSubscription : null + const freeSubscription = + entitledSubscription && !isPaid(entitledSubscription.plan) ? entitledSubscription : null + const lapsedSubscription = + !entitledSubscription && latestSubscription && isPaid(latestSubscription.plan) + ? latestSubscription + : null + const displayedSubscription = activeSubscription ?? freeSubscription ?? lapsedSubscription + const subscriptionState = activeSubscription ? 'active' : lapsedSubscription ? 'lapsed' : 'free' + const billingPeriod = entitledSubscription + ? resolveSubscriptionUsagePeriodOrDefault(entitledSubscription) + : null + + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(entitledSubscription?.plan) + const [ledgerUsage, weeklyRefreshConsumed] = billingPeriod + ? await Promise.all([ + getBillingPeriodUsageCost( + { type: 'organization', id: organizationId }, + billingPeriod, + undefined, + dbReplica + ), + entitledSubscription && weeklyRefreshDollars > 0 && entitledSubscription.periodStart + ? computeWeeklyRefreshConsumed( + { + billingEntity: { type: 'organization', id: organizationId }, + periodStart: entitledSubscription.periodStart, + periodEnd: entitledSubscription.periodEnd ?? null, + weeklyRefreshDollars, + seats: entitledSubscription.seats || 1, + }, + dbReplica + ) + : Promise.resolve(0), + ]) + : [0, 0] + + const totalCurrentUsage = Math.max(0, ledgerUsage - weeklyRefreshConsumed) + const { basePrice: pricePerSeat } = getPlanPricing(entitledSubscription?.plan ?? 'free') + const licensedSeats = entitledSubscription?.seats || 1 + const totalSeats = entitledSubscription ? getEffectiveSeats(entitledSubscription) : 0 + const configuredLimit = + entitledSubscription && organizationRecord.orgUsageLimit + ? toNumber(toDecimal(organizationRecord.orgUsageLimit)) + : null + const minimumBillingAmount = + entitledSubscription && isEnterprise(entitledSubscription.plan) + ? (configuredLimit ?? 0) + : entitledSubscription + ? licensedSeats * pricePerSeat + : 0 + const totalUsageLimit = + configuredLimit === null + ? minimumBillingAmount + : Math.max(configuredLimit, minimumBillingAmount) + return { + organizationId, + subscriptionState, + subscriptionPlan: displayedSubscription?.plan ?? 'free', + subscriptionStatus: displayedSubscription?.status ?? null, + creditBalance: toNumber(toDecimal(organizationRecord.creditBalance)), + billingInterval: resolveBillingInterval(displayedSubscription), + cancelAtPeriodEnd: displayedSubscription?.cancelAtPeriodEnd ?? false, + totalSeats, + totalCurrentUsage: roundCurrency(totalCurrentUsage), + totalUsageLimit: roundCurrency(totalUsageLimit), + minimumBillingAmount: roundCurrency(minimumBillingAmount), + billingPeriodEnd: + (billingPeriod?.end ?? displayedSubscription?.periodEnd)?.toISOString() ?? null, + ...billingStatus, + upgradeWorkspaceId, + userRole, + } + }, +}) diff --git a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts new file mode 100644 index 00000000000..745f56b40fd --- /dev/null +++ b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts @@ -0,0 +1,28 @@ +import type { Principal } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' + +export type OrganizationBillingSummaryPrincipal = Extract + +export interface OrganizationBillingSummaryOperation + extends ApplicationOperation { + readonly organizationRoles: readonly ['admin', 'owner'] + readonly workspaceApiKey: 'deny' + readonly principalKinds: readonly ['session'] +} + +function defineOrganizationBillingSummaryOperation( + operation: OrganizationBillingSummaryOperation +): OrganizationBillingSummaryOperation { + Object.freeze(operation.organizationRoles) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const organizationBillingSummaryOperations = { + read: defineOrganizationBillingSummaryOperation({ + id: 'organization_billing.summary.read', + organizationRoles: ['admin', 'owner'], + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/billing/core/payer-context.ts b/apps/sim/lib/billing/core/payer-context.ts new file mode 100644 index 00000000000..1b02e8579a2 --- /dev/null +++ b/apps/sim/lib/billing/core/payer-context.ts @@ -0,0 +1,59 @@ +import { dbReplica } from '@sim/db' +import { member, userStats, workspace } from '@sim/db/schema' +import { and, asc, eq, isNull } from 'drizzle-orm' +import type { DbClient } from '@/lib/db/types' + +export interface BillingBlockState { + billingBlocked: boolean + billingBlockedReason: 'payment_failed' | 'dispute' | null + blockedByOrgOwner: boolean +} + +/** Finds an active workspace whose host billing identity is the requested payer. */ +export async function getUpgradeWorkspaceId( + target: { type: 'user'; id: string } | { type: 'organization'; id: string }, + executor: DbClient = dbReplica +): Promise { + const targetPredicate = + target.type === 'organization' + ? eq(workspace.organizationId, target.id) + : and( + eq(workspace.ownerId, target.id), + eq(workspace.billedAccountUserId, target.id), + isNull(workspace.organizationId) + ) + + const [record] = await executor + .select({ id: workspace.id }) + .from(workspace) + .where(and(targetPredicate, isNull(workspace.archivedAt))) + .orderBy(asc(workspace.createdAt), asc(workspace.id)) + .limit(1) + + return record?.id ?? null +} + +/** Reads the organization's payer block from its owner, never from the viewer. */ +export async function getOrganizationBillingBlockState( + organizationId: string, + viewerUserId: string, + executor: DbClient = dbReplica +): Promise { + const [owner] = await executor + .select({ + userId: member.userId, + billingBlocked: userStats.billingBlocked, + billingBlockedReason: userStats.billingBlockedReason, + }) + .from(member) + .leftJoin(userStats, eq(userStats.userId, member.userId)) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + + const billingBlocked = Boolean(owner?.billingBlocked) + return { + billingBlocked, + billingBlockedReason: billingBlocked ? (owner?.billingBlockedReason ?? null) : null, + blockedByOrgOwner: billingBlocked && owner?.userId !== viewerUserId, + } +} diff --git a/apps/sim/lib/core/utils/theme.test.ts b/apps/sim/lib/core/utils/theme.test.ts new file mode 100644 index 00000000000..2d374e74eac --- /dev/null +++ b/apps/sim/lib/core/utils/theme.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { syncThemeToNextThemes } from '@/lib/core/utils/theme' + +describe('syncThemeToNextThemes', () => { + afterEach(() => { + localStorage.clear() + document.documentElement.classList.remove('light', 'dark') + vi.restoreAllMocks() + }) + + it('does not dispatch or rewrite classes when the requested theme is already applied', () => { + localStorage.setItem('sim-theme', 'dark') + document.documentElement.classList.add('dark') + const dispatchEvent = vi.spyOn(window, 'dispatchEvent') + const remove = vi.spyOn(document.documentElement.classList, 'remove') + const add = vi.spyOn(document.documentElement.classList, 'add') + + syncThemeToNextThemes('dark') + + expect(dispatchEvent).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(add).not.toHaveBeenCalled() + }) + + it('repairs the document class without emitting a redundant storage event', () => { + localStorage.setItem('sim-theme', 'dark') + document.documentElement.classList.add('light') + const dispatchEvent = vi.spyOn(window, 'dispatchEvent') + + syncThemeToNextThemes('dark') + + expect(dispatchEvent).not.toHaveBeenCalled() + expect(document.documentElement.classList.contains('dark')).toBe(true) + expect(document.documentElement.classList.contains('light')).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/utils/theme.ts b/apps/sim/lib/core/utils/theme.ts index 46035f4ce53..29a77542d6e 100644 --- a/apps/sim/lib/core/utils/theme.ts +++ b/apps/sim/lib/core/utils/theme.ts @@ -11,25 +11,30 @@ export function syncThemeToNextThemes(theme: 'system' | 'light' | 'dark') { if (typeof window === 'undefined') return const oldValue = localStorage.getItem('sim-theme') - localStorage.setItem('sim-theme', theme) + if (oldValue !== theme) { + localStorage.setItem('sim-theme', theme) - window.dispatchEvent( - new StorageEvent('storage', { - key: 'sim-theme', - newValue: theme, - oldValue: oldValue, - storageArea: localStorage, - url: window.location.href, - }) - ) + window.dispatchEvent( + new StorageEvent('storage', { + key: 'sim-theme', + newValue: theme, + oldValue, + storageArea: localStorage, + url: window.location.href, + }) + ) + } const root = document.documentElement - root.classList.remove('light', 'dark') + const appliedTheme = + theme === 'system' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : theme + const oppositeTheme = appliedTheme === 'dark' ? 'light' : 'dark' + if (root.classList.contains(appliedTheme) && !root.classList.contains(oppositeTheme)) return - if (theme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - root.classList.add(systemTheme) - } else { - root.classList.add(theme) - } + root.classList.remove('light', 'dark') + root.classList.add(appliedTheme) } diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts new file mode 100644 index 00000000000..36865af921b --- /dev/null +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + canOpenOrganizationSettingsSection: vi.fn(), + checkWorkspaceAccess: vi.fn(), + getWorkspaceOwnerSubscriptionAccess: vi.fn(), + isCredentialGroupsAvailable: vi.fn(), + isCustomBlocksEligibleForOrganization: vi.fn(), + isForkingAvailableForWorkspace: vi.fn(), + isOrganizationOnEnterprisePlan: vi.fn(), + isOrganizationSettingsSectionAvailable: vi.fn(), + isPlatformAdmin: vi.fn(), + resolveWorkspaceGroup: vi.fn(), + resolveWorkspaceNavigation: vi.fn(), +})) + +vi.mock('@/components/settings/navigation', () => ({ + getOrganizationSettingsFeatures: vi.fn((hasEnterprisePlan: boolean) => ({ hasEnterprisePlan })), + isOrganizationSettingsSectionAvailable: mocks.isOrganizationSettingsSectionAvailable, + resolveWorkspaceNavigation: mocks.resolveWorkspaceNavigation, + UNIFIED_TO_ORGANIZATION_SECTION: { + organization: 'members', + billing: 'billing', + 'access-control': 'access-control', + }, + UNIFIED_TO_WORKSPACE_SECTION: { + secrets: 'secrets', + 'credential-groups': 'credential-groups', + forks: 'forks', + 'custom-blocks': 'custom-blocks', + }, + workspaceSectionUsesPermissionConfig: vi.fn((section: string) => + ['secrets', 'api-keys', 'inbox', 'mcp', 'custom-tools'].includes(section) + ), +})) +vi.mock('@/lib/billing/core/workspace-access', () => ({ + getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mocks.isOrganizationOnEnterprisePlan, +})) +vi.mock('@/lib/credential-groups/availability', () => ({ + isCredentialGroupsAvailable: mocks.isCredentialGroupsAvailable, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true, isHosted: true })) +vi.mock('@/lib/organizations/settings-access', () => ({ + canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, +})) +vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: mocks.isPlatformAdmin })) +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + isCustomBlocksEligibleForOrganization: mocks.isCustomBlocksEligibleForOrganization, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveWorkspaceGroup: mocks.resolveWorkspaceGroup, +})) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + isForkingAvailableForWorkspace: mocks.isForkingAvailableForWorkspace, +})) + +import { authorizeWorkspaceSettingsSection } from '@/lib/settings/application/workspace-section-access' + +const PERSONAL_ACCESS = { + exists: true, + hasAccess: true, + permission: 'admin', + workspace: { + id: 'workspace-1', + organizationId: null, + billedAccountUserId: 'owner-1', + }, +} + +const ORGANIZATION_ACCESS = { + ...PERSONAL_ACCESS, + workspace: { + ...PERSONAL_ACCESS.workspace, + organizationId: 'organization-1', + }, +} + +function authorize(section: Parameters[0]['section']) { + return authorizeWorkspaceSettingsSection({ + workspaceId: 'workspace-1', + userId: 'viewer-1', + section, + }) +} + +describe('authorizeWorkspaceSettingsSection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS) + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true }) + mocks.isCredentialGroupsAvailable.mockResolvedValue(true) + mocks.isCustomBlocksEligibleForOrganization.mockResolvedValue(true) + mocks.isForkingAvailableForWorkspace.mockResolvedValue(true) + mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(true) + mocks.isOrganizationSettingsSectionAvailable.mockReturnValue(true) + mocks.isPlatformAdmin.mockResolvedValue(true) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(true) + mocks.resolveWorkspaceGroup.mockResolvedValue({ config: {} }) + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }]) + }) + + it('conceals missing and inaccessible workspaces before section-specific reads', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: false, + permission: null, + workspace: PERSONAL_ACCESS.workspace, + }) + + await expect(authorize('billing')).resolves.toEqual({ + allowed: false, + disposition: 'not-found', + }) + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + }) + + it('opens ordinary sections from workspace access alone', async () => { + await expect(authorize('general')).resolves.toEqual({ allowed: true }) + + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() + expect(mocks.resolveWorkspaceGroup).not.toHaveBeenCalled() + expect(mocks.isPlatformAdmin).not.toHaveBeenCalled() + }) + + it('conceals platform sections from non-platform admins', async () => { + mocks.isPlatformAdmin.mockResolvedValue(false) + + await expect(authorize('admin')).resolves.toEqual({ + allowed: false, + disposition: 'not-found', + }) + expect(mocks.isPlatformAdmin).toHaveBeenCalledWith('viewer-1') + }) + + it('loads owner billing and permission-group policy only for affected organization sections', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.resolveWorkspaceGroup.mockResolvedValue({ config: { hideSecretsTab: true } }) + mocks.resolveWorkspaceNavigation.mockReturnValue([]) + + await expect(authorize('secrets')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith('workspace-1') + expect(mocks.resolveWorkspaceGroup).toHaveBeenCalledWith( + 'viewer-1', + 'organization-1', + 'workspace-1' + ) + expect(mocks.resolveWorkspaceNavigation).toHaveBeenCalledWith( + expect.objectContaining({ permissionConfig: { hideSecretsTab: true } }) + ) + }) + + it('does not resolve billing or permission groups for the same section in a personal workspace', async () => { + await authorize('secrets') + + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.resolveWorkspaceGroup).not.toHaveBeenCalled() + }) + + it('resolves the exact entitlement source only for gated workspace sections', async () => { + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'credential-groups' }]) + await authorize('credential-groups') + expect(mocks.isCredentialGroupsAvailable).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + ownerBilling: { isEnterprise: true }, + }) + expect(mocks.isForkingAvailableForWorkspace).not.toHaveBeenCalled() + + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'forks' }]) + await authorize('forks') + expect(mocks.isForkingAvailableForWorkspace).toHaveBeenCalledWith(null, 'viewer-1') + + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'custom-blocks' }]) + await authorize('custom-blocks') + expect(mocks.isCustomBlocksEligibleForOrganization).toHaveBeenCalledWith('organization-1') + }) + + it('allows personal billing only to the billed account owner', async () => { + await expect(authorize('billing')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + + mocks.checkWorkspaceAccess.mockResolvedValue({ + ...PERSONAL_ACCESS, + workspace: { ...PERSONAL_ACCESS.workspace, billedAccountUserId: 'viewer-1' }, + }) + await expect(authorize('billing')).resolves.toEqual({ allowed: true }) + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() + }) + + it('requires current organization access and plan availability for enterprise sections', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) + + await expect(authorize('access-control')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith( + 'organization-1', + 'viewer-1', + 'access-control' + ) + expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledWith('organization-1') + }) +}) diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts new file mode 100644 index 00000000000..c6be3baa0ca --- /dev/null +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -0,0 +1,138 @@ +import { + getOrganizationSettingsFeatures, + isOrganizationSettingsSectionAvailable, + resolveWorkspaceNavigation, + UNIFIED_TO_ORGANIZATION_SECTION, + UNIFIED_TO_WORKSPACE_SECTION, + type UnifiedSettingsSection, + type WorkspaceSettingsSection, + workspaceSectionUsesPermissionConfig, +} from '@/components/settings/navigation' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' +import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' +import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' +import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' + +export type WorkspaceSettingsSectionAccess = + | { allowed: true } + | { allowed: false; disposition: 'not-found' | 'redirect-general' } + +interface AuthorizeWorkspaceSettingsSectionInput { + workspaceId: string + userId: string + section: UnifiedSettingsSection +} + +async function canOpenWorkspaceSection( + section: WorkspaceSettingsSection, + input: AuthorizeWorkspaceSettingsSectionInput, + workspace: { + organizationId: string | null + }, + permission: NonNullable>['permission']> +): Promise { + const needsOwnerBilling = + section === 'credential-groups' || + (workspace.organizationId !== null && workspaceSectionUsesPermissionConfig(section)) + const ownerBilling = needsOwnerBilling + ? await getWorkspaceOwnerSubscriptionAccess(input.workspaceId) + : null + + const [permissionGroup, credentialGroupsAvailable, forksAvailable, customBlocksAvailable] = + await Promise.all([ + workspace.organizationId && + ownerBilling?.isEnterprise && + workspaceSectionUsesPermissionConfig(section) + ? resolveWorkspaceGroup(input.userId, workspace.organizationId, input.workspaceId) + : null, + section === 'credential-groups' && ownerBilling + ? isCredentialGroupsAvailable({ workspaceId: input.workspaceId, ownerBilling }) + : false, + section === 'forks' + ? isForkingAvailableForWorkspace(workspace.organizationId, input.userId) + : false, + section === 'custom-blocks' && workspace.organizationId + ? isCustomBlocksEligibleForOrganization(workspace.organizationId) + : false, + ]) + + const navigation = resolveWorkspaceNavigation({ + permission, + permissionConfig: permissionGroup?.config ?? {}, + entitlements: { + byok: isHosted, + credentialGroups: credentialGroupsAvailable, + inbox: true, + customBlocks: customBlocksAvailable, + forks: forksAvailable, + sandboxes: true, + }, + }) + return navigation.some((item) => item.id === section) +} + +async function canOpenOrganizationSection( + input: AuthorizeWorkspaceSettingsSectionInput, + workspace: { + organizationId: string | null + billedAccountUserId: string + } +): Promise { + const organizationSection = UNIFIED_TO_ORGANIZATION_SECTION[input.section] + if (!organizationSection) return true + if (!isBillingEnabled && (input.section === 'billing' || input.section === 'organization')) { + return false + } + if (!workspace.organizationId) { + return input.section === 'billing' && workspace.billedAccountUserId === input.userId + } + + const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing' + const [canOpenSection, isEnterpriseOrganization] = await Promise.all([ + canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection), + needsEnterprisePlan + ? isOrganizationOnEnterprisePlan(workspace.organizationId) + : Promise.resolve(false), + ]) + return ( + canOpenSection && + isOrganizationSettingsSectionAvailable( + organizationSection, + getOrganizationSettingsFeatures(needsEnterprisePlan && isEnterpriseOrganization) + ) + ) +} + +export async function authorizeWorkspaceSettingsSection( + input: AuthorizeWorkspaceSettingsSectionInput +): Promise { + const requiresPlatformAdmin = input.section === 'admin' || input.section === 'mothership' + const [access, viewerIsPlatformAdmin] = await Promise.all([ + checkWorkspaceAccess(input.workspaceId, input.userId), + requiresPlatformAdmin ? isPlatformAdmin(input.userId) : Promise.resolve(false), + ]) + if (!access.exists || !access.hasAccess || !access.workspace || !access.permission) { + return { allowed: false, disposition: 'not-found' } + } + if (requiresPlatformAdmin && !viewerIsPlatformAdmin) { + return { allowed: false, disposition: 'not-found' } + } + + const workspaceSection = UNIFIED_TO_WORKSPACE_SECTION[input.section] + if ( + workspaceSection && + !(await canOpenWorkspaceSection(workspaceSection, input, access.workspace, access.permission)) + ) { + return { allowed: false, disposition: 'redirect-general' } + } + if (!(await canOpenOrganizationSection(input, access.workspace))) { + return { allowed: false, disposition: 'redirect-general' } + } + return { allowed: true } +} diff --git a/apps/sim/lib/users/application/authorization.ts b/apps/sim/lib/users/application/authorization.ts new file mode 100644 index 00000000000..441ec1e20e2 --- /dev/null +++ b/apps/sim/lib/users/application/authorization.ts @@ -0,0 +1,16 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application' +import type { UserAccountOperation } from '@/lib/users/application/operations' + +/** Restricts self-service account operations to the authenticated account session. */ +export function requireUserAccountPrincipal( + principal: Principal, + operation: UserAccountOperation +): asserts principal is SessionPrincipal { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new ForbiddenOperationError( + 'PRINCIPAL_KIND_NOT_PERMITTED', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}; a first-party session is required` + ) + } +} diff --git a/apps/sim/lib/users/application/delete-account.ts b/apps/sim/lib/users/application/delete-account.ts index 54c4ccd0730..64cbed3ac89 100644 --- a/apps/sim/lib/users/application/delete-account.ts +++ b/apps/sim/lib/users/application/delete-account.ts @@ -1,10 +1,10 @@ import { AuditAction, AuditResourceType, recordAuditBatch } from '@sim/audit' -import type { Principal, SessionPrincipal } from '@sim/auth/principal' import { normalizeEmail } from '@sim/utils/string' import type { AccountDeletionPlan } from '@/lib/api/contracts/user' import type { OperationUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { deleteUserAccount, getAccountDeletionPlan } from '@/lib/users/account-deletion' +import { requireUserAccountPrincipal } from '@/lib/users/application/authorization' import { userAccountOperations } from '@/lib/users/application/operations' import { getUserProfile } from '@/lib/users/queries' @@ -14,12 +14,6 @@ import { getUserProfile } from '@/lib/users/queries' * key or a delegated service must never be able to erase the human behind it. * Defence in depth: the route's `internalSessionAuth` already returns nothing else. */ -function requireSelf(principal: Principal): asserts principal is SessionPrincipal { - if (principal.kind !== 'session') { - throw new OrchestrationError('forbidden', 'Session authentication required') - } -} - export const previewAccountDeletionUseCase: OperationUseCase< typeof userAccountOperations.previewDeletion, Record, @@ -27,7 +21,7 @@ export const previewAccountDeletionUseCase: OperationUseCase< > = { operation: userAccountOperations.previewDeletion, async execute({ principal }) { - requireSelf(principal) + requireUserAccountPrincipal(principal, userAccountOperations.previewDeletion) return getAccountDeletionPlan(principal.userId) }, } @@ -44,7 +38,7 @@ export const deleteAccountUseCase: OperationUseCase< > = { operation: userAccountOperations.delete, async execute({ principal, input }) { - requireSelf(principal) + requireUserAccountPrincipal(principal, userAccountOperations.delete) const profile = await getUserProfile(principal.userId) if (!profile) throw new OrchestrationError('not_found', 'Account not found') diff --git a/apps/sim/lib/users/application/operations.ts b/apps/sim/lib/users/application/operations.ts index 145f2c9bfa3..41d107ef69a 100644 --- a/apps/sim/lib/users/application/operations.ts +++ b/apps/sim/lib/users/application/operations.ts @@ -1,5 +1,13 @@ import type { ApplicationOperation } from '@/lib/core/application' +export interface UserAccountOperation extends ApplicationOperation { + readonly principalKinds: readonly ['session'] +} + +function defineUserAccountOperation(id: Id): UserAccountOperation { + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + /** * Operations an account performs on itself. They carry no workspace scope and no * role: the resource *is* the authenticated principal, so a session is both the @@ -8,6 +16,8 @@ import type { ApplicationOperation } from '@/lib/core/application' * the principal guard in each use case — rather than restated as inert data here. */ export const userAccountOperations = { - previewDeletion: { id: 'users.account.deletion_preview' }, - delete: { id: 'users.account.delete' }, -} as const satisfies Record + readProfile: defineUserAccountOperation('users.account.profile.read'), + readSettings: defineUserAccountOperation('users.account.settings.read'), + previewDeletion: defineUserAccountOperation('users.account.deletion_preview'), + delete: defineUserAccountOperation('users.account.delete'), +} as const satisfies Record diff --git a/apps/sim/lib/users/application/read-current-user.test.ts b/apps/sim/lib/users/application/read-current-user.test.ts new file mode 100644 index 00000000000..5c8779dce40 --- /dev/null +++ b/apps/sim/lib/users/application/read-current-user.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import type { PersonalApiKeyPrincipal, SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getUserProfile: vi.fn(), + getUserSettings: vi.fn(), +})) + +vi.mock('@/lib/users/queries', () => ({ + getUserProfile: mocks.getUserProfile, + getUserSettings: mocks.getUserSettings, +})) + +import { ForbiddenOperationError } from '@/lib/core/application' +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getCurrentUserProfileUseCase, + getCurrentUserSettingsUseCase, +} from '@/lib/users/application/read-current-user' + +const session: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} +const personalKey: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', +} + +describe('current-user reads', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('rejects non-session principals before loading account data', async () => { + await expect( + getCurrentUserProfileUseCase.execute({ principal: personalKey, input: {} }) + ).rejects.toBeInstanceOf(ForbiddenOperationError) + await expect( + getCurrentUserSettingsUseCase.execute({ principal: personalKey, input: {} }) + ).rejects.toBeInstanceOf(ForbiddenOperationError) + expect(mocks.getUserProfile).not.toHaveBeenCalled() + expect(mocks.getUserSettings).not.toHaveBeenCalled() + }) + + it('reads both resources for the authenticated account identity', async () => { + const profile = { id: 'user-1', name: 'User', email: 'user@example.com', image: null } + const settings = { theme: 'dark' } + mocks.getUserProfile.mockResolvedValue(profile) + mocks.getUserSettings.mockResolvedValue(settings) + + await expect( + getCurrentUserProfileUseCase.execute({ principal: session, input: {} }) + ).resolves.toEqual(profile) + await expect( + getCurrentUserSettingsUseCase.execute({ principal: session, input: {} }) + ).resolves.toEqual(settings) + expect(mocks.getUserProfile).toHaveBeenCalledWith('user-1') + expect(mocks.getUserSettings).toHaveBeenCalledWith('user-1') + }) + + it('classifies a missing current-user profile as not found', async () => { + mocks.getUserProfile.mockResolvedValue(null) + + await expect( + getCurrentUserProfileUseCase.execute({ principal: session, input: {} }) + ).rejects.toMatchObject>({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/users/application/read-current-user.ts b/apps/sim/lib/users/application/read-current-user.ts new file mode 100644 index 00000000000..f05d75dcce0 --- /dev/null +++ b/apps/sim/lib/users/application/read-current-user.ts @@ -0,0 +1,32 @@ +import type { UserProfileApiUser, UserSettingsApi } from '@/lib/api/contracts/user' +import type { OperationUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { requireUserAccountPrincipal } from '@/lib/users/application/authorization' +import { userAccountOperations } from '@/lib/users/application/operations' +import { getUserProfile, getUserSettings } from '@/lib/users/queries' + +export const getCurrentUserProfileUseCase: OperationUseCase< + typeof userAccountOperations.readProfile, + Record, + UserProfileApiUser +> = { + operation: userAccountOperations.readProfile, + async execute({ principal }) { + requireUserAccountPrincipal(principal, userAccountOperations.readProfile) + const profile = await getUserProfile(principal.userId) + if (!profile) throw new OrchestrationError('not_found', 'User not found') + return profile + }, +} + +export const getCurrentUserSettingsUseCase: OperationUseCase< + typeof userAccountOperations.readSettings, + Record, + UserSettingsApi +> = { + operation: userAccountOperations.readSettings, + async execute({ principal }) { + requireUserAccountPrincipal(principal, userAccountOperations.readSettings) + return getUserSettings(principal.userId) + }, +} diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 6c83a34867d..19cc0e1cfe8 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -58,6 +58,7 @@ function accessibleWorkspace( organizationId, workspaceMode: organizationId ? 'organization' : 'personal', billedAccountUserId: 'owner-1', + allowPersonalApiKeys: false, }, } } @@ -80,6 +81,7 @@ describe('getWorkspaceHostContextForViewer', () => { expect(context).toEqual( expect.objectContaining({ + workspace: expect.objectContaining({ allowPersonalApiKeys: false }), hostOrganizationId: 'org-host', viewer: { permission: 'write', diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 0c66496eb27..6fdd788d90a 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -36,6 +36,7 @@ async function resolveWorkspaceHostContextForViewer( name: access.workspace.name, workspaceMode: access.workspace.workspaceMode, billedAccountUserId: access.workspace.billedAccountUserId, + allowPersonalApiKeys: access.workspace.allowPersonalApiKeys, }, hostOrganizationId, ownerBilling, diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts new file mode 100644 index 00000000000..9a436cc9082 --- /dev/null +++ b/apps/sim/stores/index.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockModuleLoaded, mockResetAllStores } = vi.hoisted(() => ({ + mockModuleLoaded: vi.fn(), + mockResetAllStores: vi.fn(), +})) + +vi.mock('@/stores/reset-all-stores', () => { + mockModuleLoaded() + return { resetAllStores: mockResetAllStores } +}) + +import { clearUserData, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' + +class EnumerableStorage implements Storage { + get length(): number { + return Object.keys(this).length + } + + clear(): void { + Object.keys(this).forEach((key) => Reflect.deleteProperty(this, key)) + } + + getItem(key: string): string | null { + const value = Reflect.get(this, key) + return typeof value === 'string' ? value : null + } + + key(index: number): string | null { + return Object.keys(this)[index] ?? null + } + + removeItem(key: string): void { + Reflect.deleteProperty(this, key) + } + + setItem(key: string, value: string): void { + Object.defineProperty(this, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) + } +} + +describe('clearUserData', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('localStorage', new EnumerableStorage()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('loads the broad store graph only when cleanup runs and preserves allowed preferences', async () => { + expect(mockModuleLoaded).not.toHaveBeenCalled() + + localStorage.setItem('next-favicon', 'favicon') + localStorage.setItem('theme', 'dark') + localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, '["user-a"]') + localStorage.setItem('private-cache', 'remove-me') + + await clearUserData() + + expect(mockModuleLoaded).toHaveBeenCalledOnce() + expect(mockResetAllStores).toHaveBeenCalledOnce() + expect(localStorage.getItem('next-favicon')).toBe('favicon') + expect(localStorage.getItem('theme')).toBe('dark') + expect(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY)).toBe('["user-a"]') + expect(localStorage.getItem('private-cache')).toBeNull() + }) + + it('clears persisted user data even when the lazy store reset fails', async () => { + localStorage.setItem('private-cache', 'remove-me') + mockResetAllStores.mockImplementationOnce(() => { + throw new Error('Chunk unavailable') + }) + + await clearUserData() + + expect(mockResetAllStores).toHaveBeenCalledOnce() + expect(localStorage.getItem('private-cache')).toBeNull() + }) +}) diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 529cb891a55..de26897ccb9 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -1,64 +1,36 @@ 'use client' import { createLogger } from '@sim/logger' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { environmentKeys } from '@/hooks/queries/environment' -import { useExecutionStore } from '@/stores/execution' -import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' -import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('Stores') /** localStorage key for the admin recent-impersonations list; kept through clearUserData. */ export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' -/** - * Reset all Zustand stores and React Query caches to initial state. - */ -export const resetAllStores = () => { - useWorkflowRegistry.setState({ - activeWorkflowId: null, - error: null, - hydration: { - phase: 'idle', - workspaceId: null, - workflowId: null, - requestId: null, - error: null, - }, - }) - useWorkflowStore.getState().clear() - useSubBlockStore.getState().clear() - getQueryClient().removeQueries({ queryKey: environmentKeys.all }) - useExecutionStore.getState().reset() - useTerminalConsoleStore.setState({ - workflowEntries: {}, - entryIdsByBlockExecution: {}, - entryLocationById: {}, - isOpen: false, - }) - consolePersistence.persist() - useMothershipDraftsStore.setState({ drafts: {} }) -} - /** * Clear all user data when signing out. */ export async function clearUserData(): Promise { if (typeof window === 'undefined') return - try { - resetAllStores() + let cleanupFailed = false + try { const keysToKeep = ['next-favicon', 'theme', RECENT_IMPERSONATIONS_STORAGE_KEY] const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) + } catch (error) { + cleanupFailed = true + logger.error('Error clearing persisted user data:', { error }) + } - logger.info('User data cleared successfully') + try { + const { resetAllStores } = await import('@/stores/reset-all-stores') + resetAllStores() } catch (error) { - logger.error('Error clearing user data:', { error }) + cleanupFailed = true + logger.error('Error resetting in-memory user data:', { error }) } + + if (!cleanupFailed) logger.info('User data cleared successfully') } diff --git a/apps/sim/stores/reset-all-stores.ts b/apps/sim/stores/reset-all-stores.ts new file mode 100644 index 00000000000..b5694850731 --- /dev/null +++ b/apps/sim/stores/reset-all-stores.ts @@ -0,0 +1,36 @@ +'use client' + +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { environmentKeys } from '@/hooks/queries/environment' +import { useExecutionStore } from '@/stores/execution' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' +import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +export function resetAllStores(): void { + useWorkflowRegistry.setState({ + activeWorkflowId: null, + error: null, + hydration: { + phase: 'idle', + workspaceId: null, + workflowId: null, + requestId: null, + error: null, + }, + }) + useWorkflowStore.getState().clear() + useSubBlockStore.getState().clear() + getQueryClient().removeQueries({ queryKey: environmentKeys.all }) + useExecutionStore.getState().reset() + useTerminalConsoleStore.setState({ + workflowEntries: {}, + entryIdsByBlockExecution: {}, + entryLocationById: {}, + isOpen: false, + }) + consolePersistence.persist() + useMothershipDraftsStore.setState({ drafts: {} }) +} From a1a1b3636bb131e32b0d44bc243624736bc2c631 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:40:18 -0700 Subject: [PATCH 02/11] fix(settings): harden loading and session boundaries --- .../[id]/billing-summary/route.test.ts | 98 +++++++++++++++ .../impersonation-banner.tsx | 2 +- .../agent-group/tool-permission-card.tsx | 2 +- .../app/workspace/[workspaceId]/prefetch.ts | 2 +- .../[workspaceId]/settings/[section]/page.tsx | 1 - .../settings/[section]/prefetch.test.ts | 39 +++--- .../settings/[section]/prefetch.ts | 26 +--- .../settings/components/admin/admin.tsx | 2 +- .../components/billing/billing.test.tsx | 48 +++++++- .../settings/components/billing/billing.tsx | 24 +++- .../components/settings-empty-state/index.ts | 2 +- .../settings-empty-state.tsx | 31 ++++- .../team-management/team-management.test.tsx | 31 ++++- .../team-management/team-management.tsx | 65 +++++++--- .../settings-query-warmers.test.ts | 29 ++++- .../settings-query-warmers.ts | 6 + .../prefetch-standalone-general.test.ts | 7 +- .../settings/prefetch-standalone-general.ts | 30 +---- apps/sim/hooks/queries/credentials.ts | 41 +------ .../hooks/queries/general-settings-data.ts | 43 +++++++ ....ts => general-settings-timezone.test.tsx} | 53 ++++++-- .../hooks/queries/general-settings.test.tsx | 7 +- apps/sim/hooks/queries/general-settings.ts | 65 ++-------- .../queries/organization-billing-summary.ts | 12 +- apps/sim/hooks/queries/organization.test.tsx | 24 +++- apps/sim/hooks/queries/user-profile-data.ts | 19 +++ apps/sim/hooks/queries/user-profile.ts | 38 ++---- .../utils/fetch-workspace-credentials.ts | 23 +++- .../utils/prefetch-query-on-intent.test.ts | 19 +++ .../queries/utils/prefetch-query-on-intent.ts | 3 +- .../queries/workflow-mcp-servers.test.tsx | 32 +++++ .../sim/hooks/queries/workflow-mcp-servers.ts | 10 +- .../lib/billing/core/payer-context.test.ts | 72 +++++++++++ apps/sim/lib/billing/core/payer-context.ts | 6 +- .../workspace-section-access.test.ts | 46 +++++-- .../application/workspace-section-access.ts | 18 +-- .../prefetch-current-user-settings.ts | 29 +++++ apps/sim/stores/index.test.ts | 19 ++- apps/sim/stores/index.ts | 29 +++-- apps/sim/stores/reset-all-stores.test.ts | 114 ++++++++++++++++++ apps/sim/stores/reset-all-stores.ts | 33 +++-- apps/sim/stores/terminal/console/index.ts | 8 +- .../stores/terminal/console/storage.test.ts | 32 ++++- apps/sim/stores/terminal/console/storage.ts | 22 ++++ apps/sim/stores/terminal/console/store.ts | 11 +- apps/sim/stores/terminal/index.ts | 2 + 46 files changed, 971 insertions(+), 304 deletions(-) create mode 100644 apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts create mode 100644 apps/sim/hooks/queries/general-settings-data.ts rename apps/sim/hooks/queries/{general-settings.test.ts => general-settings-timezone.test.tsx} (60%) create mode 100644 apps/sim/hooks/queries/user-profile-data.ts create mode 100644 apps/sim/lib/billing/core/payer-context.test.ts create mode 100644 apps/sim/lib/settings/prefetch-current-user-settings.ts create mode 100644 apps/sim/stores/reset-all-stores.test.ts diff --git a/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts b/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts new file mode 100644 index 00000000000..a5dffcd17f1 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application' + +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, + }, + }) +) + +import { GET } from '@/app/api/organizations/[id]/billing-summary/route' + +const routeContext = { params: Promise.resolve({ id: 'organization-1' }) } +const summary = { + organizationId: 'organization-1', + subscriptionState: 'active' as const, + subscriptionPlan: 'team', + subscriptionStatus: 'active', + creditBalance: 10, + billingInterval: 'month' as const, + cancelAtPeriodEnd: false, + totalSeats: 3, + totalCurrentUsage: 25, + totalUsageLimit: 100, + minimumBillingAmount: 60, + billingPeriodEnd: '2026-09-30T00:00:00.000Z', + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + upgradeWorkspaceId: 'workspace-1', + userRole: 'admin' as const, +} + +describe('GET /api/organizations/[id]/billing-summary', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockReadBillingSummary.mockResolvedValue(summary) + }) + + it('rejects an unauthenticated request before the protected read runs', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await GET(createMockRequest('GET'), routeContext) + + expect(response.status).toBe(401) + expect(mockReadBillingSummary).not.toHaveBeenCalled() + }) + + it('projects an authorization refusal without exposing billing data', async () => { + mockReadBillingSummary.mockRejectedValue( + new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization admin or owner authority is required to read billing information' + ) + ) + + const response = await GET(createMockRequest('GET'), routeContext) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body).toEqual({ + error: 'Organization admin or owner authority is required to read billing information', + }) + expect(body).not.toHaveProperty('data') + }) + + it('maps the authenticated viewer and route organization into the semantic read', async () => { + const response = await GET(createMockRequest('GET'), routeContext) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ success: true, data: summary }) + expect(mockReadBillingSummary).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, + input: { organizationId: 'organization-1' }, + }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx index 6416d73a255..f88977be585 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx @@ -38,7 +38,7 @@ export function ImpersonationBanner() { }, onSuccess: async () => { setIsRedirecting(true) - await clearUserData() + await clearUserData({ preserveRecentImpersonations: true }) window.location.assign('/workspace') }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx index cebea1b0ce3..b40ec6702c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx @@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' -import { generalSettingsKeys } from '@/hooks/queries/general-settings' +import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' import { useToolPermissionStore } from '@/stores/tool-permission/store' const logger = createLogger('ToolPermissionCard') diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index ebabb4df975..8bc991c7815 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -19,7 +19,7 @@ import { mapUserProfileResponse, USER_PROFILE_STALE_TIME, userProfileKeys, -} from '@/hooks/queries/user-profile' +} from '@/hooks/queries/user-profile-data' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index e58cafd30b4..742a35d37b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -58,7 +58,6 @@ export default async function WorkspaceSettingsSectionPage({ const sectionPrefetch = SECTION_PREFETCHERS[parsed]?.(queryClient, { workspaceId, - userId: session.user.id, }) ?? Promise.resolve() await sectionPrefetch diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index 0f31daaed49..e51c277cee5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -4,14 +4,14 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserSettings, mockExecute, mockAuthenticate } = vi.hoisted(() => ({ - mockGetUserSettings: vi.fn(), +const { mockGetCurrentUserSettings, mockExecute, mockAuthenticate } = vi.hoisted(() => ({ + mockGetCurrentUserSettings: vi.fn(), mockExecute: vi.fn(), mockAuthenticate: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserSettings: mockGetUserSettings, +vi.mock('@/lib/users/application/read-current-user', () => ({ + getCurrentUserSettingsUseCase: { execute: mockGetCurrentUserSettings }, })) vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ @@ -21,17 +21,22 @@ vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ vi.mock('@/lib/api/server/routes/internal-json-route', () => ({ internalSessionAuth: { authenticate: mockAuthenticate }, })) +vi.mock('@/lib/api/server/routes', () => ({ + internalSessionAuth: { authenticate: mockAuthenticate }, +})) -import { - prefetchGeneralSettings, - SECTION_PREFETCHERS, -} from '@/app/workspace/[workspaceId]/settings/[section]/prefetch' -import { generalSettingsKeys } from '@/hooks/queries/general-settings' +import { SECTION_PREFETCHERS } from '@/app/workspace/[workspaceId]/settings/[section]/prefetch' +import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' -describe('prefetchGeneralSettings', () => { - it('uses the authenticated viewer id supplied by the route', async () => { - mockGetUserSettings.mockResolvedValue({ +describe('general settings prefetch', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticate.mockResolvedValue({ kind: 'session', userId: 'viewer-a', sessionId: 's1' }) + }) + + it('hydrates through the current-user application operation and response contract', async () => { + mockGetCurrentUserSettings.mockResolvedValue({ autoConnect: true, superUserModeEnabled: false, mothershipEnvironment: 'prod', @@ -47,9 +52,12 @@ describe('prefetchGeneralSettings', () => { }) const queryClient = new QueryClient() - await prefetchGeneralSettings(queryClient, 'viewer-a') + await SECTION_PREFETCHERS.general?.(queryClient, { workspaceId: 'workspace-a' }) - expect(mockGetUserSettings).toHaveBeenCalledWith('viewer-a') + expect(mockGetCurrentUserSettings).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'viewer-a', sessionId: 's1' }, + input: {}, + }) expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({ theme: 'system', telemetryEnabled: true, @@ -82,7 +90,6 @@ describe('credential-groups prefetch', () => { await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { workspaceId: 'w1', - userId: 'u1', }) expect(mockExecute).toHaveBeenCalledWith({ @@ -106,7 +113,6 @@ describe('credential-groups prefetch', () => { await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { workspaceId: 'w1', - userId: 'u1', }) expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toBeUndefined() @@ -118,7 +124,6 @@ describe('credential-groups prefetch', () => { await SECTION_PREFETCHERS['credential-groups']?.(queryClient, { workspaceId: 'w1', - userId: 'u1', }) expect(mockExecute).not.toHaveBeenCalled() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 596cb24a1ac..5f4bc94e742 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -2,30 +2,13 @@ import type { QueryClient } from '@tanstack/react-query' import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups' import { internalSessionAuth } from '@/lib/api/server/routes/internal-json-route' import { listCredentialGroupSettings } from '@/lib/credential-groups/application/manage-groups' -import { getUserSettings } from '@/lib/users/queries' +import { prefetchCurrentUserSettings } from '@/lib/settings/prefetch-current-user-settings' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { - GENERAL_SETTINGS_STALE_TIME, - generalSettingsKeys, - mapGeneralSettingsResponse, -} from '@/hooks/queries/general-settings' import { CREDENTIAL_GROUP_LIST_STALE_TIME, credentialGroupKeys, } from '@/hooks/queries/utils/credential-group-queries' -/** Prefetches the same key and mapped value as `useGeneralSettings`. */ -export function prefetchGeneralSettings(queryClient: QueryClient, userId: string) { - return queryClient.prefetchQuery({ - queryKey: generalSettingsKeys.settings(), - queryFn: async () => { - const data = await getUserSettings(userId) - return mapGeneralSettingsResponse(data) - }, - staleTime: GENERAL_SETTINGS_STALE_TIME, - }) -} - /** Prefetches credential groups through the route's authorization and response boundaries. */ async function prefetchCredentialGroups( queryClient: QueryClient, @@ -53,7 +36,6 @@ async function prefetchCredentialGroups( export interface SettingsSectionPrefetchContext { workspaceId: string - userId: string } /** @@ -67,8 +49,8 @@ export const SECTION_PREFETCHERS: Partial< (queryClient: QueryClient, context: SettingsSectionPrefetchContext) => Promise > > = { - general: (queryClient, { userId }) => prefetchGeneralSettings(queryClient, userId), - billing: (queryClient, { userId }) => prefetchGeneralSettings(queryClient, userId), - admin: (queryClient, { userId }) => prefetchGeneralSettings(queryClient, userId), + general: (queryClient) => prefetchCurrentUserSettings(queryClient), + billing: (queryClient) => prefetchCurrentUserSettings(queryClient), + admin: (queryClient) => prefetchCurrentUserSettings(queryClient), 'credential-groups': prefetchCredentialGroups, } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 4cbe2f03ed2..6ca773ac144 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -157,7 +157,7 @@ export function Admin() { }, onSuccess: async () => { recordImpersonation(email) - await clearUserData() + await clearUserData({ preserveRecentImpersonations: true }) window.location.assign('/workspace') }, } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index c879d3fc76e..4de57568c50 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { getErrorMessage } from '@sim/utils/errors' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -186,6 +187,24 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state' {children}
), + SettingsQueryErrorState: ({ + error, + fallback, + isRetrying, + onRetry, + }: { + error: unknown + fallback: string + isRetrying: boolean + onRetry: () => void + }) => ( +
+ {getErrorMessage(error, fallback)} + +
+ ), })) vi.mock( @@ -432,11 +451,14 @@ describe('Billing payer scope', () => { }) it('renders the canonical error state when the active billing query fails', async () => { + const refetch = vi.fn().mockResolvedValue(undefined) mockPersonalQuery.current = { data: undefined, error: new Error('Billing temporarily unavailable'), + isFetchedAfterMount: true, + isFetching: false, isLoading: false, - refetch: vi.fn(), + refetch, } await act(async () => { @@ -445,7 +467,26 @@ describe('Billing payer scope', () => { const errorState = container.querySelector('[data-testid="settings-empty-state"]') expect(errorState).toHaveAttribute('data-tone', 'error') - expect(errorState?.textContent).toBe('Billing temporarily unavailable') + expect(errorState?.textContent).toContain('Billing temporarily unavailable') + expect(errorState?.textContent).toContain('Try again') + + act(() => { + errorState?.querySelector('button')?.click() + }) + expect(refetch).toHaveBeenCalledOnce() + + mockPersonalQuery.current = { + data: undefined, + error: null, + isFetchedAfterMount: true, + isFetching: true, + isLoading: true, + refetch, + } + await act(async () => root.render()) + expect(container.textContent).toContain('Failed to load billing information') + expect(container.textContent).toContain('Retrying…') + expect(container.querySelector('button')).toBeDisabled() }) it('keeps cached billing content visible when a background refresh fails', async () => { @@ -478,6 +519,7 @@ describe('Billing payer scope', () => { const errorState = container.querySelector('[data-testid="settings-empty-state"]') expect(errorState).toHaveAttribute('data-tone', 'error') - expect(errorState?.textContent).toBe('Failed to load billing information') + expect(errorState?.textContent).toContain('Failed to load billing information') + expect(errorState?.textContent).toContain('Try again') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 4d7205b88be..8a130399657 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -48,7 +48,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section' import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -124,6 +124,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps const { data: subscriptionData, error: subscriptionError, + isFetchedAfterMount: isSubscriptionFetchedAfterMount, + isFetching: isSubscriptionFetching, isLoading: isSubscriptionLoading, refetch: refetchSubscription, } = useSubscriptionData({ @@ -135,6 +137,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps const { data: organizationBillingData, error: organizationBillingError, + isFetchedAfterMount: isOrganizationBillingFetchedAfterMount, + isFetching: isOrganizationBillingFetching, isLoading: isOrgBillingLoading, refetch: refetchOrganizationBilling, } = useOrganizationBillingSummary(billingOrganizationId || '', { @@ -167,6 +171,10 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps ? (organizationBilling?.subscriptionStatus ?? 'inactive') : (subscriptionData?.data?.status ?? 'inactive') const isLoading = isOrganizationScope ? isOrgBillingLoading : isSubscriptionLoading + const isFetchedAfterMount = isOrganizationScope + ? isOrganizationBillingFetchedAfterMount + : isSubscriptionFetchedAfterMount + const isFetching = isOrganizationScope ? isOrganizationBillingFetching : isSubscriptionFetching const billingError = isOrganizationScope ? organizationBillingError : subscriptionError const subscription = { @@ -416,13 +424,19 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps } } - if (isLoading) return null + if (isLoading && !isFetchedAfterMount) return null if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) { return ( - - {getErrorMessage(billingError, 'Failed to load billing information')} - + { + if (isOrganizationScope) void refetchOrganizationBilling() + else void refetchSubscription() + }} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/index.ts index de63267194e..2d1fec165b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/index.ts @@ -1 +1 @@ -export { SettingsEmptyState } from './settings-empty-state' +export { SettingsEmptyState, SettingsQueryErrorState } from './settings-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx index 6e1cc077ce4..5858934e150 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' -import { cn } from '@sim/emcn' +import { Chip, cn } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' interface SettingsEmptyStateProps { children: ReactNode @@ -13,6 +14,14 @@ interface SettingsEmptyStateProps { tone?: 'muted' | 'error' } +interface SettingsQueryErrorStateProps { + error: unknown + fallback: string + isRetrying: boolean + onRetry: () => void + variant?: 'fill' | 'inline' +} + /** * Canonical muted status message for settings surfaces: empty lists, search * "no results", and entitlement/loading gates. Centralizes the text token and @@ -35,3 +44,23 @@ export function SettingsEmptyState({
) } + +/** Canonical recoverable error state for settings queries. */ +export function SettingsQueryErrorState({ + error, + fallback, + isRetrying, + onRetry, + variant, +}: SettingsQueryErrorStateProps) { + return ( + +
+ {getErrorMessage(error, fallback)} + + {isRetrying ? 'Retrying…' : 'Try again'} + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx index 120263937c2..0ff76850276 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { getErrorMessage } from '@sim/utils/errors' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -39,6 +40,24 @@ vi.mock('@/app/workspace/[workspaceId]/components/invite-modal', () => ({ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, + SettingsQueryErrorState: ({ + error, + fallback, + isRetrying, + onRetry, + }: { + error: unknown + fallback: string + isRetrying: boolean + onRetry: () => void + }) => ( +
+ {getErrorMessage(error, fallback)} + +
+ ), })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ @@ -167,7 +186,8 @@ describe('TeamManagement organization errors', () => { expect(container.textContent).not.toContain('organization-member-lists') }) - it('shows a billing failure instead of a subscription upsell', () => { + it('shows a retryable billing failure instead of a subscription upsell', async () => { + const refetch = vi.fn().mockResolvedValue(undefined) mockIsAdminOrOwner.mockReturnValue(true) mockUseOrganization.mockReturnValue({ data: { id: 'org-1' }, @@ -178,6 +198,7 @@ describe('TeamManagement organization errors', () => { data: undefined, error: new Error('Billing request failed'), isLoading: false, + refetch, }) act(() => @@ -187,6 +208,14 @@ describe('TeamManagement organization errors', () => { ) expect(container.textContent).toContain('Billing request failed') + expect(container.textContent).toContain('Try again') expect(container.textContent).not.toContain('team-seats-overview') + + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Try again') + ?.click() + }) + expect(refetch).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 5520e9b52ec..2d21ca6bed7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -9,7 +9,10 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { NoOrganizationView, @@ -47,7 +50,14 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr const { isInvitationsDisabled } = usePermissionConfig() const [memberQuery, setMemberQuery] = useSettingsSearch() - const { data: organization, isLoading, error: orgError } = useOrganization(organizationId) + const { + data: organization, + isLoading, + error: orgError, + isFetchedAfterMount: isOrganizationFetchedAfterMount, + isFetching: isOrganizationFetching, + refetch: refetchOrganization, + } = useOrganization(organizationId) /** * Personal billing only supports the legacy missing-organization recovery view. A valid * organization page derives its plan from organization billing, so avoid that unrelated read @@ -68,12 +78,18 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr data: organizationBillingData, isLoading: isOrgBillingLoading, error: organizationBillingError, + isFetchedAfterMount: isOrganizationBillingFetchedAfterMount, + isFetching: isOrganizationBillingFetching, + refetch: refetchOrganizationBilling, } = useOrganizationBilling(organizationId, { enabled: adminOrOwner }) const { data: roster, isLoading: isLoadingRoster, error: rosterError, + isFetchedAfterMount: isRosterFetchedAfterMount, + isFetching: isRosterFetching, + refetch: refetchRoster, } = useOrganizationRoster(organizationId) const removeMemberMutation = useRemoveMember() @@ -289,16 +305,22 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr const displayOrganization = organization - if (isLoading && !displayOrganization) { + if (isLoading && !isOrganizationFetchedAfterMount && !displayOrganization) { return null } - if (orgError && !displayOrganization) { + if ( + (orgError || (isOrganizationFetching && isOrganizationFetchedAfterMount)) && + !displayOrganization + ) { return ( - - {getErrorMessage(orgError, 'Failed to load organization')} - + void refetchOrganization()} + /> ) } @@ -353,10 +375,16 @@ export function TeamManagement({ organizationId, billingHref }: TeamManagementPr } > {adminOrOwner && - (organizationBillingError && organizationBillingData === undefined ? ( - - {getErrorMessage(organizationBillingError, 'Failed to load seat information')} - + ((organizationBillingError || + (isOrganizationBillingFetching && isOrganizationBillingFetchedAfterMount)) && + organizationBillingData === undefined ? ( + void refetchOrganizationBilling()} + variant='inline' + /> ) : ( ))} - {isLoadingRoster ? ( + {isLoadingRoster && !isRosterFetchedAfterMount ? ( Loading members… - ) : rosterError && roster === undefined ? ( - - {getErrorMessage(rosterError, 'Failed to load organization members')} - + ) : (rosterError || (isRosterFetching && isRosterFetchedAfterMount)) && + roster === undefined ? ( + void refetchRoster()} + variant='inline' + /> ) : ( ({ import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' let queryClient: QueryClient const personalContext = { workspaceId: 'workspace-1', billingOrganizationId: null } @@ -30,6 +31,9 @@ describe('settings query warmers', () => { if (contract.path === '/api/workspaces/[id]/sandboxes') { return Promise.resolve({ sandboxes: [], entitled: true, strategy: 'prebuilt' }) } + if (contract.path === '/api/credentials') { + return Promise.resolve({ credentials: [] }) + } return Promise.resolve({ keys: [] }) }) }) @@ -44,11 +48,12 @@ describe('settings query warmers', () => { expect(warmSettingsSectionQuery(queryClient, personalContext, 'sandboxes')).toBe(true) expect(warmSettingsSectionQuery(queryClient, personalContext, 'byok')).toBe(true) expect(warmSettingsSectionQuery(queryClient, personalContext, 'mcp')).toBe(true) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'secrets')).toBe(true) expect(warmSettingsSectionQuery(queryClient, personalContext, 'workflow-mcp-servers')).toBe( true ) - await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(6)) + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(7)) expect(mockRequestJson.mock.calls.map(([contract]) => contract.path)).toEqual( expect.arrayContaining([ '/api/workspaces/[id]/api-keys', @@ -57,12 +62,17 @@ describe('settings query warmers', () => { '/api/workspaces/[id]/byok-keys', '/api/mcp/servers', '/api/mcp/workflow-servers', + '/api/credentials', ]) ) + expect( + mockRequestJson.mock.calls.find(([contract]) => contract.path === '/api/credentials')?.[1] + ).toEqual( + expect.objectContaining({ query: { workspaceId: 'workspace-1', type: 'env_workspace' } }) + ) }) - it('does not warm sensitive or broad settings data', () => { - expect(warmSettingsSectionQuery(queryClient, personalContext, 'secrets')).toBe(false) + it('does not warm broad settings data', () => { expect(warmSettingsSectionQuery(queryClient, personalContext, 'custom-tools')).toBe(false) expect(mockRequestJson).not.toHaveBeenCalled() @@ -98,4 +108,17 @@ describe('settings query warmers', () => { expect(mockRequestJson).toHaveBeenCalledTimes(2) }) + + it('keeps the Secrets warmer and consumer on mount-recoverable shared options', () => { + const options = workspaceCredentialListQueryOptions('workspace-1', 'env_workspace') + + expect(options.retryOnMount).toBe(true) + expect(options.queryKey).toEqual([ + 'workspaceCredentials', + 'list', + 'workspace-1', + 'env_workspace', + 'all', + ]) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts index 99fb01326fd..5f05cb3e92a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts @@ -6,12 +6,18 @@ import { mcpServersQueryOptions } from '@/hooks/queries/mcp-server-list' import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' import { getSandboxListQueryOptions } from '@/hooks/queries/sandbox-list' import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-server-list' const SETTINGS_QUERY_WARMERS: Partial< Record void> > = { + secrets: (queryClient, { workspaceId }) => + prefetchQueryOnIntent( + queryClient, + workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') + ), apikeys: (queryClient, { workspaceId }) => prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), sandboxes: (queryClient, { workspaceId }) => diff --git a/apps/sim/components/settings/prefetch-standalone-general.test.ts b/apps/sim/components/settings/prefetch-standalone-general.test.ts index 380378f6837..9cf9d4e31ff 100644 --- a/apps/sim/components/settings/prefetch-standalone-general.test.ts +++ b/apps/sim/components/settings/prefetch-standalone-general.test.ts @@ -13,6 +13,9 @@ const { mockAuthenticate, mockGetUserProfile, mockGetUserSettings } = vi.hoisted vi.mock('@/lib/api/server/routes', () => ({ internalSessionAuth: { authenticate: mockAuthenticate }, })) +vi.mock('@/lib/api/server/routes/internal-json-route', () => ({ + internalSessionAuth: { authenticate: mockAuthenticate }, +})) vi.mock('@/lib/users/application/read-current-user', () => ({ getCurrentUserProfileUseCase: { execute: mockGetUserProfile }, @@ -20,8 +23,8 @@ vi.mock('@/lib/users/application/read-current-user', () => ({ })) import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general' -import { generalSettingsKeys } from '@/hooks/queries/general-settings' -import { userProfileKeys } from '@/hooks/queries/user-profile' +import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' +import { userProfileKeys } from '@/hooks/queries/user-profile-data' describe('prefetchStandaloneGeneral', () => { beforeEach(() => { diff --git a/apps/sim/components/settings/prefetch-standalone-general.ts b/apps/sim/components/settings/prefetch-standalone-general.ts index 463d2ebfa53..072ca6a63f7 100644 --- a/apps/sim/components/settings/prefetch-standalone-general.ts +++ b/apps/sim/components/settings/prefetch-standalone-general.ts @@ -1,20 +1,13 @@ import type { QueryClient } from '@tanstack/react-query' -import { getUserProfileContract, getUserSettingsContract } from '@/lib/api/contracts/user' -import { internalSessionAuth } from '@/lib/api/server/routes' -import { - getCurrentUserProfileUseCase, - getCurrentUserSettingsUseCase, -} from '@/lib/users/application/read-current-user' -import { - GENERAL_SETTINGS_STALE_TIME, - generalSettingsKeys, - mapGeneralSettingsResponse, -} from '@/hooks/queries/general-settings' +import { getUserProfileContract } from '@/lib/api/contracts/user' +import { internalSessionAuth } from '@/lib/api/server/routes/internal-json-route' +import { prefetchCurrentUserSettings } from '@/lib/settings/prefetch-current-user-settings' +import { getCurrentUserProfileUseCase } from '@/lib/users/application/read-current-user' import { mapUserProfileResponse, USER_PROFILE_STALE_TIME, userProfileKeys, -} from '@/hooks/queries/user-profile' +} from '@/hooks/queries/user-profile-data' /** * Hydrates the authenticated viewer's standalone General page with the exact @@ -40,17 +33,6 @@ export async function prefetchStandaloneGeneral(queryClient: QueryClient): Promi }, staleTime: USER_PROFILE_STALE_TIME, }), - queryClient.prefetchQuery({ - queryKey: generalSettingsKeys.settings(), - queryFn: async () => { - const settings = await getCurrentUserSettingsUseCase.execute({ - principal: await getPrincipal(), - input: {}, - }) - const response = getUserSettingsContract.response.schema.parse({ data: settings }) - return mapGeneralSettingsResponse(response.data) - }, - staleTime: GENERAL_SETTINGS_STALE_TIME, - }), + prefetchCurrentUserSettings(queryClient, getPrincipal), ]) } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index f7a9f876ca1..6fd44768f66 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -1,6 +1,5 @@ 'use client' -import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts' @@ -12,7 +11,6 @@ import { getSecretUsageContract, getWorkspaceCredentialContract, listWorkspaceCredentialMembersContract, - listWorkspaceCredentialsContract, removeWorkspaceCredentialMemberContract, type SecretUsageScope, updateWorkspaceCredentialContract, @@ -25,11 +23,7 @@ import { import { environmentKeys } from '@/hooks/queries/environment' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { - fetchWorkspaceCredentialList, - requireWorkspaceCredentialListResponse, - WORKSPACE_CREDENTIAL_LIST_STALE_TIME, -} from '@/hooks/queries/utils/fetch-workspace-credentials' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' /** @@ -48,22 +42,6 @@ export type { WorkspaceCredentialType, } -/** - * Prefetch workspace credentials into a QueryClient cache. - * Use on hover to warm data before navigation. - */ -export function prefetchWorkspaceCredentials( - queryClient: QueryClient, - workspaceId: string, - type?: WorkspaceCredentialType -) { - queryClient.prefetchQuery({ - queryKey: workspaceCredentialKeys.list(workspaceId, type), - queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal, type), - staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, - }) -} - export function useWorkspaceCredentials(params: { workspaceId?: string type?: WorkspaceCredentialType @@ -72,22 +50,9 @@ export function useWorkspaceCredentials(params: { }) { const { workspaceId, type, providerId, enabled = true } = params - return useQuery({ - queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId), - queryFn: async ({ signal }) => { - if (!workspaceId) return [] - const data = await requestJson(listWorkspaceCredentialsContract, { - query: { - workspaceId, - type, - providerId, - }, - signal, - }) - return requireWorkspaceCredentialListResponse(data) - }, + return useQuery({ + ...workspaceCredentialListQueryOptions(workspaceId, type, providerId), enabled: Boolean(workspaceId) && enabled, - staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/general-settings-data.ts b/apps/sim/hooks/queries/general-settings-data.ts new file mode 100644 index 00000000000..e9e5192be60 --- /dev/null +++ b/apps/sim/hooks/queries/general-settings-data.ts @@ -0,0 +1,43 @@ +import type { MothershipEnvironment, UserSettingsApi } from '@/lib/api/contracts/user' + +export const generalSettingsKeys = { + all: ['generalSettings'] as const, + settings: () => [...generalSettingsKeys.all, 'settings'] as const, +} + +export const GENERAL_SETTINGS_STALE_TIME = 60 * 60 * 1000 + +export interface GeneralSettings { + autoConnect: boolean + superUserModeEnabled: boolean + mothershipEnvironment: MothershipEnvironment + theme: 'light' | 'dark' | 'system' + telemetryEnabled: boolean + billingUsageNotificationsEnabled: boolean + errorNotificationsEnabled: boolean + snapToGridSize: number + showActionBar: boolean + /** Whether clicking a block on the canvas animates the camera to center it. */ + autoFocusOnClick: boolean + /** Copilot tool ids the user picked "always allow" for. */ + copilotAutoAllowedTools: string[] + /** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */ + timezone: string | null +} + +export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettings { + return { + autoConnect: data.autoConnect, + superUserModeEnabled: data.superUserModeEnabled, + mothershipEnvironment: data.mothershipEnvironment, + theme: data.theme, + telemetryEnabled: data.telemetryEnabled, + billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled, + errorNotificationsEnabled: data.errorNotificationsEnabled, + snapToGridSize: data.snapToGridSize, + showActionBar: data.showActionBar, + autoFocusOnClick: data.autoFocusOnClick, + copilotAutoAllowedTools: data.copilotAutoAllowedTools ?? [], + timezone: data.timezone ?? null, + } +} diff --git a/apps/sim/hooks/queries/general-settings.test.ts b/apps/sim/hooks/queries/general-settings-timezone.test.tsx similarity index 60% rename from apps/sim/hooks/queries/general-settings.test.ts rename to apps/sim/hooks/queries/general-settings-timezone.test.tsx index bba242a5390..f65b8755b08 100644 --- a/apps/sim/hooks/queries/general-settings.test.ts +++ b/apps/sim/hooks/queries/general-settings-timezone.test.tsx @@ -1,7 +1,9 @@ /** - * @vitest-environment node + * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetBrowserTimezone, mockIsValidTimezone, mockUseQuery } = vi.hoisted(() => ({ mockGetBrowserTimezone: vi.fn(), @@ -21,18 +23,43 @@ vi.mock('@/lib/core/utils/timezone', () => ({ import { useTimezone, useTimezoneState } from '@/hooks/queries/general-settings' +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = [] + +function renderHookResult(useHook: () => T): T { + const container = document.createElement('div') + const root = createRoot(container) + let result: T | undefined + + function Probe() { + result = useHook() + return null + } + + act(() => root.render()) + mountedRoots.push({ container, root }) + return result as T +} + describe('useTimezone', () => { beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() mockGetBrowserTimezone.mockReturnValue('America/Los_Angeles') mockIsValidTimezone.mockReturnValue(true) }) + afterEach(() => { + for (const { container, root } of mountedRoots.splice(0)) { + act(() => root.unmount()) + container.remove() + } + }) + it('uses the browser timezone while no preference is saved', () => { mockUseQuery.mockReturnValue({ data: { timezone: null } }) - expect(useTimezone()).toBe('America/Los_Angeles') - expect(useTimezoneState()).toEqual({ + expect(renderHookResult(useTimezone)).toBe('America/Los_Angeles') + expect(renderHookResult(useTimezoneState)).toEqual({ timezone: 'America/Los_Angeles', savedTimezone: null, status: 'ready', @@ -42,8 +69,8 @@ describe('useTimezone', () => { it('uses a saved timezone instead of the browser fallback', () => { mockUseQuery.mockReturnValue({ data: { timezone: 'Asia/Kathmandu' } }) - expect(useTimezone()).toBe('Asia/Kathmandu') - expect(useTimezoneState()).toEqual({ + expect(renderHookResult(useTimezone)).toBe('Asia/Kathmandu') + expect(renderHookResult(useTimezoneState)).toEqual({ timezone: 'Asia/Kathmandu', savedTimezone: 'Asia/Kathmandu', status: 'ready', @@ -55,29 +82,29 @@ describe('useTimezone', () => { mockUseQuery.mockReturnValue({ data: { timezone: 'Not/AZone' } }) mockIsValidTimezone.mockReturnValue(false) - expect(useTimezoneState()).toEqual({ + expect(renderHookResult(useTimezoneState)).toEqual({ timezone: 'America/Los_Angeles', savedTimezone: 'Not/AZone', status: 'invalid', }) - expect(useTimezone()).toBe('America/Los_Angeles') + expect(renderHookResult(useTimezone)).toBe('America/Los_Angeles') }) it('reads the current setting again after it changes', () => { let timezone: string | null = 'America/New_York' mockUseQuery.mockImplementation(() => ({ data: { timezone } })) - expect(useTimezone()).toBe('America/New_York') + expect(renderHookResult(useTimezone)).toBe('America/New_York') timezone = 'Asia/Tokyo' - expect(useTimezone()).toBe('Asia/Tokyo') + expect(renderHookResult(useTimezone)).toBe('Asia/Tokyo') timezone = null - expect(useTimezone()).toBe('America/Los_Angeles') + expect(renderHookResult(useTimezone)).toBe('America/Los_Angeles') }) it('distinguishes an unresolved preference from an explicit browser fallback', () => { mockUseQuery.mockReturnValue({ data: undefined, isError: false }) - expect(useTimezoneState()).toEqual({ + expect(renderHookResult(useTimezoneState)).toEqual({ timezone: 'America/Los_Angeles', savedTimezone: null, status: 'loading', @@ -87,7 +114,7 @@ describe('useTimezone', () => { it('reports an unavailable preference instead of treating it as resolved', () => { mockUseQuery.mockReturnValue({ data: undefined, isError: true }) - expect(useTimezoneState()).toEqual({ + expect(renderHookResult(useTimezoneState)).toEqual({ timezone: 'America/Los_Angeles', savedTimezone: null, status: 'error', diff --git a/apps/sim/hooks/queries/general-settings.test.tsx b/apps/sim/hooks/queries/general-settings.test.tsx index a70ef87de5f..caf84c64310 100644 --- a/apps/sim/hooks/queries/general-settings.test.tsx +++ b/apps/sim/hooks/queries/general-settings.test.tsx @@ -19,11 +19,8 @@ vi.mock('@/lib/core/utils/theme', () => ({ syncThemeToNextThemes: mockSyncTheme, })) -import { - type GeneralSettings, - generalSettingsKeys, - useGeneralSettings, -} from '@/hooks/queries/general-settings' +import { useGeneralSettings } from '@/hooks/queries/general-settings' +import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings-data' const HYDRATED_SETTINGS: GeneralSettings = { autoConnect: true, diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 6eb12286f22..ed8e05f4be9 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -3,69 +3,18 @@ import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import { - getUserSettingsContract, - type MothershipEnvironment, - type UserSettingsApi, - updateUserSettingsContract, -} from '@/lib/api/contracts/user' +import { getUserSettingsContract, updateUserSettingsContract } from '@/lib/api/contracts/user' import { syncThemeToNextThemes } from '@/lib/core/utils/theme' import { getBrowserTimezone, isValidTimezone } from '@/lib/core/utils/timezone' +import { + GENERAL_SETTINGS_STALE_TIME, + type GeneralSettings, + generalSettingsKeys, + mapGeneralSettingsResponse, +} from '@/hooks/queries/general-settings-data' const logger = createLogger('GeneralSettingsQuery') -/** - * Query key factories for general settings - */ -export const generalSettingsKeys = { - all: ['generalSettings'] as const, - settings: () => [...generalSettingsKeys.all, 'settings'] as const, -} - -export const GENERAL_SETTINGS_STALE_TIME = 60 * 60 * 1000 - -/** - * General settings type - */ -export interface GeneralSettings { - autoConnect: boolean - superUserModeEnabled: boolean - mothershipEnvironment: MothershipEnvironment - theme: 'light' | 'dark' | 'system' - telemetryEnabled: boolean - billingUsageNotificationsEnabled: boolean - errorNotificationsEnabled: boolean - snapToGridSize: number - showActionBar: boolean - /** Whether clicking a block on the canvas animates the camera to center it. */ - autoFocusOnClick: boolean - /** Copilot tool ids the user picked "always allow" for. */ - copilotAutoAllowedTools: string[] - /** Saved IANA timezone, or `null` when unset (the app falls back to the browser zone). */ - timezone: string | null -} - -/** - * Map raw API response data to GeneralSettings with defaults. - * Shared by both client fetch and server prefetch to prevent shape drift. - */ -export function mapGeneralSettingsResponse(data: UserSettingsApi): GeneralSettings { - return { - autoConnect: data.autoConnect, - superUserModeEnabled: data.superUserModeEnabled, - mothershipEnvironment: data.mothershipEnvironment, - theme: data.theme, - telemetryEnabled: data.telemetryEnabled, - billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled, - errorNotificationsEnabled: data.errorNotificationsEnabled, - snapToGridSize: data.snapToGridSize, - showActionBar: data.showActionBar, - autoFocusOnClick: data.autoFocusOnClick, - copilotAutoAllowedTools: data.copilotAutoAllowedTools ?? [], - timezone: data.timezone ?? null, - } -} - /** * Fetch general settings from API */ diff --git a/apps/sim/hooks/queries/organization-billing-summary.ts b/apps/sim/hooks/queries/organization-billing-summary.ts index 81eca4aef14..61cbe9c0e13 100644 --- a/apps/sim/hooks/queries/organization-billing-summary.ts +++ b/apps/sim/hooks/queries/organization-billing-summary.ts @@ -1,10 +1,20 @@ import { queryOptions, useQuery } from '@tanstack/react-query' +import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' export const ORGANIZATION_BILLING_SUMMARY_STALE_TIME = 30 * 1000 +export function shouldRetryOrganizationBillingSummary( + failureCount: number, + error: unknown +): boolean { + if (failureCount >= 1) return false + if (!isApiClientError(error)) return true + return error.status === 408 || error.status === 429 || error.status >= 500 +} + export function organizationBillingSummaryOptions(orgId: string) { return queryOptions({ queryKey: organizationKeys.billingSummary(orgId), @@ -13,7 +23,7 @@ export function organizationBillingSummaryOptions(orgId: string) { params: { id: orgId }, signal, }), - retry: false, + retry: shouldRetryOrganizationBillingSummary, retryOnMount: true, staleTime: ORGANIZATION_BILLING_SUMMARY_STALE_TIME, }) diff --git a/apps/sim/hooks/queries/organization.test.tsx b/apps/sim/hooks/queries/organization.test.tsx index 9e9cb3cd0d8..16dfcccad6c 100644 --- a/apps/sim/hooks/queries/organization.test.tsx +++ b/apps/sim/hooks/queries/organization.test.tsx @@ -6,6 +6,7 @@ import { sleep } from '@sim/utils/helpers' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClientError } from '@/lib/api/client/errors' const { mockGetFullOrganization, mockRequestJson } = vi.hoisted(() => ({ mockGetFullOrganization: vi.fn(), @@ -42,7 +43,10 @@ import { useOrganizationBilling, useOrganizationRoster, } from '@/hooks/queries/organization' -import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' +import { + organizationBillingSummaryOptions, + shouldRetryOrganizationBillingSummary, +} from '@/hooks/queries/organization-billing-summary' interface Deferred { promise: Promise @@ -247,4 +251,22 @@ describe('organization identity transitions', () => { }) ) }) + + it('retries one transient billing-summary failure without retrying authorization errors', () => { + const serverError = new ApiClientError({ + status: 503, + message: 'Unavailable', + body: null, + }) + const forbiddenError = new ApiClientError({ + status: 403, + message: 'Forbidden', + body: null, + }) + + expect(shouldRetryOrganizationBillingSummary(0, serverError)).toBe(true) + expect(shouldRetryOrganizationBillingSummary(1, serverError)).toBe(false) + expect(shouldRetryOrganizationBillingSummary(0, forbiddenError)).toBe(false) + expect(shouldRetryOrganizationBillingSummary(0, new TypeError('Network error'))).toBe(true) + }) }) diff --git a/apps/sim/hooks/queries/user-profile-data.ts b/apps/sim/hooks/queries/user-profile-data.ts new file mode 100644 index 00000000000..67254d5e559 --- /dev/null +++ b/apps/sim/hooks/queries/user-profile-data.ts @@ -0,0 +1,19 @@ +import type { UserProfileApiUser } from '@/lib/api/contracts/user' + +export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000 + +export const userProfileKeys = { + all: ['userProfile'] as const, + profile: () => [...userProfileKeys.all, 'profile'] as const, +} + +export type UserProfile = Omit + +export function mapUserProfileResponse(user: UserProfileApiUser): UserProfile { + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + } +} diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index 55baf174f7e..120e519647b 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -6,41 +6,17 @@ import { forgetPasswordContract, getUserProfileContract, type UpdateUserProfileBody, - type UserProfileApiUser, updateUserProfileContract, -} from '@/lib/api/contracts' +} from '@/lib/api/contracts/user' +import { + mapUserProfileResponse, + USER_PROFILE_STALE_TIME, + type UserProfile, + userProfileKeys, +} from '@/hooks/queries/user-profile-data' const logger = createLogger('UserProfileQuery') -/** - * Query key factories for user profile - */ -export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000 - -export const userProfileKeys = { - all: ['userProfile'] as const, - profile: () => [...userProfileKeys.all, 'profile'] as const, -} - -/** - * User profile type, derived from the contract response shape minus - * the auth-only `emailVerified` field which is not displayed in the UI. - */ -export type UserProfile = Omit - -/** - * Map raw API response user object to UserProfile. - * Shared by both client fetch and server prefetch to prevent shape drift. - */ -export function mapUserProfileResponse(user: UserProfileApiUser): UserProfile { - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - } -} - /** * Fetch user profile from API */ diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index 2480796dadf..8cb64831477 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,3 +1,4 @@ +import { queryOptions } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { type ContractJsonResponse, @@ -5,6 +6,7 @@ import { type WorkspaceCredential, type WorkspaceCredentialType, } from '@/lib/api/contracts' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 @@ -27,11 +29,28 @@ export function requireWorkspaceCredentialListResponse( export async function fetchWorkspaceCredentialList( workspaceId: string, signal?: AbortSignal, - type?: WorkspaceCredentialType + type?: WorkspaceCredentialType, + providerId?: string ): Promise { const data = await requestJson(listWorkspaceCredentialsContract, { - query: { workspaceId, type }, + query: { workspaceId, type, providerId }, signal, }) return requireWorkspaceCredentialListResponse(data) } + +export function workspaceCredentialListQueryOptions( + workspaceId?: string, + type?: WorkspaceCredentialType, + providerId?: string +) { + return queryOptions({ + queryKey: workspaceCredentialKeys.list(workspaceId, type, providerId), + queryFn: ({ signal }) => + workspaceId + ? fetchWorkspaceCredentialList(workspaceId, signal, type, providerId) + : Promise.resolve([]), + retryOnMount: true, + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts index d822be1d2fb..747bcc4dce7 100644 --- a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts +++ b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.test.ts @@ -52,6 +52,25 @@ describe('prefetchQueryOnIntent', () => { expect(queryFn).toHaveBeenCalledTimes(2) }) + it('preserves usable stale data when a speculative refresh fails', async () => { + const queryClient = createQueryClient() + const queryFn = vi.fn<() => Promise>().mockRejectedValue(new Error('temporary failure')) + const options = queryOptions({ + queryKey: ['intent', 'stale-data'] as const, + queryFn, + staleTime: 0, + }) + queryClient.setQueryData(options.queryKey, 'cached') + + prefetchQueryOnIntent(queryClient, options) + + await vi.waitFor(() => expect(queryFn).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(queryClient.getQueryState(options.queryKey)?.status).toBe('error') + ) + expect(queryClient.getQueryData(options.queryKey)).toBe('cached') + }) + it('preserves a failure once a real observer is mounted', async () => { const queryClient = createQueryClient() let rejectQuery: ((error: Error) => void) | undefined diff --git a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts index 2ce32f7d460..f90f4edace7 100644 --- a/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts +++ b/apps/sim/hooks/queries/utils/prefetch-query-on-intent.ts @@ -13,7 +13,8 @@ export function prefetchQueryOnIntent ): void { void queryClient.prefetchQuery(options).then(() => { - if (queryClient.getQueryState(options.queryKey)?.status !== 'error') return + const state = queryClient.getQueryState(options.queryKey) + if (state?.status !== 'error' || state.data !== undefined) return queryClient.removeQueries({ queryKey: options.queryKey as QueryFilterKey, diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx b/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx index 413729d97fd..87bf1c818e9 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx +++ b/apps/sim/hooks/queries/workflow-mcp-servers.test.tsx @@ -111,4 +111,36 @@ describe('workflow MCP server queries', () => { queryClient.getQueryState(workflowMcpServerKeys.servers('workspace-1'))?.isInvalidated ).toBe(true) }) + + it('preserves a server detail subtree when deletion fails', async () => { + mockRequestJson.mockImplementation((contract) => { + if (contract === deleteWorkflowMcpServerContract) { + return Promise.reject(new Error('Delete failed')) + } + throw new Error('Unexpected request') + }) + const serverKey = workflowMcpServerKeys.server('workspace-1', 'server-1') + const toolsKey = workflowMcpServerKeys.tools('workspace-1', 'server-1') + queryClient.setQueryData(serverKey, { server: { id: 'server-1' }, tools: [] }) + queryClient.setQueryData(toolsKey, [{ id: 'tool-1' }]) + let mutation: ReturnType | undefined + + function Probe() { + mutation = useDeleteWorkflowMcpServer() + return null + } + + act(() => root.render({})) + await act(async () => { + await mutation + ?.mutateAsync({ workspaceId: 'workspace-1', serverId: 'server-1' }) + .catch(() => {}) + }) + + expect(queryClient.getQueryData(serverKey)).toEqual({ + server: { id: 'server-1' }, + tools: [], + }) + expect(queryClient.getQueryData(toolsKey)).toEqual([{ id: 'tool-1' }]) + }) }) diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index 39c1b877bef..a50551e4b00 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -222,14 +222,16 @@ export function useDeleteWorkflowMcpServer() { logger.info(`Deleted workflow MCP server: ${serverId}`) return data }, - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: workflowMcpServerKeys.servers(variables.workspaceId), - }) + onSuccess: (_data, variables) => { queryClient.removeQueries({ queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId), }) }, + onSettled: (_data, _error, variables) => { + return queryClient.invalidateQueries({ + queryKey: workflowMcpServerKeys.servers(variables.workspaceId), + }) + }, }) } diff --git a/apps/sim/lib/billing/core/payer-context.test.ts b/apps/sim/lib/billing/core/payer-context.test.ts new file mode 100644 index 00000000000..763b5213a67 --- /dev/null +++ b/apps/sim/lib/billing/core/payer-context.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it } from 'vitest' +import { getUpgradeWorkspaceId } from '@/lib/billing/core/payer-context' + +describe('getUpgradeWorkspaceId', () => { + beforeEach(() => { + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('uses the billed account identity for personal payer workspaces', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'workspace-1' }]) + + await expect(getUpgradeWorkspaceId({ type: 'user', id: 'payer-1' })).resolves.toBe( + 'workspace-1' + ) + + const predicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + predicate, + (node) => + node.type === 'eq' && + node.left === schemaMock.workspace.billedAccountUserId && + node.right === 'payer-1' + ) + ).toBe(true) + expect( + hasMockCondition( + predicate, + (node) => node.type === 'eq' && node.left === schemaMock.workspace.ownerId + ) + ).toBe(false) + expect( + hasMockCondition( + predicate, + (node) => node.type === 'isNull' && node.column === schemaMock.workspace.organizationId + ) + ).toBe(true) + }) + + it('scopes organization payer workspaces by organization id', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect( + getUpgradeWorkspaceId({ type: 'organization', id: 'organization-1' }) + ).resolves.toBeNull() + + const predicate = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + predicate, + (node) => + node.type === 'eq' && + node.left === schemaMock.workspace.organizationId && + node.right === 'organization-1' + ) + ).toBe(true) + expect( + hasMockCondition( + predicate, + (node) => node.type === 'eq' && node.left === schemaMock.workspace.billedAccountUserId + ) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/core/payer-context.ts b/apps/sim/lib/billing/core/payer-context.ts index 1b02e8579a2..8582d2487ed 100644 --- a/apps/sim/lib/billing/core/payer-context.ts +++ b/apps/sim/lib/billing/core/payer-context.ts @@ -17,11 +17,7 @@ export async function getUpgradeWorkspaceId( const targetPredicate = target.type === 'organization' ? eq(workspace.organizationId, target.id) - : and( - eq(workspace.ownerId, target.id), - eq(workspace.billedAccountUserId, target.id), - isNull(workspace.organizationId) - ) + : and(eq(workspace.billedAccountUserId, target.id), isNull(workspace.organizationId)) const [record] = await executor .select({ id: workspace.id }) diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 36865af921b..14d53c9fa57 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ isOrganizationOnEnterprisePlan: vi.fn(), isOrganizationSettingsSectionAvailable: vi.fn(), isPlatformAdmin: vi.fn(), - resolveWorkspaceGroup: vi.fn(), + resolveVerifiedUserAccessControlContext: vi.fn(), resolveWorkspaceNavigation: vi.fn(), })) @@ -57,7 +57,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mocks.checkWorkspaceAccess, })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ - resolveWorkspaceGroup: mocks.resolveWorkspaceGroup, + resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ isForkingAvailableForWorkspace: mocks.isForkingAvailableForWorkspace, @@ -104,7 +104,7 @@ describe('authorizeWorkspaceSettingsSection', () => { mocks.isOrganizationSettingsSectionAvailable.mockReturnValue(true) mocks.isPlatformAdmin.mockResolvedValue(true) mocks.canOpenOrganizationSettingsSection.mockResolvedValue(true) - mocks.resolveWorkspaceGroup.mockResolvedValue({ config: {} }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ config: {} }) mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }]) }) @@ -129,7 +129,7 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() - expect(mocks.resolveWorkspaceGroup).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() expect(mocks.isPlatformAdmin).not.toHaveBeenCalled() }) @@ -143,31 +143,53 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.isPlatformAdmin).toHaveBeenCalledWith('viewer-1') }) - it('loads owner billing and permission-group policy only for affected organization sections', async () => { + it('loads canonical access-control policy for affected organization sections', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.resolveWorkspaceGroup.mockResolvedValue({ config: { hideSecretsTab: true } }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + config: { hideSecretsTab: true }, + }) mocks.resolveWorkspaceNavigation.mockReturnValue([]) await expect(authorize('secrets')).resolves.toEqual({ allowed: false, disposition: 'redirect-general', }) - expect(mocks.getWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith('workspace-1') - expect(mocks.resolveWorkspaceGroup).toHaveBeenCalledWith( + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( 'viewer-1', - 'organization-1', - 'workspace-1' + 'workspace-1', + 'organization-1' ) expect(mocks.resolveWorkspaceNavigation).toHaveBeenCalledWith( expect.objectContaining({ permissionConfig: { hideSecretsTab: true } }) ) }) - it('does not resolve billing or permission groups for the same section in a personal workspace', async () => { + it('resolves environment access-control policy for the same section in a personal workspace', async () => { await authorize('secrets') expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() - expect(mocks.resolveWorkspaceGroup).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'viewer-1', + 'workspace-1', + null + ) + }) + + it('enforces canonical permission config independently of billing subscription state', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + config: { hideSecretsTab: true }, + }) + mocks.resolveWorkspaceNavigation.mockReturnValue([]) + + await expect(authorize('secrets')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() }) it('resolves the exact entitlement source only for gated workspace sections', async () => { diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index c6be3baa0ca..28227608393 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -16,7 +16,7 @@ import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings import { isPlatformAdmin } from '@/lib/permissions/super-user' import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { resolveWorkspaceGroup } from '@/ee/access-control/utils/permission-check' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' export type WorkspaceSettingsSectionAccess = @@ -37,19 +37,19 @@ async function canOpenWorkspaceSection( }, permission: NonNullable>['permission']> ): Promise { - const needsOwnerBilling = - section === 'credential-groups' || - (workspace.organizationId !== null && workspaceSectionUsesPermissionConfig(section)) + const needsOwnerBilling = section === 'credential-groups' const ownerBilling = needsOwnerBilling ? await getWorkspaceOwnerSubscriptionAccess(input.workspaceId) : null - const [permissionGroup, credentialGroupsAvailable, forksAvailable, customBlocksAvailable] = + const [accessControl, credentialGroupsAvailable, forksAvailable, customBlocksAvailable] = await Promise.all([ - workspace.organizationId && - ownerBilling?.isEnterprise && workspaceSectionUsesPermissionConfig(section) - ? resolveWorkspaceGroup(input.userId, workspace.organizationId, input.workspaceId) + ? resolveVerifiedUserAccessControlContext( + input.userId, + input.workspaceId, + workspace.organizationId + ) : null, section === 'credential-groups' && ownerBilling ? isCredentialGroupsAvailable({ workspaceId: input.workspaceId, ownerBilling }) @@ -64,7 +64,7 @@ async function canOpenWorkspaceSection( const navigation = resolveWorkspaceNavigation({ permission, - permissionConfig: permissionGroup?.config ?? {}, + permissionConfig: accessControl?.config ?? {}, entitlements: { byok: isHosted, credentialGroups: credentialGroupsAvailable, diff --git a/apps/sim/lib/settings/prefetch-current-user-settings.ts b/apps/sim/lib/settings/prefetch-current-user-settings.ts new file mode 100644 index 00000000000..6eed457cc6f --- /dev/null +++ b/apps/sim/lib/settings/prefetch-current-user-settings.ts @@ -0,0 +1,29 @@ +import type { QueryClient } from '@tanstack/react-query' +import { getUserSettingsContract } from '@/lib/api/contracts/user' +import { internalSessionAuth } from '@/lib/api/server/routes/internal-json-route' +import { getCurrentUserSettingsUseCase } from '@/lib/users/application/read-current-user' +import { + GENERAL_SETTINGS_STALE_TIME, + generalSettingsKeys, + mapGeneralSettingsResponse, +} from '@/hooks/queries/general-settings-data' + +type GetPrincipal = () => ReturnType + +export function prefetchCurrentUserSettings( + queryClient: QueryClient, + getPrincipal: GetPrincipal = () => internalSessionAuth.authenticate() +) { + return queryClient.prefetchQuery({ + queryKey: generalSettingsKeys.settings(), + queryFn: async () => { + const settings = await getCurrentUserSettingsUseCase.execute({ + principal: await getPrincipal(), + input: {}, + }) + const response = getUserSettingsContract.response.schema.parse({ data: settings }) + return mapGeneralSettingsResponse(response.data) + }, + staleTime: GENERAL_SETTINGS_STALE_TIME, + }) +} diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index 9a436cc9082..939eddd7092 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -51,28 +51,39 @@ describe('clearUserData', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('localStorage', new EnumerableStorage()) + vi.stubGlobal('sessionStorage', new EnumerableStorage()) }) afterEach(() => { vi.unstubAllGlobals() }) - it('loads the broad store graph only when cleanup runs and preserves allowed preferences', async () => { + it('clears identity data while preserving device preferences', async () => { expect(mockModuleLoaded).not.toHaveBeenCalled() localStorage.setItem('next-favicon', 'favicon') - localStorage.setItem('theme', 'dark') + localStorage.setItem('sim-theme', 'dark') localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, '["user-a"]') localStorage.setItem('private-cache', 'remove-me') + sessionStorage.setItem('mothership-queue', 'private-queued-message') await clearUserData() expect(mockModuleLoaded).toHaveBeenCalledOnce() expect(mockResetAllStores).toHaveBeenCalledOnce() expect(localStorage.getItem('next-favicon')).toBe('favicon') - expect(localStorage.getItem('theme')).toBe('dark') - expect(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY)).toBe('["user-a"]') + expect(localStorage.getItem('sim-theme')).toBe('dark') + expect(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY)).toBeNull() expect(localStorage.getItem('private-cache')).toBeNull() + expect(sessionStorage.getItem('mothership-queue')).toBeNull() + }) + + it('preserves recent impersonations only across an explicit impersonation transition', async () => { + localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, '["user-a"]') + + await clearUserData({ preserveRecentImpersonations: true }) + + expect(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY)).toBe('["user-a"]') }) it('clears persisted user data even when the lazy store reset fails', async () => { diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index de26897ccb9..9a67940993a 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -4,29 +4,42 @@ import { createLogger } from '@sim/logger' const logger = createLogger('Stores') -/** localStorage key for the admin recent-impersonations list; kept through clearUserData. */ +/** localStorage key for the admin recent-impersonations list. */ export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' -/** - * Clear all user data when signing out. - */ -export async function clearUserData(): Promise { +interface ClearUserDataOptions { + preserveRecentImpersonations?: boolean +} + +/** Clears browser and in-memory data at an authenticated identity boundary. */ +export async function clearUserData(options: ClearUserDataOptions = {}): Promise { if (typeof window === 'undefined') return let cleanupFailed = false try { - const keysToKeep = ['next-favicon', 'theme', RECENT_IMPERSONATIONS_STORAGE_KEY] + const keysToKeep = [ + 'next-favicon', + 'sim-theme', + ...(options.preserveRecentImpersonations ? [RECENT_IMPERSONATIONS_STORAGE_KEY] : []), + ] const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) } catch (error) { cleanupFailed = true - logger.error('Error clearing persisted user data:', { error }) + logger.error('Error clearing local user data:', { error }) + } + + try { + sessionStorage.clear() + } catch (error) { + cleanupFailed = true + logger.error('Error clearing tab-scoped user data:', { error }) } try { const { resetAllStores } = await import('@/stores/reset-all-stores') - resetAllStores() + await resetAllStores() } catch (error) { cleanupFailed = true logger.error('Error resetting in-memory user data:', { error }) diff --git a/apps/sim/stores/reset-all-stores.test.ts b/apps/sim/stores/reset-all-stores.test.ts new file mode 100644 index 00000000000..57ef2759e72 --- /dev/null +++ b/apps/sim/stores/reset-all-stores.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockConsolePersist, + mockConsoleReset, + mockClearAllExecutionPointers, + mockGetQueryClient, + mockMothershipQueueReset, + mockRegistrySetState, + mockSubBlockSetState, + mockWaitForConsoleHydration, + mockWorkflowSetState, +} = vi.hoisted(() => ({ + mockClearAllExecutionPointers: vi.fn(), + mockConsolePersist: vi.fn(), + mockConsoleReset: vi.fn(), + mockGetQueryClient: vi.fn(), + mockMothershipQueueReset: vi.fn(), + mockRegistrySetState: vi.fn(), + mockSubBlockSetState: vi.fn(), + mockWaitForConsoleHydration: vi.fn(), + mockWorkflowSetState: vi.fn(), +})) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: mockGetQueryClient, +})) +vi.mock('@/stores/execution', () => ({ + useExecutionStore: { getState: () => ({ reset: vi.fn() }) }, +})) +vi.mock('@/stores/mothership-drafts/store', () => ({ + useMothershipDraftsStore: { setState: vi.fn() }, +})) +vi.mock('@/stores/mothership-queue/store', () => ({ + useMothershipQueueStore: { getState: () => ({ reset: mockMothershipQueueReset }) }, +})) +vi.mock('@/stores/terminal', () => ({ + clearAllExecutionPointers: mockClearAllExecutionPointers, + consolePersistence: { persist: mockConsolePersist, reset: mockConsoleReset }, + useTerminalConsoleStore: { setState: vi.fn() }, + waitForConsoleHydration: mockWaitForConsoleHydration, +})) +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: { setState: mockRegistrySetState }, +})) +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: { setState: mockSubBlockSetState }, +})) +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: { setState: mockWorkflowSetState }, +})) + +import { resetAllStores } from '@/stores/reset-all-stores' + +describe('resetAllStores', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient() + mockGetQueryClient.mockReturnValue(queryClient) + mockConsolePersist.mockResolvedValue(undefined) + mockWaitForConsoleHydration.mockResolvedValue(undefined) + }) + + it('clears every cached server-state entry at the identity boundary', async () => { + queryClient.setQueryData(['generalSettings', 'settings'], { theme: 'dark' }) + queryClient.setQueryData(['apiKeys', 'personal'], [{ id: 'key-a' }]) + queryClient.setQueryData(['workflowMcpServers', 'detail', 'workspace-a', 'server-a'], { + id: 'server-a', + }) + + await resetAllStores() + + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + }) + + it('removes transient workflow state and replaces persisted console data', async () => { + await resetAllStores() + + expect(mockRegistrySetState).toHaveBeenCalledWith( + expect.objectContaining({ clipboard: null, pendingSelection: null }) + ) + expect(mockWorkflowSetState).toHaveBeenCalledWith( + expect.objectContaining({ currentWorkflowId: null, blocks: {}, edges: [] }) + ) + expect(mockSubBlockSetState).toHaveBeenCalledWith({ workflowValues: {} }) + expect(mockConsoleReset).toHaveBeenCalledOnce() + expect(mockClearAllExecutionPointers).toHaveBeenCalledOnce() + expect(mockMothershipQueueReset).toHaveBeenCalledOnce() + expect(mockConsolePersist).toHaveBeenCalledWith({ merge: false }) + }) + + it('waits for an in-flight console hydration before clearing identity state', async () => { + let finishHydration: (() => void) | undefined + mockWaitForConsoleHydration.mockReturnValue( + new Promise((resolve) => { + finishHydration = resolve + }) + ) + + const resetPromise = resetAllStores() + await Promise.resolve() + expect(mockRegistrySetState).not.toHaveBeenCalled() + + finishHydration?.() + await resetPromise + expect(mockRegistrySetState).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/stores/reset-all-stores.ts b/apps/sim/stores/reset-all-stores.ts index b5694850731..956d90d5f33 100644 --- a/apps/sim/stores/reset-all-stores.ts +++ b/apps/sim/stores/reset-all-stores.ts @@ -1,15 +1,22 @@ 'use client' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { environmentKeys } from '@/hooks/queries/environment' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' -import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' +import { useMothershipQueueStore } from '@/stores/mothership-queue/store' +import { + clearAllExecutionPointers, + consolePersistence, + useTerminalConsoleStore, + waitForConsoleHydration, +} from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -export function resetAllStores(): void { +export async function resetAllStores(): Promise { + await waitForConsoleHydration() + useWorkflowRegistry.setState({ activeWorkflowId: null, error: null, @@ -20,10 +27,19 @@ export function resetAllStores(): void { requestId: null, error: null, }, + clipboard: null, + pendingSelection: null, + }) + useWorkflowStore.setState({ + currentWorkflowId: null, + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + lastSaved: Date.now(), }) - useWorkflowStore.getState().clear() - useSubBlockStore.getState().clear() - getQueryClient().removeQueries({ queryKey: environmentKeys.all }) + useSubBlockStore.setState({ workflowValues: {} }) + getQueryClient().clear() useExecutionStore.getState().reset() useTerminalConsoleStore.setState({ workflowEntries: {}, @@ -31,6 +47,9 @@ export function resetAllStores(): void { entryLocationById: {}, isOpen: false, }) - consolePersistence.persist() + consolePersistence.reset() + clearAllExecutionPointers() useMothershipDraftsStore.setState({ drafts: {} }) + useMothershipQueueStore.getState().reset() + await consolePersistence.persist({ merge: false }) } diff --git a/apps/sim/stores/terminal/console/index.ts b/apps/sim/stores/terminal/console/index.ts index e8959cd431c..448342f70cd 100644 --- a/apps/sim/stores/terminal/console/index.ts +++ b/apps/sim/stores/terminal/console/index.ts @@ -1,9 +1,15 @@ export { + clearAllExecutionPointers, clearExecutionPointer, consolePersistence, loadExecutionPointer, saveExecutionPointer, } from './storage' -export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries } from './store' +export { + useConsoleEntry, + useTerminalConsoleStore, + useWorkflowConsoleEntries, + waitForConsoleHydration, +} from './store' export type { ConsoleEntry, ConsoleUpdate } from './types' export { safeConsoleStringify } from './utils' diff --git a/apps/sim/stores/terminal/console/storage.test.ts b/apps/sim/stores/terminal/console/storage.test.ts index b047430366c..8b6bfc473c9 100644 --- a/apps/sim/stores/terminal/console/storage.test.ts +++ b/apps/sim/stores/terminal/console/storage.test.ts @@ -1,10 +1,12 @@ /** - * @vitest-environment node + * @vitest-environment jsdom */ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { CONSOLE_STORAGE_VERSION, + clearAllExecutionPointers, migratePersistedConsoleData, + saveExecutionPointer, } from '@/stores/terminal/console/storage' const legacyEntry = { @@ -85,3 +87,29 @@ describe('terminal console storage migration', () => { expect(result?.data.workflowEntries['workflow-1'][0]).toEqual(projectedEntry) }) }) + +describe('terminal execution pointers', () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + it('clears every terminal pointer without removing unrelated tab state', async () => { + window.sessionStorage.setItem('unrelated', 'keep') + await saveExecutionPointer({ + workflowId: 'workflow-1', + executionId: 'execution-1', + lastEventId: 1, + }) + await saveExecutionPointer({ + workflowId: 'workflow-2', + executionId: 'execution-2', + lastEventId: 2, + }) + + clearAllExecutionPointers() + + expect(window.sessionStorage.getItem('terminal-active-execution:workflow-1')).toBeNull() + expect(window.sessionStorage.getItem('terminal-active-execution:workflow-2')).toBeNull() + expect(window.sessionStorage.getItem('unrelated')).toBe('keep') + }) +}) diff --git a/apps/sim/stores/terminal/console/storage.ts b/apps/sim/stores/terminal/console/storage.ts index df5ab1b46fc..9a8f49ee705 100644 --- a/apps/sim/stores/terminal/console/storage.ts +++ b/apps/sim/stores/terminal/console/storage.ts @@ -301,6 +301,13 @@ class ConsolePersistenceManager { return writeToIndexedDB(this.dataProvider(), options) } + /** Stops persistence work owned by the previous authenticated session. */ + reset(): void { + this.activeExecutions = 0 + this.needsInitialPersist = false + this.stopSafetyTimer() + } + private startSafetyTimer(): void { this.stopSafetyTimer() this.safetyTimer = setInterval(() => { @@ -367,3 +374,18 @@ export function clearExecutionPointer(workflowId: string): Promise { } return Promise.resolve() } + +/** Removes every reconnect pointer owned by the current browser tab. */ +export function clearAllExecutionPointers(): void { + if (typeof window === 'undefined') return + try { + const pointerKeys: string[] = [] + for (let index = 0; index < window.sessionStorage.length; index++) { + const key = window.sessionStorage.key(index) + if (key?.startsWith(EXEC_POINTER_PREFIX)) pointerKeys.push(key) + } + for (const key of pointerKeys) window.sessionStorage.removeItem(key) + } catch { + return + } +} diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 460ba524e01..4370e63731f 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -15,7 +15,7 @@ import { sendMothershipMessage } from '@/lib/mothership/events' import { saveBlob } from '@/lib/uploads/client/download' import { getQueryClient } from '@/app/_shell/providers/query-provider' import type { NormalizedBlockOutput } from '@/executor/types' -import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings' +import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings-data' import { useExecutionStore } from '@/stores/execution' import { CONSOLE_STORAGE_VERSION, @@ -833,6 +833,13 @@ async function hydrateConsoleStore(): Promise { } } +let consoleHydrationPromise = Promise.resolve() + +/** Resolves after any persisted console state discovered at module load has been applied. */ +export function waitForConsoleHydration(): Promise { + return consoleHydrationPromise +} + if (typeof window !== 'undefined') { consolePersistence.bind(() => { const state = useTerminalConsoleStore.getState() @@ -843,7 +850,7 @@ if (typeof window !== 'undefined') { } }) - hydrateConsoleStore() + consoleHydrationPromise = hydrateConsoleStore() window.addEventListener('pagehide', () => consolePersistence.persist()) } diff --git a/apps/sim/stores/terminal/index.ts b/apps/sim/stores/terminal/index.ts index cdbd140d357..5023db53e7e 100644 --- a/apps/sim/stores/terminal/index.ts +++ b/apps/sim/stores/terminal/index.ts @@ -1,5 +1,6 @@ export type { ConsoleEntry, ConsoleUpdate } from './console' export { + clearAllExecutionPointers, clearExecutionPointer, consolePersistence, loadExecutionPointer, @@ -8,5 +9,6 @@ export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries, + waitForConsoleHydration, } from './console' export { useTerminalStore } from './store' From 2bee121c97572973fb4270449d202b51b2a6fe80 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:43:00 -0700 Subject: [PATCH 03/11] fix(settings): tighten navigation and auth coverage --- apps/sim/app/api/users/me/settings/route.test.ts | 1 + .../settings/settings-intent-link.test.tsx | 13 +++++++++++++ .../components/settings/settings-intent-link.tsx | 4 +++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts index 0ef5a21ab9a..03af01cd235 100644 --- a/apps/sim/app/api/users/me/settings/route.test.ts +++ b/apps/sim/app/api/users/me/settings/route.test.ts @@ -60,5 +60,6 @@ describe('GET /api/users/me/settings', () => { await expect(response.json()).resolves.toMatchObject({ data: { theme: 'system', autoConnect: true }, }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/components/settings/settings-intent-link.test.tsx b/apps/sim/components/settings/settings-intent-link.test.tsx index 6cb0c932089..25ca0b25f50 100644 --- a/apps/sim/components/settings/settings-intent-link.test.tsx +++ b/apps/sim/components/settings/settings-intent-link.test.tsx @@ -168,4 +168,17 @@ describe('SettingsIntentLink', () => { expect(link).toHaveAttribute('data-prefetch', 'false') expect(onIntent).not.toHaveBeenCalled() }) + + it('treats a descendant page as the current settings section', () => { + mockPathname.mockReturnValue('/settings/billing/credit-usage') + const { link, onIntent } = renderLink({ href: '/settings/billing' }) + + act(() => { + link.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) + vi.runAllTimers() + }) + + expect(link).toHaveAttribute('data-prefetch', 'false') + expect(onIntent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/components/settings/settings-intent-link.tsx b/apps/sim/components/settings/settings-intent-link.tsx index 28637d3e4ca..896009de6c3 100644 --- a/apps/sim/components/settings/settings-intent-link.tsx +++ b/apps/sim/components/settings/settings-intent-link.tsx @@ -37,7 +37,9 @@ function isUnmodifiedPrimaryPointer(event: ReactPointerEvent) export function SettingsIntentLink(props: SettingsIntentLinkProps) { const pathname = usePathname() const destinationPathname = hrefPathname(props.href) - const isCurrentRoute = destinationPathname !== null && destinationPathname === pathname + const isCurrentRoute = + destinationPathname !== null && + (destinationPathname === pathname || pathname.startsWith(`${destinationPathname}/`)) const routeRole = isCurrentRoute ? 'current' : 'destination' return ( From 632f7092eeff1782d3ba2cc0c8609f2f862c7825 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:54:21 -0700 Subject: [PATCH 04/11] fix(settings): handle unavailable pathname --- .../settings/settings-intent-link.test.tsx | 13 ++++++++++++- .../components/settings/settings-intent-link.tsx | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/components/settings/settings-intent-link.test.tsx b/apps/sim/components/settings/settings-intent-link.test.tsx index 25ca0b25f50..08f612f1eec 100644 --- a/apps/sim/components/settings/settings-intent-link.test.tsx +++ b/apps/sim/components/settings/settings-intent-link.test.tsx @@ -5,7 +5,9 @@ import { act, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPathname } = vi.hoisted(() => ({ mockPathname: vi.fn(() => '/settings/billing') })) +const { mockPathname } = vi.hoisted(() => ({ + mockPathname: vi.fn<() => string | null>(() => '/settings/billing'), +})) vi.mock('next/navigation', () => ({ usePathname: mockPathname })) vi.mock('next/link', () => ({ @@ -181,4 +183,13 @@ describe('SettingsIntentLink', () => { expect(link).toHaveAttribute('data-prefetch', 'false') expect(onIntent).not.toHaveBeenCalled() }) + + it('renders before the pathname is available', () => { + mockPathname.mockReturnValue(null) + + const { link } = renderLink() + + expect(link).toHaveAttribute('href', '/settings/general') + expect(link).toHaveAttribute('data-prefetch', 'false') + }) }) diff --git a/apps/sim/components/settings/settings-intent-link.tsx b/apps/sim/components/settings/settings-intent-link.tsx index 896009de6c3..e5568163a62 100644 --- a/apps/sim/components/settings/settings-intent-link.tsx +++ b/apps/sim/components/settings/settings-intent-link.tsx @@ -39,6 +39,7 @@ export function SettingsIntentLink(props: SettingsIntentLinkProps) { const destinationPathname = hrefPathname(props.href) const isCurrentRoute = destinationPathname !== null && + pathname !== null && (destinationPathname === pathname || pathname.startsWith(`${destinationPathname}/`)) const routeRole = isCurrentRoute ? 'current' : 'destination' From 357606e812b306f8182949940bd86531ecd025cb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:02:02 -0700 Subject: [PATCH 05/11] docs(settings): document data warming pattern --- .claude/rules/sim-settings-pages.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index b65deabafe3..2c534cd223a 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -104,6 +104,10 @@ Adding a new settings page: 2. Render the component inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`. 3. Build the component body inside `` — no shell, no title block. +4. When the initial body depends on server data, export shared React Query options for both the + mounted consumer and the settings intent warmer. Warm only authorized destinations, preserve + the current section during the transition, and follow the failure-recovery rules in + `sim-react-performance.md`; never render temporary default data that will be replaced after load. ## Text-scale tokens (no literal pixel sizes) From 60172c90e55079cba4a9260033f8da1349e6400e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:26:42 -0700 Subject: [PATCH 06/11] fix(settings): isolate identity lifecycle state --- .../agent-group/tool-permission-card.tsx | 2 +- .../app/workspace/[workspaceId]/prefetch.ts | 10 +- .../settings/[section]/prefetch.test.ts | 2 +- .../settings/components/general/general.tsx | 11 +- .../hooks/use-workflow-execution.test.tsx | 51 +++++- .../hooks/use-workflow-execution.ts | 149 ++++++++++++------ .../utils/workflow-execution-utils.ts | 2 +- .../prefetch-standalone-general.test.ts | 3 +- .../settings/prefetch-standalone-general.ts | 2 +- ...-settings-data.ts => current-user-data.ts} | 24 ++- .../hooks/queries/general-settings.test.tsx | 2 +- apps/sim/hooks/queries/general-settings.ts | 2 +- apps/sim/hooks/queries/user-profile-data.ts | 19 --- apps/sim/hooks/queries/user-profile.ts | 2 +- .../get-organization-billing-summary.ts | 4 +- apps/sim/lib/billing/core/payer-context.ts | 4 +- .../tools/client/run-tool-execution.test.ts | 2 +- .../tools/client/run-tool-execution.ts | 6 +- .../prefetch-current-user-settings.ts | 2 +- apps/sim/stores/chat/store.test.ts | 32 ++++ apps/sim/stores/chat/store.ts | 35 +++- apps/sim/stores/chat/types.ts | 1 + apps/sim/stores/index.test.ts | 6 +- apps/sim/stores/index.ts | 13 +- apps/sim/stores/operation-queue/store.test.ts | 79 ++++++++++ apps/sim/stores/operation-queue/store.ts | 29 +++- apps/sim/stores/operation-queue/types.ts | 1 + apps/sim/stores/reset-all-stores.test.ts | 14 ++ apps/sim/stores/reset-all-stores.ts | 4 + apps/sim/stores/terminal/console/index.ts | 1 + .../stores/terminal/console/storage.test.ts | 43 ++++- apps/sim/stores/terminal/console/storage.ts | 24 ++- apps/sim/stores/terminal/console/store.ts | 2 +- apps/sim/stores/terminal/index.ts | 2 +- .../stores/user-data-reset-registry.test.ts | 34 ++++ apps/sim/stores/user-data-reset-registry.ts | 21 +++ 36 files changed, 518 insertions(+), 122 deletions(-) rename apps/sim/hooks/queries/{general-settings-data.ts => current-user-data.ts} (73%) delete mode 100644 apps/sim/hooks/queries/user-profile-data.ts create mode 100644 apps/sim/stores/user-data-reset-registry.test.ts create mode 100644 apps/sim/stores/user-data-reset-registry.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx index b40ec6702c2..e3b483048bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card.tsx @@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' -import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' +import { generalSettingsKeys } from '@/hooks/queries/current-user-data' import { useToolPermissionStore } from '@/stores/tool-permission/store' const logger = createLogger('ToolPermissionCard') diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 8bc991c7815..f1a3d81115a 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -10,16 +10,16 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' +import { + mapUserProfileResponse, + USER_PROFILE_STALE_TIME, + userProfileKeys, +} from '@/hooks/queries/current-user-data' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, mothershipChatKeys, } from '@/hooks/queries/mothership-chats' -import { - mapUserProfileResponse, - USER_PROFILE_STALE_TIME, - userProfileKeys, -} from '@/hooks/queries/user-profile-data' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index e51c277cee5..e8cee8a1a54 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/api/server/routes', () => ({ })) import { SECTION_PREFETCHERS } from '@/app/workspace/[workspaceId]/settings/[section]/prefetch' -import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' +import { generalSettingsKeys } from '@/hooks/queries/current-user-data' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' describe('general settings prefetch', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index faa87c11096..de90ada37ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -188,13 +188,18 @@ export function General() { } const handleSignOut = async () => { + const logoutUrl = '/login?fromLogout=true' + let canNavigateInApp = false + try { - await Promise.all([signOut(), clearUserData()]) - router.push('/login?fromLogout=true') + const [, inMemoryResetSucceeded] = await Promise.all([signOut(), clearUserData()]) + canNavigateInApp = inMemoryResetSucceeded } catch (error) { logger.error('Error signing out:', { error }) - router.push('/login?fromLogout=true') } + + if (canNavigateInApp) router.push(logoutUrl) + else window.location.assign(logoutUrl) } const handleResetPasswordConfirm = async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 15c7171064c..6e557dd87f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -14,6 +14,8 @@ const { mockFetch, mockHandleExecutionCancelledConsole, mockHandleExecutionErrorConsole, + mockPersistenceExecutionEnded, + mockPersistenceExecutionStarted, mockRequestJson, mockResolveStartCandidates, mockSelectBestTrigger, @@ -89,6 +91,8 @@ const { mockFetch: vi.fn(), mockHandleExecutionCancelledConsole: vi.fn(), mockHandleExecutionErrorConsole: vi.fn(), + mockPersistenceExecutionEnded: vi.fn(), + mockPersistenceExecutionStarted: vi.fn(() => ({})), mockRequestJson: vi.fn(), mockResolveStartCandidates: vi.fn(), mockSelectBestTrigger: vi.fn(), @@ -237,8 +241,8 @@ vi.mock('@/stores/execution', () => ({ vi.mock('@/stores/terminal', () => ({ clearExecutionPointer: vi.fn(), consolePersistence: { - executionStarted: vi.fn(), - executionEnded: vi.fn(), + executionStarted: mockPersistenceExecutionStarted, + executionEnded: mockPersistenceExecutionEnded, persist: vi.fn(), }, loadExecutionPointer: vi.fn(), @@ -402,6 +406,9 @@ describe('useWorkflowExecution cancellation', () => { describe('useWorkflowExecution attachment uploads', () => { beforeEach(() => { vi.clearAllMocks() + executionStoreState.getWorkflowExecution.mockReturnValue( + executionStoreState.workflowExecutions.get('workflow-1')! + ) executionStoreState.getCurrentExecutionId.mockReturnValue(null) mockResolveStartCandidates.mockReturnValue([]) mockSelectBestTrigger.mockReturnValue([]) @@ -538,6 +545,46 @@ describe('useWorkflowExecution attachment uploads', () => { unmount() }) + it('does not let an overlapping run without lifecycle ownership end the active run', async () => { + const persistenceExecution = {} + let resolveActiveRun: (() => void) | undefined + mockPersistenceExecutionStarted.mockReturnValueOnce(persistenceExecution) + mockExecute.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveActiveRun = resolve + }) + ) + const { result, unmount } = renderWorkflowExecutionHook() + + let activeRun: unknown + await act(async () => { + activeRun = await result().handleRunWorkflow({ input: 'active run' }) + }) + + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + isExecuting: true, + }) + + await act(async () => { + await result().handleRunWorkflow() + }) + + expect(mockPersistenceExecutionStarted).toHaveBeenCalledTimes(1) + expect(mockPersistenceExecutionEnded).not.toHaveBeenCalled() + + await act(async () => { + resolveActiveRun?.() + await drainStream(activeRun) + }) + + expect(mockPersistenceExecutionEnded).toHaveBeenCalledOnce() + expect(mockPersistenceExecutionEnded).toHaveBeenCalledWith(persistenceExecution) + + unmount() + }) + it('uses only projected live thinking without changing normal settle behavior', async () => { mockExecute.mockImplementationOnce(async (options) => { options.onExecutionId?.('execution-1') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index f19031d6447..8e562c4c610 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -73,6 +73,7 @@ import { import { WorkflowValidationError } from '@/serializer' import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution' import { + type ConsolePersistenceExecution, clearExecutionPointer, consolePersistence, loadExecutionPointer, @@ -431,23 +432,45 @@ export function useWorkflowExecution() { const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId) const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId) const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting) + const persistenceExecutionsRef = useRef(new Map()) + + const endPersistenceExecution = useCallback((workflowId: string) => { + const persistenceExecution = persistenceExecutionsRef.current.get(workflowId) + if (!persistenceExecution) return + persistenceExecutionsRef.current.delete(workflowId) + consolePersistence.executionEnded(persistenceExecution) + }, []) const setIsExecuting = useCallback( - (workflowId: string, executing: boolean) => { + (workflowId: string, executing: boolean): ConsolePersistenceExecution | undefined => { const wasExecuting = useExecutionStore.getState().getWorkflowExecution(workflowId).isExecuting if (executing) { if (!wasExecuting) { - consolePersistence.executionStarted() + const startedExecution = consolePersistence.executionStarted() + persistenceExecutionsRef.current.set(workflowId, startedExecution) + rawSetIsExecuting(workflowId, true) + return startedExecution } } else { if (wasExecuting) { - consolePersistence.executionEnded() + endPersistenceExecution(workflowId) } clearExecutionPointer(workflowId) } rawSetIsExecuting(workflowId, executing) + return undefined + }, + [endPersistenceExecution, rawSetIsExecuting] + ) + const finishOwnedExecution = useCallback( + (workflowId: string, persistenceExecution: ConsolePersistenceExecution | undefined) => { + if (!persistenceExecution) return + if (persistenceExecutionsRef.current.get(workflowId) !== persistenceExecution) return + endPersistenceExecution(workflowId) + clearExecutionPointer(workflowId) + rawSetIsExecuting(workflowId, false) }, - [rawSetIsExecuting] + [endPersistenceExecution, rawSetIsExecuting] ) const setIsDebugging = useExecutionStore((s) => s.setIsDebugging) const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks) @@ -704,7 +727,7 @@ export function useWorkflowExecution() { // Reset execution result and set execution state setExecutionResult(null) - setIsExecuting(activeWorkflowId, true) + const persistenceExecution = setIsExecuting(activeWorkflowId, true) // Set debug mode only if explicitly requested if (enableDebug) { @@ -735,7 +758,7 @@ export function useWorkflowExecution() { const message = getErrorMessage(error, 'Unexpected error uploading files') logger.error('Error uploading workflow attachments', { message }) currentChatExecutionIdRef.current = null - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setIsDebugging(activeWorkflowId, false) setActiveBlocks(activeWorkflowId, new Set()) throw new WorkflowAttachmentUploadError(message) @@ -876,7 +899,8 @@ export function useWorkflowExecution() { onBlockComplete, 'chat', undefined, - onStreamReset + onStreamReset, + persistenceExecution ) // Check if execution was cancelled @@ -959,7 +983,7 @@ export function useWorkflowExecution() { !preserveChatExecutionForRecovery && currentChatExecutionIdRef.current === executionId ) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setIsDebugging(activeWorkflowId, false) setActiveBlocks(activeWorkflowId, new Set()) } @@ -979,7 +1003,10 @@ export function useWorkflowExecution() { undefined, manualExecutionId, undefined, - 'manual' + 'manual', + undefined, + undefined, + persistenceExecution ) if (result && 'metadata' in result && result.metadata?.isDebugSession) { setDebugContext(activeWorkflowId, result.metadata.context || null) @@ -990,10 +1017,13 @@ export function useWorkflowExecution() { return result } catch (error: any) { if (isRecoverableStreamRecoveryError(error)) { - handleExecutionError(error, { executionId: manualExecutionId }) + handleExecutionError(error, { executionId: manualExecutionId, persistenceExecution }) throw error } - const errorResult = handleExecutionError(error, { executionId: manualExecutionId }) + const errorResult = handleExecutionError(error, { + executionId: manualExecutionId, + persistenceExecution, + }) return errorResult } }, @@ -1003,6 +1033,7 @@ export function useWorkflowExecution() { toggleConsole, getVariablesByWorkflowId, setIsExecuting, + finishOwnedExecution, setIsDebugging, setDebugContext, setExecutor, @@ -1019,7 +1050,8 @@ export function useWorkflowExecution() { onBlockComplete?: (blockId: string, output: any) => Promise, overrideTriggerType?: 'chat' | 'manual' | 'api', stopAfterBlockId?: string, - onStreamReset?: (blockId: string) => void + onStreamReset?: (blockId: string) => void, + persistenceExecution?: ConsolePersistenceExecution ): Promise => { // Use diff workflow for execution when available, regardless of canvas view state const executionWorkflowState = null as { @@ -1139,7 +1171,7 @@ export function useWorkflowExecution() { logger.error('No trigger blocks found for manual run', { allBlockTypes: Object.values(filteredStates).map((b) => b.type), }) - if (activeWorkflowId) setIsExecuting(activeWorkflowId, false) + if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) throw error } @@ -1155,7 +1187,7 @@ export function useWorkflowExecution() { 'Workflow Validation' ) logger.error('Multiple API triggers found') - if (activeWorkflowId) setIsExecuting(activeWorkflowId, false) + if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) throw error } @@ -1180,7 +1212,7 @@ export function useWorkflowExecution() { 'Workflow Validation' ) logger.error('Trigger has no outgoing connections', { triggerName, startBlockId }) - if (activeWorkflowId) setIsExecuting(activeWorkflowId, false) + if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) throw error } } @@ -1210,7 +1242,7 @@ export function useWorkflowExecution() { 'Workflow Validation' ) logger.error('No startBlockId found after trigger search') - if (activeWorkflowId) setIsExecuting(activeWorkflowId, false) + if (activeWorkflowId) finishOwnedExecution(activeWorkflowId, persistenceExecution) throw error } @@ -1450,7 +1482,7 @@ export function useWorkflowExecution() { // client-side stream wrapper still has buffered data to deliver. // The chat's finally block handles cleanup after the stream is fully consumed. if (!isExecutingFromChat) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setActiveBlocks(activeWorkflowId, new Set()) } scheduleUsageRefresh(queryClient) @@ -1498,7 +1530,7 @@ export function useWorkflowExecution() { if (activeWorkflowId && !workflowExecState?.isDebugging) { setExecutionResult(executionResult) if (!isExecutingFromChat) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setActiveBlocks(activeWorkflowId, new Set()) } } @@ -1543,7 +1575,7 @@ export function useWorkflowExecution() { }) if (activeWorkflowId && !isExecutingFromChat) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setIsDebugging(activeWorkflowId, false) setActiveBlocks(activeWorkflowId, new Set()) } @@ -1573,7 +1605,7 @@ export function useWorkflowExecution() { }) if (activeWorkflowId && !isExecutingFromChat) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, persistenceExecution) setIsDebugging(activeWorkflowId, false) setActiveBlocks(activeWorkflowId, new Set()) } @@ -1584,7 +1616,10 @@ export function useWorkflowExecution() { return executionResult } catch (error: any) { if (isRecoverableStreamRecoveryError(error)) { - handleExecutionError(error, { executionId: executionIdRef.current }) + handleExecutionError(error, { + executionId: executionIdRef.current, + persistenceExecution, + }) throw error } if (error.name === 'AbortError' || error.message?.includes('aborted')) { @@ -1600,7 +1635,13 @@ export function useWorkflowExecution() { throw new Error('Server-side execution is required') } - const handleExecutionError = (error: unknown, options?: { executionId?: string }) => { + const handleExecutionError = ( + error: unknown, + options?: { + executionId?: string + persistenceExecution?: ConsolePersistenceExecution + } + ) => { const normalizedMessage = normalizeErrorMessage(error) let errorResult: ExecutionResult @@ -1679,7 +1720,7 @@ export function useWorkflowExecution() { setExecutionResult(errorResult) if (activeWorkflowId) { - setIsExecuting(activeWorkflowId, false) + finishOwnedExecution(activeWorkflowId, options?.persistenceExecution) setIsDebugging(activeWorkflowId, false) setActiveBlocks(activeWorkflowId, new Set()) } @@ -2001,7 +2042,7 @@ export function useWorkflowExecution() { } } - setIsExecuting(workflowId, true) + const persistenceExecution = setIsExecuting(workflowId, true) const runOwnerId = generateId() runFromBlockOwnerRef.current = runOwnerId const executionIdRef = { current: '' } @@ -2020,7 +2061,7 @@ export function useWorkflowExecution() { const clearRunFromBlockExecutionState = () => { if (!isCurrentRunFromBlockExecution()) return false setCurrentExecutionId(workflowId, null) - setIsExecuting(workflowId, false) + finishOwnedExecution(workflowId, persistenceExecution) setActiveBlocks(workflowId, new Set()) return true } @@ -2227,7 +2268,7 @@ export function useWorkflowExecution() { const currentId = getCurrentExecutionId(workflowId) if (executionIdRef.current && currentId === executionIdRef.current) { setCurrentExecutionId(workflowId, null) - setIsExecuting(workflowId, false) + finishOwnedExecution(workflowId, persistenceExecution) setActiveBlocks(workflowId, new Set()) } else if ( !executionIdRef.current && @@ -2236,7 +2277,7 @@ export function useWorkflowExecution() { ) { const workflowExecState = useExecutionStore.getState().getWorkflowExecution(workflowId) if (workflowExecState.isExecuting) { - setIsExecuting(workflowId, false) + finishOwnedExecution(workflowId, persistenceExecution) setActiveBlocks(workflowId, new Set()) } } @@ -2253,6 +2294,7 @@ export function useWorkflowExecution() { getCurrentExecutionId, setCurrentExecutionId, setIsExecuting, + finishOwnedExecution, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, @@ -2280,13 +2322,22 @@ export function useWorkflowExecution() { logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId }) setExecutionResult(null) - setIsExecuting(workflowId, true) + const persistenceExecution = setIsExecuting(workflowId, true) const executionId = generateId() try { - await executeWorkflow(undefined, undefined, executionId, undefined, 'manual', blockId) + await executeWorkflow( + undefined, + undefined, + executionId, + undefined, + 'manual', + blockId, + undefined, + persistenceExecution + ) } catch (error) { - const errorResult = handleExecutionError(error, { executionId }) + const errorResult = handleExecutionError(error, { executionId, persistenceExecution }) return errorResult } }, @@ -2376,8 +2427,18 @@ export function useWorkflowExecution() { const MAX_DELAY_MS = 15000 let activated = false - let activationStartedPersistence = false + let activationOwnsPersistence = false + let reconnectPersistenceExecution: ConsolePersistenceExecution | undefined const isReconnectStillCurrent = canReconnectClaimWorkflow + const finishReconnectExecution = () => { + if (reconnectPersistenceExecution) { + finishOwnedExecution(reconnectWorkflowId, reconnectPersistenceExecution) + } else { + rawSetIsExecuting(reconnectWorkflowId, false) + } + reconnectPersistenceExecution = undefined + activationOwnsPersistence = false + } const stopStaleReconnect = () => { reconnectionComplete = true if (ownedReconnectExecutionId) { @@ -2390,11 +2451,8 @@ export function useWorkflowExecution() { const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId) if (currentId !== capturedExecutionId) return setCurrentExecutionId(reconnectWorkflowId, null) - if (activationStartedPersistence) { - consolePersistence.executionEnded() - activationStartedPersistence = false - } - rawSetIsExecuting(reconnectWorkflowId, false) + if (activationOwnsPersistence) finishReconnectExecution() + else rawSetIsExecuting(reconnectWorkflowId, false) setActiveBlocks(reconnectWorkflowId, new Set()) } const releaseReconnectStateWithoutTerminal = () => { @@ -2410,9 +2468,8 @@ export function useWorkflowExecution() { blockLogs: [], }) setCurrentExecutionId(reconnectWorkflowId, null) - setIsExecuting(reconnectWorkflowId, false) + finishReconnectExecution() setActiveBlocks(reconnectWorkflowId, new Set()) - activationStartedPersistence = false } const scheduleRetryableReconnect = () => { releaseReconnectOwnership() @@ -2430,11 +2487,11 @@ export function useWorkflowExecution() { } if (!activated) { activated = true - activationStartedPersistence = !useExecutionStore - .getState() - .getWorkflowExecution(reconnectWorkflowId).isExecuting setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId) - setIsExecuting(reconnectWorkflowId, true) + reconnectPersistenceExecution = + setIsExecuting(reconnectWorkflowId, true) ?? + persistenceExecutionsRef.current.get(reconnectWorkflowId) + activationOwnsPersistence = Boolean(reconnectPersistenceExecution) if (fromEventId === 0) { clearExecutionEntries(capturedExecutionId) } @@ -2499,7 +2556,7 @@ export function useWorkflowExecution() { ) finishRunningEntries(reconnectWorkflowId, capturedExecutionId) setCurrentExecutionId(reconnectWorkflowId, null) - setIsExecuting(reconnectWorkflowId, false) + finishReconnectExecution() setActiveBlocks(reconnectWorkflowId, new Set()) }, onExecutionPaused: (data) => { @@ -2518,7 +2575,7 @@ export function useWorkflowExecution() { ) finishRunningEntries(reconnectWorkflowId, capturedExecutionId) setCurrentExecutionId(reconnectWorkflowId, null) - setIsExecuting(reconnectWorkflowId, false) + finishReconnectExecution() setActiveBlocks(reconnectWorkflowId, new Set()) setExecutionResult({ success: true, @@ -2548,7 +2605,7 @@ export function useWorkflowExecution() { finalBlockLogs: data.finalBlockLogs, }) setCurrentExecutionId(reconnectWorkflowId, null) - setIsExecuting(reconnectWorkflowId, false) + finishReconnectExecution() setActiveBlocks(reconnectWorkflowId, new Set()) }, onExecutionCancelled: (data) => { @@ -2566,7 +2623,7 @@ export function useWorkflowExecution() { finalBlockLogs: data?.finalBlockLogs, }) setCurrentExecutionId(reconnectWorkflowId, null) - setIsExecuting(reconnectWorkflowId, false) + finishReconnectExecution() setActiveBlocks(reconnectWorkflowId, new Set()) }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 4dc59961db6..70bb9fbde9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -1000,7 +1000,7 @@ export async function executeWorkflowWithFullLogging( if (!isCurrentExecution()) return setCurrentExecutionId(wfId, null) clearExecutionPointer(wfId) - consolePersistence.executionEnded() + consolePersistence.persist() useExecutionStore.getState().setIsExecuting(wfId, false) setActiveBlocks(wfId, new Set()) } diff --git a/apps/sim/components/settings/prefetch-standalone-general.test.ts b/apps/sim/components/settings/prefetch-standalone-general.test.ts index 9cf9d4e31ff..f2fbc198ac4 100644 --- a/apps/sim/components/settings/prefetch-standalone-general.test.ts +++ b/apps/sim/components/settings/prefetch-standalone-general.test.ts @@ -23,8 +23,7 @@ vi.mock('@/lib/users/application/read-current-user', () => ({ })) import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general' -import { generalSettingsKeys } from '@/hooks/queries/general-settings-data' -import { userProfileKeys } from '@/hooks/queries/user-profile-data' +import { generalSettingsKeys, userProfileKeys } from '@/hooks/queries/current-user-data' describe('prefetchStandaloneGeneral', () => { beforeEach(() => { diff --git a/apps/sim/components/settings/prefetch-standalone-general.ts b/apps/sim/components/settings/prefetch-standalone-general.ts index 072ca6a63f7..e7652c2f7c8 100644 --- a/apps/sim/components/settings/prefetch-standalone-general.ts +++ b/apps/sim/components/settings/prefetch-standalone-general.ts @@ -7,7 +7,7 @@ import { mapUserProfileResponse, USER_PROFILE_STALE_TIME, userProfileKeys, -} from '@/hooks/queries/user-profile-data' +} from '@/hooks/queries/current-user-data' /** * Hydrates the authenticated viewer's standalone General page with the exact diff --git a/apps/sim/hooks/queries/general-settings-data.ts b/apps/sim/hooks/queries/current-user-data.ts similarity index 73% rename from apps/sim/hooks/queries/general-settings-data.ts rename to apps/sim/hooks/queries/current-user-data.ts index e9e5192be60..1e28014d822 100644 --- a/apps/sim/hooks/queries/general-settings-data.ts +++ b/apps/sim/hooks/queries/current-user-data.ts @@ -1,4 +1,26 @@ -import type { MothershipEnvironment, UserSettingsApi } from '@/lib/api/contracts/user' +import type { + MothershipEnvironment, + UserProfileApiUser, + UserSettingsApi, +} from '@/lib/api/contracts/user' + +export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000 + +export const userProfileKeys = { + all: ['userProfile'] as const, + profile: () => [...userProfileKeys.all, 'profile'] as const, +} + +export type UserProfile = Omit + +export function mapUserProfileResponse(user: UserProfileApiUser): UserProfile { + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + } +} export const generalSettingsKeys = { all: ['generalSettings'] as const, diff --git a/apps/sim/hooks/queries/general-settings.test.tsx b/apps/sim/hooks/queries/general-settings.test.tsx index caf84c64310..e4e25626c18 100644 --- a/apps/sim/hooks/queries/general-settings.test.tsx +++ b/apps/sim/hooks/queries/general-settings.test.tsx @@ -19,8 +19,8 @@ vi.mock('@/lib/core/utils/theme', () => ({ syncThemeToNextThemes: mockSyncTheme, })) +import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/current-user-data' import { useGeneralSettings } from '@/hooks/queries/general-settings' -import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings-data' const HYDRATED_SETTINGS: GeneralSettings = { autoConnect: true, diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index ed8e05f4be9..ad27ed3b582 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -11,7 +11,7 @@ import { type GeneralSettings, generalSettingsKeys, mapGeneralSettingsResponse, -} from '@/hooks/queries/general-settings-data' +} from '@/hooks/queries/current-user-data' const logger = createLogger('GeneralSettingsQuery') diff --git a/apps/sim/hooks/queries/user-profile-data.ts b/apps/sim/hooks/queries/user-profile-data.ts deleted file mode 100644 index 67254d5e559..00000000000 --- a/apps/sim/hooks/queries/user-profile-data.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { UserProfileApiUser } from '@/lib/api/contracts/user' - -export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000 - -export const userProfileKeys = { - all: ['userProfile'] as const, - profile: () => [...userProfileKeys.all, 'profile'] as const, -} - -export type UserProfile = Omit - -export function mapUserProfileResponse(user: UserProfileApiUser): UserProfile { - return { - id: user.id, - name: user.name, - email: user.email, - image: user.image, - } -} diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index 120e519647b..a482ebfbbbe 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -13,7 +13,7 @@ import { USER_PROFILE_STALE_TIME, type UserProfile, userProfileKeys, -} from '@/hooks/queries/user-profile-data' +} from '@/hooks/queries/current-user-data' const logger = createLogger('UserProfileQuery') diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts index cce1c07245f..9e59f0a42d1 100644 --- a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts +++ b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts @@ -1,4 +1,4 @@ -import { dbReplica } from '@sim/db' +import { db, dbReplica } from '@sim/db' import { organization, subscription as subscriptionTable } from '@sim/db/schema' import { desc, eq } from 'drizzle-orm' import { defineAuthorizedOrganizationBillingSummaryUseCase } from '@/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case' @@ -82,7 +82,7 @@ export const getOrganizationBillingSummary = defineAuthorizedOrganizationBilling .orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id)) .limit(1), getOrganizationBillingBlockState(organizationId, actorUserId, dbReplica), - getUpgradeWorkspaceId({ type: 'organization', id: organizationId }, dbReplica), + getUpgradeWorkspaceId({ type: 'organization', id: organizationId }, db), ]) const organizationRecord = organizationRows[0] diff --git a/apps/sim/lib/billing/core/payer-context.ts b/apps/sim/lib/billing/core/payer-context.ts index 8582d2487ed..e16b29dde63 100644 --- a/apps/sim/lib/billing/core/payer-context.ts +++ b/apps/sim/lib/billing/core/payer-context.ts @@ -1,4 +1,4 @@ -import { dbReplica } from '@sim/db' +import { db, dbReplica } from '@sim/db' import { member, userStats, workspace } from '@sim/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import type { DbClient } from '@/lib/db/types' @@ -12,7 +12,7 @@ export interface BillingBlockState { /** Finds an active workspace whose host billing identity is the requested payer. */ export async function getUpgradeWorkspaceId( target: { type: 'user'; id: string } | { type: 'organization'; id: string }, - executor: DbClient = dbReplica + executor: DbClient = db ): Promise { const targetPredicate = target.type === 'organization' diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index 95e58fa4640..12fc1b480a0 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -96,7 +96,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({ vi.mock('@/stores/terminal', () => ({ consolePersistence: { - executionStarted: vi.fn(), + executionStarted: vi.fn(() => ({})), executionEnded: vi.fn(), persist: vi.fn(), }, diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index ac2ead76706..ec341f1491a 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -515,7 +515,7 @@ async function doExecuteRunTool( const abortController = new AbortController() activeRunAbortByWorkflowId.set(targetWorkflowId, abortController) - consolePersistence.executionStarted() + const persistenceExecution = consolePersistence.executionStarted() setIsExecuting(targetWorkflowId, true) const executionId = generateId() setCurrentExecutionId(targetWorkflowId, executionId) @@ -524,7 +524,7 @@ async function doExecuteRunTool( const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState() if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) { clearExecId(targetWorkflowId, null) - consolePersistence.executionEnded() + consolePersistence.executionEnded(persistenceExecution) setIsExecuting(targetWorkflowId, false) setActiveBlocks(targetWorkflowId, new Set()) } @@ -715,7 +715,7 @@ async function doExecuteRunTool( if (!leaveExecutionRecoverable && activeToolCallId === toolCallId) { clearExecId(targetWorkflowId, null) clearExecutionPointer(targetWorkflowId) - consolePersistence.executionEnded() + consolePersistence.executionEnded(persistenceExecution) setIsExecuting(targetWorkflowId, false) setActiveBlocks(targetWorkflowId, new Set()) } diff --git a/apps/sim/lib/settings/prefetch-current-user-settings.ts b/apps/sim/lib/settings/prefetch-current-user-settings.ts index 6eed457cc6f..139f714a227 100644 --- a/apps/sim/lib/settings/prefetch-current-user-settings.ts +++ b/apps/sim/lib/settings/prefetch-current-user-settings.ts @@ -6,7 +6,7 @@ import { GENERAL_SETTINGS_STALE_TIME, generalSettingsKeys, mapGeneralSettingsResponse, -} from '@/hooks/queries/general-settings-data' +} from '@/hooks/queries/current-user-data' type GetPrincipal = () => ReturnType diff --git a/apps/sim/stores/chat/store.test.ts b/apps/sim/stores/chat/store.test.ts index 9700f4478ec..6f2aac80682 100644 --- a/apps/sim/stores/chat/store.test.ts +++ b/apps/sim/stores/chat/store.test.ts @@ -84,6 +84,38 @@ describe('chat store message ordering', () => { }) }) + it('resets persisted identity and transient UI state', () => { + useChatStore.setState({ + isChatOpen: true, + chatPosition: { x: 10, y: 20 }, + chatWidth: 500, + chatHeight: 400, + messages: [ + { + id: 'message-a', + content: 'private response', + workflowId: 'workflow-a', + type: 'workflow', + timestamp: '2026-08-31T00:00:00.000Z', + }, + ], + selectedWorkflowOutputs: { 'workflow-a': ['output-a'] }, + conversationIds: { 'workflow-a': 'conversation-a' }, + }) + + useChatStore.getState().reset() + + expect(useChatStore.getState()).toMatchObject({ + isChatOpen: false, + chatPosition: null, + chatWidth: 305, + chatHeight: 286, + messages: [], + selectedWorkflowOutputs: {}, + conversationIds: {}, + }) + }) + describe('exportChatCSV', () => { beforeEach(() => { mockSaveBlob.mockClear() diff --git a/apps/sim/stores/chat/store.ts b/apps/sim/stores/chat/store.ts index 33f25f22657..164f49a445c 100644 --- a/apps/sim/stores/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -5,6 +5,7 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' import { saveBlob } from '@/lib/uploads/client/download' +import { registerUserDataReset } from '@/stores/user-data-reset-registry' import type { ChatMessage, ChatState } from './types' import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils' @@ -21,6 +22,27 @@ const MAX_MESSAGES = 50 const DEFAULT_WIDTH = 305 const DEFAULT_HEIGHT = 286 +function createInitialState() { + return { + isChatOpen: false, + chatPosition: null, + chatWidth: DEFAULT_WIDTH, + chatHeight: DEFAULT_HEIGHT, + messages: [], + selectedWorkflowOutputs: {}, + conversationIds: {}, + } satisfies Pick< + ChatState, + | 'isChatOpen' + | 'chatPosition' + | 'chatWidth' + | 'chatHeight' + | 'messages' + | 'selectedWorkflowOutputs' + | 'conversationIds' + > +} + /** * Floating chat store * Manages the open/close state, position, messages, and all chat functionality @@ -29,10 +51,7 @@ export const useChatStore = create()( devtools( persist( (set, get) => ({ - isChatOpen: false, - chatPosition: null, - chatWidth: DEFAULT_WIDTH, - chatHeight: DEFAULT_HEIGHT, + ...createInitialState(), setIsChatOpen: (open) => { set({ isChatOpen: open }) @@ -53,10 +72,6 @@ export const useChatStore = create()( set({ chatPosition: null }) }, - messages: [], - selectedWorkflowOutputs: {}, - conversationIds: {}, - addMessage: (message) => { set((state) => { const newMessage: ChatMessage = { @@ -218,6 +233,8 @@ export const useChatStore = create()( return { messages: newMessages } }) }, + + reset: () => set(createInitialState()), }), { name: 'chat-store', @@ -262,3 +279,5 @@ export const useChatStore = create()( ) ) ) + +registerUserDataReset('chat', () => useChatStore.getState().reset()) diff --git a/apps/sim/stores/chat/types.ts b/apps/sim/stores/chat/types.ts index 4ed03e7dba5..14bc6a70c7f 100644 --- a/apps/sim/stores/chat/types.ts +++ b/apps/sim/stores/chat/types.ts @@ -69,4 +69,5 @@ export interface ChatState { finalizeMessageStream: (messageId: string) => void getConversationId: (workflowId: string) => string generateNewConversationId: (workflowId: string) => string + reset: () => void } diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index 939eddd7092..0d9b7f03968 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -67,10 +67,11 @@ describe('clearUserData', () => { localStorage.setItem('private-cache', 'remove-me') sessionStorage.setItem('mothership-queue', 'private-queued-message') - await clearUserData() + const inMemoryResetSucceeded = await clearUserData() expect(mockModuleLoaded).toHaveBeenCalledOnce() expect(mockResetAllStores).toHaveBeenCalledOnce() + expect(inMemoryResetSucceeded).toBe(true) expect(localStorage.getItem('next-favicon')).toBe('favicon') expect(localStorage.getItem('sim-theme')).toBe('dark') expect(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY)).toBeNull() @@ -92,9 +93,10 @@ describe('clearUserData', () => { throw new Error('Chunk unavailable') }) - await clearUserData() + const inMemoryResetSucceeded = await clearUserData() expect(mockResetAllStores).toHaveBeenCalledOnce() + expect(inMemoryResetSucceeded).toBe(false) expect(localStorage.getItem('private-cache')).toBeNull() }) }) diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 9a67940993a..77ca9bb0a42 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -11,11 +11,16 @@ interface ClearUserDataOptions { preserveRecentImpersonations?: boolean } -/** Clears browser and in-memory data at an authenticated identity boundary. */ -export async function clearUserData(options: ClearUserDataOptions = {}): Promise { - if (typeof window === 'undefined') return +/** + * Clears browser and in-memory data at an authenticated identity boundary. + * Returns whether the in-memory reset completed, so SPA callers can fall back + * to a full document navigation when the reset chunk is unavailable. + */ +export async function clearUserData(options: ClearUserDataOptions = {}): Promise { + if (typeof window === 'undefined') return true let cleanupFailed = false + let inMemoryResetSucceeded = true try { const keysToKeep = [ @@ -42,8 +47,10 @@ export async function clearUserData(options: ClearUserDataOptions = {}): Promise await resetAllStores() } catch (error) { cleanupFailed = true + inMemoryResetSucceeded = false logger.error('Error resetting in-memory user data:', { error }) } if (!cleanupFailed) logger.info('User data cleared successfully') + return inMemoryResetSucceeded } diff --git a/apps/sim/stores/operation-queue/store.test.ts b/apps/sim/stores/operation-queue/store.test.ts index 2380ed71036..0dcf2f47027 100644 --- a/apps/sim/stores/operation-queue/store.test.ts +++ b/apps/sim/stores/operation-queue/store.test.ts @@ -485,4 +485,83 @@ describe('operation queue room gating', () => { vi.useRealTimers() } }) + + it('reset cancels an active operation timeout and detaches the registered emitters', async () => { + vi.useFakeTimers() + try { + const workflowEmit = vi.fn(() => true) + registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), 'workflow-a') + useOperationQueueStore.getState().addToQueue({ + id: 'op-1', + workflowId: 'workflow-a', + userId: 'user-1', + operation: { + operation: 'replace-state', + target: 'workflow', + payload: { state: {} }, + }, + }) + + expect(workflowEmit).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(1) + + useOperationQueueStore.getState().reset() + + expect(vi.getTimerCount()).toBe(0) + expect(useOperationQueueStore.getState()).toMatchObject({ + operations: [], + workflowOperationVersions: {}, + remoteApplyVersions: {}, + isProcessing: false, + hasOperationError: false, + }) + + useOperationQueueStore.getState().addToQueue({ + id: 'op-2', + workflowId: 'workflow-a', + userId: 'user-1', + operation: { + operation: 'replace-state', + target: 'workflow', + payload: { state: {} }, + }, + }) + await vi.runAllTimersAsync() + + expect(workflowEmit).toHaveBeenCalledOnce() + expect(useOperationQueueStore.getState().hasOperationError).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('reset cancels a scheduled retry', async () => { + vi.useFakeTimers() + try { + const workflowEmit = vi.fn(() => true) + registerEmitFunctions(workflowEmit, vi.fn(), vi.fn(), 'workflow-a') + useOperationQueueStore.getState().addToQueue({ + id: 'op-1', + workflowId: 'workflow-a', + userId: 'user-1', + operation: { + operation: 'replace-state', + target: 'workflow', + payload: { state: {} }, + }, + }) + useOperationQueueStore.getState().failOperation('op-1') + + expect(vi.getTimerCount()).toBe(1) + + useOperationQueueStore.getState().reset() + await vi.runAllTimersAsync() + + expect(vi.getTimerCount()).toBe(0) + expect(workflowEmit).toHaveBeenCalledOnce() + expect(useOperationQueueStore.getState().operations).toEqual([]) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/sim/stores/operation-queue/store.ts b/apps/sim/stores/operation-queue/store.ts index b3feae6b49b..427f2481560 100644 --- a/apps/sim/stores/operation-queue/store.ts +++ b/apps/sim/stores/operation-queue/store.ts @@ -47,6 +47,13 @@ const retryTimeouts = new Map() const operationTimeouts = new Map() const DEFAULT_WORKFLOW_DRAIN_TIMEOUT_MS = 20000 +function clearOperationQueueTimers(): void { + retryTimeouts.forEach((timeout) => clearTimeout(timeout)) + retryTimeouts.clear() + operationTimeouts.forEach((timeout) => clearTimeout(timeout)) + operationTimeouts.clear() +} + let emitWorkflowOperation: WorkflowOperationEmit | null = null let emitSubblockUpdate: SubblockUpdateEmit | null = null let emitVariableUpdate: VariableUpdateEmit | null = null @@ -640,10 +647,7 @@ export const useOperationQueueStore = create((set, get) => triggerOfflineMode: () => { logger.error('Operation failed after retries - triggering offline mode') - retryTimeouts.forEach((timeout) => clearTimeout(timeout)) - retryTimeouts.clear() - operationTimeouts.forEach((timeout) => clearTimeout(timeout)) - operationTimeouts.clear() + clearOperationQueueTimers() set({ operations: [], @@ -655,6 +659,23 @@ export const useOperationQueueStore = create((set, get) => clearError: () => { set({ hasOperationError: false }) }, + + reset: () => { + clearOperationQueueTimers() + + emitWorkflowOperation = null + emitSubblockUpdate = null + emitVariableUpdate = null + currentRegisteredWorkflowId = null + + set({ + operations: [], + workflowOperationVersions: {}, + remoteApplyVersions: {}, + isProcessing: false, + hasOperationError: false, + }) + }, })) /** diff --git a/apps/sim/stores/operation-queue/types.ts b/apps/sim/stores/operation-queue/types.ts index 796fc527ae7..6961ef8e954 100644 --- a/apps/sim/stores/operation-queue/types.ts +++ b/apps/sim/stores/operation-queue/types.ts @@ -71,4 +71,5 @@ export interface OperationQueueState { triggerOfflineMode: () => void clearError: () => void + reset: () => void } diff --git a/apps/sim/stores/reset-all-stores.test.ts b/apps/sim/stores/reset-all-stores.test.ts index 57ef2759e72..969d47b3bd5 100644 --- a/apps/sim/stores/reset-all-stores.test.ts +++ b/apps/sim/stores/reset-all-stores.test.ts @@ -10,6 +10,8 @@ const { mockClearAllExecutionPointers, mockGetQueryClient, mockMothershipQueueReset, + mockOperationQueueReset, + mockResetRegisteredUserData, mockRegistrySetState, mockSubBlockSetState, mockWaitForConsoleHydration, @@ -20,6 +22,8 @@ const { mockConsoleReset: vi.fn(), mockGetQueryClient: vi.fn(), mockMothershipQueueReset: vi.fn(), + mockOperationQueueReset: vi.fn(), + mockResetRegisteredUserData: vi.fn(), mockRegistrySetState: vi.fn(), mockSubBlockSetState: vi.fn(), mockWaitForConsoleHydration: vi.fn(), @@ -29,6 +33,9 @@ const { vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: mockGetQueryClient, })) +vi.mock('@/stores/user-data-reset-registry', () => ({ + resetRegisteredUserData: mockResetRegisteredUserData, +})) vi.mock('@/stores/execution', () => ({ useExecutionStore: { getState: () => ({ reset: vi.fn() }) }, })) @@ -38,6 +45,9 @@ vi.mock('@/stores/mothership-drafts/store', () => ({ vi.mock('@/stores/mothership-queue/store', () => ({ useMothershipQueueStore: { getState: () => ({ reset: mockMothershipQueueReset }) }, })) +vi.mock('@/stores/operation-queue/store', () => ({ + useOperationQueueStore: { getState: () => ({ reset: mockOperationQueueReset }) }, +})) vi.mock('@/stores/terminal', () => ({ clearAllExecutionPointers: mockClearAllExecutionPointers, consolePersistence: { persist: mockConsolePersist, reset: mockConsoleReset }, @@ -89,6 +99,8 @@ describe('resetAllStores', () => { expect.objectContaining({ currentWorkflowId: null, blocks: {}, edges: [] }) ) expect(mockSubBlockSetState).toHaveBeenCalledWith({ workflowValues: {} }) + expect(mockOperationQueueReset).toHaveBeenCalledOnce() + expect(mockResetRegisteredUserData).toHaveBeenCalledOnce() expect(mockConsoleReset).toHaveBeenCalledOnce() expect(mockClearAllExecutionPointers).toHaveBeenCalledOnce() expect(mockMothershipQueueReset).toHaveBeenCalledOnce() @@ -104,6 +116,8 @@ describe('resetAllStores', () => { ) const resetPromise = resetAllStores() + expect(mockOperationQueueReset).toHaveBeenCalledOnce() + expect(mockResetRegisteredUserData).toHaveBeenCalledOnce() await Promise.resolve() expect(mockRegistrySetState).not.toHaveBeenCalled() diff --git a/apps/sim/stores/reset-all-stores.ts b/apps/sim/stores/reset-all-stores.ts index 956d90d5f33..9665d01a09f 100644 --- a/apps/sim/stores/reset-all-stores.ts +++ b/apps/sim/stores/reset-all-stores.ts @@ -4,17 +4,21 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' +import { useOperationQueueStore } from '@/stores/operation-queue/store' import { clearAllExecutionPointers, consolePersistence, useTerminalConsoleStore, waitForConsoleHydration, } from '@/stores/terminal' +import { resetRegisteredUserData } from '@/stores/user-data-reset-registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' export async function resetAllStores(): Promise { + useOperationQueueStore.getState().reset() + resetRegisteredUserData() await waitForConsoleHydration() useWorkflowRegistry.setState({ diff --git a/apps/sim/stores/terminal/console/index.ts b/apps/sim/stores/terminal/console/index.ts index 448342f70cd..d7ab6637a6f 100644 --- a/apps/sim/stores/terminal/console/index.ts +++ b/apps/sim/stores/terminal/console/index.ts @@ -1,3 +1,4 @@ +export type { ConsolePersistenceExecution } from './storage' export { clearAllExecutionPointers, clearExecutionPointer, diff --git a/apps/sim/stores/terminal/console/storage.test.ts b/apps/sim/stores/terminal/console/storage.test.ts index 8b6bfc473c9..e238e1f2dfa 100644 --- a/apps/sim/stores/terminal/console/storage.test.ts +++ b/apps/sim/stores/terminal/console/storage.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CONSOLE_STORAGE_VERSION, clearAllExecutionPointers, + consolePersistence, migratePersistedConsoleData, saveExecutionPointer, } from '@/stores/terminal/console/storage' @@ -113,3 +114,43 @@ describe('terminal execution pointers', () => { expect(window.sessionStorage.getItem('unrelated')).toBe('keep') }) }) + +describe('console persistence execution lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + consolePersistence.reset() + }) + + afterEach(() => { + consolePersistence.reset() + vi.useRealTimers() + }) + + it('ignores an execution ending after its authenticated session was reset', () => { + const previousSessionExecution = consolePersistence.executionStarted() + consolePersistence.reset() + const currentSessionExecution = consolePersistence.executionStarted() + + consolePersistence.executionEnded(previousSessionExecution) + + expect(vi.getTimerCount()).toBe(1) + + consolePersistence.executionEnded(currentSessionExecution) + + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not let a duplicate completion end another active execution', () => { + const firstExecution = consolePersistence.executionStarted() + const secondExecution = consolePersistence.executionStarted() + + consolePersistence.executionEnded(firstExecution) + consolePersistence.executionEnded(firstExecution) + + expect(vi.getTimerCount()).toBe(1) + + consolePersistence.executionEnded(secondExecution) + + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/sim/stores/terminal/console/storage.ts b/apps/sim/stores/terminal/console/storage.ts index 9a8f49ee705..dbba3d49fd1 100644 --- a/apps/sim/stores/terminal/console/storage.ts +++ b/apps/sim/stores/terminal/console/storage.ts @@ -148,6 +148,12 @@ interface PersistOptions { merge?: boolean } +declare const consolePersistenceExecutionBrand: unique symbol + +export interface ConsolePersistenceExecution { + readonly [consolePersistenceExecutionBrand]: never +} + function entryTimestamp(entry: ConsoleEntry): number { return Date.parse(entry.endedAt ?? entry.startedAt ?? entry.timestamp) } @@ -246,7 +252,7 @@ function writeToIndexedDB( class ConsolePersistenceManager { private dataProvider: (() => PersistedConsoleData) | null = null private safetyTimer: ReturnType | null = null - private activeExecutions = 0 + private activeExecutions = new Set() private needsInitialPersist = false /** @@ -261,12 +267,14 @@ class ConsolePersistenceManager { * Signals that a workflow execution has started. * Starts the long-execution safety-net timer if this is the first active execution. */ - executionStarted(): void { - this.activeExecutions++ + executionStarted(): ConsolePersistenceExecution { + const execution = {} as ConsolePersistenceExecution + this.activeExecutions.add(execution) this.needsInitialPersist = true - if (this.activeExecutions === 1) { + if (this.activeExecutions.size === 1) { this.startSafetyTimer() } + return execution } /** @@ -284,10 +292,10 @@ class ConsolePersistenceManager { * Signals that a workflow execution has ended (success, error, or cancel). * Triggers an immediate persist and stops the safety timer if no executions remain. */ - executionEnded(): void { - this.activeExecutions = Math.max(0, this.activeExecutions - 1) + executionEnded(execution: ConsolePersistenceExecution): void { + if (!this.activeExecutions.delete(execution)) return this.persist() - if (this.activeExecutions === 0) { + if (this.activeExecutions.size === 0) { this.stopSafetyTimer() } } @@ -303,7 +311,7 @@ class ConsolePersistenceManager { /** Stops persistence work owned by the previous authenticated session. */ reset(): void { - this.activeExecutions = 0 + this.activeExecutions.clear() this.needsInitialPersist = false this.stopSafetyTimer() } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 4370e63731f..a21a933534c 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -15,7 +15,7 @@ import { sendMothershipMessage } from '@/lib/mothership/events' import { saveBlob } from '@/lib/uploads/client/download' import { getQueryClient } from '@/app/_shell/providers/query-provider' import type { NormalizedBlockOutput } from '@/executor/types' -import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings-data' +import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/current-user-data' import { useExecutionStore } from '@/stores/execution' import { CONSOLE_STORAGE_VERSION, diff --git a/apps/sim/stores/terminal/index.ts b/apps/sim/stores/terminal/index.ts index 5023db53e7e..302fbde6203 100644 --- a/apps/sim/stores/terminal/index.ts +++ b/apps/sim/stores/terminal/index.ts @@ -1,4 +1,4 @@ -export type { ConsoleEntry, ConsoleUpdate } from './console' +export type { ConsoleEntry, ConsolePersistenceExecution, ConsoleUpdate } from './console' export { clearAllExecutionPointers, clearExecutionPointer, diff --git a/apps/sim/stores/user-data-reset-registry.test.ts b/apps/sim/stores/user-data-reset-registry.test.ts new file mode 100644 index 00000000000..590cf8b66e6 --- /dev/null +++ b/apps/sim/stores/user-data-reset-registry.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { registerUserDataReset, resetRegisteredUserData } from '@/stores/user-data-reset-registry' + +describe('user data reset registry', () => { + it('runs every loaded store reset and replaces duplicate registrations', () => { + const firstReset = vi.fn() + const replacementReset = vi.fn() + const secondReset = vi.fn() + registerUserDataReset('test-first', firstReset) + registerUserDataReset('test-first', replacementReset) + registerUserDataReset('test-second', secondReset) + + resetRegisteredUserData() + + expect(firstReset).not.toHaveBeenCalled() + expect(replacementReset).toHaveBeenCalledOnce() + expect(secondReset).toHaveBeenCalledOnce() + }) + + it('continues resetting loaded stores before reporting a failure', () => { + const resetError = new Error('reset failed') + const successfulReset = vi.fn() + registerUserDataReset('test-failing', () => { + throw resetError + }) + registerUserDataReset('test-successful', successfulReset) + + expect(() => resetRegisteredUserData()).toThrow(resetError) + expect(successfulReset).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/stores/user-data-reset-registry.ts b/apps/sim/stores/user-data-reset-registry.ts new file mode 100644 index 00000000000..66f215fb8cb --- /dev/null +++ b/apps/sim/stores/user-data-reset-registry.ts @@ -0,0 +1,21 @@ +'use client' + +const userDataResets = new Map void>() + +/** Registers a loaded client store for authenticated identity resets. */ +export function registerUserDataReset(storeId: string, reset: () => void): void { + userDataResets.set(storeId, reset) +} + +/** Resets every identity-scoped store that is currently loaded. */ +export function resetRegisteredUserData(): void { + const errors: unknown[] = [] + userDataResets.forEach((reset) => { + try { + reset() + } catch (error) { + errors.push(error) + } + }) + if (errors.length > 0) throw errors[0] +} From 7a448219856a180309b9890ada65fb6b8dc1b887 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 14:58:59 -0700 Subject: [PATCH 07/11] fix(settings): harden async lifecycle consistency --- .../deploy/hooks/use-deploy-readiness.test.ts | 2 +- .../deploy/hooks/use-deploy-readiness.ts | 4 +- .../hooks/use-workflow-execution.test.tsx | 70 +++++-- .../hooks/use-workflow-execution.ts | 16 +- apps/sim/hooks/use-collaborative-workflow.ts | 10 +- .../get-organization-billing-summary.test.ts | 197 ++++++++++++++++++ .../get-organization-billing-summary.ts | 8 +- apps/sim/stores/operation-queue/store.test.ts | 26 ++- apps/sim/stores/operation-queue/store.ts | 21 +- apps/sim/stores/operation-queue/types.ts | 7 +- .../stores/terminal/console/storage.test.ts | 34 +++ apps/sim/stores/terminal/console/storage.ts | 34 +++ 12 files changed, 387 insertions(+), 42 deletions(-) create mode 100644 apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.test.ts index 39b0a3849b2..fe810744c7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.test.ts @@ -10,7 +10,7 @@ vi.mock('@/stores/operation-queue/store', () => ({ getState: () => ({ hasOperationError: false, hasPendingOperations: () => false, - waitForWorkflowOperations: () => Promise.resolve(true), + waitForWorkflowOperations: () => Promise.resolve('drained'), }), } ), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.ts index 23490ce63b3..40604a3ed94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deploy-readiness.ts @@ -131,8 +131,8 @@ export function useDeployReadiness(workflowId: string | null): DeployReadiness { const queue = useOperationQueueStore.getState() if (queue.hasOperationError) return false - const drained = await queue.waitForWorkflowOperations(workflowId) - if (!drained) return false + const drainResult = await queue.waitForWorkflowOperations(workflowId) + if (drainResult !== 'drained') return false const latestQueue = useOperationQueueStore.getState() const diff = useWorkflowDiffStore.getState() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 6e557dd87f1..c76ad50f9ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -9,13 +9,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { executionStoreState, mockCancel, + mockAdoptScopedExecution, + mockBeginScopedExecution, + mockEndScopedExecution, mockExecute, mockExecuteFromBlock, mockFetch, mockHandleExecutionCancelledConsole, mockHandleExecutionErrorConsole, - mockPersistenceExecutionEnded, - mockPersistenceExecutionStarted, + mockLoadExecutionPointer, + mockReconnect, mockRequestJson, mockResolveStartCandidates, mockSelectBestTrigger, @@ -86,13 +89,16 @@ const { return { executionStoreState, mockCancel: vi.fn(), + mockAdoptScopedExecution: vi.fn(), + mockBeginScopedExecution: vi.fn(() => ({})), + mockEndScopedExecution: vi.fn(() => true), mockExecute: vi.fn(), mockExecuteFromBlock: vi.fn(), mockFetch: vi.fn(), mockHandleExecutionCancelledConsole: vi.fn(), mockHandleExecutionErrorConsole: vi.fn(), - mockPersistenceExecutionEnded: vi.fn(), - mockPersistenceExecutionStarted: vi.fn(() => ({})), + mockLoadExecutionPointer: vi.fn(), + mockReconnect: vi.fn(), mockRequestJson: vi.fn(), mockResolveStartCandidates: vi.fn(), mockSelectBestTrigger: vi.fn(), @@ -210,7 +216,7 @@ vi.mock('@/hooks/use-execution-stream', () => { useExecutionStream: () => ({ execute: mockExecute, executeFromBlock: mockExecuteFromBlock, - reconnect: vi.fn(), + reconnect: mockReconnect, cancel: mockCancel, cancelExecute: vi.fn(), cancelReconnect: vi.fn(), @@ -241,11 +247,12 @@ vi.mock('@/stores/execution', () => ({ vi.mock('@/stores/terminal', () => ({ clearExecutionPointer: vi.fn(), consolePersistence: { - executionStarted: mockPersistenceExecutionStarted, - executionEnded: mockPersistenceExecutionEnded, + adoptScopedExecution: mockAdoptScopedExecution, + beginScopedExecution: mockBeginScopedExecution, + endScopedExecution: mockEndScopedExecution, persist: vi.fn(), }, - loadExecutionPointer: vi.fn(), + loadExecutionPointer: mockLoadExecutionPointer, saveExecutionPointer: vi.fn(), useTerminalConsoleStore: Object.assign( (selector: (state: typeof terminalStoreState) => unknown) => selector(terminalStoreState), @@ -406,10 +413,14 @@ describe('useWorkflowExecution cancellation', () => { describe('useWorkflowExecution attachment uploads', () => { beforeEach(() => { vi.clearAllMocks() + terminalStoreState._hasHydrated = false executionStoreState.getWorkflowExecution.mockReturnValue( executionStoreState.workflowExecutions.get('workflow-1')! ) executionStoreState.getCurrentExecutionId.mockReturnValue(null) + mockAdoptScopedExecution.mockReturnValue(undefined) + mockLoadExecutionPointer.mockResolvedValue(null) + mockReconnect.mockResolvedValue(undefined) mockResolveStartCandidates.mockReturnValue([]) mockSelectBestTrigger.mockReturnValue([]) vi.stubGlobal('fetch', mockFetch) @@ -548,7 +559,7 @@ describe('useWorkflowExecution attachment uploads', () => { it('does not let an overlapping run without lifecycle ownership end the active run', async () => { const persistenceExecution = {} let resolveActiveRun: (() => void) | undefined - mockPersistenceExecutionStarted.mockReturnValueOnce(persistenceExecution) + mockBeginScopedExecution.mockReturnValueOnce(persistenceExecution) mockExecute.mockImplementationOnce( () => new Promise((resolve) => { @@ -571,16 +582,49 @@ describe('useWorkflowExecution attachment uploads', () => { await result().handleRunWorkflow() }) - expect(mockPersistenceExecutionStarted).toHaveBeenCalledTimes(1) - expect(mockPersistenceExecutionEnded).not.toHaveBeenCalled() + expect(mockBeginScopedExecution).toHaveBeenCalledTimes(1) + expect(mockEndScopedExecution).not.toHaveBeenCalled() await act(async () => { resolveActiveRun?.() await drainStream(activeRun) }) - expect(mockPersistenceExecutionEnded).toHaveBeenCalledOnce() - expect(mockPersistenceExecutionEnded).toHaveBeenCalledWith(persistenceExecution) + expect(mockEndScopedExecution).toHaveBeenCalledOnce() + expect(mockEndScopedExecution).toHaveBeenCalledWith('workflow-1', persistenceExecution) + + unmount() + }) + + it('adopts and finishes persistence ownership created before the hook mounted', async () => { + const persistenceExecution = {} + terminalStoreState._hasHydrated = true + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + status: 'running', + isExecuting: true, + currentExecutionId: 'execution-1', + }) + executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1') + mockLoadExecutionPointer.mockResolvedValue({ + workflowId: 'workflow-1', + executionId: 'execution-1', + lastEventId: 0, + }) + mockAdoptScopedExecution.mockReturnValue(persistenceExecution) + mockReconnect.mockImplementationOnce(async ({ callbacks }) => { + callbacks.onExecutionCompleted({ finalBlockLogs: [] }) + }) + + const { unmount } = renderWorkflowExecutionHook() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockBeginScopedExecution).not.toHaveBeenCalled() + expect(mockAdoptScopedExecution).toHaveBeenCalledWith('workflow-1') + expect(mockEndScopedExecution).toHaveBeenCalledWith('workflow-1', persistenceExecution) unmount() }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 8e562c4c610..e80df524a28 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -432,13 +432,11 @@ export function useWorkflowExecution() { const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId) const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId) const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting) - const persistenceExecutionsRef = useRef(new Map()) const endPersistenceExecution = useCallback((workflowId: string) => { - const persistenceExecution = persistenceExecutionsRef.current.get(workflowId) + const persistenceExecution = consolePersistence.adoptScopedExecution(workflowId) if (!persistenceExecution) return - persistenceExecutionsRef.current.delete(workflowId) - consolePersistence.executionEnded(persistenceExecution) + consolePersistence.endScopedExecution(workflowId, persistenceExecution) }, []) const setIsExecuting = useCallback( @@ -446,8 +444,7 @@ export function useWorkflowExecution() { const wasExecuting = useExecutionStore.getState().getWorkflowExecution(workflowId).isExecuting if (executing) { if (!wasExecuting) { - const startedExecution = consolePersistence.executionStarted() - persistenceExecutionsRef.current.set(workflowId, startedExecution) + const startedExecution = consolePersistence.beginScopedExecution(workflowId) rawSetIsExecuting(workflowId, true) return startedExecution } @@ -465,12 +462,11 @@ export function useWorkflowExecution() { const finishOwnedExecution = useCallback( (workflowId: string, persistenceExecution: ConsolePersistenceExecution | undefined) => { if (!persistenceExecution) return - if (persistenceExecutionsRef.current.get(workflowId) !== persistenceExecution) return - endPersistenceExecution(workflowId) + if (!consolePersistence.endScopedExecution(workflowId, persistenceExecution)) return clearExecutionPointer(workflowId) rawSetIsExecuting(workflowId, false) }, - [endPersistenceExecution, rawSetIsExecuting] + [rawSetIsExecuting] ) const setIsDebugging = useExecutionStore((s) => s.setIsDebugging) const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks) @@ -2490,7 +2486,7 @@ export function useWorkflowExecution() { setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId) reconnectPersistenceExecution = setIsExecuting(reconnectWorkflowId, true) ?? - persistenceExecutionsRef.current.get(reconnectWorkflowId) + consolePersistence.adoptScopedExecution(reconnectWorkflowId) activationOwnsPersistence = Boolean(reconnectPersistenceExecution) if (fromEventId === 0) { clearExecutionEntries(capturedExecutionId) diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 75516648bc1..517690d752f 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -865,8 +865,13 @@ export function useCollaborativeWorkflow() { workflowId, }) diffStore.markExternalUpdatePending(workflowId) - void operationQueue.waitForWorkflowOperations(workflowId).then((ready) => { - if (!ready) { + void operationQueue.waitForWorkflowOperations(workflowId).then((result) => { + if (result === 'cancelled') { + useWorkflowDiffStore.getState().clearExternalUpdatePending(workflowId) + return + } + + if (result === 'failed') { const latestQueue = useOperationQueueStore.getState() if (latestQueue.hasPendingOperations(workflowId) && !latestQueue.hasOperationError) { return @@ -879,6 +884,7 @@ export function useCollaborativeWorkflow() { ) return } + void replayPendingExternalUpdate(workflowId, 'deferred external update after local save') }) return diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts new file mode 100644 index 00000000000..3458829351e --- /dev/null +++ b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const tables = { + member: { name: 'member' }, + organization: { name: 'organization' }, + subscription: { name: 'subscription' }, + } + const primaryRows = new Map() + const selectedPrimaryTables: object[] = [] + + const primaryDb = { + select: vi.fn(() => { + let selectedTable: object + const query = { + from: vi.fn((table: object) => { + selectedTable = table + selectedPrimaryTables.push(table) + return query + }), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + limit: vi.fn(async () => primaryRows.get(selectedTable) ?? []), + } + return query + }), + } + const replicaDb = { + select: vi.fn(() => { + throw new Error('Canonical billing state must not be read from the replica') + }), + } + + return { + tables, + primaryRows, + selectedPrimaryTables, + primaryDb, + replicaDb, + getOrganizationSubscription: vi.fn(), + getOrganizationBillingBlockState: vi.fn(), + getUpgradeWorkspaceId: vi.fn(), + resolveSubscriptionUsagePeriodOrDefault: vi.fn(), + getBillingPeriodUsageCost: vi.fn(), + computeWeeklyRefreshConsumed: vi.fn(), + } +}) + +vi.mock('@sim/db', () => ({ + db: mocks.primaryDb, + dbReplica: mocks.replicaDb, +})) + +vi.mock('@sim/db/schema', () => ({ + member: mocks.tables.member, + organization: mocks.tables.organization, + subscription: mocks.tables.subscription, +})) + +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mocks.getOrganizationSubscription, + getPlanPricing: vi.fn(() => ({ basePrice: 20 })), +})) + +vi.mock('@/lib/billing/core/payer-context', () => ({ + getOrganizationBillingBlockState: mocks.getOrganizationBillingBlockState, + getUpgradeWorkspaceId: mocks.getUpgradeWorkspaceId, +})) + +vi.mock('@/lib/billing/core/reporting-period', () => ({ + resolveSubscriptionUsagePeriodOrDefault: mocks.resolveSubscriptionUsagePeriodOrDefault, +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + resolveBillingInterval: vi.fn(() => 'month'), +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getBillingPeriodUsageCost: mocks.getBillingPeriodUsageCost, +})) + +vi.mock('@/lib/billing/credits/weekly-refresh', () => ({ + computeWeeklyRefreshConsumed: mocks.computeWeeklyRefreshConsumed, +})) + +vi.mock('@/lib/billing/plan-helpers', () => ({ + getPlanWeeklyRefreshDollars: vi.fn(() => 10), + isEnterprise: vi.fn(() => false), + isPaid: vi.fn((plan: string | null | undefined) => plan !== 'free'), +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + getEffectiveSeats: vi.fn(() => 2), +})) + +vi.mock('@/lib/billing/utils/decimal', () => ({ + toDecimal: vi.fn((value: string | number | null | undefined) => Number(value ?? 0)), + toNumber: vi.fn((value: number) => value), +})) + +import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary' + +const session: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} + +describe('organization billing summary query routing', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.primaryRows.clear() + mocks.selectedPrimaryTables.length = 0 + + const periodStart = new Date('2026-08-01T00:00:00.000Z') + const periodEnd = new Date('2026-09-01T00:00:00.000Z') + const subscription = { + id: 'sub-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 2, + periodStart, + periodEnd, + cancelAtPeriodEnd: false, + } + + mocks.primaryRows.set(mocks.tables.member, [{ role: 'owner' }]) + mocks.primaryRows.set(mocks.tables.organization, [ + { id: 'org-1', orgUsageLimit: null, creditBalance: '3' }, + ]) + mocks.primaryRows.set(mocks.tables.subscription, [subscription]) + mocks.getOrganizationSubscription.mockResolvedValue(subscription) + mocks.resolveSubscriptionUsagePeriodOrDefault.mockReturnValue({ + start: periodStart, + end: periodEnd, + }) + mocks.getOrganizationBillingBlockState.mockResolvedValue({ + billingBlocked: false, + billingBlockedReason: null, + blockedByOrgOwner: false, + }) + mocks.getUpgradeWorkspaceId.mockResolvedValue('workspace-1') + mocks.getBillingPeriodUsageCost.mockResolvedValue(25) + mocks.computeWeeklyRefreshConsumed.mockResolvedValue(5) + }) + + it('uses primary state for payer decisions and the replica only for usage aggregates', async () => { + await expect( + getOrganizationBillingSummary.execute({ + principal: session, + input: { organizationId: 'org-1' }, + }) + ).resolves.toMatchObject({ + organizationId: 'org-1', + subscriptionPlan: 'team', + totalCurrentUsage: 20, + upgradeWorkspaceId: 'workspace-1', + }) + + expect(mocks.selectedPrimaryTables).toEqual([ + mocks.tables.member, + mocks.tables.organization, + mocks.tables.subscription, + ]) + expect(mocks.replicaDb.select).not.toHaveBeenCalled() + expect(mocks.getOrganizationSubscription).toHaveBeenCalledWith('org-1', { + executor: mocks.primaryDb, + onError: 'throw', + }) + expect(mocks.getOrganizationBillingBlockState).toHaveBeenCalledWith( + 'org-1', + 'user-1', + mocks.primaryDb + ) + expect(mocks.getUpgradeWorkspaceId).toHaveBeenCalledWith( + { type: 'organization', id: 'org-1' }, + mocks.primaryDb + ) + expect(mocks.getBillingPeriodUsageCost).toHaveBeenCalledWith( + { type: 'organization', id: 'org-1' }, + expect.any(Object), + undefined, + mocks.replicaDb + ) + expect(mocks.computeWeeklyRefreshConsumed).toHaveBeenCalledWith( + expect.objectContaining({ + billingEntity: { type: 'organization', id: 'org-1' }, + }), + mocks.replicaDb + ) + }) +}) diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts index 9e59f0a42d1..c3093fc6d20 100644 --- a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts +++ b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts @@ -62,7 +62,7 @@ export const getOrganizationBillingSummary = defineAuthorizedOrganizationBilling billingStatus, upgradeWorkspaceId, ] = await Promise.all([ - dbReplica + db .select({ id: organization.id, orgUsageLimit: organization.orgUsageLimit, @@ -72,16 +72,16 @@ export const getOrganizationBillingSummary = defineAuthorizedOrganizationBilling .where(eq(organization.id, organizationId)) .limit(1), getOrganizationSubscription(organizationId, { - executor: dbReplica, + executor: db, onError: 'throw', }), - dbReplica + db .select() .from(subscriptionTable) .where(eq(subscriptionTable.referenceId, organizationId)) .orderBy(desc(subscriptionTable.periodStart), desc(subscriptionTable.id)) .limit(1), - getOrganizationBillingBlockState(organizationId, actorUserId, dbReplica), + getOrganizationBillingBlockState(organizationId, actorUserId, db), getUpgradeWorkspaceId({ type: 'organization', id: organizationId }, db), ]) diff --git a/apps/sim/stores/operation-queue/store.test.ts b/apps/sim/stores/operation-queue/store.test.ts index 0dcf2f47027..33cb8e0eb11 100644 --- a/apps/sim/stores/operation-queue/store.test.ts +++ b/apps/sim/stores/operation-queue/store.test.ts @@ -425,7 +425,7 @@ describe('operation queue room gating', () => { const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a') useOperationQueueStore.getState().confirmOperation('op-1') - await expect(drained).resolves.toBe(true) + await expect(drained).resolves.toBe('drained') }) it('does not wait on operations from other workflows', async () => { @@ -442,7 +442,7 @@ describe('operation queue room gating', () => { await expect( useOperationQueueStore.getState().waitForWorkflowOperations('workflow-b') - ).resolves.toBe(true) + ).resolves.toBe('drained') }) it('stops waiting when an operation error is reported', async () => { @@ -460,7 +460,7 @@ describe('operation queue room gating', () => { const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a') useOperationQueueStore.setState({ hasOperationError: true }) - await expect(drained).resolves.toBe(false) + await expect(drained).resolves.toBe('failed') }) it('stops waiting when matching workflow operations do not drain before timeout', async () => { @@ -480,7 +480,7 @@ describe('operation queue room gating', () => { const drained = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a', 100) await vi.advanceTimersByTimeAsync(100) - await expect(drained).resolves.toBe(false) + await expect(drained).resolves.toBe('failed') } finally { vi.useRealTimers() } @@ -535,6 +535,24 @@ describe('operation queue room gating', () => { } }) + it('reports an in-flight workflow drain as cancelled when the queue resets', async () => { + useOperationQueueStore.getState().addToQueue({ + id: 'op-1', + workflowId: 'workflow-a', + userId: 'user-1', + operation: { + operation: 'replace-state', + target: 'workflow', + payload: { state: {} }, + }, + }) + + const drainResult = useOperationQueueStore.getState().waitForWorkflowOperations('workflow-a') + useOperationQueueStore.getState().reset() + + await expect(drainResult).resolves.toBe('cancelled') + }) + it('reset cancels a scheduled retry', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/stores/operation-queue/store.ts b/apps/sim/stores/operation-queue/store.ts index 427f2481560..e1655007aba 100644 --- a/apps/sim/stores/operation-queue/store.ts +++ b/apps/sim/stores/operation-queue/store.ts @@ -6,6 +6,7 @@ import type { SubblockUpdateEmit, VariableUpdateEmit, WorkflowOperationEmit, + WorkflowOperationsDrainResult, } from './types' function isBlockStillPresent(blockId: string | undefined): boolean { @@ -57,6 +58,7 @@ function clearOperationQueueTimers(): void { let emitWorkflowOperation: WorkflowOperationEmit | null = null let emitSubblockUpdate: SubblockUpdateEmit | null = null let emitVariableUpdate: VariableUpdateEmit | null = null +let resetVersion = 0 export function registerEmitFunctions( workflowEmit: WorkflowOperationEmit, @@ -472,29 +474,37 @@ export const useOperationQueueStore = create((set, get) => workflowId: string, timeoutMs = DEFAULT_WORKFLOW_DRAIN_TIMEOUT_MS ) => { + const waitResetVersion = resetVersion if (!get().hasPendingOperations(workflowId)) { - return Promise.resolve(true) + return Promise.resolve('drained' as const) } - return new Promise((resolve) => { + return new Promise((resolve) => { let unsubscribe = () => {} const timeout = setTimeout(() => { unsubscribe() - resolve(false) + resolve('failed') }, timeoutMs) unsubscribe = useOperationQueueStore.subscribe((state) => { + if (resetVersion !== waitResetVersion) { + clearTimeout(timeout) + unsubscribe() + resolve('cancelled') + return + } + if (state.hasOperationError) { clearTimeout(timeout) unsubscribe() - resolve(false) + resolve('failed') return } if (!state.operations.some((op) => op.workflowId === workflowId)) { clearTimeout(timeout) unsubscribe() - resolve(true) + resolve('drained') } }) }) @@ -662,6 +672,7 @@ export const useOperationQueueStore = create((set, get) => reset: () => { clearOperationQueueTimers() + resetVersion += 1 emitWorkflowOperation = null emitSubblockUpdate = null diff --git a/apps/sim/stores/operation-queue/types.ts b/apps/sim/stores/operation-queue/types.ts index 6961ef8e954..dcf98d08429 100644 --- a/apps/sim/stores/operation-queue/types.ts +++ b/apps/sim/stores/operation-queue/types.ts @@ -41,6 +41,8 @@ export interface QueuedOperation { userId: string } +export type WorkflowOperationsDrainResult = 'drained' | 'failed' | 'cancelled' + export interface OperationQueueState { operations: QueuedOperation[] workflowOperationVersions: Record @@ -63,7 +65,10 @@ export interface OperationQueueState { handleOperationTimeout: (operationId: string) => void processNextOperation: () => void hasPendingOperations: (workflowId: string) => boolean - waitForWorkflowOperations: (workflowId: string, timeoutMs?: number) => Promise + waitForWorkflowOperations: ( + workflowId: string, + timeoutMs?: number + ) => Promise cancelOperationsForBlock: (blockId: string) => void cancelOperationsForVariable: (variableId: string) => void diff --git a/apps/sim/stores/terminal/console/storage.test.ts b/apps/sim/stores/terminal/console/storage.test.ts index e238e1f2dfa..1dba98e58b5 100644 --- a/apps/sim/stores/terminal/console/storage.test.ts +++ b/apps/sim/stores/terminal/console/storage.test.ts @@ -153,4 +153,38 @@ describe('console persistence execution lifecycle', () => { expect(vi.getTimerCount()).toBe(0) }) + + it('lets a new owner adopt and finish a scoped execution', () => { + const execution = consolePersistence.beginScopedExecution('workflow-1') + + expect(consolePersistence.adoptScopedExecution('workflow-1')).toBe(execution) + expect(consolePersistence.endScopedExecution('workflow-1', execution)).toBe(true) + expect(consolePersistence.adoptScopedExecution('workflow-1')).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not let a stale scoped completion end its replacement', () => { + const staleExecution = consolePersistence.beginScopedExecution('workflow-1') + const currentExecution = consolePersistence.beginScopedExecution('workflow-1') + + expect(consolePersistence.endScopedExecution('workflow-1', staleExecution)).toBe(false) + expect(consolePersistence.adoptScopedExecution('workflow-1')).toBe(currentExecution) + expect(vi.getTimerCount()).toBe(1) + + expect(consolePersistence.endScopedExecution('workflow-1', currentExecution)).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('clears scoped ownership across authenticated-session resets', () => { + const previousSessionExecution = consolePersistence.beginScopedExecution('workflow-1') + + consolePersistence.reset() + const currentSessionExecution = consolePersistence.beginScopedExecution('workflow-1') + + expect(consolePersistence.endScopedExecution('workflow-1', previousSessionExecution)).toBe( + false + ) + expect(consolePersistence.adoptScopedExecution('workflow-1')).toBe(currentSessionExecution) + expect(vi.getTimerCount()).toBe(1) + }) }) diff --git a/apps/sim/stores/terminal/console/storage.ts b/apps/sim/stores/terminal/console/storage.ts index dbba3d49fd1..178ca2a67f1 100644 --- a/apps/sim/stores/terminal/console/storage.ts +++ b/apps/sim/stores/terminal/console/storage.ts @@ -253,6 +253,8 @@ class ConsolePersistenceManager { private dataProvider: (() => PersistedConsoleData) | null = null private safetyTimer: ReturnType | null = null private activeExecutions = new Set() + private scopedExecutions = new Map() + private executionScopes = new Map() private needsInitialPersist = false /** @@ -277,6 +279,24 @@ class ConsolePersistenceManager { return execution } + /** Starts a lifecycle that another owner can recover by its stable scope. */ + beginScopedExecution(scope: string): ConsolePersistenceExecution { + const existingExecution = this.scopedExecutions.get(scope) + if (existingExecution) { + this.executionEnded(existingExecution) + } + + const execution = this.executionStarted() + this.scopedExecutions.set(scope, execution) + this.executionScopes.set(execution, scope) + return execution + } + + /** Returns the active lifecycle for a stable scope without creating a new one. */ + adoptScopedExecution(scope: string): ConsolePersistenceExecution | undefined { + return this.scopedExecutions.get(scope) + } + /** * Called by the store when a running entry is added during an active execution. * Triggers one immediate persist so refreshes can hydrate visible terminal rows, @@ -294,12 +314,24 @@ class ConsolePersistenceManager { */ executionEnded(execution: ConsolePersistenceExecution): void { if (!this.activeExecutions.delete(execution)) return + const scope = this.executionScopes.get(execution) + if (scope !== undefined && this.scopedExecutions.get(scope) === execution) { + this.scopedExecutions.delete(scope) + } + this.executionScopes.delete(execution) this.persist() if (this.activeExecutions.size === 0) { this.stopSafetyTimer() } } + /** Ends a scoped lifecycle only when the caller still owns its exact token. */ + endScopedExecution(scope: string, execution: ConsolePersistenceExecution): boolean { + if (this.scopedExecutions.get(scope) !== execution) return false + this.executionEnded(execution) + return true + } + /** * Triggers an immediate persist. Used for explicit user actions * like clearing the console, and for page-hide durability. @@ -312,6 +344,8 @@ class ConsolePersistenceManager { /** Stops persistence work owned by the previous authenticated session. */ reset(): void { this.activeExecutions.clear() + this.scopedExecutions.clear() + this.executionScopes.clear() this.needsInitialPersist = false this.stopSafetyTimer() } From 32f4477ec53b5c2de219275cd2f2aee125067f34 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 15:14:00 -0700 Subject: [PATCH 08/11] fix(settings): release superseded reconnect ownership --- .../hooks/use-workflow-execution.test.tsx | 52 +++++++++++++++++- .../hooks/use-workflow-execution.ts | 53 ++++++++++++++++--- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index c76ad50f9ac..a8331883c9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -11,6 +11,7 @@ const { mockCancel, mockAdoptScopedExecution, mockBeginScopedExecution, + mockClearExecutionPointer, mockEndScopedExecution, mockExecute, mockExecuteFromBlock, @@ -91,6 +92,7 @@ const { mockCancel: vi.fn(), mockAdoptScopedExecution: vi.fn(), mockBeginScopedExecution: vi.fn(() => ({})), + mockClearExecutionPointer: vi.fn(), mockEndScopedExecution: vi.fn(() => true), mockExecute: vi.fn(), mockExecuteFromBlock: vi.fn(), @@ -245,7 +247,7 @@ vi.mock('@/stores/execution', () => ({ })) vi.mock('@/stores/terminal', () => ({ - clearExecutionPointer: vi.fn(), + clearExecutionPointer: mockClearExecutionPointer, consolePersistence: { adoptScopedExecution: mockAdoptScopedExecution, beginScopedExecution: mockBeginScopedExecution, @@ -629,6 +631,54 @@ describe('useWorkflowExecution attachment uploads', () => { unmount() }) + it('releases only its persistence ownership when a reconnect retry is superseded', async () => { + const persistenceExecution = {} + terminalStoreState._hasHydrated = true + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + status: 'running', + isExecuting: true, + currentExecutionId: 'execution-1', + }) + executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1') + mockLoadExecutionPointer.mockResolvedValue({ + workflowId: 'workflow-1', + executionId: 'execution-1', + lastEventId: 0, + }) + mockAdoptScopedExecution.mockReturnValue(persistenceExecution) + mockReconnect.mockImplementationOnce(async ({ callbacks }) => { + callbacks.onBlockStarted({ + blockId: 'start', + blockName: 'Start', + blockType: 'starter', + executionOrder: 1, + }) + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...executionStoreState.getWorkflowExecution(), + status: 'running', + isExecuting: true, + currentExecutionId: 'execution-2', + }) + executionStoreState.getCurrentExecutionId.mockReturnValue('execution-2') + throw new Error('Reconnect failed after replacement started') + }) + + const { unmount } = renderWorkflowExecutionHook() + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockEndScopedExecution).toHaveBeenCalledWith('workflow-1', persistenceExecution) + expect(executionStoreState.setCurrentExecutionId).not.toHaveBeenCalledWith('workflow-1', null) + expect(executionStoreState.setIsExecuting).not.toHaveBeenCalledWith('workflow-1', false) + expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled() + expect(mockClearExecutionPointer).not.toHaveBeenCalled() + + unmount() + }) + it('uses only projected live thinking without changing normal settle behavior', async () => { mockExecute.mockImplementationOnce(async (options) => { options.onExecutionId?.('execution-1') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index e80df524a28..839793f243c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -2425,6 +2425,13 @@ export function useWorkflowExecution() { let activated = false let activationOwnsPersistence = false let reconnectPersistenceExecution: ConsolePersistenceExecution | undefined + const releaseReconnectPersistenceOwnership = () => { + const persistenceExecution = reconnectPersistenceExecution + reconnectPersistenceExecution = undefined + activationOwnsPersistence = false + if (!persistenceExecution) return + consolePersistence.endScopedExecution(reconnectWorkflowId, persistenceExecution) + } const isReconnectStillCurrent = canReconnectClaimWorkflow const finishReconnectExecution = () => { if (reconnectPersistenceExecution) { @@ -2440,12 +2447,16 @@ export function useWorkflowExecution() { if (ownedReconnectExecutionId) { executionStream.cancelReconnect(reconnectWorkflowId, ownedReconnectExecutionId) } + releaseReconnectPersistenceOwnership() releaseReconnectOwnership() } const releaseActivatedReconnectState = () => { if (!activated) return const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId) - if (currentId !== capturedExecutionId) return + if (currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } setCurrentExecutionId(reconnectWorkflowId, null) if (activationOwnsPersistence) finishReconnectExecution() else rawSetIsExecuting(reconnectWorkflowId, false) @@ -2456,7 +2467,10 @@ export function useWorkflowExecution() { .getState() .getWorkflowExecution(reconnectWorkflowId) const currentId = executionState?.currentExecutionId ?? null - if (currentId && currentId !== capturedExecutionId) return + if (currentId && currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } handleExecutionErrorConsole({ workflowId: reconnectWorkflowId, executionId: capturedExecutionId, @@ -2470,9 +2484,12 @@ export function useWorkflowExecution() { const scheduleRetryableReconnect = () => { releaseReconnectOwnership() retryTimeoutId = setTimeout(() => { - if (!cleanupRan && !reconnectionComplete) { - setReconnectAttemptNonce((nonce) => nonce + 1) + if (cleanupRan || reconnectionComplete) return + if (!isReconnectStillCurrent()) { + stopStaleReconnect() + return } + setReconnectAttemptNonce((nonce) => nonce + 1) }, MAX_DELAY_MS) } const ensureActivated = () => { @@ -2509,6 +2526,10 @@ export function useWorkflowExecution() { const delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS) await sleep(delay) if (cleanupRan || reconnectionComplete) return + if (!isReconnectStillCurrent()) { + stopStaleReconnect() + return + } } try { @@ -2543,7 +2564,10 @@ export function useWorkflowExecution() { const currentId = useExecutionStore .getState() .getCurrentExecutionId(reconnectWorkflowId) - if (currentId !== capturedExecutionId) return + if (currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } reconcileFinalBlockLogs( updateConsole, reconnectWorkflowId, @@ -2562,7 +2586,10 @@ export function useWorkflowExecution() { const currentId = useExecutionStore .getState() .getCurrentExecutionId(reconnectWorkflowId) - if (currentId !== capturedExecutionId) return + if (currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } reconcileFinalBlockLogs( updateConsole, reconnectWorkflowId, @@ -2591,7 +2618,10 @@ export function useWorkflowExecution() { const currentId = useExecutionStore .getState() .getCurrentExecutionId(reconnectWorkflowId) - if (currentId !== capturedExecutionId) return + if (currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } handleExecutionErrorConsole({ workflowId: reconnectWorkflowId, executionId: capturedExecutionId, @@ -2611,7 +2641,10 @@ export function useWorkflowExecution() { const currentId = useExecutionStore .getState() .getCurrentExecutionId(reconnectWorkflowId) - if (currentId !== capturedExecutionId) return + if (currentId !== capturedExecutionId) { + releaseReconnectPersistenceOwnership() + return + } handleExecutionCancelledConsole({ workflowId: reconnectWorkflowId, executionId: capturedExecutionId, @@ -2625,6 +2658,10 @@ export function useWorkflowExecution() { }, }) } catch (error) { + if (!isReconnectStillCurrent()) { + stopStaleReconnect() + return + } if (isReconnectNonRetryable(error)) { logger.info('Reconnection skipped; run buffer no longer exists', { executionId: capturedExecutionId, From f0227a4f254916508869bbd6a58e001c100ffcf1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 15:31:14 -0700 Subject: [PATCH 09/11] fix(settings): preserve debug lifecycle ownership --- .claude/rules/sim-react-performance.md | 9 + .../hooks/use-workflow-execution.test.tsx | 65 +++++- .../hooks/use-workflow-execution.ts | 189 +++++++++++------- 3 files changed, 186 insertions(+), 77 deletions(-) diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index 932e1e1f4da..a78c2e984e4 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -90,6 +90,15 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams]) Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read). +## Carry exact lifecycle ownership across async boundaries + +When asynchronous work can outlive an execution, session, or resource instance, capture its +opaque ownership token before the first `await` and pass that exact token through completion and +error cleanup. Never re-adopt the current owner from delayed cleanup: a replacement may now own +the same scope. End the lifecycle by exact-token match, and clear shared state only when that end +succeeds. Current-owner adoption is reserved for synchronous user actions that explicitly stop +the current lifecycle. + ## Prefetch dynamic destination lists on intent For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index a8331883c9b..3b480344010 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { executionStoreState, + idleExecution, mockCancel, mockAdoptScopedExecution, mockBeginScopedExecution, @@ -89,6 +90,7 @@ const { return { executionStoreState, + idleExecution, mockCancel: vi.fn(), mockAdoptScopedExecution: vi.fn(), mockBeginScopedExecution: vi.fn(() => ({})), @@ -416,9 +418,8 @@ describe('useWorkflowExecution attachment uploads', () => { beforeEach(() => { vi.clearAllMocks() terminalStoreState._hasHydrated = false - executionStoreState.getWorkflowExecution.mockReturnValue( - executionStoreState.workflowExecutions.get('workflow-1')! - ) + executionStoreState.workflowExecutions.set('workflow-1', idleExecution) + executionStoreState.getWorkflowExecution.mockReturnValue(idleExecution) executionStoreState.getCurrentExecutionId.mockReturnValue(null) mockAdoptScopedExecution.mockReturnValue(undefined) mockLoadExecutionPointer.mockResolvedValue(null) @@ -679,6 +680,64 @@ describe('useWorkflowExecution attachment uploads', () => { unmount() }) + it('does not let delayed debug completion reset a replacement execution', async () => { + const debugPersistenceExecution = {} + const replacementPersistenceExecution = {} + let currentPersistenceExecution: object | undefined = debugPersistenceExecution + let resolveDebugStep: ((result: unknown) => void) | undefined + const continueExecution = vi.fn( + () => + new Promise((resolve) => { + resolveDebugStep = resolve + }) + ) + const debugExecution = { + ...idleExecution, + status: 'running', + isExecuting: true, + isDebugging: true, + pendingBlocks: ['start'], + executor: { continueExecution }, + debugContext: { blockLogs: [] }, + } + executionStoreState.workflowExecutions.set('workflow-1', debugExecution) + executionStoreState.getWorkflowExecution.mockReturnValue(debugExecution) + mockAdoptScopedExecution.mockImplementation(() => currentPersistenceExecution) + mockEndScopedExecution.mockImplementation((_workflowId, persistenceExecution) => { + if (persistenceExecution !== currentPersistenceExecution) return false + currentPersistenceExecution = undefined + return true + }) + + const { result, unmount } = renderWorkflowExecutionHook() + let debugStep: Promise + act(() => { + debugStep = result().handleStepDebug() + }) + expect(continueExecution).toHaveBeenCalledOnce() + + currentPersistenceExecution = replacementPersistenceExecution + resolveDebugStep?.({ success: true, output: {}, logs: [] }) + await act(async () => { + await debugStep + }) + + expect(mockEndScopedExecution).not.toHaveBeenCalledWith( + 'workflow-1', + replacementPersistenceExecution + ) + expect(mockClearExecutionPointer).not.toHaveBeenCalled() + expect(executionStoreState.setIsExecuting).not.toHaveBeenCalledWith('workflow-1', false) + expect(executionStoreState.setIsDebugging).not.toHaveBeenCalledWith('workflow-1', false) + expect(executionStoreState.setDebugContext).not.toHaveBeenCalledWith('workflow-1', null) + expect(executionStoreState.setExecutor).not.toHaveBeenCalledWith('workflow-1', null) + expect(executionStoreState.setPendingBlocks).not.toHaveBeenCalledWith('workflow-1', []) + expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled() + expect(mockRequestJson).not.toHaveBeenCalled() + + unmount() + }) + it('uses only projected live thinking without changing normal settle behavior', async () => { mockExecute.mockImplementationOnce(async (options) => { options.onExecutionId?.('execution-1') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 839793f243c..114fbf22fa0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -109,6 +109,16 @@ interface DebugValidationResult { error?: string } +function ownsScopedPersistenceExecution( + workflowId: string, + persistenceExecution: ConsolePersistenceExecution | undefined +): persistenceExecution is ConsolePersistenceExecution { + return ( + persistenceExecution !== undefined && + consolePersistence.adoptScopedExecution(workflowId) === persistenceExecution + ) +} + const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed' function getExecutionDisplayError(data: unknown): { @@ -433,41 +443,41 @@ export function useWorkflowExecution() { const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId) const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting) - const endPersistenceExecution = useCallback((workflowId: string) => { - const persistenceExecution = consolePersistence.adoptScopedExecution(workflowId) - if (!persistenceExecution) return - consolePersistence.endScopedExecution(workflowId, persistenceExecution) - }, []) - - const setIsExecuting = useCallback( - (workflowId: string, executing: boolean): ConsolePersistenceExecution | undefined => { + const startExecution = useCallback( + (workflowId: string): ConsolePersistenceExecution | undefined => { const wasExecuting = useExecutionStore.getState().getWorkflowExecution(workflowId).isExecuting - if (executing) { - if (!wasExecuting) { - const startedExecution = consolePersistence.beginScopedExecution(workflowId) - rawSetIsExecuting(workflowId, true) - return startedExecution - } - } else { - if (wasExecuting) { - endPersistenceExecution(workflowId) - } - clearExecutionPointer(workflowId) - } - rawSetIsExecuting(workflowId, executing) - return undefined + if (wasExecuting) return undefined + const persistenceExecution = consolePersistence.beginScopedExecution(workflowId) + rawSetIsExecuting(workflowId, true) + return persistenceExecution }, - [endPersistenceExecution, rawSetIsExecuting] + [rawSetIsExecuting] ) const finishOwnedExecution = useCallback( - (workflowId: string, persistenceExecution: ConsolePersistenceExecution | undefined) => { - if (!persistenceExecution) return - if (!consolePersistence.endScopedExecution(workflowId, persistenceExecution)) return + ( + workflowId: string, + persistenceExecution: ConsolePersistenceExecution | undefined + ): boolean => { + if (!persistenceExecution) return false + if (!consolePersistence.endScopedExecution(workflowId, persistenceExecution)) return false clearExecutionPointer(workflowId) rawSetIsExecuting(workflowId, false) + return true }, [rawSetIsExecuting] ) + const finishCurrentExecution = useCallback( + (workflowId: string): boolean => { + const persistenceExecution = consolePersistence.adoptScopedExecution(workflowId) + if (persistenceExecution) { + return finishOwnedExecution(workflowId, persistenceExecution) + } + clearExecutionPointer(workflowId) + rawSetIsExecuting(workflowId, false) + return true + }, + [finishOwnedExecution, rawSetIsExecuting] + ) const setIsDebugging = useExecutionStore((s) => s.setIsDebugging) const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks) const setExecutor = useExecutionStore((s) => s.setExecutor) @@ -509,23 +519,30 @@ export function useWorkflowExecution() { /** * Resets all debug-related state */ + const clearDebugState = useCallback( + (workflowId: string) => { + setIsDebugging(workflowId, false) + setDebugContext(workflowId, null) + setExecutor(workflowId, null) + setPendingBlocks(workflowId, []) + setActiveBlocks(workflowId, new Set()) + }, + [setActiveBlocks, setDebugContext, setExecutor, setIsDebugging, setPendingBlocks] + ) + + const resetOwnedDebugState = useCallback( + (workflowId: string, persistenceExecution: ConsolePersistenceExecution | undefined) => { + if (!finishOwnedExecution(workflowId, persistenceExecution)) return + clearDebugState(workflowId) + }, + [clearDebugState, finishOwnedExecution] + ) + const resetDebugState = useCallback(() => { if (!activeWorkflowId) return - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setDebugContext(activeWorkflowId, null) - setExecutor(activeWorkflowId, null) - setPendingBlocks(activeWorkflowId, []) - setActiveBlocks(activeWorkflowId, new Set()) - }, [ - activeWorkflowId, - setIsExecuting, - setIsDebugging, - setDebugContext, - setExecutor, - setPendingBlocks, - setActiveBlocks, - ]) + if (!finishCurrentExecution(activeWorkflowId)) return + clearDebugState(activeWorkflowId) + }, [activeWorkflowId, clearDebugState, finishCurrentExecution]) const handleExecutionErrorConsole = useCallback( (params: { @@ -591,45 +608,60 @@ export function useWorkflowExecution() { * Handles debug session completion */ const handleDebugSessionComplete = useCallback( - async (result: ExecutionResult) => { + async ( + result: ExecutionResult, + workflowId: string, + persistenceExecution: ConsolePersistenceExecution | undefined + ) => { + if (!ownsScopedPersistenceExecution(workflowId, persistenceExecution)) return logger.info('Debug session complete') setExecutionResult(result) // Persist logs - await persistLogs(generateId(), result) + await persistLogs(workflowId, generateId(), result) // Reset debug state - resetDebugState() + resetOwnedDebugState(workflowId, persistenceExecution) }, - [activeWorkflowId, resetDebugState] + [resetOwnedDebugState] ) /** * Handles debug session continuation */ const handleDebugSessionContinuation = useCallback( - (result: ExecutionResult) => { - if (!activeWorkflowId) return + ( + result: ExecutionResult, + workflowId: string, + persistenceExecution: ConsolePersistenceExecution | undefined + ) => { + if (!ownsScopedPersistenceExecution(workflowId, persistenceExecution)) return logger.info('Debug step completed, next blocks pending', { nextPendingBlocks: result.metadata?.pendingBlocks?.length || 0, }) // Update debug context and pending blocks if (result.metadata?.context) { - setDebugContext(activeWorkflowId, result.metadata.context) + setDebugContext(workflowId, result.metadata.context) } if (result.metadata?.pendingBlocks) { - setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks) + setPendingBlocks(workflowId, result.metadata.pendingBlocks) } }, - [activeWorkflowId, setDebugContext, setPendingBlocks] + [setDebugContext, setPendingBlocks] ) /** * Handles debug execution errors */ const handleDebugExecutionError = useCallback( - async (error: any, operation: string) => { + async ( + error: any, + operation: string, + workflowId: string, + persistenceExecution: ConsolePersistenceExecution | undefined + ) => { + if (!ownsScopedPersistenceExecution(workflowId, persistenceExecution)) return logger.error(`Debug ${operation} Error:`, error) const errorMessage = toError(error).message @@ -643,15 +675,16 @@ export function useWorkflowExecution() { setExecutionResult(errorResult) // Persist logs - await persistLogs(generateId(), errorResult) + await persistLogs(workflowId, generateId(), errorResult) // Reset debug state - resetDebugState() + resetOwnedDebugState(workflowId, persistenceExecution) }, - [debugContext, activeWorkflowId, resetDebugState] + [debugContext, resetOwnedDebugState] ) const persistLogs = async ( + workflowId: string, executionId: string, result: ExecutionResult, streamContent?: string @@ -690,9 +723,8 @@ export function useWorkflowExecution() { } } - if (!activeWorkflowId) return executionId await requestJson(workflowLogContract, { - params: { id: activeWorkflowId }, + params: { id: workflowId }, body: { executionId, result: enrichedResult, @@ -723,7 +755,7 @@ export function useWorkflowExecution() { // Reset execution result and set execution state setExecutionResult(null) - const persistenceExecution = setIsExecuting(activeWorkflowId, true) + const persistenceExecution = startExecution(activeWorkflowId) // Set debug mode only if explicitly requested if (enableDebug) { @@ -1028,7 +1060,7 @@ export function useWorkflowExecution() { currentWorkflow, toggleConsole, getVariablesByWorkflowId, - setIsExecuting, + startExecution, finishOwnedExecution, setIsDebugging, setDebugContext, @@ -1752,6 +1784,8 @@ export function useWorkflowExecution() { resetDebugState() return } + if (!activeWorkflowId) return + const persistenceExecution = consolePersistence.adoptScopedExecution(activeWorkflowId) try { logger.info('Executing debug step with blocks:', pendingBlocks) @@ -1759,12 +1793,12 @@ export function useWorkflowExecution() { logger.info('Debug step execution result:', result) if (isDebugSessionComplete(result)) { - await handleDebugSessionComplete(result) + await handleDebugSessionComplete(result, activeWorkflowId, persistenceExecution) } else { - handleDebugSessionContinuation(result) + handleDebugSessionContinuation(result, activeWorkflowId, persistenceExecution) } } catch (error: any) { - await handleDebugExecutionError(error, 'step') + await handleDebugExecutionError(error, 'step', activeWorkflowId, persistenceExecution) } }, [ executor, @@ -1795,6 +1829,8 @@ export function useWorkflowExecution() { resetDebugState() return } + if (!activeWorkflowId) return + const persistenceExecution = consolePersistence.adoptScopedExecution(activeWorkflowId) try { logger.info('Resuming workflow execution until completion') @@ -1821,6 +1857,7 @@ export function useWorkflowExecution() { ) currentResult = await executor!.continueExecution(currentPendingBlocks, currentContext) + if (!ownsScopedPersistenceExecution(activeWorkflowId, persistenceExecution)) return logger.info('Resume iteration result:', { success: currentResult.success, @@ -1863,9 +1900,9 @@ export function useWorkflowExecution() { }) // Handle completion - await handleDebugSessionComplete(currentResult) + await handleDebugSessionComplete(currentResult, activeWorkflowId, persistenceExecution) } catch (error: any) { - await handleDebugExecutionError(error, 'resume') + await handleDebugExecutionError(error, 'resume', activeWorkflowId, persistenceExecution) } }, [ executor, @@ -1894,6 +1931,9 @@ export function useWorkflowExecution() { logger.info('Workflow execution cancellation requested') const storedExecutionId = getCurrentExecutionId(activeWorkflowId) + const debugPersistenceExecution = isDebugging + ? consolePersistence.adoptScopedExecution(activeWorkflowId) + : undefined if (storedExecutionId) { void requestJson(cancelWorkflowExecutionContract, { @@ -1928,19 +1968,20 @@ export function useWorkflowExecution() { executionStream.cancel(activeWorkflowId) currentChatExecutionIdRef.current = null runFromBlockOwnerRef.current = null - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) } if (isDebugging) { - resetDebugState() + resetOwnedDebugState(activeWorkflowId, debugPersistenceExecution) + } else if (!storedExecutionId) { + finishCurrentExecution(activeWorkflowId) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) } }, [ executionStream, isDebugging, - resetDebugState, - setIsExecuting, + resetOwnedDebugState, + finishCurrentExecution, setIsDebugging, setActiveBlocks, activeWorkflowId, @@ -2038,7 +2079,7 @@ export function useWorkflowExecution() { } } - const persistenceExecution = setIsExecuting(workflowId, true) + const persistenceExecution = startExecution(workflowId) const runOwnerId = generateId() runFromBlockOwnerRef.current = runOwnerId const executionIdRef = { current: '' } @@ -2289,7 +2330,7 @@ export function useWorkflowExecution() { clearLastExecutionSnapshot, getCurrentExecutionId, setCurrentExecutionId, - setIsExecuting, + startExecution, finishOwnedExecution, setActiveBlocks, setBlockRunStatus, @@ -2318,7 +2359,7 @@ export function useWorkflowExecution() { logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId }) setExecutionResult(null) - const persistenceExecution = setIsExecuting(workflowId, true) + const persistenceExecution = startExecution(workflowId) const executionId = generateId() try { @@ -2337,7 +2378,7 @@ export function useWorkflowExecution() { return errorResult } }, - [activeWorkflowId, setExecutionResult, setIsExecuting] + [activeWorkflowId, setExecutionResult, startExecution] ) useEffect(() => { @@ -2502,7 +2543,7 @@ export function useWorkflowExecution() { activated = true setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId) reconnectPersistenceExecution = - setIsExecuting(reconnectWorkflowId, true) ?? + startExecution(reconnectWorkflowId) ?? consolePersistence.adoptScopedExecution(reconnectWorkflowId) activationOwnsPersistence = Boolean(reconnectPersistenceExecution) if (fromEventId === 0) { From 220fc37ee025e22a6e1394b511a57f69c5999845 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 15:57:08 -0700 Subject: [PATCH 10/11] fix(settings): tighten intent and execution boundaries --- .claude/rules/sim-settings-pages.md | 9 +-- .../settings-empty-state.tsx | 1 - .../hooks/use-workflow-execution.test.tsx | 51 +++++++++++++++-- .../hooks/use-workflow-execution.ts | 23 +++++--- .../settings-query-warmers.test.ts | 54 ++++++------------ .../settings-query-warmers.ts | 17 +----- apps/sim/hooks/queries/byok-key-list.ts | 34 ----------- apps/sim/hooks/queries/byok-keys.ts | 40 +++++++++++-- apps/sim/hooks/queries/mcp-server-list.ts | 47 ---------------- apps/sim/hooks/queries/mcp.ts | 56 ++++++++++++++++--- .../hooks/queries/workflow-mcp-server-list.ts | 51 ----------------- .../sim/hooks/queries/workflow-mcp-servers.ts | 54 ++++++++++++++---- apps/sim/stores/chat/store.test.ts | 5 +- apps/sim/stores/index.test.ts | 5 +- apps/sim/stores/index.ts | 1 - .../stores/user-data-reset-registry.test.ts | 3 +- 16 files changed, 214 insertions(+), 237 deletions(-) delete mode 100644 apps/sim/hooks/queries/byok-key-list.ts delete mode 100644 apps/sim/hooks/queries/mcp-server-list.ts delete mode 100644 apps/sim/hooks/queries/workflow-mcp-server-list.ts diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 2c534cd223a..763803ed7db 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -104,10 +104,11 @@ Adding a new settings page: 2. Render the component inside the shell's `effectiveSection` switch in `settings/[section]/settings.tsx`. 3. Build the component body inside `` — no shell, no title block. -4. When the initial body depends on server data, export shared React Query options for both the - mounted consumer and the settings intent warmer. Warm only authorized destinations, preserve - the current section during the transition, and follow the failure-recovery rules in - `sim-react-performance.md`; never render temporary default data that will be replaced after load. +4. When a real second consumer or server boundary needs it, extract client-safe React Query options; + otherwise keep them with the hook. Approved intent warmers reuse those exact options and must keep + `check-tool-registry-boundary` green. Warm only authorized destinations, preserve the current + section during the transition, and follow `sim-react-performance.md` recovery rules; never render + temporary default data that will be replaced after load. ## Text-scale tokens (no literal pixel sizes) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx index 5858934e150..ec541844f65 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx @@ -45,7 +45,6 @@ export function SettingsEmptyState({ ) } -/** Canonical recoverable error state for settings queries. */ export function SettingsQueryErrorState({ error, fallback, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index 3b480344010..4eab4ada664 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -417,6 +417,7 @@ describe('useWorkflowExecution cancellation', () => { describe('useWorkflowExecution attachment uploads', () => { beforeEach(() => { vi.clearAllMocks() + mockEndScopedExecution.mockReset().mockReturnValue(true) terminalStoreState._hasHydrated = false executionStoreState.workflowExecutions.set('workflow-1', idleExecution) executionStoreState.getWorkflowExecution.mockReturnValue(idleExecution) @@ -562,18 +563,23 @@ describe('useWorkflowExecution attachment uploads', () => { it('does not let an overlapping run without lifecycle ownership end the active run', async () => { const persistenceExecution = {} let resolveActiveRun: (() => void) | undefined + let markExecutionStarted: (() => void) | undefined + const executionStarted = new Promise((resolve) => { + markExecutionStarted = resolve + }) mockBeginScopedExecution.mockReturnValueOnce(persistenceExecution) - mockExecute.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveActiveRun = resolve - }) - ) + mockExecute.mockImplementationOnce(() => { + markExecutionStarted?.() + return new Promise((resolve) => { + resolveActiveRun = resolve + }) + }) const { result, unmount } = renderWorkflowExecutionHook() let activeRun: unknown await act(async () => { activeRun = await result().handleRunWorkflow({ input: 'active run' }) + await executionStarted }) executionStoreState.getWorkflowExecution.mockReturnValue({ @@ -586,7 +592,11 @@ describe('useWorkflowExecution attachment uploads', () => { }) expect(mockBeginScopedExecution).toHaveBeenCalledTimes(1) + expect(mockExecute).toHaveBeenCalledTimes(1) expect(mockEndScopedExecution).not.toHaveBeenCalled() + expect(executionStoreState.setCurrentExecutionId).not.toHaveBeenCalled() + expect(executionStoreState.setIsDebugging).not.toHaveBeenCalled() + expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled() await act(async () => { resolveActiveRun?.() @@ -599,6 +609,35 @@ describe('useWorkflowExecution attachment uploads', () => { unmount() }) + it('rejects overlapping block runs before starting another execution', async () => { + executionStoreState.getWorkflowExecution.mockReturnValue({ + ...idleExecution, + isExecuting: true, + }) + const startCandidate = { + blockId: 'start', + block: workflowBlocks.start, + path: 'legacy-starter', + } + mockResolveStartCandidates.mockReturnValue([startCandidate]) + + const { result, unmount } = renderWorkflowExecutionHook() + + await act(async () => { + await result().handleRunUntilBlock('start', 'workflow-1') + await result().handleRunFromBlock('start', 'workflow-1') + }) + + expect(mockBeginScopedExecution).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() + expect(mockExecuteFromBlock).not.toHaveBeenCalled() + expect(executionStoreState.setCurrentExecutionId).not.toHaveBeenCalled() + expect(executionStoreState.setIsDebugging).not.toHaveBeenCalled() + expect(executionStoreState.setActiveBlocks).not.toHaveBeenCalled() + + unmount() + }) + it('adopts and finishes persistence ownership created before the hook mounted', async () => { const persistenceExecution = {} terminalStoreState._hasHydrated = true diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 114fbf22fa0..6fd7b15084b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -443,7 +443,7 @@ export function useWorkflowExecution() { const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId) const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting) - const startExecution = useCallback( + const tryStartExecution = useCallback( (workflowId: string): ConsolePersistenceExecution | undefined => { const wasExecuting = useExecutionStore.getState().getWorkflowExecution(workflowId).isExecuting if (wasExecuting) return undefined @@ -753,9 +753,11 @@ export function useWorkflowExecution() { return } + const persistenceExecution = tryStartExecution(activeWorkflowId) + if (!persistenceExecution) return + // Reset execution result and set execution state setExecutionResult(null) - const persistenceExecution = startExecution(activeWorkflowId) // Set debug mode only if explicitly requested if (enableDebug) { @@ -1060,7 +1062,7 @@ export function useWorkflowExecution() { currentWorkflow, toggleConsole, getVariablesByWorkflowId, - startExecution, + tryStartExecution, finishOwnedExecution, setIsDebugging, setDebugContext, @@ -2079,7 +2081,9 @@ export function useWorkflowExecution() { } } - const persistenceExecution = startExecution(workflowId) + const persistenceExecution = tryStartExecution(workflowId) + if (!persistenceExecution) return + const runOwnerId = generateId() runFromBlockOwnerRef.current = runOwnerId const executionIdRef = { current: '' } @@ -2330,7 +2334,7 @@ export function useWorkflowExecution() { clearLastExecutionSnapshot, getCurrentExecutionId, setCurrentExecutionId, - startExecution, + tryStartExecution, finishOwnedExecution, setActiveBlocks, setBlockRunStatus, @@ -2356,10 +2360,11 @@ export function useWorkflowExecution() { return } - logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId }) + const persistenceExecution = tryStartExecution(workflowId) + if (!persistenceExecution) return + logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId }) setExecutionResult(null) - const persistenceExecution = startExecution(workflowId) const executionId = generateId() try { @@ -2378,7 +2383,7 @@ export function useWorkflowExecution() { return errorResult } }, - [activeWorkflowId, setExecutionResult, startExecution] + [activeWorkflowId, setExecutionResult, tryStartExecution] ) useEffect(() => { @@ -2543,7 +2548,7 @@ export function useWorkflowExecution() { activated = true setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId) reconnectPersistenceExecution = - startExecution(reconnectWorkflowId) ?? + tryStartExecution(reconnectWorkflowId) ?? consolePersistence.adoptScopedExecution(reconnectWorkflowId) activationOwnsPersistence = Boolean(reconnectPersistenceExecution) if (fromEventId === 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts index 13322f0769a..c11fda3f450 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.test.ts @@ -13,7 +13,6 @@ vi.mock('@/lib/api/client/request', () => ({ })) import { warmSettingsSectionQuery } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers' -import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' let queryClient: QueryClient @@ -25,16 +24,16 @@ describe('settings query warmers', () => { defaultOptions: { queries: { retry: false, retryOnMount: false } }, }) mockRequestJson.mockImplementation((contract: { path: string }) => { - if (contract.path === '/api/mcp/servers' || contract.path === '/api/mcp/workflow-servers') { - return Promise.resolve({ data: { servers: [] } }) - } - if (contract.path === '/api/workspaces/[id]/sandboxes') { - return Promise.resolve({ sandboxes: [], entitled: true, strategy: 'prebuilt' }) - } if (contract.path === '/api/credentials') { return Promise.resolve({ credentials: [] }) } - return Promise.resolve({ keys: [] }) + if ( + contract.path === '/api/billing' || + contract.path === '/api/organizations/[id]/billing-summary' + ) { + return Promise.resolve({}) + } + throw new Error(`Unexpected settings warmer contract: ${contract.path}`) }) }) @@ -43,28 +42,11 @@ describe('settings query warmers', () => { vi.clearAllMocks() }) - it('warms only the approved first-content list for each section', async () => { - expect(warmSettingsSectionQuery(queryClient, personalContext, 'apikeys')).toBe(true) - expect(warmSettingsSectionQuery(queryClient, personalContext, 'sandboxes')).toBe(true) - expect(warmSettingsSectionQuery(queryClient, personalContext, 'byok')).toBe(true) - expect(warmSettingsSectionQuery(queryClient, personalContext, 'mcp')).toBe(true) + it('warms only first-content data already present in the shared sidebar graph', async () => { expect(warmSettingsSectionQuery(queryClient, personalContext, 'secrets')).toBe(true) - expect(warmSettingsSectionQuery(queryClient, personalContext, 'workflow-mcp-servers')).toBe( - true - ) - await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(7)) - expect(mockRequestJson.mock.calls.map(([contract]) => contract.path)).toEqual( - expect.arrayContaining([ - '/api/workspaces/[id]/api-keys', - '/api/users/me/api-keys', - '/api/workspaces/[id]/sandboxes', - '/api/workspaces/[id]/byok-keys', - '/api/mcp/servers', - '/api/mcp/workflow-servers', - '/api/credentials', - ]) - ) + await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(1)) + expect(mockRequestJson.mock.calls[0][0].path).toBe('/api/credentials') expect( mockRequestJson.mock.calls.find(([contract]) => contract.path === '/api/credentials')?.[1] ).toEqual( @@ -73,6 +55,13 @@ describe('settings query warmers', () => { }) it('does not warm broad settings data', () => { + expect(warmSettingsSectionQuery(queryClient, personalContext, 'apikeys')).toBe(false) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'sandboxes')).toBe(false) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'byok')).toBe(false) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'mcp')).toBe(false) + expect(warmSettingsSectionQuery(queryClient, personalContext, 'workflow-mcp-servers')).toBe( + false + ) expect(warmSettingsSectionQuery(queryClient, personalContext, 'custom-tools')).toBe(false) expect(mockRequestJson).not.toHaveBeenCalled() @@ -100,15 +89,6 @@ describe('settings query warmers', () => { ) }) - it('deduplicates a successful API-key warm with the eventual consumer', async () => { - warmSettingsSectionQuery(queryClient, personalContext, 'apikeys') - await vi.waitFor(() => expect(mockRequestJson).toHaveBeenCalledTimes(2)) - - await queryClient.fetchQuery(apiKeysQueryOptions('workspace-1', 'combined')) - - expect(mockRequestJson).toHaveBeenCalledTimes(2) - }) - it('keeps the Secrets warmer and consumer on mount-recoverable shared options', () => { const options = workspaceCredentialListQueryOptions('workspace-1', 'env_workspace') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts index 5f05cb3e92a..cd3f68d822c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-query-warmers.ts @@ -1,14 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { apiKeysQueryOptions } from '@/hooks/queries/api-key-list' -import { byokKeysQueryOptions } from '@/hooks/queries/byok-key-list' -import { mcpServersQueryOptions } from '@/hooks/queries/mcp-server-list' import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary' -import { getSandboxListQueryOptions } from '@/hooks/queries/sandbox-list' import { subscriptionDataQueryOptions } from '@/hooks/queries/subscription-data' import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent' -import { workflowMcpServersQueryOptions } from '@/hooks/queries/workflow-mcp-server-list' const SETTINGS_QUERY_WARMERS: Partial< Record void> @@ -18,16 +13,6 @@ const SETTINGS_QUERY_WARMERS: Partial< queryClient, workspaceCredentialListQueryOptions(workspaceId, 'env_workspace') ), - apikeys: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, apiKeysQueryOptions(workspaceId, 'combined')), - sandboxes: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, getSandboxListQueryOptions(workspaceId)), - byok: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, byokKeysQueryOptions(workspaceId)), - mcp: (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, mcpServersQueryOptions(workspaceId)), - 'workflow-mcp-servers': (queryClient, { workspaceId }) => - prefetchQueryOnIntent(queryClient, workflowMcpServersQueryOptions(workspaceId)), billing: (queryClient, { billingOrganizationId }) => { if (billingOrganizationId) { prefetchQueryOnIntent(queryClient, organizationBillingSummaryOptions(billingOrganizationId)) @@ -42,7 +27,7 @@ export interface SettingsQueryWarmContext { billingOrganizationId: string | null } -/** Starts only the first-content query explicitly approved for a settings section. */ +/** Starts approved first-content data within the workspace graph's enforced module budget. */ export function warmSettingsSectionQuery( queryClient: QueryClient, context: SettingsQueryWarmContext, diff --git a/apps/sim/hooks/queries/byok-key-list.ts b/apps/sim/hooks/queries/byok-key-list.ts deleted file mode 100644 index f935141cbb5..00000000000 --- a/apps/sim/hooks/queries/byok-key-list.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { requestJson } from '@/lib/api/client/request' -import { type BYOKKeysResponse, listByokKeysContract } from '@/lib/api/contracts/byok-keys' - -export const byokKeysKeys = { - all: ['byok-keys'] as const, - lists: () => [...byokKeysKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const, - organizationLists: () => [...byokKeysKeys.all, 'organization-list'] as const, - organizationList: (organizationId?: string) => - [...byokKeysKeys.organizationLists(), organizationId ?? ''] as const, - inheritedStatuses: () => [...byokKeysKeys.all, 'inherited-status'] as const, - inheritedStatus: (workspaceId?: string) => - [...byokKeysKeys.inheritedStatuses(), workspaceId ?? ''] as const, -} - -export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000 - -async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { - const data = await requestJson(listByokKeysContract, { - params: { id: workspaceId }, - signal, - }) - return { keys: data.keys ?? [] } -} - -export function byokKeysQueryOptions(workspaceId: string) { - return queryOptions({ - queryKey: byokKeysKeys.list(workspaceId), - queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal), - retryOnMount: true, - staleTime: BYOK_KEY_LIST_STALE_TIME, - }) -} diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 06ce3bfbb69..03767561e63 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -1,28 +1,56 @@ import { createLogger } from '@sim/logger' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { type BYOKKey, + type BYOKKeysResponse, deleteByokKeyContract, deleteOrganizationByokKeyContract, getInheritedByokStatusContract, type InheritedBYOKStatusResponse, + listByokKeysContract, listOrganizationByokKeysContract, type OrganizationBYOKKeysResponse, upsertByokKeyContract, upsertOrganizationByokKeyContract, } from '@/lib/api/contracts' -import { - BYOK_KEY_LIST_STALE_TIME, - byokKeysKeys, - byokKeysQueryOptions, -} from '@/hooks/queries/byok-key-list' const logger = createLogger('BYOKKeysQueries') export type { BYOKKey } +export const byokKeysKeys = { + all: ['byok-keys'] as const, + lists: () => [...byokKeysKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const, + organizationLists: () => [...byokKeysKeys.all, 'organization-list'] as const, + organizationList: (organizationId?: string) => + [...byokKeysKeys.organizationLists(), organizationId ?? ''] as const, + inheritedStatuses: () => [...byokKeysKeys.all, 'inherited-status'] as const, + inheritedStatus: (workspaceId?: string) => + [...byokKeysKeys.inheritedStatuses(), workspaceId ?? ''] as const, +} + +export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000 + +async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { + const data = await requestJson(listByokKeysContract, { + params: { id: workspaceId }, + signal, + }) + return { keys: data.keys ?? [] } +} + +export function byokKeysQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: byokKeysKeys.list(workspaceId), + queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal), + retryOnMount: true, + staleTime: BYOK_KEY_LIST_STALE_TIME, + }) +} + async function fetchOrganizationBYOKKeys( organizationId: string, signal?: AbortSignal diff --git a/apps/sim/hooks/queries/mcp-server-list.ts b/apps/sim/hooks/queries/mcp-server-list.ts deleted file mode 100644 index c1b783aba38..00000000000 --- a/apps/sim/hooks/queries/mcp-server-list.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { ApiClientError } from '@/lib/api/client/errors' -import { requestJson } from '@/lib/api/client/request' -import { listMcpServersContract, type McpServer } from '@/lib/api/contracts/mcp' - -export type { McpServer } - -export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 - -export const mcpKeys = { - all: ['mcp'] as const, - servers: () => [...mcpKeys.all, 'servers'] as const, - serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, - serverTools: () => [...mcpKeys.all, 'serverTools'] as const, - serverToolsWorkspace: (workspaceId?: string) => - [...mcpKeys.serverTools(), workspaceId ?? ''] as const, - serverToolsList: (workspaceId?: string, serverId?: string) => - [...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const, - storedTools: () => [...mcpKeys.all, 'storedTools'] as const, - storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const, - allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const, -} - -async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise { - try { - const data = await requestJson(listMcpServersContract, { - query: { workspaceId }, - signal, - }) - return data.data.servers - } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { - return [] - } - throw error - } -} - -export function mcpServersQueryOptions(workspaceId: string) { - return queryOptions({ - queryKey: mcpKeys.serversList(workspaceId), - queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), - retry: false, - retryOnMount: true, - staleTime: MCP_SERVER_LIST_STALE_TIME, - }) -} diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index 94c010f2f70..e3134e3f8d2 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -2,7 +2,13 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' import { isLoopbackHostname } from '@sim/security/hostnames' import { getErrorMessage } from '@sim/utils/errors' -import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' +import { + queryOptions, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { @@ -10,7 +16,9 @@ import { deleteMcpServerContract, discoverMcpToolsContract, getAllowedMcpDomainsContract, + listMcpServersContract, listStoredMcpToolsContract, + type McpServer, type McpServerTestBody, type McpServerTestResult, type RefreshMcpServerResult, @@ -31,19 +39,51 @@ import type { McpTransport, StoredMcpTool, } from '@/lib/mcp/types' -import { type McpServer, mcpKeys, mcpServersQueryOptions } from '@/hooks/queries/mcp-server-list' import { workflowMcpServerKeys } from '@/hooks/queries/workflow-mcp-servers' const logger = createLogger('McpQueries') export type { McpServerStatusConfig, McpTool, StoredMcpTool } +export type { McpServer } + +export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 + +export const mcpKeys = { + all: ['mcp'] as const, + servers: () => [...mcpKeys.all, 'servers'] as const, + serversList: (workspaceId?: string) => [...mcpKeys.servers(), workspaceId ?? ''] as const, + serverTools: () => [...mcpKeys.all, 'serverTools'] as const, + serverToolsWorkspace: (workspaceId?: string) => + [...mcpKeys.serverTools(), workspaceId ?? ''] as const, + serverToolsList: (workspaceId?: string, serverId?: string) => + [...mcpKeys.serverToolsWorkspace(workspaceId), serverId ?? ''] as const, + storedTools: () => [...mcpKeys.all, 'storedTools'] as const, + storedToolsList: (workspaceId?: string) => [...mcpKeys.storedTools(), workspaceId ?? ''] as const, + allowedDomains: () => [...mcpKeys.all, 'allowedDomains'] as const, +} -export { - MCP_SERVER_LIST_STALE_TIME, - type McpServer, - mcpKeys, - mcpServersQueryOptions, -} from '@/hooks/queries/mcp-server-list' +async function fetchMcpServers(workspaceId: string, signal?: AbortSignal): Promise { + try { + const data = await requestJson(listMcpServersContract, { + query: { workspaceId }, + signal, + }) + return data.data.servers + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) return [] + throw error + } +} + +export function mcpServersQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: mcpKeys.serversList(workspaceId), + queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), + retry: false, + retryOnMount: true, + staleTime: MCP_SERVER_LIST_STALE_TIME, + }) +} /** * Tool discovery is kept fresh by the `list_changed` → SSE push (see `useMcpToolsEvents`), * so the query only needs a re-probe-on-visit fallback for servers without push. Matches the diff --git a/apps/sim/hooks/queries/workflow-mcp-server-list.ts b/apps/sim/hooks/queries/workflow-mcp-server-list.ts deleted file mode 100644 index 61909713a00..00000000000 --- a/apps/sim/hooks/queries/workflow-mcp-server-list.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { ApiClientError } from '@/lib/api/client/errors' -import { requestJson } from '@/lib/api/client/request' -import { - listWorkflowMcpServersContract, - type WorkflowMcpServer, -} from '@/lib/api/contracts/workflow-mcp-servers' - -export const workflowMcpServerKeys = { - all: ['workflow-mcp-servers'] as const, - serverLists: () => [...workflowMcpServerKeys.all, 'server-list'] as const, - servers: (workspaceId: string) => [...workflowMcpServerKeys.serverLists(), workspaceId] as const, - details: () => [...workflowMcpServerKeys.all, 'detail'] as const, - server: (workspaceId: string, serverId: string) => - [...workflowMcpServerKeys.details(), workspaceId, serverId] as const, - tools: (workspaceId: string, serverId: string) => - [...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const, - deployedWorkflowLists: () => [...workflowMcpServerKeys.all, 'deployed-workflow-list'] as const, - deployedWorkflows: (workspaceId: string) => - [...workflowMcpServerKeys.deployedWorkflowLists(), workspaceId] as const, -} - -export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000 - -async function fetchWorkflowMcpServers( - workspaceId: string, - signal?: AbortSignal -): Promise { - try { - const data = await requestJson(listWorkflowMcpServersContract, { - query: { workspaceId }, - signal, - }) - return data.data.servers - } catch (error) { - if (error instanceof ApiClientError && error.status === 404) { - return [] - } - throw error - } -} - -export function workflowMcpServersQueryOptions(workspaceId: string) { - return queryOptions({ - queryKey: workflowMcpServerKeys.servers(workspaceId), - queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), - retry: false, - retryOnMount: true, - staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, - }) -} diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index a50551e4b00..81fcccde779 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { @@ -10,28 +10,60 @@ import { deleteWorkflowMcpToolContract, getWorkflowMcpServerContract, listWorkflowMcpDeployedWorkflowsContract, + listWorkflowMcpServersContract, listWorkflowMcpToolsContract, updateWorkflowMcpServerContract, updateWorkflowMcpToolContract, type WorkflowMcpServer, type WorkflowMcpTool, } from '@/lib/api/contracts/workflow-mcp-servers' -import { - workflowMcpServerKeys, - workflowMcpServersQueryOptions, -} from '@/hooks/queries/workflow-mcp-server-list' const logger = createLogger('WorkflowMcpServerQueries') export type { DeployedWorkflow } +export type { WorkflowMcpServer, WorkflowMcpTool } -export { - WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, - workflowMcpServerKeys, - workflowMcpServersQueryOptions, -} from '@/hooks/queries/workflow-mcp-server-list' +export const workflowMcpServerKeys = { + all: ['workflow-mcp-servers'] as const, + serverLists: () => [...workflowMcpServerKeys.all, 'server-list'] as const, + servers: (workspaceId: string) => [...workflowMcpServerKeys.serverLists(), workspaceId] as const, + details: () => [...workflowMcpServerKeys.all, 'detail'] as const, + server: (workspaceId: string, serverId: string) => + [...workflowMcpServerKeys.details(), workspaceId, serverId] as const, + tools: (workspaceId: string, serverId: string) => + [...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const, + deployedWorkflowLists: () => [...workflowMcpServerKeys.all, 'deployed-workflow-list'] as const, + deployedWorkflows: (workspaceId: string) => + [...workflowMcpServerKeys.deployedWorkflowLists(), workspaceId] as const, +} -export type { WorkflowMcpServer, WorkflowMcpTool } +export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000 + +async function fetchWorkflowMcpServers( + workspaceId: string, + signal?: AbortSignal +): Promise { + try { + const data = await requestJson(listWorkflowMcpServersContract, { + query: { workspaceId }, + signal, + }) + return data.data.servers + } catch (error) { + if (error instanceof ApiClientError && error.status === 404) return [] + throw error + } +} + +export function workflowMcpServersQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: workflowMcpServerKeys.servers(workspaceId), + queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), + retry: false, + retryOnMount: true, + staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, + }) +} export const WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME = 30 * 1000 export const WORKFLOW_MCP_TOOLS_STALE_TIME = 30 * 1000 diff --git a/apps/sim/stores/chat/store.test.ts b/apps/sim/stores/chat/store.test.ts index 6f2aac80682..726ca41a044 100644 --- a/apps/sim/stores/chat/store.test.ts +++ b/apps/sim/stores/chat/store.test.ts @@ -38,6 +38,8 @@ vi.hoisted(() => { import { useChatStore } from '@/stores/chat/store' +const migratedMessageIds = useChatStore.getState().messages.map((message) => message.id) + function readBlob(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -49,8 +51,7 @@ function readBlob(blob: Blob): Promise { describe('chat store message ordering', () => { it('migrates v0 persisted messages from newest-first to insertion order', () => { - const messages = useChatStore.getState().messages - expect(messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']) + expect(migratedMessageIds).toEqual(['msg-1', 'msg-2']) }) describe('addMessage', () => { diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index 0d9b7f03968..5edcc87f96a 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -15,6 +15,8 @@ vi.mock('@/stores/reset-all-stores', () => { import { clearUserData, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' +expect(mockModuleLoaded).not.toHaveBeenCalled() + class EnumerableStorage implements Storage { get length(): number { return Object.keys(this).length @@ -59,8 +61,6 @@ describe('clearUserData', () => { }) it('clears identity data while preserving device preferences', async () => { - expect(mockModuleLoaded).not.toHaveBeenCalled() - localStorage.setItem('next-favicon', 'favicon') localStorage.setItem('sim-theme', 'dark') localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, '["user-a"]') @@ -69,7 +69,6 @@ describe('clearUserData', () => { const inMemoryResetSucceeded = await clearUserData() - expect(mockModuleLoaded).toHaveBeenCalledOnce() expect(mockResetAllStores).toHaveBeenCalledOnce() expect(inMemoryResetSucceeded).toBe(true) expect(localStorage.getItem('next-favicon')).toBe('favicon') diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 77ca9bb0a42..46237daf774 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger' const logger = createLogger('Stores') -/** localStorage key for the admin recent-impersonations list. */ export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' interface ClearUserDataOptions { diff --git a/apps/sim/stores/user-data-reset-registry.test.ts b/apps/sim/stores/user-data-reset-registry.test.ts index 590cf8b66e6..73805659f8e 100644 --- a/apps/sim/stores/user-data-reset-registry.test.ts +++ b/apps/sim/stores/user-data-reset-registry.test.ts @@ -23,9 +23,10 @@ describe('user data reset registry', () => { it('continues resetting loaded stores before reporting a failure', () => { const resetError = new Error('reset failed') const successfulReset = vi.fn() - registerUserDataReset('test-failing', () => { + const failingReset = vi.fn().mockImplementationOnce(() => { throw resetError }) + registerUserDataReset('test-failing', failingReset) registerUserDataReset('test-successful', successfulReset) expect(() => resetRegisteredUserData()).toThrow(resetError) From ea401d407904f1f7b4d679afa0830090d3bba34b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 16:10:29 -0700 Subject: [PATCH 11/11] refactor(settings): remove orphan sandbox query module --- apps/sim/hooks/queries/sandbox-list.ts | 27 ------------------------- apps/sim/hooks/queries/sandboxes.ts | 28 ++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 29 deletions(-) delete mode 100644 apps/sim/hooks/queries/sandbox-list.ts diff --git a/apps/sim/hooks/queries/sandbox-list.ts b/apps/sim/hooks/queries/sandbox-list.ts deleted file mode 100644 index 07a6cdbcceb..00000000000 --- a/apps/sim/hooks/queries/sandbox-list.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { requestJson } from '@/lib/api/client/request' -import { listSandboxesContract, type SandboxListResponse } from '@/lib/api/contracts/sandboxes' - -export const sandboxKeys = { - all: ['sandboxes'] as const, - lists: () => [...sandboxKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...sandboxKeys.lists(), workspaceId ?? ''] as const, -} - -export const SANDBOX_LIST_STALE_TIME = 30 * 1000 - -async function fetchSandboxes( - workspaceId: string, - signal?: AbortSignal -): Promise { - return requestJson(listSandboxesContract, { params: { id: workspaceId }, signal }) -} - -export function getSandboxListQueryOptions(workspaceId: string) { - return queryOptions({ - queryKey: sandboxKeys.list(workspaceId), - queryFn: ({ signal }) => fetchSandboxes(workspaceId, signal), - retryOnMount: true, - staleTime: SANDBOX_LIST_STALE_TIME, - }) -} diff --git a/apps/sim/hooks/queries/sandboxes.ts b/apps/sim/hooks/queries/sandboxes.ts index 3092adfcde9..026c6f87071 100644 --- a/apps/sim/hooks/queries/sandboxes.ts +++ b/apps/sim/hooks/queries/sandboxes.ts @@ -1,20 +1,28 @@ import { createLogger } from '@sim/logger' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { ContractBodyInput } from '@/lib/api/contracts' import { createSandboxContract, deleteSandboxContract, + listSandboxesContract, type Sandbox, type SandboxListResponse, updateSandboxContract, } from '@/lib/api/contracts' -import { getSandboxListQueryOptions, sandboxKeys } from '@/hooks/queries/sandbox-list' const logger = createLogger('SandboxQueries') export type { Sandbox, SandboxListResponse } +export const sandboxKeys = { + all: ['sandboxes'] as const, + lists: () => [...sandboxKeys.all, 'list'] as const, + list: (workspaceId?: string) => [...sandboxKeys.lists(), workspaceId ?? ''] as const, +} + +const SANDBOX_LIST_STALE_TIME = 30 * 1000 + /** Poll cadence while any sandbox is still building; see {@link useSandboxes}. */ export const SANDBOX_BUILD_POLL_INTERVAL = 3 * 1000 @@ -25,6 +33,22 @@ export const SANDBOX_BUILD_POLL_INTERVAL = 3 * 1000 */ const MAX_BUILD_POLLS = 350 +async function fetchSandboxes( + workspaceId: string, + signal?: AbortSignal +): Promise { + return requestJson(listSandboxesContract, { params: { id: workspaceId }, signal }) +} + +function getSandboxListQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: sandboxKeys.list(workspaceId), + queryFn: ({ signal }) => fetchSandboxes(workspaceId, signal), + retryOnMount: true, + staleTime: SANDBOX_LIST_STALE_TIME, + }) +} + /** True while at least one sandbox has a build that has not reached a terminal state. */ export function hasPendingBuild(sandboxes: readonly Sandbox[]): boolean { return sandboxes.some(