diff --git a/README.md b/README.md index be3b0ec97c..6b4be430e7 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,46 @@

- Sim Logo + + Sim Logo +

-

- License: Apache-2.0 - Discord - Twitter - PRs welcome - Documentation -

+

Build and deploy AI agent workflows in minutes.

- Sim is a lightweight, user-friendly platform for building AI agent workflows. + Sim.ai + Discord + Twitter + Documentation

Sim Demo

-## Getting Started +## Quickstart -1. Use our [cloud-hosted version](https://sim.ai) -2. Self-host using one of the methods below +### Cloud-hosted: [sim.ai](https://sim.ai) -## Self-Hosting Options +Sim.ai -### Option 1: NPM Package (Simplest) - -The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme): +### Self-hosted: NPM Package ```bash npx simstudio ``` +→ http://localhost:3000 -After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser. +#### Note +Docker must be installed and running on your machine. #### Options -- `-p, --port `: Specify the port to run Sim on (default: 3000) -- `--no-pull`: Skip pulling the latest Docker images +| Flag | Description | +|------|-------------| +| `-p, --port ` | Port to run Sim on (default `3000`) | +| `--no-pull` | Skip pulling latest Docker images | -#### Requirements - -- Docker must be installed and running on your machine - -### Option 2: Docker Compose +### Self-hosted: Docker Compose ```bash # Clone the repository @@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b ``` -### Option 3: Dev Containers +### Self-hosted: Dev Containers 1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 2. Open the project and click "Reopen in Container" when prompted 3. Run `bun run dev:full` in the terminal or use the `sim-start` alias - This starts both the main application and the realtime socket server -### Option 4: Manual Setup +### Self-hosted: Manual Setup **Requirements:** - [Bun](https://bun.sh/) runtime @@ -158,6 +154,13 @@ cd apps/sim bun run dev:sockets ``` +## Copilot API Keys + +Copilot is a Sim-managed service. To use Copilot on a self-hosted instance: + +- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key +- Set `COPILOT_API_KEY` in your self-hosted environment to that value + ## Tech Stack - **Framework**: [Next.js](https://nextjs.org/) (App Router) @@ -180,4 +183,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. -

Made with ❤️ by the Sim Team

\ No newline at end of file +

Made with ❤️ by the Sim Team

diff --git a/apps/docs/content/docs/copilot/index.mdx b/apps/docs/content/docs/copilot/index.mdx new file mode 100644 index 0000000000..e4017816bf --- /dev/null +++ b/apps/docs/content/docs/copilot/index.mdx @@ -0,0 +1,94 @@ +--- +title: Copilot +description: Build and edit workflows with Sim Copilot +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Card, Cards } from 'fumadocs-ui/components/card' +import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react' + +## What is Copilot + +Copilot is your in-editor assistant that helps you build, understand, and improve workflows. It can: + +- **Explain**: Answer questions about Sim and your current workflow +- **Guide**: Suggest edits and best practices +- **Edit**: Make changes to blocks, connections, and settings when you approve + + + Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot) and set `COPILOT_API_KEY` in your environment. + + +## Modes + + + +
+ + + +
+

+ Q&A mode for explanations, guidance, and suggestions without making changes to your workflow. +

+
+
+
+ +
+ + + +
+

+ Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve. +

+
+
+
+
+ +## Depth Levels + + + +
+ + + +
+

Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.

+
+
+
+ +
+ + + +
+

Balanced speed and reasoning. Recommended default for most tasks.

+
+
+
+ +
+ + + +
+

More reasoning for larger workflows and complex edits while staying performant.

+
+
+
+ +
+ + + +
+

Maximum reasoning for deep planning, debugging, and complex architectural changes.

+
+
+
+
\ No newline at end of file diff --git a/apps/docs/content/docs/copilot/meta.json b/apps/docs/content/docs/copilot/meta.json new file mode 100644 index 0000000000..5d83f94d73 --- /dev/null +++ b/apps/docs/content/docs/copilot/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Copilot", + "pages": ["index"] +} diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index e62d335a2f..1aa9ce7084 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -12,6 +12,8 @@ "connections", "---Execution---", "execution", + "---Copilot---", + "copilot", "---Advanced---", "./variables/index", "yaml", diff --git a/apps/sim/app/api/copilot/chat/route.test.ts b/apps/sim/app/api/copilot/chat/route.test.ts index e3ad25c534..5206d7167a 100644 --- a/apps/sim/app/api/copilot/chat/route.test.ts +++ b/apps/sim/app/api/copilot/chat/route.test.ts @@ -105,6 +105,7 @@ describe('Copilot Chat API Route', () => { env: { SIM_AGENT_API_URL: 'http://localhost:8000', COPILOT_API_KEY: 'test-sim-agent-key', + BETTER_AUTH_URL: 'http://localhost:3000', }, })) @@ -225,6 +226,7 @@ describe('Copilot Chat API Route', () => { mode: 'agent', provider: 'openai', depth: 0, + origin: 'http://localhost:3000', }), }) ) @@ -288,6 +290,7 @@ describe('Copilot Chat API Route', () => { mode: 'agent', provider: 'openai', depth: 0, + origin: 'http://localhost:3000', }), }) ) @@ -343,6 +346,7 @@ describe('Copilot Chat API Route', () => { mode: 'agent', provider: 'openai', depth: 0, + origin: 'http://localhost:3000', }), }) ) @@ -438,6 +442,7 @@ describe('Copilot Chat API Route', () => { mode: 'ask', provider: 'openai', depth: 0, + origin: 'http://localhost:3000', }), }) ) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index b13ed91667..3c22e25c92 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -28,6 +28,15 @@ const logger = createLogger('CopilotChatAPI') // Sim Agent API configuration const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT +function getRequestOrigin(_req: NextRequest): string { + try { + // Strictly use configured Better Auth URL + return env.BETTER_AUTH_URL || '' + } catch (_) { + return '' + } +} + function deriveKey(keyString: string): Buffer { return createHash('sha256').update(keyString, 'utf8').digest() } @@ -72,7 +81,8 @@ const ChatMessageSchema = z.object({ chatId: z.string().optional(), workflowId: z.string().min(1, 'Workflow ID is required'), mode: z.enum(['ask', 'agent']).optional().default('agent'), - depth: z.number().int().min(0).max(3).optional().default(0), + depth: z.number().int().min(-2).max(3).optional().default(0), + prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), stream: z.boolean().optional().default(true), implicitFeedback: z.string().optional(), @@ -189,6 +199,7 @@ export async function POST(req: NextRequest) { workflowId, mode, depth, + prefetch, createNewChat, stream, implicitFeedback, @@ -197,6 +208,27 @@ export async function POST(req: NextRequest) { conversationId, } = ChatMessageSchema.parse(body) + // Derive request origin for downstream service + const requestOrigin = getRequestOrigin(req) + + if (!requestOrigin) { + logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`) + return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL') + } + + // Consolidation mapping: map negative depths to base depth with prefetch=true + let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined + let effectivePrefetch: boolean | undefined = prefetch + if (typeof effectiveDepth === 'number') { + if (effectiveDepth === -2) { + effectiveDepth = 1 + effectivePrefetch = true + } else if (effectiveDepth === -1) { + effectiveDepth = 0 + effectivePrefetch = true + } + } + logger.info(`[${tracker.requestId}] Processing copilot chat request`, { userId: authenticatedUserId, workflowId, @@ -209,6 +241,8 @@ export async function POST(req: NextRequest) { provider: provider || 'openai', hasConversationId: !!conversationId, depth, + prefetch, + origin: requestOrigin, }) // Handle chat context @@ -384,8 +418,10 @@ export async function POST(req: NextRequest) { mode: mode, provider: providerToUse, ...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}), - ...(typeof depth === 'number' ? { depth } : {}), + ...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}), + ...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}), ...(session?.user?.name && { userName: session.user.name }), + ...(requestOrigin ? { origin: requestOrigin } : {}), } // Log the payload being sent to the streaming endpoint @@ -397,8 +433,10 @@ export async function POST(req: NextRequest) { stream, workflowId, hasConversationId: !!effectiveConversationId, - depth: typeof depth === 'number' ? depth : undefined, + depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined, + prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined, messagesCount: requestPayload.messages.length, + ...(requestOrigin ? { origin: requestOrigin } : {}), }) // Full payload as JSON string logger.info( @@ -458,6 +496,12 @@ export async function POST(req: NextRequest) { let isFirstDone = true let responseIdFromStart: string | undefined let responseIdFromDone: string | undefined + // Track tool call progress to identify a safe done event + const announcedToolCallIds = new Set() + const startedToolExecutionIds = new Set() + const completedToolExecutionIds = new Set() + let lastDoneResponseId: string | undefined + let lastSafeDoneResponseId: string | undefined // Send chatId as first event if (actualChatId) { @@ -575,6 +619,9 @@ export async function POST(req: NextRequest) { ) if (!event.data?.partial) { toolCalls.push(event.data) + if (event.data?.id) { + announcedToolCallIds.add(event.data.id) + } } break @@ -584,6 +631,14 @@ export async function POST(req: NextRequest) { toolName: event.toolName, status: event.status, }) + if (event.toolCallId) { + if (event.status === 'completed') { + startedToolExecutionIds.add(event.toolCallId) + completedToolExecutionIds.add(event.toolCallId) + } else { + startedToolExecutionIds.add(event.toolCallId) + } + } break case 'tool_result': @@ -594,6 +649,9 @@ export async function POST(req: NextRequest) { result: `${JSON.stringify(event.result).substring(0, 200)}...`, resultSize: JSON.stringify(event.result).length, }) + if (event.toolCallId) { + completedToolExecutionIds.add(event.toolCallId) + } break case 'tool_error': @@ -603,6 +661,9 @@ export async function POST(req: NextRequest) { error: event.error, success: event.success, }) + if (event.toolCallId) { + completedToolExecutionIds.add(event.toolCallId) + } break case 'start': @@ -617,9 +678,25 @@ export async function POST(req: NextRequest) { case 'done': if (event.data?.responseId) { responseIdFromDone = event.data.responseId + lastDoneResponseId = responseIdFromDone logger.info( `[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}` ) + // Mark this done as safe only if no tool call is currently in progress or pending + const announced = announcedToolCallIds.size + const completed = completedToolExecutionIds.size + const started = startedToolExecutionIds.size + const hasToolInProgress = announced > completed || started > completed + if (!hasToolInProgress) { + lastSafeDoneResponseId = responseIdFromDone + logger.info( + `[${tracker.requestId}] Marked done as SAFE (no tools in progress)` + ) + } else { + logger.info( + `[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})` + ) + } } if (isFirstDone) { logger.info( @@ -714,7 +791,9 @@ export async function POST(req: NextRequest) { ) } - const responseId = responseIdFromDone + // Persist only a safe conversationId to avoid continuing from a state that expects tool outputs + const previousConversationId = currentChat?.conversationId as string | undefined + const responseId = lastSafeDoneResponseId || previousConversationId || undefined // Update chat in database immediately (without title) await db diff --git a/apps/sim/app/api/copilot/methods/route.test.ts b/apps/sim/app/api/copilot/methods/route.test.ts index 02cae1bc63..243a9b9c5c 100644 --- a/apps/sim/app/api/copilot/methods/route.test.ts +++ b/apps/sim/app/api/copilot/methods/route.test.ts @@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => { vi.doMock('@/lib/env', () => ({ env: { INTERNAL_API_SECRET: 'test-secret-key', + COPILOT_API_KEY: 'test-copilot-key', }, })) @@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => { expect(response.status).toBe(401) const responseData = await response.json() - expect(responseData).toEqual({ - success: false, - error: 'Invalid API key', - }) + expect(responseData.success).toBe(false) + expect(typeof responseData.error).toBe('string') }) it('should return 401 when internal API key is not configured', async () => { @@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => { vi.doMock('@/lib/env', () => ({ env: { INTERNAL_API_SECRET: undefined, + COPILOT_API_KEY: 'test-copilot-key', }, })) @@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => { expect(response.status).toBe(401) const responseData = await response.json() - expect(responseData).toEqual({ - success: false, - error: 'Internal API key not configured', - }) + expect(responseData.status).toBeUndefined() + expect(responseData.success).toBe(false) + expect(typeof responseData.error).toBe('string') }) it('should return 400 for invalid request body - missing methodId', async () => { diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index bde914dbf4..4af0bfad1a 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry' import type { NotificationStatus } from '@/lib/copilot/types' -import { checkInternalApiKey } from '@/lib/copilot/utils' +import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils' import { createLogger } from '@/lib/logs/console/logger' import { getRedisClient } from '@/lib/redis' import { createErrorResponse } from '@/app/api/copilot/methods/utils' @@ -232,10 +232,13 @@ export async function POST(req: NextRequest) { const startTime = Date.now() try { - // Check authentication (internal API key) - const authResult = checkInternalApiKey(req) - if (!authResult.success) { - return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), { + // Evaluate both auth schemes; pass if either is valid + const internalAuth = checkInternalApiKey(req) + const copilotAuth = checkCopilotApiKey(req) + const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success) + if (!isAuthenticated) { + const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed' + return NextResponse.json(createErrorResponse(errorMessage), { status: 401, }) } @@ -243,7 +246,7 @@ export async function POST(req: NextRequest) { const body = await req.json() const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body) - logger.info(`[${requestId}] Method execution request: ${methodId}`, { + logger.info(`[${requestId}] Method execution request`, { methodId, toolCallId, hasParams: !!params && Object.keys(params).length > 0, diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts new file mode 100644 index 0000000000..e34e01d979 --- /dev/null +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -0,0 +1,120 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { db } from '@/db' +import { user } from '@/db/schema' + +const logger = createLogger('UpdateUserProfileAPI') + +// Schema for updating user profile +const UpdateProfileSchema = z + .object({ + name: z.string().min(1, 'Name is required').optional(), + }) + .refine((data) => data.name !== undefined, { + message: 'Name field must be provided', + }) + +export const dynamic = 'force-dynamic' + +export async function PATCH(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const session = await getSession() + + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized profile update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const body = await request.json() + + const validatedData = UpdateProfileSchema.parse(body) + + // Build update object + const updateData: any = { updatedAt: new Date() } + if (validatedData.name !== undefined) updateData.name = validatedData.name + + // Update user profile + const [updatedUser] = await db + .update(user) + .set(updateData) + .where(eq(user.id, userId)) + .returning() + + if (!updatedUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + logger.info(`[${requestId}] User profile updated`, { + userId, + updatedFields: Object.keys(validatedData), + }) + + return NextResponse.json({ + success: true, + user: { + id: updatedUser.id, + name: updatedUser.name, + email: updatedUser.email, + image: updatedUser.image, + }, + }) + } catch (error: any) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid profile data`, { + errors: error.errors, + }) + return NextResponse.json( + { error: 'Invalid profile data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Profile update error`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// GET endpoint to fetch current user profile +export async function GET() { + const requestId = crypto.randomUUID().slice(0, 8) + + 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 db + .select({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + emailVerified: user.emailVerified, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + 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 }) + } +} diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 93191243d8..7bda24dec3 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -7,7 +7,6 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils' -// Define mock functions at the top level to be used in mocks const hasProcessedMessageMock = vi.fn().mockResolvedValue(false) const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true) const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined) @@ -33,7 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({ }, }) -// Mock the DB schema objects const webhookMock = { id: 'webhook-id-column', path: 'path-column', @@ -43,10 +41,6 @@ const webhookMock = { } const workflowMock = { id: 'workflow-id-column' } -// Mock global timers -vi.useFakeTimers() - -// Mock modules at file scope before any tests vi.mock('@/lib/redis', () => ({ hasProcessedMessage: hasProcessedMessageMock, markMessageAsProcessed: markMessageAsProcessedMock, @@ -77,19 +71,6 @@ vi.mock('@/executor', () => ({ })), })) -// Mock setTimeout and other timer functions -vi.mock('timers', () => { - return { - setTimeout: (callback: any) => { - // Immediately invoke the callback - callback() - // Return a fake timer id - return 123 - }, - } -}) - -// Mock the database and schema vi.mock('@/db', () => { const dbMock = { select: vi.fn().mockImplementation((columns) => ({ @@ -128,11 +109,9 @@ describe('Webhook Trigger API Route', () => { beforeEach(() => { vi.resetModules() vi.resetAllMocks() - vi.clearAllTimers() mockExecutionDependencies() - // Mock services/queue for rate limiting vi.doMock('@/services/queue', () => ({ RateLimiter: vi.fn().mockImplementation(() => ({ checkRateLimit: vi.fn().mockResolvedValue({ @@ -284,10 +263,340 @@ describe('Webhook Trigger API Route', () => { expect(text).toMatch(/not found/i) // Response should contain "not found" message }) - /** - * Test Slack-specific webhook handling - * Verifies that Slack signature verification is performed - */ - // TODO: Fix failing test - returns 500 instead of 200 - // it('should handle Slack webhooks with signature verification', async () => { ... }) + describe('Generic Webhook Authentication', () => { + const setupGenericWebhook = async (config: Record) => { + const { db } = await import('@/db') + const limitMock = vi.fn().mockReturnValue([ + { + webhook: { + id: 'generic-webhook-id', + provider: 'generic', + path: 'test-path', + isActive: true, + providerConfig: config, + workflowId: 'test-workflow-id', + }, + workflow: { + id: 'test-workflow-id', + userId: 'test-user-id', + name: 'Test Workflow', + }, + }, + ]) + const whereMock = vi.fn().mockReturnValue({ limit: limitMock }) + const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock }) + const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock }) + + const subscriptionLimitMock = vi.fn().mockReturnValue([{ plan: 'pro' }]) + const subscriptionWhereMock = vi.fn().mockReturnValue({ limit: subscriptionLimitMock }) + const subscriptionFromMock = vi.fn().mockReturnValue({ where: subscriptionWhereMock }) + + // @ts-ignore - mocking the query chain + db.select.mockImplementation((columns: any) => { + if (columns.plan) { + return { from: subscriptionFromMock } + } + return { from: fromMock } + }) + } + + /** + * Test generic webhook without authentication (default behavior) + */ + it('should process generic webhook without authentication', async () => { + await setupGenericWebhook({ requireAuth: false }) + + const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) + const params = Promise.resolve({ path: 'test-path' }) + + vi.doMock('@trigger.dev/sdk/v3', () => ({ + tasks: { + trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), + }, + })) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + // Authentication passed if we don't get 401 + expect(response.status).not.toBe(401) + }) + + /** + * Test generic webhook with Bearer token authentication (no custom header) + */ + it('should authenticate with Bearer token when no custom header is configured', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'test-token-123', + // No secretHeaderName - should default to Bearer + }) + + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token-123', + } + const req = createMockRequest('POST', { event: 'bearer.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + vi.doMock('@trigger.dev/sdk/v3', () => ({ + tasks: { + trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), + }, + })) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + // Authentication passed if we don't get 401 + expect(response.status).not.toBe(401) + }) + + /** + * Test generic webhook with custom header authentication + */ + it('should authenticate with custom header when configured', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'secret-token-456', + secretHeaderName: 'X-Custom-Auth', + }) + + const headers = { + 'Content-Type': 'application/json', + 'X-Custom-Auth': 'secret-token-456', + } + const req = createMockRequest('POST', { event: 'custom.header.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + vi.doMock('@trigger.dev/sdk/v3', () => ({ + tasks: { + trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), + }, + })) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + // Authentication passed if we don't get 401 + expect(response.status).not.toBe(401) + }) + + /** + * Test case insensitive Bearer token authentication + */ + it('should handle case insensitive Bearer token authentication', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'case-test-token', + }) + + vi.doMock('@trigger.dev/sdk/v3', () => ({ + tasks: { + trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), + }, + })) + + const testCases = [ + 'Bearer case-test-token', + 'bearer case-test-token', + 'BEARER case-test-token', + 'BeArEr case-test-token', + ] + + for (const authHeader of testCases) { + const headers = { + 'Content-Type': 'application/json', + Authorization: authHeader, + } + const req = createMockRequest('POST', { event: 'case.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + // Authentication passed if we don't get 401 + expect(response.status).not.toBe(401) + } + }) + + /** + * Test case insensitive custom header authentication + */ + it('should handle case insensitive custom header authentication', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'custom-token-789', + secretHeaderName: 'X-Secret-Key', + }) + + vi.doMock('@trigger.dev/sdk/v3', () => ({ + tasks: { + trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), + }, + })) + + const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key'] + + for (const headerName of testCases) { + const headers = { + 'Content-Type': 'application/json', + [headerName]: 'custom-token-789', + } + const req = createMockRequest('POST', { event: 'custom.case.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + // Authentication passed if we don't get 401 + expect(response.status).not.toBe(401) + } + }) + + /** + * Test rejection of wrong Bearer token + */ + it('should reject wrong Bearer token', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'correct-token', + }) + + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer wrong-token', + } + const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain('Unauthorized - Invalid authentication token') + expect(processWebhookMock).not.toHaveBeenCalled() + }) + + /** + * Test rejection of wrong custom header token + */ + it('should reject wrong custom header token', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'correct-custom-token', + secretHeaderName: 'X-Auth-Key', + }) + + const headers = { + 'Content-Type': 'application/json', + 'X-Auth-Key': 'wrong-custom-token', + } + const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain('Unauthorized - Invalid authentication token') + expect(processWebhookMock).not.toHaveBeenCalled() + }) + + /** + * Test rejection of missing authentication + */ + it('should reject missing authentication when required', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'required-token', + }) + + const req = createMockRequest('POST', { event: 'no.auth.test' }) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain('Unauthorized - Invalid authentication token') + expect(processWebhookMock).not.toHaveBeenCalled() + }) + + /** + * Test exclusivity - Bearer token should be rejected when custom header is configured + */ + it('should reject Bearer token when custom header is configured', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'exclusive-token', + secretHeaderName: 'X-Only-Header', + }) + + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer exclusive-token', // Correct token but wrong header type + } + const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain('Unauthorized - Invalid authentication token') + expect(processWebhookMock).not.toHaveBeenCalled() + }) + + /** + * Test wrong custom header name is rejected + */ + it('should reject wrong custom header name', async () => { + await setupGenericWebhook({ + requireAuth: true, + token: 'correct-token', + secretHeaderName: 'X-Expected-Header', + }) + + const headers = { + 'Content-Type': 'application/json', + 'X-Wrong-Header': 'correct-token', // Correct token but wrong header name + } + const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain('Unauthorized - Invalid authentication token') + expect(processWebhookMock).not.toHaveBeenCalled() + }) + + /** + * Test authentication required but no token configured + */ + it('should reject when auth is required but no token is configured', async () => { + await setupGenericWebhook({ + requireAuth: true, + // No token configured + }) + + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer any-token', + } + const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers) + const params = Promise.resolve({ path: 'test-path' }) + + const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') + const response = await POST(req, { params }) + + expect(response.status).toBe(401) + expect(await response.text()).toContain( + 'Unauthorized - Authentication required but not configured' + ) + expect(processWebhookMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 437a384faa..a5a7c61782 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -196,6 +196,53 @@ export async function POST( } } + // Handle generic webhook authentication if enabled + if (foundWebhook.provider === 'generic') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + + if (providerConfig.requireAuth) { + const configToken = providerConfig.token + const secretHeaderName = providerConfig.secretHeaderName + + // --- Token Validation --- + if (configToken) { + let isTokenValid = false + + if (secretHeaderName) { + // Check custom header (headers are case-insensitive) + const headerValue = request.headers.get(secretHeaderName.toLowerCase()) + if (headerValue === configToken) { + isTokenValid = true + } + } else { + // Check standard Authorization header (case-insensitive Bearer keyword) + const authHeader = request.headers.get('authorization') + + // Case-insensitive comparison for "Bearer" keyword + if (authHeader?.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7) // Remove "Bearer " (7 characters) + if (token === configToken) { + isTokenValid = true + } + } + } + + if (!isTokenValid) { + const expectedHeader = secretHeaderName || 'Authorization: Bearer TOKEN' + logger.warn( + `[${requestId}] Generic webhook authentication failed. Expected header: ${expectedHeader}` + ) + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } + } else { + logger.warn(`[${requestId}] Generic webhook requires auth but no token configured`) + return new NextResponse('Unauthorized - Authentication required but not configured', { + status: 401, + }) + } + } + } + // --- PHASE 3: Rate limiting for webhook execution --- try { // Get user subscription for rate limiting diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 24dbca36cb..62242cc734 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -206,23 +206,22 @@ } ::-webkit-scrollbar-track { - background-color: hsl(var(--scrollbar-track)); - border-radius: var(--radius); + background: transparent; } ::-webkit-scrollbar-thumb { - background-color: hsl(var(--scrollbar-thumb)); + background-color: hsl(var(--muted-foreground) / 0.3); border-radius: var(--radius); } ::-webkit-scrollbar-thumb:hover { - background-color: hsl(var(--scrollbar-thumb-hover)); + background-color: hsl(var(--muted-foreground) / 0.3); } /* For Firefox */ * { scrollbar-width: thin; - scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track)); + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent; } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/export-controls/export-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/export-controls/export-controls.tsx index 63ade29895..cdefee7b20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/export-controls/export-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/export-controls/export-controls.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Download } from 'lucide-react' +import { Upload } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' @@ -81,7 +81,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) { {isDisabled ? (
- +
) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index 1943bb9938..e750496d82 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -191,6 +191,11 @@ export function DiffControls() { logger.info('Accepting proposed changes with backup protection') try { + // Create a checkpoint before applying changes so it appears under the triggering user message + await createCheckpoint().catch((error) => { + logger.warn('Failed to create checkpoint before accept:', error) + }) + // Clear preview YAML immediately await clearPreviewYaml().catch((error) => { logger.warn('Failed to clear preview YAML:', error) @@ -219,10 +224,10 @@ export function DiffControls() { logger.warn('Failed to clear preview YAML:', error) }) - // Reject is immediate (no server save needed) - rejectChanges() - - logger.info('Successfully rejected proposed changes') + // Reject changes optimistically + rejectChanges().catch((error) => { + logger.error('Failed to reject changes (background):', error) + }) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx index ab93278422..fae4bb8cc0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -13,6 +13,7 @@ import { import Image from 'next/image' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' +import { redactApiKeys } from '@/lib/utils' import { CodeDisplay, JSONView, @@ -349,9 +350,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { // For code display, copy just the code string textToCopy = entry.input.code } else { - // For regular JSON display, copy the full JSON + // For regular JSON display, copy the full JSON with redaction applied const dataToCopy = showInput ? entry.input : entry.output - textToCopy = JSON.stringify(dataToCopy, null, 2) + const redactedData = redactApiKeys(dataToCopy) + textToCopy = JSON.stringify(redactedData, null, 2) } navigator.clipboard.writeText(textToCopy) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx index 17daf1d7ff..7cfc80294a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' +import { redactApiKeys } from '@/lib/utils' interface JSONViewProps { data: any @@ -154,6 +155,9 @@ export const JSONView = ({ data }: JSONViewProps) => { y: number } | null>(null) + // Apply redaction to the data before displaying + const redactedData = redactApiKeys(data) + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault() setContextMenuPosition({ x: e.clientX, y: e.clientY }) @@ -167,18 +171,18 @@ export const JSONView = ({ data }: JSONViewProps) => { } }, [contextMenuPosition]) - if (data === null) + if (redactedData === null) return null // For non-object data, show simple JSON - if (typeof data !== 'object') { - const stringValue = JSON.stringify(data) + if (typeof redactedData !== 'object') { + const stringValue = JSON.stringify(redactedData) return ( - {typeof data === 'string' ? ( + {typeof redactedData === 'string' ? ( ) : ( @@ -192,7 +196,7 @@ export const JSONView = ({ data }: JSONViewProps) => { > @@ -206,7 +210,7 @@ export const JSONView = ({ data }: JSONViewProps) => { return (
-        
+        
       
{contextMenuPosition && (
{ > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index e05eb7ddaa..9ffc56f508 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -27,6 +27,18 @@ export function ThinkingBlock({ } }, [persistedStartTime]) + useEffect(() => { + // Auto-collapse when streaming ends + if (!isStreaming) { + setIsExpanded(false) + return + } + // Expand once there is visible content while streaming + if (content && content.trim().length > 0) { + setIsExpanded(true) + } + }, [isStreaming, content]) + useEffect(() => { // If we already have a persisted duration, just use it if (typeof persistedDuration === 'number') { @@ -52,29 +64,10 @@ export function ThinkingBlock({ return `${seconds}s` } - if (!isExpanded) { - return ( - - ) - } - return (
-
-
-          {content}
-          {isStreaming && }
-        
-
+ + {isExpanded && ( +
+
+            {content}
+            {isStreaming && (
+              
+            )}
+          
+
+ )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 7c8ea15063..b37945000a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -643,41 +643,49 @@ const CopilotMessage: FC = memo( {/* Checkpoints below message */} {hasCheckpoints && (
- {showRestoreConfirmation ? ( -
- Restore? - - +
+ + Restore{showRestoreConfirmation && ?} + +
+ {showRestoreConfirmation ? ( +
+ + +
+ ) : ( + + )}
- ) : ( - - )} +
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/copilot-slider.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/copilot-slider.tsx new file mode 100644 index 0000000000..f9b8ef823a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/copilot-slider.tsx @@ -0,0 +1,25 @@ +'use client' + +import * as React from 'react' +import * as SliderPrimitive from '@radix-ui/react-slider' +import { cn } from '@/lib/utils' + +export const CopilotSlider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +CopilotSlider.displayName = 'CopilotSlider' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 35950868cb..611e7e539e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -16,6 +16,7 @@ import { FileText, Image, Infinity as InfinityIcon, + Info, Loader2, MessageCircle, Package, @@ -30,11 +31,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' import { useCopilotStore } from '@/stores/copilot/store' +import { CopilotSlider as Slider } from './copilot-slider' export interface MessageFileAttachment { id: string @@ -426,32 +429,31 @@ const UserInput = forwardRef( } // Depth toggle state comes from global store; access via useCopilotStore - const { agentDepth, setAgentDepth } = useCopilotStore() + const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore() const cycleDepth = () => { - // Allowed UI values: 0 (Lite), 1 (Default), 2 (Pro), 3 (Max) - const next = agentDepth === 0 ? 1 : agentDepth === 1 ? 2 : agentDepth === 2 ? 3 : 0 - setAgentDepth(next) + // 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping. + const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3) + if (nextDepth === 0 && agentDepth === 3) { + setAgentPrefetch(!agentPrefetch) + } + setAgentDepth(nextDepth) } - const getDepthLabel = () => { - if (agentDepth === 0) return 'Fast' - if (agentDepth === 1) return 'Auto' - if (agentDepth === 2) return 'Pro' - return 'Max' + const getCollapsedModeLabel = () => { + const base = getDepthLabelFor(agentDepth) + return !agentPrefetch ? `${base} MAX` : base } const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => { - if (value === 0) return 'Fast' - if (value === 1) return 'Auto' - if (value === 2) return 'Pro' - return 'Max' + return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert' } + // Removed descriptive suffixes; concise labels only const getDepthDescription = (value: 0 | 1 | 2 | 3) => { if (value === 0) return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.' - if (value === 1) return 'Automatically balances speed and reasoning. Good fit for most tasks.' + if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.' if (value === 2) return 'More reasoning for larger workflows and complex edits, still balanced for speed.' return 'Maximum reasoning power. Best for complex workflow building and debugging.' @@ -548,7 +550,7 @@ const UserInput = forwardRef( placeholder={isDragging ? 'Drop files here...' : placeholder} disabled={disabled} rows={1} - className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0' style={{ height: 'auto' }} /> @@ -635,126 +637,72 @@ const UserInput = forwardRef( variant='ghost' size='sm' className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs' - title='Choose depth' + title='Choose mode' > {getDepthIcon()} - {getDepthLabel()} + {getCollapsedModeLabel()} - -
- - - setAgentDepth(1)} - className={cn( - 'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4', - agentDepth === 1 && 'bg-muted/40' - )} - > - - - Auto - - {agentDepth === 1 && ( - - )} - - - - Automatically balances speed and reasoning. Good fit for most tasks. - - - - - setAgentDepth(0)} - className={cn( - 'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4', - agentDepth === 0 && 'bg-muted/40' - )} - > - - - Fast - - {agentDepth === 0 && ( - - )} - - - - Fastest and cheapest. Good for small edits, simple workflows, and small - tasks. - - - - - setAgentDepth(2)} - className={cn( - 'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4', - agentDepth === 2 && 'bg-muted/40' - )} - > - - - Pro - - {agentDepth === 2 && ( - - )} - - - - More reasoning for larger workflows and complex edits, still balanced - for speed. - - - - - setAgentDepth(3)} - className={cn( - 'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4', - agentDepth === 3 && 'bg-muted/40' - )} - > - - - Max - - {agentDepth === 3 && ( - - )} - - - - Maximum reasoning power. Best for complex workflow building and - debugging. - - + +
+
+
+ MAX mode + + + + + + Significantly increases depth of reasoning + + +
+ setAgentPrefetch(!checked)} + /> +
+
+
+
+
+
+ Mode + + {getDepthLabelFor(agentDepth)} + +
+
+ + setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3) + } + /> +
+
+
+
+
+
+
+ {getDepthDescription(agentDepth)} +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 888f64a00e..0e34f7e577 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -44,6 +44,9 @@ export const Copilot = forwardRef(({ panelWidth }, ref // Scroll state const [isNearBottom, setIsNearBottom] = useState(true) const [showScrollButton, setShowScrollButton] = useState(false) + // New state to track if user has intentionally scrolled during streaming + const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false) + const isUserScrollingRef = useRef(false) // Track if scroll event is user-initiated const { activeWorkflowId } = useWorkflowRegistry() @@ -119,6 +122,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref '[data-radix-scroll-area-viewport]' ) if (scrollContainer) { + // Mark that we're programmatically scrolling + isUserScrollingRef.current = false scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', @@ -143,7 +148,15 @@ export const Copilot = forwardRef(({ panelWidth }, ref const nearBottom = distanceFromBottom <= 100 setIsNearBottom(nearBottom) setShowScrollButton(!nearBottom) - }, []) + + // If user scrolled up during streaming, mark it + if (isSendingMessage && !nearBottom && isUserScrollingRef.current) { + setUserHasScrolledDuringStream(true) + } + + // Reset the user scrolling flag after processing + isUserScrollingRef.current = true + }, [isSendingMessage]) // Attach scroll listener useEffect(() => { @@ -154,7 +167,13 @@ export const Copilot = forwardRef(({ panelWidth }, ref const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') if (!viewport) return - viewport.addEventListener('scroll', handleScroll, { passive: true }) + // Mark user-initiated scrolls + const handleUserScroll = () => { + isUserScrollingRef.current = true + handleScroll() + } + + viewport.addEventListener('scroll', handleUserScroll, { passive: true }) // Also listen for scrollend event if available (for smooth scrolling) if ('onscrollend' in viewport) { @@ -165,34 +184,63 @@ export const Copilot = forwardRef(({ panelWidth }, ref setTimeout(handleScroll, 100) return () => { - viewport.removeEventListener('scroll', handleScroll) + viewport.removeEventListener('scroll', handleUserScroll) if ('onscrollend' in viewport) { viewport.removeEventListener('scrollend', handleScroll) } } }, [handleScroll]) - // Smart auto-scroll: only scroll if user is near bottom or for user messages + // Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming useEffect(() => { if (messages.length === 0) return const lastMessage = messages[messages.length - 1] const isNewUserMessage = lastMessage?.role === 'user' - // Always scroll for new user messages, or only if near bottom for assistant messages - if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) { + // Conditions for auto-scrolling: + // 1. Always scroll for new user messages (resets the user scroll state) + // 2. For assistant messages during streaming: only if user hasn't scrolled up + // 3. For assistant messages when not streaming: only if near bottom + const shouldAutoScroll = + isNewUserMessage || + (isSendingMessage && !userHasScrolledDuringStream) || + (!isSendingMessage && isNearBottom) + + if (shouldAutoScroll && scrollAreaRef.current) { const scrollContainer = scrollAreaRef.current.querySelector( '[data-radix-scroll-area-viewport]' ) if (scrollContainer) { + // Mark that we're programmatically scrolling + isUserScrollingRef.current = false scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }) - // Let the scroll event handler update the state naturally after animation completes } } - }, [messages, isNearBottom]) + }, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream]) + + // Reset user scroll state when streaming starts or when user sends a message + useEffect(() => { + const lastMessage = messages[messages.length - 1] + if (lastMessage?.role === 'user') { + // User sent a new message - reset scroll state + setUserHasScrolledDuringStream(false) + isUserScrollingRef.current = false + } + }, [messages]) + + // Reset user scroll state when streaming completes + const prevIsSendingRef = useRef(false) + useEffect(() => { + // When streaming transitions from true to false, reset the user scroll state + if (prevIsSendingRef.current && !isSendingMessage) { + setUserHasScrolledDuringStream(false) + } + prevIsSendingRef.current = isSendingMessage + }, [isSendingMessage]) // Auto-scroll to bottom when chat loads in useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx index eac138805e..1dbacd709d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -33,6 +33,10 @@ export function ChannelSelectorInput({ // Use the proper hook to get the current value and setter (same as file-selector) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + // Reactive upstream fields + const [authMethod] = useSubBlockValue(blockId, 'authMethod') + const [botToken] = useSubBlockValue(blockId, 'botToken') + const [connectedCredential] = useSubBlockValue(blockId, 'credential') const [selectedChannelId, setSelectedChannelId] = useState('') const [_channelInfo, setChannelInfo] = useState(null) @@ -40,17 +44,14 @@ export function ChannelSelectorInput({ const provider = subBlock.provider || 'slack' const isSlack = provider === 'slack' - // Get the credential for the provider - use provided credential or fall back to store - const authMethod = getValue(blockId, 'authMethod') as string - const botToken = getValue(blockId, 'botToken') as string - + // Get the credential for the provider - use provided credential or fall back to reactive values let credential: string if (providedCredential) { credential = providedCredential - } else if (authMethod === 'bot_token' && botToken) { - credential = botToken + } else if ((authMethod as string) === 'bot_token' && (botToken as string)) { + credential = botToken as string } else { - credential = (getValue(blockId, 'credential') as string) || '' + credential = (connectedCredential as string) || '' } // Use preview value when in preview mode, otherwise use store value @@ -58,18 +59,11 @@ export function ChannelSelectorInput({ // Get the current value from the store or prop value if in preview mode (same pattern as file-selector) useEffect(() => { - if (isPreview && previewValue !== undefined) { - const value = previewValue - if (value && typeof value === 'string') { - setSelectedChannelId(value) - } - } else { - const value = getValue(blockId, subBlock.id) - if (value && typeof value === 'string') { - setSelectedChannelId(value) - } + const val = isPreview && previewValue !== undefined ? previewValue : storeValue + if (val && typeof val === 'string') { + setSelectedChannelId(val) } - }, [blockId, subBlock.id, getValue, isPreview, previewValue]) + }, [isPreview, previewValue, storeValue]) // Handle channel selection (same pattern as file-selector) const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index d76b94c7b5..2eec5197c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -124,12 +124,10 @@ export function CredentialSelector({ } }, [effectiveProviderId, selectedId, activeWorkflowId]) - // Fetch credentials on initial mount + // Fetch credentials on initial mount and whenever the subblock value changes externally useEffect(() => { fetchCredentials() - // This effect should only run once on mount, so empty dependency array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [fetchCredentials, effectiveValue]) // When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign useEffect(() => { @@ -180,6 +178,19 @@ export function CredentialSelector({ } }, [fetchCredentials]) + // Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably + useEffect(() => { + const handlePageShow = (event: any) => { + if (event?.persisted) { + fetchCredentials() + } + } + window.addEventListener('pageshow', handlePageShow) + return () => { + window.removeEventListener('pageshow', handlePageShow) + } + }, [fetchCredentials]) + // Handle popover open to fetch fresh credentials const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen) @@ -193,6 +204,13 @@ export function CredentialSelector({ const selectedCredential = credentials.find((cred) => cred.id === selectedId) const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta) + // If the list doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI + const displayName = selectedCredential + ? selectedCredential.name + : isForeign + ? 'Saved by collaborator' + : undefined + // Handle selection const handleSelect = (credentialId: string) => { const previousId = selectedId || (effectiveValue as string) || '' @@ -263,15 +281,9 @@ export function CredentialSelector({
{getProviderIcon(provider)} - {selectedCredential - ? selectedCredential.name - : isForeign - ? 'Saved by collaborator' - : label} + {displayName || label}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx index 8c3baa727e..d5489b94b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -46,6 +46,8 @@ interface ConfluenceFileSelectorProps { showPreview?: boolean onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void credentialId?: string + workflowId?: string + isForeignCredential?: boolean } export function ConfluenceFileSelector({ @@ -60,6 +62,8 @@ export function ConfluenceFileSelector({ showPreview = true, onFileInfoChange, credentialId, + workflowId, + isForeignCredential = false, }: ConfluenceFileSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -71,6 +75,12 @@ export function ConfluenceFileSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const initialFetchRef = useRef(false) const [error, setError] = useState(null) + // Keep internal credential in sync with prop (handles late arrival and BFCache restores) + useEffect(() => { + if (credentialId && credentialId !== selectedCredentialId) { + setSelectedCredentialId(credentialId) + } + }, [credentialId, selectedCredentialId]) // Handle search with debounce const searchTimeoutRef = useRef(null) @@ -156,6 +166,7 @@ export function ConfluenceFileSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -189,6 +200,18 @@ export function ConfluenceFileSelector({ if (data.file) { setSelectedFile(data.file) onFileInfoChange?.(data.file) + } else { + const fileInfo: ConfluenceFileInfo = { + id: data.id || pageId, + name: data.title || `Page ${pageId}`, + mimeType: 'confluence/page', + webViewLink: undefined, + modifiedTime: undefined, + spaceId: undefined, + url: undefined, + } + setSelectedFile(fileInfo) + onFileInfoChange?.(fileInfo) } } catch (error) { logger.error('Error fetching page info:', error) @@ -197,13 +220,14 @@ export function ConfluenceFileSelector({ setIsLoading(false) } }, - [selectedCredentialId, domain, onFileInfoChange] + [selectedCredentialId, domain, onFileInfoChange, workflowId] ) // Fetch pages from Confluence const fetchFiles = useCallback( async (searchQuery?: string) => { if (!selectedCredentialId || !domain) return + if (isForeignCredential) return // Validate domain format const trimmedDomain = domain.trim().toLowerCase() @@ -228,6 +252,7 @@ export function ConfluenceFileSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -267,6 +292,12 @@ export function ConfluenceFileSelector({ if (!response.ok) { const errorData = await response.json() + if (response.status === 401 || response.status === 403) { + logger.info('Confluence pages fetch unauthorized (expected for collaborator)') + setFiles([]) + setIsLoading(false) + return + } logger.error('Confluence API error:', errorData) throw new Error(errorData.error || 'Failed to fetch pages') } @@ -294,7 +325,15 @@ export function ConfluenceFileSelector({ setIsLoading(false) } }, - [selectedCredentialId, domain, selectedFileId, onFileInfoChange, fetchPageInfo] + [ + selectedCredentialId, + domain, + selectedFileId, + onFileInfoChange, + fetchPageInfo, + workflowId, + isForeignCredential, + ] ) // Fetch credentials on initial mount @@ -310,7 +349,7 @@ export function ConfluenceFileSelector({ setOpen(isOpen) // Only fetch files when opening the dropdown and if we have valid credentials and domain - if (isOpen && selectedCredentialId && domain && domain.includes('.')) { + if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) { fetchFiles() } } @@ -320,7 +359,15 @@ export function ConfluenceFileSelector({ if (value && selectedCredentialId && !selectedFile && domain && domain.includes('.')) { fetchPageInfo(value) } - }, [value, selectedCredentialId, selectedFile, domain, fetchPageInfo]) + }, [ + value, + selectedCredentialId, + selectedFile, + domain, + fetchPageInfo, + workflowId, + isForeignCredential, + ]) // Keep internal selectedFileId in sync with the value prop useEffect(() => { @@ -363,7 +410,7 @@ export function ConfluenceFileSelector({ role='combobox' aria-expanded={open} className='h-10 w-full min-w-0 justify-between' - disabled={disabled || !domain} + disabled={disabled || !domain || isForeignCredential} >
{selectedFile ? ( @@ -381,118 +428,122 @@ export function ConfluenceFileSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading pages... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Confluence account to continue. -

-
- ) : ( -
-

No pages found.

-

- Try a different search or account. -

-
+ {!isForeignCredential && ( + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + )} - +
+ )} - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Files list */} - {files.length > 0 && ( - -
- Pages -
- {files.map((file) => ( - handleSelectFile(file)} - > -
- - {file.name} -
- {file.id === selectedFileId && } -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Confluence account + + + + + {isLoading ? ( +
+ + Loading pages...
- - - )} -
-
- + ) : error ? ( +
+

{error}

+
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Confluence account to continue. +

+
+ ) : ( +
+

No pages found.

+

+ Try a different search or account. +

+
+ )} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ + {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Files list */} + {files.length > 0 && ( + +
+ Pages +
+ {files.map((file) => ( + handleSelectFile(file)} + > +
+ + {file.name} +
+ {file.id === selectedFileId && } +
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ + Connect Confluence account +
+
+
+ )} + + + + )} {/* File preview */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index 52130ea3b2..3335babae5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -1,18 +1,10 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { Check, ChevronDown, ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react' +import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react' import useDrivePicker from 'react-google-drive-picker' import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons' import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { @@ -74,7 +66,6 @@ export function GoogleDrivePicker({ credentialId, workflowId, }: GoogleDrivePickerProps) { - const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) const [selectedCredentialId, setSelectedCredentialId] = useState('') const [selectedFileId, setSelectedFileId] = useState(value) @@ -237,8 +228,9 @@ export function GoogleDrivePicker({ ]) // Fetch the access token for the selected credential - const fetchAccessToken = async (): Promise => { - if (!selectedCredentialId) { + const fetchAccessToken = async (credentialOverrideId?: string): Promise => { + const effectiveCredentialId = credentialOverrideId || selectedCredentialId + if (!effectiveCredentialId) { logger.error('No credential ID selected for Google Drive Picker') return null } @@ -246,7 +238,7 @@ export function GoogleDrivePicker({ setIsLoading(true) try { const url = new URL('/api/auth/oauth/token', window.location.origin) - url.searchParams.set('credentialId', selectedCredentialId) + url.searchParams.set('credentialId', effectiveCredentialId) // include workflowId if available via global registry (server adds session owner otherwise) const response = await fetch(url.toString()) @@ -265,10 +257,10 @@ export function GoogleDrivePicker({ } // Handle opening the Google Drive Picker - const handleOpenPicker = async () => { + const handleOpenPicker = async (credentialOverrideId?: string) => { try { // First, get the access token for the selected credential - const accessToken = await fetchAccessToken() + const accessToken = await fetchAccessToken(credentialOverrideId) if (!accessToken) { logger.error('Failed to get access token for Google Drive Picker') @@ -335,7 +327,6 @@ export function GoogleDrivePicker({ const handleAddCredential = () => { // Show the OAuth modal setShowOAuthModal(true) - setOpen(false) } // Clear selection @@ -426,136 +417,47 @@ export function GoogleDrivePicker({ return ( <>
- - - - - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- {getProviderIcon(provider)} - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
+ -
- - )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- {getProviderIcon(provider)} - Connect {getProviderName(provider)} account -
-
-
- )} - - - - +
+ {/* File preview */} {showPreview && selectedFile && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index 6c0a5cc4b4..a58beb806d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -48,6 +48,7 @@ interface JiraIssueSelectorProps { projectId?: string credentialId?: string isForeignCredential?: boolean + workflowId?: string } export function JiraIssueSelector({ @@ -63,6 +64,8 @@ export function JiraIssueSelector({ onIssueInfoChange, projectId, credentialId, + isForeignCredential = false, + workflowId, }: JiraIssueSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -168,6 +171,7 @@ export function JiraIssueSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -264,6 +268,7 @@ export function JiraIssueSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -377,6 +382,10 @@ export function JiraIssueSelector({ // Handle open change const handleOpenChange = (isOpen: boolean) => { + if (disabled || isForeignCredential) { + setOpen(false) + return + } setOpen(isOpen) // Only fetch recent/default issues when opening the dropdown @@ -451,7 +460,7 @@ export function JiraIssueSelector({ role='combobox' aria-expanded={open} className='h-10 w-full min-w-0 justify-between' - disabled={disabled || !domain || !selectedCredentialId} + disabled={disabled || !domain || !selectedCredentialId || isForeignCredential} >
{selectedIssue ? ( @@ -469,118 +478,122 @@ export function JiraIssueSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading issues... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Jira account to continue. -

-
- ) : ( -
-

No issues found.

-

- Try a different search or account. -

-
+ {!isForeignCredential && ( + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + )} - +
+ )} - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Issues list */} - {issues.length > 0 && ( - -
- Issues -
- {issues.map((issue) => ( - handleSelectIssue(issue)} - > -
- - {issue.name} -
- {issue.id === selectedIssueId && } -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Jira account + + + + + {isLoading ? ( +
+ + Loading issues...
- - - )} -
-
- + ) : error ? ( +
+

{error}

+
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Jira account to continue. +

+
+ ) : ( +
+

No issues found.

+

+ Try a different search or account. +

+
+ )} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ + {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Issues list */} + {issues.length > 0 && ( + +
+ Issues +
+ {issues.map((issue) => ( + handleSelectIssue(issue)} + > +
+ + {issue.name} +
+ {issue.id === selectedIssueId && } +
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ + Connect Jira account +
+
+
+ )} + + + + )} {/* Issue preview */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index d11c3e547f..38c3908bcc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -55,6 +55,9 @@ interface MicrosoftFileSelectorProps { showPreview?: boolean onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void planId?: string + workflowId?: string + credentialId?: string + isForeignCredential?: boolean } export function MicrosoftFileSelector({ @@ -68,10 +71,13 @@ export function MicrosoftFileSelector({ showPreview = true, onFileInfoChange, planId, + workflowId, + credentialId, + isForeignCredential = false, }: MicrosoftFileSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedCredentialId, setSelectedCredentialId] = useState(credentialId || '') const [selectedFileId, setSelectedFileId] = useState(value) const [selectedFile, setSelectedFile] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -112,23 +118,11 @@ export function MicrosoftFileSelector({ const data = await response.json() setCredentials(data.credentials) - // Auto-select logic for credentials - if (data.credentials.length > 0) { - // If we already have a selected credential ID, check if it's valid - if ( - selectedCredentialId && - data.credentials.some((cred: Credential) => cred.id === selectedCredentialId) - ) { - // Keep the current selection - } else { - // Otherwise, select the default or first credential - const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) - if (defaultCred) { - setSelectedCredentialId(defaultCred.id) - } else if (data.credentials.length === 1) { - setSelectedCredentialId(data.credentials[0].id) - } - } + // If a credentialId prop is provided (collaborator case), do not auto-select + if (!credentialId && data.credentials.length > 0 && !selectedCredentialId) { + const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault) + if (defaultCred) setSelectedCredentialId(defaultCred.id) + else if (data.credentials.length === 1) setSelectedCredentialId(data.credentials[0].id) } } } catch (error) { @@ -137,11 +131,18 @@ export function MicrosoftFileSelector({ setIsLoading(false) setCredentialsLoaded(true) } - }, [provider, getProviderId, selectedCredentialId]) + }, [provider, getProviderId, selectedCredentialId, credentialId]) + + // Keep internal credential in sync with prop + useEffect(() => { + if (credentialId && credentialId !== selectedCredentialId) { + setSelectedCredentialId(credentialId) + } + }, [credentialId, selectedCredentialId]) // Fetch available files for the selected credential const fetchAvailableFiles = useCallback(async () => { - if (!selectedCredentialId) return + if (!selectedCredentialId || isForeignCredential) return setIsLoadingFiles(true) try { @@ -170,9 +171,13 @@ export function MicrosoftFileSelector({ const data = await response.json() setAvailableFiles(data.files || []) } else { - logger.error('Error fetching available files:', { - error: await response.text(), - }) + const txt = await response.text() + if (response.status === 401 || response.status === 403) { + // Suppress noisy auth errors for collaborators; lists are intentionally gated + logger.info('Skipping list fetch (auth)', { status: response.status }) + } else { + logger.warn('Non-OK list fetch', { status: response.status, txt }) + } setAvailableFiles([]) } } catch (error) { @@ -181,7 +186,7 @@ export function MicrosoftFileSelector({ } finally { setIsLoadingFiles(false) } - }, [selectedCredentialId, searchQuery, serviceId]) + }, [selectedCredentialId, searchQuery, serviceId, isForeignCredential]) // Fetch a single file by ID when we have a selectedFileId but no metadata const fetchFileById = useCallback( @@ -190,49 +195,90 @@ export function MicrosoftFileSelector({ setIsLoadingSelectedFile(true) try { - // Construct query parameters - const queryParams = new URLSearchParams({ - credentialId: selectedCredentialId, - fileId: fileId, - }) - - // Route to correct endpoint based on service - let endpoint: string - if (serviceId === 'onedrive') { - endpoint = `/api/tools/onedrive/folder?${queryParams.toString()}` - } else if (serviceId === 'sharepoint') { - // Change from fileId to siteId for SharePoint - const sharepointParams = new URLSearchParams({ - credentialId: selectedCredentialId, - siteId: fileId, // Use siteId instead of fileId + // Use owner-scoped token for OneDrive items (files/folders) and Excel + if (serviceId !== 'sharepoint') { + const tokenRes = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }), }) - endpoint = `/api/tools/sharepoint/site?${sharepointParams.toString()}` - } else { - endpoint = `/api/auth/oauth/microsoft/file?${queryParams.toString()}` + if (!tokenRes.ok) { + const err = await tokenRes.text() + logger.error('Failed to get access token for Microsoft file fetch', { err }) + return null + } + const { accessToken } = await tokenRes.json() + if (!accessToken) return null + + const graphUrl = + `https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}?` + + new URLSearchParams({ + $select: + 'id,name,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy,file,folder', + }).toString() + const resp = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!resp.ok) { + const t = await resp.text() + // For 404/403, keep current selection; this often means the item moved or is shared differently. + if (resp.status !== 404 && resp.status !== 403) { + logger.warn('Graph error fetching file by ID', { status: resp.status, t }) + } + return null + } + const file = await resp.json() + const fileInfo: MicrosoftFileInfo = { + id: file.id, + name: file.name, + mimeType: + file?.file?.mimeType || (file.folder ? 'application/vnd.ms-onedrive.folder' : ''), + iconLink: file.thumbnails?.[0]?.small?.url, + webViewLink: file.webUrl, + thumbnailLink: file.thumbnails?.[0]?.medium?.url, + createdTime: file.createdDateTime, + modifiedTime: file.lastModifiedDateTime, + size: file.size?.toString(), + owners: file.createdBy + ? [ + { + displayName: file.createdBy.user?.displayName || 'Unknown', + emailAddress: file.createdBy.user?.email || '', + }, + ] + : [], + } + setSelectedFile(fileInfo) + onFileInfoChange?.(fileInfo) + return fileInfo } - const response = await fetch(endpoint) - - if (response.ok) { - const data = await response.json() - if (data.file) { - setSelectedFile(data.file) - onFileInfoChange?.(data.file) - return data.file - } - } else { - const errorText = await response.text() - logger.error('Error fetching file by ID:', { error: errorText }) - - // If file not found or access denied, clear the selection - if (response.status === 404 || response.status === 403) { - logger.info('File not accessible, clearing selection') - setSelectedFileId('') - onChange('') - onFileInfoChange?.(null) + // SharePoint site: fetch via Graph sites endpoint for collaborator visibility + const tokenRes = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }), + }) + if (!tokenRes.ok) return null + const { accessToken: spToken } = await tokenRes.json() + if (!spToken) return null + const spResp = await fetch( + `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(fileId)}?$select=id,displayName,webUrl`, + { + headers: { Authorization: `Bearer ${spToken}` }, } + ) + if (!spResp.ok) return null + const site = await spResp.json() + const siteInfo: MicrosoftFileInfo = { + id: site.id, + name: site.displayName, + mimeType: 'sharepoint/site', + webViewLink: site.webUrl, } - return null + setSelectedFile(siteInfo) + onFileInfoChange?.(siteInfo) + return siteInfo } catch (error) { logger.error('Error fetching file by ID:', { error }) return null @@ -240,16 +286,22 @@ export function MicrosoftFileSelector({ setIsLoadingSelectedFile(false) } }, - [selectedCredentialId, onFileInfoChange, serviceId] + [selectedCredentialId, onFileInfoChange, serviceId, workflowId, onChange] ) // Fetch Microsoft Planner tasks when planId and credentials are available const fetchPlannerTasks = useCallback(async () => { - if (!selectedCredentialId || !planId || serviceId !== 'microsoft-planner') { + if ( + !selectedCredentialId || + !planId || + serviceId !== 'microsoft-planner' || + isForeignCredential + ) { logger.info('Skipping task fetch - missing requirements:', { selectedCredentialId: !!selectedCredentialId, planId: !!planId, serviceId, + isForeignCredential, }) return } @@ -296,11 +348,17 @@ export function MicrosoftFileSelector({ setPlannerTasks(transformedTasks) } else { const errorText = await response.text() - logger.error('API response not ok:', { - status: response.status, - statusText: response.statusText, - errorText, - }) + if (response.status === 401 || response.status === 403) { + logger.info('Planner list fetch unauthorized (expected for collaborator)', { + status: response.status, + }) + } else { + logger.warn('Planner tasks fetch non-OK', { + status: response.status, + statusText: response.statusText, + errorText, + }) + } setPlannerTasks([]) } } catch (error) { @@ -309,7 +367,50 @@ export function MicrosoftFileSelector({ } finally { setIsLoadingTasks(false) } - }, [selectedCredentialId, planId, serviceId]) + }, [selectedCredentialId, planId, serviceId, isForeignCredential]) + + // Fetch a single planner task by ID for collaborator preview + const fetchPlannerTaskById = useCallback( + async (taskId: string) => { + if (!selectedCredentialId || !taskId || serviceId !== 'microsoft-planner') return null + setIsLoadingTasks(true) + try { + const tokenRes = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }), + }) + if (!tokenRes.ok) return null + const { accessToken } = await tokenRes.json() + if (!accessToken) return null + const resp = await fetch( + `https://graph.microsoft.com/v1.0/planner/tasks/${encodeURIComponent(taskId)}`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + if (!resp.ok) return null + const task = await resp.json() + const taskAsFileInfo: MicrosoftFileInfo = { + id: task.id, + name: task.title, + mimeType: 'planner/task', + webViewLink: `https://tasks.office.com/planner/task/${task.id}`, + createdTime: task.createdDateTime, + modifiedTime: task.createdDateTime, + } + setSelectedTask(task) + setSelectedFile(taskAsFileInfo) + onFileInfoChange?.(taskAsFileInfo) + return taskAsFileInfo + } catch { + return null + } finally { + setIsLoadingTasks(false) + } + }, + [selectedCredentialId, workflowId, onFileInfoChange, serviceId] + ) // Fetch credentials on initial mount useEffect(() => { @@ -339,10 +440,15 @@ export function MicrosoftFileSelector({ // Fetch planner tasks when credentials and planId change useEffect(() => { - if (serviceId === 'microsoft-planner' && selectedCredentialId && planId) { + if ( + serviceId === 'microsoft-planner' && + selectedCredentialId && + planId && + !isForeignCredential + ) { fetchPlannerTasks() } - }, [selectedCredentialId, planId, serviceId, fetchPlannerTasks]) + }, [selectedCredentialId, planId, serviceId, isForeignCredential, fetchPlannerTasks]) // Handle task selection for planner const handleTaskSelect = (task: PlannerTask) => { @@ -357,26 +463,23 @@ export function MicrosoftFileSelector({ modifiedTime: task.createdDateTime, } + // Update internal state first to avoid race with list refetch setSelectedFileId(taskId) setSelectedFile(taskAsFileInfo) setSelectedTask(task) + // Then propagate up onChange(taskId, taskAsFileInfo) onFileInfoChange?.(taskAsFileInfo) setOpen(false) setSearchQuery('') } - // Keep internal selectedFileId in sync with the value prop + // Keep internal selectedFileId in sync with the value prop (do not clear selectedFile; we'll resolve new metadata below) useEffect(() => { if (value !== selectedFileId) { - const previousFileId = selectedFileId setSelectedFileId(value) - // Only clear selected file info if we had a different file before (not initial load) - if (previousFileId && previousFileId !== value && selectedFile) { - setSelectedFile(null) - } } - }, [value, selectedFileId, selectedFile]) + }, [value, selectedFileId]) // Track previous credential ID to detect changes const prevCredentialIdRef = useRef('') @@ -403,18 +506,19 @@ export function MicrosoftFileSelector({ // Fetch the selected file metadata once credentials are loaded or changed useEffect(() => { - // Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet + // Fetch metadata when the external value doesn't match our current selectedFile if ( value && selectedCredentialId && credentialsLoaded && - !selectedFile && - !isLoadingSelectedFile && - serviceId !== 'microsoft-planner' && - serviceId !== 'sharepoint' && - serviceId !== 'onedrive' + (!selectedFile || selectedFile.id !== value) && + !isLoadingSelectedFile ) { - fetchFileById(value) + if (serviceId === 'microsoft-planner') { + void fetchPlannerTaskById(value) + } else { + fetchFileById(value) + } } }, [ value, @@ -423,9 +527,30 @@ export function MicrosoftFileSelector({ selectedFile, isLoadingSelectedFile, fetchFileById, + fetchPlannerTaskById, serviceId, ]) + // Resolve planner task selection for collaborators + useEffect(() => { + if ( + value && + selectedCredentialId && + credentialsLoaded && + !selectedTask && + serviceId === 'microsoft-planner' + ) { + void fetchPlannerTaskById(value) + } + }, [ + value, + selectedCredentialId, + credentialsLoaded, + selectedTask, + serviceId, + fetchPlannerTaskById, + ]) + // Handle selecting a file from the available files const handleFileSelect = (file: MicrosoftFileInfo) => { setSelectedFileId(file.id) @@ -620,7 +745,9 @@ export function MicrosoftFileSelector({ role='combobox' aria-expanded={open} className='h-10 w-full min-w-0 justify-between' - disabled={disabled || (serviceId === 'microsoft-planner' && !planId)} + disabled={ + disabled || isForeignCredential || (serviceId === 'microsoft-planner' && !planId) + } >
{selectedFile ? ( @@ -643,154 +770,158 @@ export function MicrosoftFileSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- {getProviderIcon(provider)} - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - + {!isForeignCredential && ( + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ {getProviderIcon(provider)} + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + + )}
- {credentials.length > 1 && ( - - )} -
- )} + )} - - - - - {isLoading || isLoadingFiles || isLoadingTasks ? ( -
- - Loading... -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a {getProviderName(provider)} account to continue. -

-
- ) : serviceId === 'microsoft-planner' && !planId ? ( -
-

Plan ID required.

-

- Please enter a Plan ID first to see tasks. -

-
- ) : filteredTasks.length === 0 ? ( -
-

{getEmptyStateText().title}

-

- {getEmptyStateText().description} -

-
- ) : null} -
- - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- {getProviderIcon(cred.provider)} - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Available files/tasks - only show if we have credentials and items */} - {credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && ( - -
- {getFileTypeTitleCase()} -
- {filteredTasks.map((item) => { - const isPlanner = serviceId === 'microsoft-planner' - const isPlannerTask = isPlanner && 'title' in item - const plannerTask = item as PlannerTask - const fileInfo = item as MicrosoftFileInfo - - const displayName = isPlannerTask ? plannerTask.title : fileInfo.name - const dateField = isPlannerTask - ? plannerTask.createdDateTime - : fileInfo.createdTime - - return ( - - isPlannerTask - ? handleTaskSelect(plannerTask) - : handleFileSelect(fileInfo) - } - > -
- {getFileIcon( - isPlannerTask - ? { - ...fileInfo, - id: plannerTask.id || '', - name: plannerTask.title, - mimeType: 'planner/task', - } - : fileInfo, - 'sm' - )} -
- {displayName} - {dateField && ( -
- Modified {new Date(dateField).toLocaleDateString()} -
- )} -
-
- {item.id === selectedFileId && } -
- ) - })} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- {getProviderIcon(provider)} - Connect {getProviderName(provider)} account + + + + + {isLoading || isLoadingFiles || isLoadingTasks ? ( +
+ + Loading...
- - - )} -
-
- + ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a {getProviderName(provider)} account to continue. +

+
+ ) : serviceId === 'microsoft-planner' && !planId ? ( +
+

Plan ID required.

+

+ Please enter a Plan ID first to see tasks. +

+
+ ) : filteredTasks.length === 0 ? ( +
+

{getEmptyStateText().title}

+

+ {getEmptyStateText().description} +

+
+ ) : null} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ {getProviderIcon(cred.provider)} + {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Available files/tasks - only show if we have credentials and items */} + {credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && ( + +
+ {getFileTypeTitleCase()} +
+ {filteredTasks.map((item) => { + const isPlanner = serviceId === 'microsoft-planner' + const isPlannerTask = isPlanner && 'title' in item + const plannerTask = item as PlannerTask + const fileInfo = item as MicrosoftFileInfo + + const displayName = isPlannerTask ? plannerTask.title : fileInfo.name + const dateField = isPlannerTask + ? plannerTask.createdDateTime + : fileInfo.createdTime + + return ( + + isPlannerTask + ? handleTaskSelect(plannerTask) + : handleFileSelect(fileInfo) + } + > +
+ {getFileIcon( + isPlannerTask + ? { + ...fileInfo, + id: plannerTask.id || '', + name: plannerTask.title, + mimeType: 'planner/task', + } + : fileInfo, + 'sm' + )} +
+ {displayName} + {dateField && ( +
+ Modified {new Date(dateField).toLocaleDateString()} +
+ )} +
+
+ {item.id === selectedFileId && } +
+ ) + })} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ {getProviderIcon(provider)} + Connect {getProviderName(provider)} account +
+
+
+ )} + + + + )} {/* File preview */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index ecbaf2ae8b..170d8360cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -48,6 +48,7 @@ interface TeamsMessageSelectorProps { selectionType?: 'team' | 'channel' | 'chat' initialTeamId?: string workflowId: string + isForeignCredential?: boolean } export function TeamsMessageSelector({ @@ -64,6 +65,7 @@ export function TeamsMessageSelector({ selectionType = 'team', initialTeamId, workflowId, + isForeignCredential = false, }: TeamsMessageSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -324,6 +326,10 @@ export function TeamsMessageSelector({ // Handle open change const handleOpenChange = (isOpen: boolean) => { + if (disabled || isForeignCredential) { + setOpen(false) + return + } setOpen(isOpen) // Only fetch data when opening the dropdown if (isOpen && selectedCredentialId) { @@ -693,7 +699,7 @@ export function TeamsMessageSelector({ role='combobox' aria-expanded={open} className='h-10 w-full min-w-0 justify-between' - disabled={disabled} + disabled={disabled || isForeignCredential} >
{selectedMessage ? ( @@ -715,120 +721,124 @@ export function TeamsMessageSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading {selectionStage}s... -
- ) : error ? ( -
-

{error}

- {selectionStage === 'chat' && error.includes('teams') && ( -

- There was an issue fetching chats. Please try again or connect a different - account. -

- )} -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Microsoft Teams account to{' '} - {selectionStage === 'chat' - ? 'access your chats' - : selectionStage === 'channel' - ? 'see your channels' - : 'continue'} - . -

-
- ) : ( -
-

No {selectionStage}s found.

-

- {selectionStage === 'team' - ? 'Try a different account.' - : selectionStage === 'channel' - ? selectedTeamId - ? 'This team has no channels or you may not have access.' - : 'Please select a team first to see its channels.' - : 'Try a different account or check if you have any active chats.'} -

-
+ {!isForeignCredential && ( + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + )} - +
+ )} - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - { - setSelectedCredentialId(cred.id) - setOpen(false) - }} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Display appropriate options based on selection stage */} - {renderSelectionOptions()} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Microsoft Teams account + + + + + {isLoading ? ( +
+ + Loading {selectionStage}s...
- - - )} -
-
- + ) : error ? ( +
+

{error}

+ {selectionStage === 'chat' && error.includes('teams') && ( +

+ There was an issue fetching chats. Please try again or connect a + different account. +

+ )} +
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Microsoft Teams account to{' '} + {selectionStage === 'chat' + ? 'access your chats' + : selectionStage === 'channel' + ? 'see your channels' + : 'continue'} + . +

+
+ ) : ( +
+

No {selectionStage}s found.

+

+ {selectionStage === 'team' + ? 'Try a different account.' + : selectionStage === 'channel' + ? selectedTeamId + ? 'This team has no channels or you may not have access.' + : 'Please select a team first to see its channels.' + : 'Try a different account or check if you have any active chats.'} +

+
+ )} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + { + setSelectedCredentialId(cred.id) + setOpen(false) + }} + > +
+ + {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Display appropriate options based on selection stage */} + {renderSelectionOptions()} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ + Connect Microsoft Teams account +
+
+
+ )} + + + + )} {/* Selection preview */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 16a9d19956..9875817e0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -22,6 +22,7 @@ import { WealthboxFileSelector, type WealthboxItemInfo, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components' +import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -70,6 +71,7 @@ export function FileSelectorInput({ // Use the proper hook to get the current value and setter const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const [connectedCredential] = useSubBlockValue(blockId, 'credential') const [selectedFileId, setSelectedFileId] = useState('') const [_fileInfo, setFileInfo] = useState(null) const [selectedIssueId, setSelectedIssueId] = useState('') @@ -84,34 +86,10 @@ export function FileSelectorInput({ const [wealthboxItemInfo, setWealthboxItemInfo] = useState(null) // Determine if the persisted credential belongs to the current viewer - const [isForeignCredential, setIsForeignCredential] = useState(false) - useEffect(() => { - const cred = (getValue(blockId, 'credential') as string) || '' - if (!cred) { - setIsForeignCredential(false) - return - } - let aborted = false - ;(async () => { - try { - const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`) - if (aborted) return - if (!resp.ok) { - setIsForeignCredential(true) - return - } - const data = await resp.json() - // If credential not returned for this session user, it's foreign - setIsForeignCredential(!(data.credentials && data.credentials.length === 1)) - } catch { - setIsForeignCredential(true) - } - })() - return () => { - aborted = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blockId, getValue(blockId, 'credential')]) + const { isForeignCredential } = useForeignCredential( + subBlock.provider || subBlock.serviceId || '', + (connectedCredential as string) || '' + ) // Get provider-specific values const provider = subBlock.provider || 'google-drive' @@ -254,7 +232,7 @@ export function FileSelectorInput({ // Render Google Calendar selector if (isGoogleCalendar) { - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' return ( @@ -321,7 +299,7 @@ export function FileSelectorInput({ // Render the appropriate picker based on provider if (isConfluence) { - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' return ( @@ -347,6 +325,8 @@ export function FileSelectorInput({ showPreview={true} onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void} credentialId={credential} + workflowId={workflowIdFromUrl} + isForeignCredential={isForeignCredential} />
@@ -361,7 +341,7 @@ export function FileSelectorInput({ } if (isJira) { - const credential = jiraCredential + const credential = (connectedCredential as string) || '' return ( @@ -391,6 +371,7 @@ export function FileSelectorInput({ credentialId={credential} projectId={(getValue(blockId, 'projectId') as string) || ''} isForeignCredential={isForeignCredential} + workflowId={activeWorkflowId || ''} />
@@ -413,8 +394,8 @@ export function FileSelectorInput({ } if (isMicrosoftExcel) { - // Get credential using the same pattern as other tools - const credential = (getValue(blockId, 'credential') as string) || '' + // Get credential reactively + const credential = (connectedCredential as string) || '' return ( @@ -431,6 +412,9 @@ export function FileSelectorInput({ disabled={disabled || !credential} showPreview={true} onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void} + workflowId={activeWorkflowId || ''} + credentialId={credential} + isForeignCredential={isForeignCredential} />
@@ -446,8 +430,8 @@ export function FileSelectorInput({ // Handle Microsoft Word selector if (isMicrosoftWord) { - // Get credential using the same pattern as other tools - const credential = (getValue(blockId, 'credential') as string) || '' + // Get credential reactively + const credential = (connectedCredential as string) || '' return ( @@ -479,7 +463,7 @@ export function FileSelectorInput({ // Handle Microsoft OneDrive selector if (isMicrosoftOneDrive) { - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' return ( @@ -496,6 +480,9 @@ export function FileSelectorInput({ disabled={disabled || !credential} showPreview={true} onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void} + workflowId={activeWorkflowId || ''} + credentialId={credential} + isForeignCredential={isForeignCredential} />
@@ -511,7 +498,7 @@ export function FileSelectorInput({ // Handle Microsoft SharePoint selector if (isMicrosoftSharePoint) { - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' return ( @@ -528,6 +515,9 @@ export function FileSelectorInput({ disabled={disabled || !credential} showPreview={true} onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void} + workflowId={activeWorkflowId || ''} + credentialId={credential} + isForeignCredential={isForeignCredential} />
@@ -543,7 +533,7 @@ export function FileSelectorInput({ // Handle Microsoft Planner task selector if (isMicrosoftPlanner) { - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' const planId = (getValue(blockId, 'planId') as string) || '' return ( @@ -562,6 +552,9 @@ export function FileSelectorInput({ showPreview={true} onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void} planId={planId} + workflowId={activeWorkflowId || ''} + credentialId={credential} + isForeignCredential={isForeignCredential} />
@@ -582,7 +575,7 @@ export function FileSelectorInput({ // Handle Microsoft Teams selector if (isMicrosoftTeams) { // Get credential using the same pattern as other tools - const credential = (getValue(blockId, 'credential') as string) || '' + const credential = (connectedCredential as string) || '' // Determine the selector type based on the subBlock ID let selectionType: 'team' | 'channel' | 'chat' = 'team' @@ -633,6 +626,7 @@ export function FileSelectorInput({ selectionType={selectionType} initialTeamId={selectedTeamId} workflowId={activeWorkflowId || ''} + isForeignCredential={isForeignCredential} />
@@ -648,8 +642,8 @@ export function FileSelectorInput({ // Render Wealthbox selector if (isWealthbox) { - // Get credential using the same pattern as other tools - const credential = (getValue(blockId, 'credential') as string) || '' + // Get credential reactively + const credential = (connectedCredential as string) || '' // Only handle contacts now - both notes and tasks use short-input if (subBlock.id === 'contactId') { @@ -697,32 +691,47 @@ export function FileSelectorInput({ } // Default to Google Drive picker - return ( - { - setSelectedFileId(val) - setFileInfo(info || null) - collaborativeSetSubblockValue(blockId, subBlock.id, val) - }} - provider={provider} - requiredScopes={subBlock.requiredScopes || []} - label={subBlock.placeholder || 'Select file'} - disabled={disabled} - serviceId={subBlock.serviceId} - mimeTypeFilter={subBlock.mimeType} - showPreview={true} - onFileInfoChange={setFileInfo} - clientId={clientId} - apiKey={apiKey} - credentialId={ - ((isPreview && previewContextValues?.credential?.value) || - (getValue(blockId, 'credential') as string) || - '') as string - } - workflowId={workflowIdFromUrl} - /> - ) + { + const credential = ((isPreview && previewContextValues?.credential?.value) || + (connectedCredential as string) || + '') as string + + return ( + + + +
+ { + setSelectedFileId(val) + setFileInfo(info || null) + collaborativeSetSubblockValue(blockId, subBlock.id, val) + }} + provider={provider} + requiredScopes={subBlock.requiredScopes || []} + label={subBlock.placeholder || 'Select file'} + disabled={disabled || !credential} + serviceId={subBlock.serviceId} + mimeTypeFilter={subBlock.mimeType} + showPreview={true} + onFileInfoChange={setFileInfo} + clientId={clientId} + apiKey={apiKey} + credentialId={credential} + workflowId={workflowIdFromUrl} + /> +
+
+ {!credential && ( + +

Please select Google Drive credentials first

+
+ )} +
+
+ ) + } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx index a8d5119073..bdf6c71305 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/components/folder-selector-input.tsx @@ -5,9 +5,11 @@ import { type FolderInfo, FolderSelector, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector' +import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface FolderSelectorInputProps { blockId: string @@ -25,9 +27,15 @@ export function FolderSelectorInput({ previewValue, }: FolderSelectorInputProps) { const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const [connectedCredential] = useSubBlockValue(blockId, 'credential') const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() + const { activeWorkflowId } = useWorkflowRegistry() const [selectedFolderId, setSelectedFolderId] = useState('') const [_folderInfo, setFolderInfo] = useState(null) + const { isForeignCredential } = useForeignCredential( + subBlock.provider || subBlock.serviceId || 'outlook', + (connectedCredential as string) || '' + ) // Get the current value from the store or prop value if in preview mode useEffect(() => { @@ -67,6 +75,9 @@ export function FolderSelectorInput({ disabled={disabled} serviceId={subBlock.serviceId} onFolderInfoChange={setFolderInfo} + credentialId={(connectedCredential as string) || ''} + workflowId={activeWorkflowId || ''} + isForeignCredential={isForeignCredential} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx index ee5eb7e562..d331b8685c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx @@ -38,6 +38,9 @@ interface FolderSelectorProps { onFolderInfoChange?: (folderInfo: FolderInfo | null) => void isPreview?: boolean previewValue?: any | null + credentialId?: string + workflowId?: string + isForeignCredential?: boolean } export function FolderSelector({ @@ -51,11 +54,16 @@ export function FolderSelector({ onFolderInfoChange, isPreview = false, previewValue, + credentialId, + workflowId, + isForeignCredential = false, }: FolderSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) const [folders, setFolders] = useState([]) - const [selectedCredentialId, setSelectedCredentialId] = useState('') + const [selectedCredentialId, setSelectedCredentialId] = useState( + credentialId || '' + ) const [selectedFolderId, setSelectedFolderId] = useState('') const [selectedFolder, setSelectedFolder] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -72,6 +80,13 @@ export function FolderSelector({ } }, [value, isPreview, previewValue]) + // Keep internal credential in sync with prop + useEffect(() => { + if (credentialId && credentialId !== selectedCredentialId) { + setSelectedCredentialId(credentialId) + } + }, [credentialId, selectedCredentialId]) + // Determine the appropriate service ID based on provider and scopes const getServiceId = (): string => { if (serviceId) return serviceId @@ -124,18 +139,43 @@ export function FolderSelector({ // Fetch a single folder by ID when we have a selectedFolderId but no metadata const fetchFolderById = useCallback( async (folderId: string) => { - if (!selectedCredentialId || !folderId || provider === 'outlook') return null + if (!selectedCredentialId || !folderId) return null setIsLoadingSelectedFolder(true) try { - // Construct query parameters + if (provider === 'outlook') { + // Resolve Outlook folder name with owner-scoped token + const tokenRes = await fetch('/api/auth/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }), + }) + if (!tokenRes.ok) return null + const { accessToken } = await tokenRes.json() + if (!accessToken) return null + const resp = await fetch( + `https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (!resp.ok) return null + const folder = await resp.json() + const folderInfo: FolderInfo = { + id: folder.id, + name: folder.displayName, + type: 'folder', + messagesTotal: folder.totalItemCount, + messagesUnread: folder.unreadItemCount, + } + setSelectedFolder(folderInfo) + onFolderInfoChange?.(folderInfo) + return folderInfo + } + // Gmail label resolution const queryParams = new URLSearchParams({ credentialId: selectedCredentialId, labelId: folderId, }) - const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`) - if (response.ok) { const data = await response.json() if (data.label) { @@ -156,7 +196,7 @@ export function FolderSelector({ setIsLoadingSelectedFolder(false) } }, - [selectedCredentialId, onFolderInfoChange, provider] + [selectedCredentialId, onFolderInfoChange, provider, workflowId] ) // Fetch folders from Gmail or Outlook @@ -178,6 +218,12 @@ export function FolderSelector({ // Determine the API endpoint based on provider let apiEndpoint: string if (provider === 'outlook') { + // Skip list fetch for collaborators; only show selected + if (isForeignCredential) { + setFolders([]) + setIsLoading(false) + return + } apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}` } else { // Default to Gmail @@ -206,9 +252,12 @@ export function FolderSelector({ } } } else { - logger.error('Error fetching folders:', { - error: await response.text(), - }) + const text = await response.text() + if (response.status === 401 || response.status === 403) { + logger.info('Folder list fetch unauthorized (expected for collaborator)') + } else { + logger.warn('Error fetching folders', { status: response.status, text }) + } setFolders([]) } } catch (error) { @@ -218,7 +267,14 @@ export function FolderSelector({ setIsLoading(false) } }, - [selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById, provider] + [ + selectedCredentialId, + selectedFolderId, + onFolderInfoChange, + fetchFolderById, + provider, + isForeignCredential, + ] ) // Fetch credentials on initial mount @@ -244,21 +300,17 @@ export function FolderSelector({ } }, [value, isPreview, previewValue]) - // Fetch the selected folder metadata once credentials are ready (Gmail only) + // Fetch the selected folder metadata once credentials are ready or value changes useEffect(() => { - const currentValue = isPreview ? previewValue : value - if (currentValue && selectedCredentialId && !selectedFolder && provider !== 'outlook') { + const currentValue = isPreview ? (previewValue as string) : (value as string) + if ( + currentValue && + selectedCredentialId && + (!selectedFolder || selectedFolder.id !== currentValue) + ) { fetchFolderById(currentValue) } - }, [ - value, - selectedCredentialId, - selectedFolder, - fetchFolderById, - provider, - isPreview, - previewValue, - ]) + }, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue]) // Handle folder selection const handleSelectFolder = (folder: FolderInfo) => { @@ -317,7 +369,7 @@ export function FolderSelector({ role='combobox' aria-expanded={open} className='w-full justify-between' - disabled={disabled} + disabled={disabled || isForeignCredential} > {selectedFolder ? (
@@ -333,114 +385,120 @@ export function FolderSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading {getFolderLabel()}... -
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a {getProviderName()} account to continue. -

-
- ) : ( -
-

No {getFolderLabel()} found.

-

- Try a different search or account. -

-
+ {!isForeignCredential && ( + + {/* Current account indicator */} + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + )} - +
+ )} - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Folders list */} - {folders.length > 0 && ( - -
- {getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)} -
- {folders.map((folder) => ( - handleSelectFolder(folder)} - > -
- {getFolderIcon('sm')} - {folder.name} - {folder.id === selectedFolderId && } -
-
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- Connect {getProviderName()} account + + + + + {isLoading ? ( +
+ + Loading {getFolderLabel()}...
- - - )} -
-
- + ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a {getProviderName()} account to continue. +

+
+ ) : ( +
+

No {getFolderLabel()} found.

+

+ Try a different search or account. +

+
+ )} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Folders list */} + {folders.length > 0 && ( + +
+ {getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)} +
+ {folders.map((folder) => ( + handleSelectFolder(folder)} + > +
+ {getFolderIcon('sm')} + {folder.name} + {folder.id === selectedFolderId && ( + + )} +
+
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ Connect {getProviderName()} account +
+
+
+ )} + + + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx index fb0ac63b8a..e52e4a6837 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx @@ -50,6 +50,7 @@ interface JiraProjectSelectorProps { onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void credentialId?: string isForeignCredential?: boolean + workflowId?: string } export function JiraProjectSelector({ @@ -64,6 +65,8 @@ export function JiraProjectSelector({ showPreview = true, onProjectInfoChange, credentialId, + isForeignCredential = false, + workflowId, }: JiraProjectSelectorProps) { const [open, setOpen] = useState(false) const [credentials, setCredentials] = useState([]) @@ -153,6 +156,7 @@ export function JiraProjectSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -238,6 +242,7 @@ export function JiraProjectSelector({ }, body: JSON.stringify({ credentialId: selectedCredentialId, + workflowId, }), }) @@ -334,16 +339,12 @@ export function JiraProjectSelector({ // Fetch the selected project metadata once credentials are ready or changed useEffect(() => { - if ( - value && - selectedCredentialId && - domain && - domain.includes('.') && - (!selectedProject || selectedProject.id !== value) - ) { - fetchProjectInfo(value) + if (value && selectedCredentialId && domain && domain.includes('.')) { + if (!selectedProject || selectedProject.id !== value) { + fetchProjectInfo(value) + } } - }, [value, selectedCredentialId, selectedProject, domain, fetchProjectInfo]) + }, [value, selectedCredentialId, domain, fetchProjectInfo, selectedProject]) // Keep internal selectedProjectId in sync with the value prop useEffect(() => { @@ -396,7 +397,7 @@ export function JiraProjectSelector({ role='combobox' aria-expanded={open} className='w-full justify-between' - disabled={disabled || !domain || !selectedCredentialId} + disabled={disabled || !domain || !selectedCredentialId || isForeignCredential} > {selectedProject ? (
@@ -417,126 +418,131 @@ export function JiraProjectSelector({ - - {/* Current account indicator */} - {selectedCredentialId && credentials.length > 0 && ( -
-
- - - {credentials.find((cred) => cred.id === selectedCredentialId)?.name || - 'Unknown'} - -
- {credentials.length > 1 && ( - - )} -
- )} - - - - - - {isLoading ? ( -
- - Loading projects... -
- ) : error ? ( -
-

{error}

-
- ) : credentials.length === 0 ? ( -
-

No accounts connected.

-

- Connect a Jira account to continue. -

-
- ) : ( -
-

No projects found.

-

- Try a different search or account. -

-
+ {!isForeignCredential && ( + + {selectedCredentialId && credentials.length > 0 && ( +
+
+ + + {credentials.find((cred) => cred.id === selectedCredentialId)?.name || + 'Unknown'} + +
+ {credentials.length > 1 && ( + )} - +
+ )} - {/* Account selection - only show if we have multiple accounts */} - {credentials.length > 1 && ( - -
- Switch Account -
- {credentials.map((cred) => ( - setSelectedCredentialId(cred.id)} - > -
- - {cred.name} -
- {cred.id === selectedCredentialId && } -
- ))} -
- )} - - {/* Projects list */} - {projects.length > 0 && ( - -
- Projects -
- {projects.map((project) => ( - handleSelectProject(project)} - > -
- {project.avatarUrl ? ( - {project.name} - ) : ( - - )} - {project.name} -
- {project.id === selectedProjectId && } -
- ))} -
- )} - - {/* Connect account option - only show if no credentials */} - {credentials.length === 0 && ( - - -
- - Connect Jira account + + + + + {isLoading ? ( +
+ + Loading projects...
- - - )} -
-
- + ) : error ? ( +
+

{error}

+
+ ) : credentials.length === 0 ? ( +
+

No accounts connected.

+

+ Connect a Jira account to continue. +

+
+ ) : ( +
+

No projects found.

+

+ Try a different search or account. +

+
+ )} + + + {/* Account selection - only show if we have multiple accounts */} + {credentials.length > 1 && ( + +
+ Switch Account +
+ {credentials.map((cred) => ( + setSelectedCredentialId(cred.id)} + > +
+ + {cred.name} +
+ {cred.id === selectedCredentialId && ( + + )} +
+ ))} +
+ )} + + {/* Projects list */} + {projects.length > 0 && ( + +
+ Projects +
+ {projects.map((project) => ( + handleSelectProject(project)} + > +
+ {project.avatarUrl ? ( + {project.name} + ) : ( + + )} + {project.name} +
+ {project.id === selectedProjectId && ( + + )} +
+ ))} +
+ )} + + {/* Connect account option - only show if no credentials */} + {credentials.length === 0 && ( + + +
+ + Connect Jira account +
+
+
+ )} + + + + )} {/* Project preview */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index 15545a757c..cd44cb9aea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -18,6 +18,7 @@ import { type LinearTeamInfo, LinearTeamSelector, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector' +import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -43,10 +44,13 @@ export function ProjectSelectorInput({ const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const [selectedProjectId, setSelectedProjectId] = useState('') const [_projectInfo, setProjectInfo] = useState(null) - const [isForeignCredential, setIsForeignCredential] = useState(false) - // Use the proper hook to get the current value and setter const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const [connectedCredential] = useSubBlockValue(blockId, 'credential') + const { isForeignCredential } = useForeignCredential( + subBlock.provider || subBlock.serviceId || 'jira', + (connectedCredential as string) || '' + ) // Local setters for related Jira fields to ensure immediate UI clearing const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue(blockId, 'issueKey') const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue( @@ -70,32 +74,6 @@ export function ProjectSelectorInput({ const botToken = '' // Verify Jira credential belongs to current user; if not, treat as absent - useEffect(() => { - const cred = (jiraCredential as string) || '' - if (!cred) { - setIsForeignCredential(false) - return - } - let aborted = false - ;(async () => { - try { - const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`) - if (aborted) return - if (!resp.ok) { - setIsForeignCredential(true) - return - } - const data = await resp.json() - setIsForeignCredential(!(data.credentials && data.credentials.length === 1)) - } catch { - setIsForeignCredential(true) - } - })() - return () => { - aborted = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blockId, jiraCredential]) // Get the current value from the store or prop value if in preview mode useEffect(() => { @@ -240,6 +218,7 @@ export function ProjectSelectorInput({ onProjectInfoChange={setProjectInfo} credentialId={(jiraCredential as string) || ''} isForeignCredential={isForeignCredential} + workflowId={activeWorkflowId || ''} />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx index 3d1c29857e..37538f0582 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx @@ -176,18 +176,28 @@ export function TriggerModal({ let finalPath = triggerPath - // If no path exists, generate one automatically - if (!finalPath) { + // If no path exists and we haven't generated one yet, generate one + if (!finalPath && !generatedPath) { // Use UUID format consistent with other webhooks - finalPath = crypto.randomUUID() - setGeneratedPath(finalPath) + const newPath = crypto.randomUUID() + setGeneratedPath(newPath) + finalPath = newPath + } else if (generatedPath && !triggerPath) { + // Use the already generated path + finalPath = generatedPath } if (finalPath) { const baseUrl = window.location.origin setWebhookUrl(`${baseUrl}/api/webhooks/trigger/${finalPath}`) } - }, [triggerPath, triggerDef.provider, triggerDef.requiresCredentials, triggerDef.webhook]) + }, [ + triggerPath, + generatedPath, + triggerDef.provider, + triggerDef.requiresCredentials, + triggerDef.webhook, + ]) const handleConfigChange = (fieldId: string, value: any) => { setConfig((prev) => ({ @@ -357,10 +367,12 @@ export function TriggerModal({ {/* Import Workflow */} - {userPermissions.canEdit && !isDev && ( + {userPermissions.canEdit && ( +
+ )} +
+ + {/* Email Field - Read Only */} +
+ +

{email}

+
+ + {/* Password Field */} +
+ +
+ •••••••• + +
+
+ + {/* Sign Out Button */} +
+ +
+ + )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index b8a45baf56..e149f47553 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { Check, Copy, KeySquare, Plus, Trash2 } from 'lucide-react' +import { Check, Copy, Plus, Search } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -13,15 +13,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' @@ -56,6 +47,13 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) { const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [copySuccess, setCopySuccess] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [deleteConfirmationName, setDeleteConfirmationName] = useState('') + + // Filter API keys based on search term + const filteredApiKeys = apiKeys.filter((key) => + key.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) // Fetch API keys const fetchApiKeys = async () => { @@ -96,10 +94,10 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) { // Show the new key dialog with the API key (only shown once) setNewKey(data.key) setShowNewKeyDialog(true) - // Reset form - setNewKeyName('') // Refresh the keys list fetchApiKeys() + // Close the create dialog + setIsCreating(false) } } catch (error) { logger.error('Error creating API key:', { error }) @@ -154,196 +152,236 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) { } return ( -
-
-

API Keys

- +
+ {/* Fixed Header */} +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ + setSearchTerm(e.target.value)} + className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ )}
-

- API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They - have access to your account and workflows. -

- - {isLoading ? ( -
- - -
- ) : apiKeys.length === 0 ? ( -
-
-
- + {/* Scrollable Content */} +
+
+ {isLoading ? ( +
+ + +
-

No API keys yet

-

- You don't have any API keys yet. Create one to get started with the Sim SDK. -

- -
-
- ) : ( -
- {apiKeys.map((key) => ( - -
-
-

{key.name}

-
-

- Created: {formatDate(key.createdAt)} • Last used: {formatDate(key.lastUsed)} -

-
- •••••{key.key.slice(-6)} + ) : apiKeys.length === 0 ? ( +
+ Click "Create Key" below to get started +
+ ) : ( +
+ {filteredApiKeys.map((key) => ( +
+ +
+
+
+ + •••••{key.key.slice(-6)} + +
+

+ Last used: {formatDate(key.lastUsed)} +

+ +
- -
- - ))} + ))} + {/* Show message when search has no results but there are keys */} + {searchTerm.trim() && filteredApiKeys.length === 0 && apiKeys.length > 0 && ( +
+ No API keys found matching "{searchTerm}" +
+ )} +
+ )}
- )} +
+ + {/* Footer */} +
+
+ {isLoading ? ( + <> + +
+ + ) : ( + <> + +
Keep your API keys secure
+ + )} +
+
{/* Create API Key Dialog */} - - - - Create new API key - - Name your API key to help you identify it later. This key will have access to your - account and workflows. - - -
-
- - setNewKeyName(e.target.value)} - className='focus-visible:ring-primary' - /> -
+ + + + Create new API key + + This key will have access to your account and workflows. Make sure to copy it after + creation as you won't be able to see it again. + + + +
+

+ Enter a name for your API key to help you identify it later. +

+ setNewKeyName(e.target.value)} + placeholder='e.g., Development, Production' + className='h-9 rounded-[8px]' + autoFocus + />
- - - - - -
+ + { + handleCreateKey() + setNewKeyName('') + }} + className='h-9 w-full rounded-[8px] bg-primary text-primary-foreground transition-all duration-200 hover:bg-primary/90' + disabled={!newKeyName.trim()} + > + Create Key + + + + {/* New API Key Dialog */} - { setShowNewKeyDialog(open) - if (!open) setNewKey(null) + if (!open) { + setNewKey(null) + setCopySuccess(false) + } }} > - - - Your API key has been created - - This is the only time you will see your API key. Copy it now and store it securely. - - + + + Your API key has been created + + This is the only time you will see your API key.{' '} + Copy it now and store it securely. + + + {newKey && ( -
-
- -
- - -
-

- For security, we don't store the complete key. You won't be able to view - it again. -

+
+
+ + {newKey.key} +
+
)} - - - - -
+ + {/* Delete Confirmation Dialog */} - + - Delete API Key + Delete API key? - {deleteKey && ( - <> - Are you sure you want to delete the API key{' '} - {deleteKey.name}? This action cannot be - undone and any integrations using this key will no longer work. - - )} + Deleting this API key will immediately revoke access for any integrations using it.{' '} + This action cannot be undone. - - setDeleteKey(null)}>Cancel + + {deleteKey && ( +
+

+ Enter the API key name {deleteKey.name} to + confirm. +

+ setDeleteConfirmationName(e.target.value)} + placeholder='Type key name to confirm' + className='h-9 rounded-[8px]' + autoFocus + /> +
+ )} + + + { + setDeleteKey(null) + setDeleteConfirmationName('') + }} + > + Cancel + { + handleDeleteKey() + setDeleteConfirmationName('') + }} + className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' + disabled={!deleteKey || deleteConfirmationName !== deleteKey.name} > Delete @@ -354,16 +392,18 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) { ) } -function KeySkeleton() { +// Loading skeleton for API keys +function ApiKeySkeleton() { return ( - -
-
- - +
+ {/* API key name */} +
+
+ {/* Key preview */} + {/* Last used */}
- + {/* Delete button */}
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index afd585ff70..32315f6a44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { Check, Copy, Eye, EyeOff, KeySquare, Plus, Trash2 } from 'lucide-react' +import { Check, Copy, Eye, EyeOff, Plus, Search } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -10,13 +10,6 @@ import { AlertDialogHeader, AlertDialogTitle, Button, - Card, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, Input, Label, Skeleton, @@ -36,8 +29,9 @@ interface CopilotKey { export function Copilot() { const [keys, setKeys] = useState([]) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) const [visible, setVisible] = useState>({}) + const [searchTerm, setSearchTerm] = useState('') // Create flow state const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) @@ -49,13 +43,16 @@ export function Copilot() { const [deleteKey, setDeleteKey] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const hasKeys = keys.length > 0 + // Filter keys based on search term + const filteredKeys = keys.filter((key) => + key.apiKey.toLowerCase().includes(searchTerm.toLowerCase()) + ) const maskedValue = useCallback((value: string, show: boolean) => { if (show) return value if (!value) return '' const last6 = value.slice(-6) - return `••••••••••${last6}` + return `•••••${last6}` }, []) const fetchKeys = useCallback(async () => { @@ -134,216 +131,210 @@ export function Copilot() { } } - // UI helpers - const isFetching = isLoading && keys.length === 0 - return ( -
-

Copilot API Keys

- -

- Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret - and rotate them regularly. -

-

- For external deployments, set the COPILOT_API_KEY{' '} - environment variable on that instance to one of the keys generated here. -

- - {isFetching ? ( -
- -
-
- - -
- -
-
- -
-
- - -
- -
-
-
- ) : !hasKeys ? ( -
-
-
- -
-

No Copilot keys yet

-

- Generate a Copilot API key to authenticate requests to the Copilot SDK and methods. -

- +
+ {/* Fixed Header */} +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ + setSearchTerm(e.target.value)} + className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + />
-
- ) : ( -
- {keys.map((k) => { - const isVisible = !!visible[k.id] - const value = maskedValue(k.apiKey, isVisible) - return ( - -
-
-
{value}
-
-
- - - - - - {isVisible ? 'Hide' : 'Reveal'} - - + )} +
- - - - - - Copy - - + {/* Scrollable Content */} +
+
+ {isLoading ? ( +
+ + + +
+ ) : keys.length === 0 ? ( +
+ Click "Generate Key" below to get started +
+ ) : ( +
+ {filteredKeys.map((k) => { + const isVisible = !!visible[k.id] + const value = maskedValue(k.apiKey, isVisible) + return ( +
+ +
+
+
+ {value} +
+
+ + + + + + {isVisible ? 'Hide' : 'Reveal'} + + - - - - - - Delete - - + + + + + + Copy + + +
+
+ + +
+ ) + })} + {/* Show message when search has no results but there are keys */} + {searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 && ( +
+ No API keys found matching "{searchTerm}"
- - ) - })} + )} +
+ )}
- )} +
- {/* New Key Dialog */} - +
+ {isLoading ? ( + <> + +
+ + ) : ( + <> + +
Keep your API keys secure
+ + )} +
+
+ + {/* New API Key Dialog */} + { setShowNewKeyDialog(open) - if (!open) setNewKey(null) + if (!open) { + setNewKey(null) + setNewKeyCopySuccess(false) + } }} > - - - Your Copilot API key has been created - - This is the only time you will see the full key. Copy it now and store it securely. - - + + + New Copilot API Key + + Copy it now and store it securely. + + + {newKey && ( -
-
- -
- - -
-

- For security, we don't store the complete key. You won't be able to view it again. -

+
+
+ + {newKey.apiKey} +
+
)} - - - - -
+ + {/* Delete Confirmation Dialog */} - + - Delete Copilot API Key + Delete Copilot API key? - {deleteKey && ( - <> - Are you sure you want to delete this Copilot API key? This action cannot be - undone. - - )} + Deleting this API key will immediately revoke access for any integrations using it.{' '} + This action cannot be undone. - - setDeleteKey(null)}>Cancel + + + setDeleteKey(null)} + > + Cancel + { if (deleteKey) { @@ -352,7 +343,7 @@ export function Copilot() { setShowDeleteDialog(false) setDeleteKey(null) }} - className='bg-destructive text-destructive-foreground hover:bg-destructive/90' + className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' > Delete @@ -362,3 +353,22 @@ export function Copilot() {
) } + +// Loading skeleton for Copilot API keys +function CopilotKeySkeleton() { + return ( +
+ {/* API key label */} +
+
+ {/* Key preview */} +
+ {/* Show/Hide button */} + {/* Copy button */} +
+
+ {/* Delete button */} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx index 097c0d510d..b8fd8e59f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx @@ -1,11 +1,11 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Check, ChevronDown, ExternalLink, RefreshCw, Search } from 'lucide-react' +import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' @@ -294,192 +294,166 @@ export function Credentials({ onOpenChange }: CredentialsProps) { } return ( -
-
-
-

Credentials

- - {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className='h-9 pl-9 text-sm' - /> -
+
+ {/* Search Input */} +
+
+ + setSearchTerm(e.target.value)} + className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + />
-

- Connect your accounts to use tools that require authentication. -

- {/* Success message */} - {authSuccess && ( -
-
-
- + {/* Scrollable Content */} +
+
+ {/* Success message */} + {authSuccess && ( +
+
+
+ +
+
+

+ Account connected successfully! +

+
+
-
-

Account connected successfully!

+ )} + + {/* Pending service message - only shown when coming from OAuth required modal */} + {pendingService && showActionRequired && ( +
+
+ +
+
+

+ Action Required: Please connect + your account to enable the requested features. The required service is highlighted + below. +

+ +
-
-
- )} + )} - {/* Pending service message - only shown when coming from OAuth required modal */} - {pendingService && showActionRequired && ( -
-
- -
-
-

- Action Required: Please connect your - account to enable the requested features. The required service is highlighted below. -

- -
-
- )} - - {/* Loading state */} - {isLoading ? ( -
- - - - -
- ) : ( -
- {/* Group services by provider */} - {Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => ( -
-

- {OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'} -

-
- {providerServices.map((service) => ( - -
-
-
+ {/* Loading state */} + {isLoading ? ( +
+ {/* Google section - 5 blocks */} +
+ {/* "GOOGLE" label */} + + + + + +
+ {/* Microsoft section - 6 blocks */} +
+ {/* "MICROSOFT" label */} + + + + + + +
+
+ ) : ( +
+ {/* Services list */} + {Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => ( +
+ + {providerServices.map((service) => ( +
+
+
{typeof service.icon === 'function' ? service.icon({ className: 'h-5 w-5' }) : service.icon}
-
-
-

{service.name}

-

+

+
+ {service.name} +
+ {service.accounts && service.accounts.length > 0 ? ( +

+ {service.accounts.map((a) => a.name).join(', ')} +

+ ) : ( +

{service.description}

-
- {service.accounts && service.accounts.length > 0 && ( -
- {service.accounts.map((account) => ( -
-
-
- -
- {account.name} -
- -
- ))} - {/* */} -
)}
- {!service.accounts?.length && ( -
- -
+ {service.accounts && service.accounts.length > 0 ? ( + + ) : ( + )}
- - ))} -
-
- ))} + ))} +
+ ))} - {/* Show message when search has no results */} - {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( -
- No services found matching "{searchTerm}" + {/* Show message when search has no results */} + {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( +
+ No services found matching "{searchTerm}" +
+ )}
)}
- )} +
) } @@ -487,17 +461,15 @@ export function Credentials({ onOpenChange }: CredentialsProps) { // Loading skeleton for connections function ConnectionSkeleton() { return ( - -
-
- -
- - -
+
+
+ +
+ +
-
- + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index 872fd8fe8c..f7c743902c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' -import { Search } from 'lucide-react' +import { Plus, Search } from 'lucide-react' import { AlertDialog, AlertDialogAction, @@ -14,7 +14,7 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' import { useEnvironmentStore } from '@/stores/settings/environment/store' import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types' @@ -28,15 +28,20 @@ interface UIEnvironmentVariable extends StoreEnvironmentVariable { interface EnvironmentVariablesProps { onOpenChange: (open: boolean) => void + registerCloseHandler?: (handler: (open: boolean) => void) => void } -export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) { - const { variables } = useEnvironmentStore() +export function EnvironmentVariables({ + onOpenChange, + registerCloseHandler, +}: EnvironmentVariablesProps) { + const { variables, isLoading } = useEnvironmentStore() const [envVars, setEnvVars] = useState([]) const [searchTerm, setSearchTerm] = useState('') const [focusedValueIndex, setFocusedValueIndex] = useState(null) const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) + const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false) const scrollContainerRef = useRef(null) const pendingClose = useRef(false) @@ -75,6 +80,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps return false }, [envVars]) + // Intercept close attempts to check for unsaved changes + const handleModalClose = (open: boolean) => { + if (!open && hasChanges) { + setShowUnsavedChanges(true) + pendingClose.current = true + } else { + onOpenChange(open) + } + } + // Initialization effect useEffect(() => { const existingVars = Object.values(variables) @@ -84,15 +99,23 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps pendingClose.current = false }, [variables]) - // Scroll effect + // Register close handler with parent useEffect(() => { - if (scrollContainerRef.current) { + if (registerCloseHandler) { + registerCloseHandler(handleModalClose) + } + }, [registerCloseHandler, hasChanges]) + + // Scroll effect - only when explicitly adding a new variable + useEffect(() => { + if (shouldScrollToBottom && scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: 'smooth', }) + setShouldScrollToBottom(false) } - }, [envVars.length]) + }, [shouldScrollToBottom]) // Variable management functions const addEnvVar = () => { @@ -100,6 +123,8 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps setEnvVars([...envVars, newVar]) // Clear search to ensure the new variable is visible setSearchTerm('') + // Trigger scroll to bottom + setShouldScrollToBottom(true) } const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { @@ -168,18 +193,12 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps if (parsedVars.length > 0) { const existingVars = envVars.filter((v) => v.key || v.value) setEnvVars([...existingVars, ...parsedVars]) + // Scroll to bottom when pasting multiple variables + setShouldScrollToBottom(true) } } // Dialog management - const handleClose = () => { - if (hasChanges) { - setShowUnsavedChanges(true) - pendingClose.current = true - } else { - onOpenChange(false) - } - } const handleCancel = () => { setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current))) @@ -227,6 +246,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps autoCapitalize='off' spellCheck='false' name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`} + className='h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0' /> setFocusedValueIndex(null)} onPaste={(e) => handlePaste(e, originalIndex)} placeholder='Enter value' - className='allow-scroll' + className='allow-scroll h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0' autoComplete='off' autoCorrect='off' autoCapitalize='off' @@ -249,7 +269,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps variant='ghost' size='icon' onClick={() => removeEnvVar(originalIndex)} - className='h-10 w-10' + className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70' > × @@ -257,64 +277,82 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps ) return ( -
+
{/* Fixed Header */} -
-
-

Environment Variables

- - {/* Search Input */} -
- +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ setSearchTerm(e.target.value)} - className='h-9 pl-9 text-sm' + className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' />
-
- -
- - -
-
+ )}
{/* Scrollable Content */}
-
- {filteredEnvVars.map(({ envVar, originalIndex }) => - renderEnvVarRow(envVar, originalIndex) - )} - {/* Show message when search has no results but there are variables */} - {searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && ( -
- No environment variables found matching "{searchTerm}" -
+
+ {isLoading ? ( + <> + {/* Show 3 skeleton rows */} + {[1, 2, 3].map((index) => ( +
+ + + +
+ ))} + + ) : ( + <> + {filteredEnvVars.map(({ envVar, originalIndex }) => + renderEnvVarRow(envVar, originalIndex) + )} + {/* Show message when search has no results but there are variables */} + {searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && ( +
+ No environment variables found matching "{searchTerm}" +
+ )} + )}
- {/* Fixed Footer */} -
-
- + {/* Footer */} +
+
+ {isLoading ? ( + <> + + + + ) : ( + <> + -
- - -
+ + + )}
@@ -326,9 +364,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps You have unsaved changes. Do you want to save them before closing? - - Discard Changes - Save Changes + + + Discard Changes + + + Save Changes + diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx index c6600b18ed..7c4e34dd76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from 'react' -import { AlertTriangle, Info } from 'lucide-react' -import { Alert, AlertDescription } from '@/components/ui/alert' +import { useEffect } from 'react' +import { Info } from 'lucide-react' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { @@ -23,10 +22,7 @@ const TOOLTIPS = { } export function General() { - const [retryCount, setRetryCount] = useState(0) - const isLoading = useGeneralStore((state) => state.isLoading) - const error = useGeneralStore((state) => state.error) const theme = useGeneralStore((state) => state.theme) const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled) @@ -53,10 +49,10 @@ export function General() { useEffect(() => { const loadData = async () => { - await loadSettings(retryCount > 0) + await loadSettings() } loadData() - }, [loadSettings, retryCount]) + }, [loadSettings]) const handleThemeChange = async (value: 'system' | 'light' | 'dark') => { await setTheme(value) @@ -80,129 +76,193 @@ export function General() { } } - const handleRetry = () => { - setRetryCount((prev) => prev + 1) - } - return ( -
- {error && ( - - - - Failed to load settings: {error} - - - - )} +
+
+ {isLoading ? ( + <> + {/* Theme setting with skeleton value */} +
+
+ +
+ +
-
-

General Settings

-
- {isLoading ? ( - <> - - - - - - ) : ( - <> -
-
- -
- -
-
-
- - - - - - -

{TOOLTIPS.autoConnect}

-
-
-
- + {/* Auto-connect setting with skeleton value */} +
+
+ + + + + + +

{TOOLTIPS.autoConnect}

+
+
+ +
-
-
- - - - - - -

{TOOLTIPS.consoleExpandedByDefault}

-
-
-
- + {/* Console expanded setting with skeleton value */} +
+
+ + + + + + +

{TOOLTIPS.consoleExpandedByDefault}

+
+
- - )} -
+ +
+ + ) : ( + <> +
+
+ +
+ +
+
+
+ + + + + + +

{TOOLTIPS.autoConnect}

+
+
+
+ +
+ +
+
+ + + + + + +

{TOOLTIPS.consoleExpandedByDefault}

+
+
+
+ +
+ + )}
) } -const SettingRowSkeleton = () => ( -
+const SettingRowSkeleton = ({ + hasInfoButton = false, + isSwitch = false, +}: { + hasInfoButton?: boolean + isSwitch?: boolean +}) => ( +
+ {hasInfoButton && }
- + {isSwitch ? ( + + ) : ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx index 8d7bba04db..5b2d47b368 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx @@ -45,61 +45,69 @@ export function Privacy() { } return ( -
-
-

Privacy Settings

-
- {isLoading ? ( - - ) : ( -
-
- - - - - - -

{TOOLTIPS.telemetry}

-
-
-
- +
+
+ {isLoading ? ( + + ) : ( +
+
+ + + + + + +

{TOOLTIPS.telemetry}

+
+
- )} -
-
+ +
+ )} -
-

- We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is collected - in accordance with our privacy policy, and you can opt-out at any time. This setting - applies to your account on all devices. -

+
+

+ We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is + collected in accordance with our privacy policy, and you can opt-out at any time. This + setting applies to your account on all devices. +

+
) } -const SettingRowSkeleton = () => ( -
+const SettingRowSkeleton = ({ + hasInfoButton = false, + isSwitch = false, +}: { + hasInfoButton?: boolean + isSwitch?: boolean +}) => ( +
- + + {hasInfoButton && }
- + {isSwitch ? ( + + ) : ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index 851a1d7d70..1100f90610 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -1,13 +1,13 @@ import { Bot, CreditCard, - KeyRound, - KeySquare, - Lock, + FileCode, + Key, Settings, Shield, - UserCircle, + User, Users, + Waypoints, } from 'lucide-react' import { getEnv, isTruthy } from '@/lib/env' import { isHosted } from '@/lib/environment' @@ -56,29 +56,29 @@ const allNavigationItems: NavigationItem[] = [ label: 'General', icon: Settings, }, + { + id: 'credentials', + label: 'Integrations', + icon: Waypoints, + }, { id: 'environment', label: 'Environment', - icon: KeyRound, + icon: FileCode, }, { id: 'account', label: 'Account', - icon: UserCircle, - }, - { - id: 'credentials', - label: 'Credentials', - icon: Lock, + icon: User, }, { id: 'apikeys', label: 'API Keys', - icon: KeySquare, + icon: Key, }, { id: 'copilot', - label: 'Copilot', + label: 'Copilot Keys', icon: Bot, }, { @@ -126,22 +126,36 @@ export function SettingsNavigation({ }) return ( -
+
{navigationItems.map((item) => ( - +
+ +
))}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx deleted file mode 100644 index 3d223ddafd..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useEffect, useState } from 'react' -import { AlertCircle } from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { useActiveOrganization, useSession } from '@/lib/auth-client' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('BillingSummary') - -interface BillingSummaryData { - type: 'individual' | 'organization' - plan: string - currentUsage: number - planMinimum: number - projectedCharge: number - usageLimit: number - percentUsed: number - isWarning: boolean - isExceeded: boolean - daysRemaining: number - organizationData?: { - seatCount: number - averageUsagePerSeat: number - totalMinimum: number - } -} - -interface BillingSummaryProps { - showDetails?: boolean - className?: string - onDataLoaded?: (data: BillingSummaryData) => void - onError?: (error: string) => void -} - -export function BillingSummary({ - showDetails = true, - className = '', - onDataLoaded, - onError, -}: BillingSummaryProps) { - const { data: session } = useSession() - const { data: activeOrg } = useActiveOrganization() - - const [billingSummary, setBillingSummary] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - async function loadBillingSummary() { - if (!session?.user?.id) return - - try { - setIsLoading(true) - - const url = new URL('/api/billing', window.location.origin) - if (activeOrg?.id) { - url.searchParams.set('context', 'organization') - url.searchParams.set('id', activeOrg.id) - } else { - url.searchParams.set('context', 'user') - } - - const response = await fetch(url.toString()) - if (!response.ok) { - throw new Error(`Failed to fetch billing summary: ${response.statusText}`) - } - - const result = await response.json() - if (!result.success) { - throw new Error(result.error || 'Failed to load billing data') - } - - setBillingSummary(result.data) - setError(null) - onDataLoaded?.(result.data) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load billing data' - setError(errorMessage) - onError?.(errorMessage) - logger.error('Failed to load billing summary', { error: err }) - } finally { - setIsLoading(false) - } - } - - loadBillingSummary() - }, [session?.user?.id, activeOrg?.id, onDataLoaded, onError]) - - const getStatusBadge = () => { - if (!billingSummary) return null - - if (billingSummary.isExceeded) { - return ( - - - Limit Exceeded - - ) - } - if (billingSummary.isWarning) { - return ( - - - Approaching Limit - - ) - } - return null - } - - const formatCurrency = (amount: number) => `$${amount.toFixed(2)}` - - if (isLoading || error || !billingSummary) { - return null - } - - return ( -
- {/* Status Badge */} - {getStatusBadge()} - - {/* Billing Details */} - {showDetails && ( -
-
- Plan minimum: - {formatCurrency(billingSummary.planMinimum)} -
-
- Projected charge: - {formatCurrency(billingSummary.projectedCharge)} -
- {billingSummary.organizationData && ( -
- Team seats: - {billingSummary.organizationData.seatCount} -
- )} -
- )} -
- ) -} - -export type { BillingSummaryData } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx similarity index 60% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index e31061f5e5..ee24a02644 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -1,16 +1,20 @@ -import { useState } from 'react' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' +'use client' + +import { useEffect, useState } from 'react' import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' import { useOrganizationStore } from '@/stores/organization' import { useSubscriptionStore } from '@/stores/subscription/store' @@ -37,6 +41,16 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const { activeOrganization } = useOrganizationStore() const { getSubscriptionStatus } = useSubscriptionStore() + // Clear error after 3 seconds + useEffect(() => { + if (error) { + const timer = setTimeout(() => { + setError(null) + }, 3000) + return () => clearTimeout(timer) + } + }, [error]) + // Don't show for free plans if (!subscription.isPaid) { return null @@ -115,44 +129,41 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub return ( <> -
-
-
- Cancel Subscription -

- You'll keep access until {formatDate(periodEndDate)} -

-
- +
+
+ Manage Subscription +

+ You'll keep access until {formatDate(periodEndDate)} +

- - {error && ( - - {error} - - )} +
- - - - Cancel {subscription.plan} subscription? - + + + + Cancel {subscription.plan} subscription? + You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '} {formatDate(periodEndDate)}, then downgrade to free plan. - - + + -
-
-
    +
    +
    +
    • • Keep all features until {formatDate(periodEndDate)}
    • • No more charges
    • • Data preserved
    • @@ -161,16 +172,24 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
    - - - - - -
+ + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts new file mode 100644 index 0000000000..a7d0def45c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts @@ -0,0 +1 @@ +export { CancelSubscription } from './cancel-subscription' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts index 17c5db87cf..73b2e8bc7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts @@ -1,6 +1,4 @@ -export { BillingSummary } from './billing-summary' export { CancelSubscription } from './cancel-subscription' -export { EditMemberLimitDialog } from './edit-member-limit-dialog' -export { TeamSeatsDialog } from './team-seats-dialog' -export { TeamUsageOverview } from './team-usage-overview' -export { UsageLimitEditor } from './usage-limit-editor' +export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card' +export type { UsageLimitRef } from './usage-limit' +export { UsageLimit } from './usage-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts new file mode 100644 index 0000000000..5685e9ac3b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts @@ -0,0 +1 @@ +export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx new file mode 100644 index 0000000000..a8599da299 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx @@ -0,0 +1,123 @@ +'use client' + +import type { ReactNode } from 'react' +import type { LucideIcon } from 'lucide-react' +import { Button } from '@/components/ui' +import { cn } from '@/lib/utils' + +export interface PlanFeature { + icon: LucideIcon + text: string +} + +export interface PlanCardProps { + name: string + price: string | ReactNode + priceSubtext?: string + features: PlanFeature[] + buttonText: string + onButtonClick: () => void + isError?: boolean + variant?: 'default' | 'compact' + layout?: 'vertical' | 'horizontal' + className?: string +} + +/** + * PlanCard component for displaying subscription plan information + * Supports both vertical and horizontal layouts with flexible pricing display + */ +export function PlanCard({ + name, + price, + priceSubtext, + features, + buttonText, + onButtonClick, + isError = false, + variant = 'default', + layout = 'vertical', + className, +}: PlanCardProps) { + const isHorizontal = layout === 'horizontal' + + const renderPrice = () => { + if (typeof price === 'string') { + return ( + <> + {price} + {priceSubtext && ( + {priceSubtext} + )} + + ) + } + return price + } + + const renderFeatures = () => { + if (isHorizontal) { + return ( +
+ {features.map((feature, index) => ( +
+ + {feature.text} + {index < features.length - 1 && ( + + ))} +
+ ) + } + + return ( +
    + {features.map((feature, index) => ( +
  • +
  • + ))} +
+ ) + } + + return ( +
+
+

{name}

+
{renderPrice()}
+ {isHorizontal && renderFeatures()} +
+ + {!isHorizontal && renderFeatures()} + +
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx deleted file mode 100644 index c356c02c95..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useState } from 'react' -import { Input } from '@/components/ui/input' -import { createLogger } from '@/lib/logs/console/logger' -import { useSubscriptionStore } from '@/stores/subscription/store' - -const logger = createLogger('UsageLimitEditor') - -interface UsageLimitEditorProps { - currentLimit: number - canEdit: boolean - minimumLimit: number - onLimitUpdated?: (newLimit: number) => void -} - -export function UsageLimitEditor({ - currentLimit, - canEdit, - minimumLimit, - onLimitUpdated, -}: UsageLimitEditorProps) { - const [inputValue, setInputValue] = useState(currentLimit.toString()) - const [isSaving, setIsSaving] = useState(false) - - const { updateUsageLimit } = useSubscriptionStore() - - useEffect(() => { - setInputValue(currentLimit.toString()) - }, [currentLimit]) - - const handleSubmit = async () => { - const newLimit = Number.parseInt(inputValue, 10) - - if (Number.isNaN(newLimit) || newLimit < minimumLimit) { - setInputValue(currentLimit.toString()) - return - } - - if (newLimit === currentLimit) { - return - } - - setIsSaving(true) - - try { - const result = await updateUsageLimit(newLimit) - - if (!result.success) { - throw new Error(result.error || 'Failed to update limit') - } - - setInputValue(newLimit.toString()) - onLimitUpdated?.(newLimit) - } catch (error) { - logger.error('Failed to update usage limit', { error }) - setInputValue(currentLimit.toString()) - } finally { - setIsSaving(false) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit() - } - } - - return ( -
- $ - {canEdit ? ( - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleSubmit} - className='h-8 w-20 font-medium text-sm' - min={minimumLimit} - step='1' - disabled={isSaving} - autoComplete='off' - data-form-type='other' - name='usage-limit' - /> - ) : ( - {currentLimit} - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts new file mode 100644 index 0000000000..d09782c8e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts @@ -0,0 +1,2 @@ +export type { UsageLimitRef } from './usage-limit' +export { UsageLimit } from './usage-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx new file mode 100644 index 0000000000..a3b8fe1be7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx @@ -0,0 +1,209 @@ +'use client' + +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { Check, Pencil, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import { useSubscriptionStore } from '@/stores/subscription/store' + +const logger = createLogger('UsageLimit') + +interface UsageLimitProps { + currentLimit: number + currentUsage: number + canEdit: boolean + minimumLimit: number + onLimitUpdated?: (newLimit: number) => void +} + +export interface UsageLimitRef { + startEdit: () => void +} + +export const UsageLimit = forwardRef( + ({ currentLimit, currentUsage, canEdit, minimumLimit, onLimitUpdated }, ref) => { + const [inputValue, setInputValue] = useState(currentLimit.toString()) + const [isSaving, setIsSaving] = useState(false) + const [hasError, setHasError] = useState(false) + const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null) + const [isEditing, setIsEditing] = useState(false) + const inputRef = useRef(null) + + const { updateUsageLimit } = useSubscriptionStore() + + const handleStartEdit = () => { + if (!canEdit) return + setIsEditing(true) + setInputValue(currentLimit.toString()) + } + + // Expose startEdit method through ref + useImperativeHandle( + ref, + () => ({ + startEdit: handleStartEdit, + }), + [canEdit, currentLimit] + ) + + useEffect(() => { + setInputValue(currentLimit.toString()) + }, [currentLimit]) + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) + + // Clear error after 2 seconds + useEffect(() => { + if (hasError) { + const timer = setTimeout(() => { + setHasError(false) + setErrorType(null) + }, 2000) + return () => clearTimeout(timer) + } + }, [hasError]) + + const handleSubmit = async () => { + const newLimit = Number.parseInt(inputValue, 10) + + if (Number.isNaN(newLimit) || newLimit < minimumLimit) { + setInputValue(currentLimit.toString()) + setIsEditing(false) + return + } + + // Check if new limit is below current usage + if (newLimit < currentUsage) { + setHasError(true) + setErrorType('belowUsage') + // Don't reset input value - let user see what they typed + return + } + + if (newLimit === currentLimit) { + setIsEditing(false) + return + } + + setIsSaving(true) + + try { + const result = await updateUsageLimit(newLimit) + + if (!result.success) { + throw new Error(result.error || 'Failed to update limit') + } + + setInputValue(newLimit.toString()) + onLimitUpdated?.(newLimit) + setIsEditing(false) + setErrorType(null) + } catch (error) { + logger.error('Failed to update usage limit', { error }) + + // Check if the error is about being below current usage + if (error instanceof Error && error.message.includes('below current usage')) { + setErrorType('belowUsage') + } else { + setErrorType('general') + } + + setHasError(true) + } finally { + setIsSaving(false) + } + } + + const handleCancelEdit = () => { + setIsEditing(false) + setInputValue(currentLimit.toString()) + setHasError(false) + setErrorType(null) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSubmit() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelEdit() + } + } + + return ( +
+ {isEditing ? ( + <> + $ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={(e) => { + // Don't submit if clicking on the button (it will handle submission) + const relatedTarget = e.relatedTarget as HTMLElement + if (relatedTarget?.closest('button')) { + return + } + handleSubmit() + }} + className={cn( + 'w-[3ch] border-0 bg-transparent p-0 text-xs tabular-nums', + 'outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', + '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none', + hasError && 'text-red-500' + )} + min={minimumLimit} + max='999' + step='1' + disabled={isSaving} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + + ) : ( + ${currentLimit} + )} + {canEdit && ( + + )} +
+ ) + } +) + +UsageLimit.displayName = 'UsageLimit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts new file mode 100644 index 0000000000..c4bb43bd2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts @@ -0,0 +1,35 @@ +import { + Building2, + Clock, + Database, + HeadphonesIcon, + Infinity as InfinityIcon, + MessageSquare, + Server, + Users, + Workflow, + Zap, +} from 'lucide-react' +import type { PlanFeature } from './components/plan-card' + +export const PRO_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: '25 runs per minute (sync)' }, + { icon: Clock, text: '200 runs per minute (async)' }, + { icon: Building2, text: 'Unlimited workspaces' }, + { icon: Workflow, text: 'Unlimited workflows' }, + { icon: Users, text: 'Unlimited invites' }, + { icon: Database, text: 'Unlimited log retention' }, +] + +export const TEAM_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: '75 runs per minute (sync)' }, + { icon: Clock, text: '500 runs per minute (async)' }, + { icon: InfinityIcon, text: 'Everything in Pro' }, + { icon: MessageSquare, text: 'Dedicated Slack channel' }, +] + +export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [ + { icon: Zap, text: 'Custom rate limits' }, + { icon: Server, text: 'Enterprise hosting' }, + { icon: HeadphonesIcon, text: 'Dedicated support' }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts new file mode 100644 index 0000000000..cf945ca47c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts @@ -0,0 +1,69 @@ +export interface SubscriptionPermissions { + canUpgradeToPro: boolean + canUpgradeToTeam: boolean + canViewEnterprise: boolean + canManageTeam: boolean + canEditUsageLimit: boolean + canCancelSubscription: boolean + showTeamMemberView: boolean + showUpgradePlans: boolean +} + +export interface SubscriptionState { + isFree: boolean + isPro: boolean + isTeam: boolean + isEnterprise: boolean + isPaid: boolean + plan: string + status: string +} + +export interface UserRole { + isTeamAdmin: boolean + userRole: string +} + +export function getSubscriptionPermissions( + subscription: SubscriptionState, + userRole: UserRole +): SubscriptionPermissions { + const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription + const { isTeamAdmin } = userRole + + return { + canUpgradeToPro: isFree, + canUpgradeToTeam: isFree || (isPro && !isTeam), + canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members + canManageTeam: isTeam && isTeamAdmin, + canEditUsageLimit: (isFree || (isPro && !isTeam)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users see pencil + canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel + showTeamMemberView: isTeam && !isTeamAdmin, + showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans + } +} + +export function getVisiblePlans( + subscription: SubscriptionState, + userRole: UserRole +): ('pro' | 'team' | 'enterprise')[] { + const plans: ('pro' | 'team' | 'enterprise')[] = [] + const { isFree, isPro, isTeam } = subscription + const { isTeamAdmin } = userRole + + // Free users see all plans + if (isFree) { + plans.push('pro', 'team', 'enterprise') + } + // Pro users see team and enterprise + else if (isPro && !isTeam) { + plans.push('team', 'enterprise') + } + // Team owners see only enterprise (no team plan since they already have it) + else if (isTeam && isTeamAdmin) { + plans.push('enterprise') + } + // Team members, Enterprise users see no plans + + return plans +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 3c47e7f55c..51a34cc9bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -1,41 +1,188 @@ -import { useCallback, useEffect, useState } from 'react' -import { AlertCircle, Users } from 'lucide-react' -import { - Alert, - AlertDescription, - AlertTitle, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, -} from '@/components/ui' +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Badge, Progress, Skeleton } from '@/components/ui' import { useSession, useSubscription } from '@/lib/auth-client' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' import { - BillingSummary, CancelSubscription, - TeamSeatsDialog, - UsageLimitEditor, + PlanCard, + UsageLimit, + type UsageLimitRef, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components' +import { + ENTERPRISE_PLAN_FEATURES, + PRO_PLAN_FEATURES, + TEAM_PLAN_FEATURES, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs' +import { + getSubscriptionPermissions, + getVisiblePlans, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions' import { useOrganizationStore } from '@/stores/organization' import { useSubscriptionStore } from '@/stores/subscription/store' +// Logger const logger = createLogger('Subscription') +// Constants +const CONSTANTS = { + UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds + TYPEFORM_ENTERPRISE_URL: 'https://form.typeform.com/to/jqCO12pF', + PRO_PRICE: '$20', + TEAM_PRICE: '$40', + INITIAL_TEAM_SEATS: 1, +} as const + +// Styles +const STYLES = { + GRADIENT_BADGE: + 'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer', +} as const + +// Types +type TargetPlan = 'pro' | 'team' + interface SubscriptionProps { onOpenChange: (open: boolean) => void } +/** + * Skeleton component for subscription loading state + */ +function SubscriptionSkeleton() { + return ( +
+
+ {/* Current Plan skeleton - matches usage indicator style */} +
+
+
+
+
+ + +
+
+ + / + +
+
+ +
+
+
+ + {/* Plan cards skeleton */} +
+ {/* Pro and Team skeleton grid */} +
+ {/* Pro Plan Card Skeleton */} +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {/* Team Plan Card Skeleton */} +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* Enterprise skeleton - horizontal layout */} +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+
+ ) +} + +// Utility functions +const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() + plan.slice(1) + +/** + * Subscription management component + * Handles plan display, upgrades, and billing management + */ export function Subscription({ onOpenChange }: SubscriptionProps) { const { data: session } = useSession() const betterAuthSubscription = useSubscription() const { isLoading, - error, getSubscriptionStatus, getUsage, getBillingStatus, @@ -43,18 +190,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { subscriptionData, } = useSubscriptionStore() - const { - activeOrganization, - organizationBillingData, - isLoadingOrgBilling, - loadOrganizationBillingData, - getUserRole, - addSeats, - } = useOrganizationStore() + const { activeOrganization, organizationBillingData, loadOrganizationBillingData, getUserRole } = + useOrganizationStore() - const [isSeatsDialogOpen, setIsSeatsDialogOpen] = useState(false) - const [isUpdatingSeats, setIsUpdatingSeats] = useState(false) + const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null) + const usageLimitRef = useRef(null) + // Get real subscription data from store const subscription = getSubscriptionStatus() const usage = getUsage() const billingStatus = getBillingStatus() @@ -64,19 +206,73 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { if (subscription.isTeam && activeOrgId) { loadOrganizationBillingData(activeOrgId) } - }, [activeOrgId, subscription.isTeam]) + }, [activeOrgId, subscription.isTeam, loadOrganizationBillingData]) - // Determine if user is team admin/owner + // Auto-clear upgrade error + useEffect(() => { + if (upgradeError) { + const timer = setTimeout(() => { + setUpgradeError(null) + }, CONSTANTS.UPGRADE_ERROR_TIMEOUT) + return () => clearTimeout(timer) + } + }, [upgradeError]) + + // User role and permissions const userRole = getUserRole(session?.user?.email) const isTeamAdmin = ['owner', 'admin'].includes(userRole) - const shouldShowOrgBilling = subscription.isTeam && isTeamAdmin && organizationBillingData + + // Get permissions based on subscription state and user role + const permissions = getSubscriptionPermissions( + { + isFree: subscription.isFree, + isPro: subscription.isPro, + isTeam: subscription.isTeam, + isEnterprise: subscription.isEnterprise, + isPaid: subscription.isPaid, + plan: subscription.plan || 'free', + status: subscription.status || 'inactive', + }, + { + isTeamAdmin, + userRole: userRole || 'member', + } + ) + + // Get visible plans based on current subscription + const visiblePlans = getVisiblePlans( + { + isFree: subscription.isFree, + isPro: subscription.isPro, + isTeam: subscription.isTeam, + isEnterprise: subscription.isEnterprise, + isPaid: subscription.isPaid, + plan: subscription.plan || 'free', + status: subscription.status || 'inactive', + }, + { + isTeamAdmin, + userRole: userRole || 'member', + } + ) + + // UI state computed values + const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView + const badgeText = subscription.isFree ? 'Upgrade' : 'Add' + + const handleBadgeClick = () => { + if (subscription.isFree) { + handleUpgrade('pro') + } else if (permissions.canEditUsageLimit && usageLimitRef.current) { + usageLimitRef.current.startEdit() + } + } const handleUpgrade = useCallback( - async (targetPlan: 'pro' | 'team') => { + async (targetPlan: TargetPlan) => { if (!session?.user?.id) return - // Get current subscription data including stripeSubscriptionId - const subscriptionData = useSubscriptionStore.getState().subscriptionData + const { subscriptionData } = useSubscriptionStore.getState() const currentSubscriptionId = subscriptionData?.stripeSubscriptionId let referenceId = session.user.id @@ -84,33 +280,32 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { referenceId = activeOrgId } - const currentUrl = window.location.origin + window.location.pathname + const currentUrl = `${window.location.origin}${window.location.pathname}` try { - const upgradeParams: any = { + const upgradeParams = { plan: targetPlan, referenceId, successUrl: currentUrl, cancelUrl: currentUrl, - seats: targetPlan === 'team' ? 1 : undefined, - } + ...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }), + } as const - // Add subscriptionId if we have an existing subscription to ensure proper plan switching - if (currentSubscriptionId) { - upgradeParams.subscriptionId = currentSubscriptionId - logger.info('Upgrading existing subscription', { + // Add subscriptionId for existing subscriptions to ensure proper plan switching + const finalParams = currentSubscriptionId + ? { ...upgradeParams, subscriptionId: currentSubscriptionId } + : upgradeParams + + logger.info( + currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription', + { targetPlan, currentSubscriptionId, referenceId, - }) - } else { - logger.info('Creating new subscription (no existing subscription found)', { - targetPlan, - referenceId, - }) - } + } + ) - await betterAuthSubscription.upgrade(upgradeParams) + await betterAuthSubscription.upgrade(finalParams) } catch (error) { logger.error('Failed to initiate subscription upgrade:', error) alert('Failed to initiate upgrade. Please try again or contact support.') @@ -119,310 +314,213 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { [session?.user?.id, subscription.isTeam, activeOrgId, betterAuthSubscription] ) - const handleSeatsUpdate = useCallback( - async (seats: number) => { - if (!activeOrgId) { - logger.error('No active organization found for seat update') - return - } + const renderPlanCard = useCallback( + (planType: 'pro' | 'team' | 'enterprise', layout: 'vertical' | 'horizontal' = 'vertical') => { + const handleContactEnterprise = () => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank') - try { - setIsUpdatingSeats(true) - await addSeats(seats) - setIsSeatsDialogOpen(false) - } catch (error) { - logger.error('Failed to update seats:', error) - } finally { - setIsUpdatingSeats(false) + switch (planType) { + case 'pro': + return ( + handleUpgrade('pro')} + isError={upgradeError === 'pro'} + layout={layout} + /> + ) + + case 'team': + return ( + handleUpgrade('team')} + isError={upgradeError === 'team'} + layout={layout} + /> + ) + + case 'enterprise': + return ( + Custom} + priceSubtext={ + layout === 'horizontal' + ? 'Custom solutions tailored to your enterprise needs' + : undefined + } + features={ENTERPRISE_PLAN_FEATURES} + buttonText='Contact' + onButtonClick={handleContactEnterprise} + layout={layout} + /> + ) + + default: + return null } }, - [activeOrgId] + [subscription.isFree, upgradeError, handleUpgrade] ) if (isLoading) { - return ( -
- - - -
- ) - } - - if (error) { - return ( -
- - - Error - {error} - -
- ) + return } return ( -
-
- {/* Current Plan & Usage Overview */} -
-
-

Current Plan

-
- - {subscription.plan} Plan - - {!subscription.isFree && } -
-
+
+
+ {/* Current Plan & Usage Overview - Styled like usage-indicator */} +
+
+
+ {/* Plan and usage info */} +
+
+ + {formatPlanName(subscription.plan)} + + {showBadge && ( + { + e.stopPropagation() + handleBadgeClick() + }} + > + {badgeText} + + )} + {/* Team seats info for admins */} + {permissions.canManageTeam && ( + + ({organizationBillingData?.totalSeats || subscription.seats || 1} seats) + + )} +
+
+ ${usage.current.toFixed(2)} + / + {!subscription.isFree && + (permissions.canEditUsageLimit || + permissions.showTeamMemberView || + subscription.isEnterprise) ? ( + + ) : ( + ${usage.limit} + )} +
+
-
- - ${usage.current.toFixed(2)} / ${usage.limit} - -
- - {usage.percentUsed}% used this period - + {/* Progress Bar */} +
- {/* Usage Alerts */} - {billingStatus === 'exceeded' && ( - - - Usage Limit Exceeded - - You've exceeded your usage limit of ${usage.limit}. Please upgrade your plan or - increase your limit. - - - )} - - {billingStatus === 'warning' && ( - - - Approaching Usage Limit - - You've used {usage.percentUsed}% of your ${usage.limit} limit. Consider upgrading or - increasing your limit. - - - )} - - {/* Usage Limit Editor */} -
-
- - {subscription.isTeam ? 'Individual Limit' : 'Monthly Limit'} - - {isLoadingOrgBilling ? ( - - ) : ( - - )} -
- {subscription.isFree && ( -

- Upgrade to Pro ($20 minimum) or Team ($40 minimum) to customize your usage limit. + {/* Team Member Notice */} + {permissions.showTeamMemberView && ( +

+

+ Contact your team admin to increase limits

- )} - {subscription.isPro && ( -

- Pro plan minimum: $20. You can set your individual limit higher. -

- )} - {subscription.isTeam && !isTeamAdmin && ( -

- Contact your team owner to adjust your limit. Team plan minimum: $40. -

- )} - {subscription.isTeam && isTeamAdmin && ( -

- Team plan minimum: $40 per member. Manage team member limits in the Team tab. -

- )} -
- - {/* Team Management */} - {subscription.isTeam && ( -
- {isLoadingOrgBilling ? ( - - -
-
- - -
- -
-
- -
-
- - -
-
- - -
-
- -
-
- ) : shouldShowOrgBilling ? ( - - -
- - - Team Plan - -
-
- - {/* Team Summary */} -
-
- Licensed Seats - - {organizationBillingData.totalSeats} seats - -
-
- Monthly Bill - - ${organizationBillingData.totalSeats * 40} - -
-
- Current Usage - - ${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0} - -
-
- - {/* Simple Explanation */} -
-

- You pay ${organizationBillingData.totalSeats * 40}/month for{' '} - {organizationBillingData.totalSeats} licensed seats, regardless of usage. If - your team uses more than ${organizationBillingData.totalSeats * 40}, you'll be - charged for the overage. -

-
-
-
- ) : ( - - - - - Team Plan - - - -
-
- Your monthly allowance - ${usage.limit} -
-

- Contact your team owner to adjust your limit -

-
-
-
- )}
)} - {/* Upgrade Actions */} - {subscription.isFree && ( -
- - -
-

- Need a custom plan?{' '} - - Contact us - {' '} - for Enterprise pricing -

-
-
- )} + {/* Upgrade Plans */} + {permissions.showUpgradePlans && ( +
+ {/* Render plans based on what should be visible */} + {(() => { + const totalPlans = visiblePlans.length + const hasEnterprise = visiblePlans.includes('enterprise') - {subscription.isPro && !subscription.isTeam && ( - + // Special handling for Pro users - show team and enterprise side by side + if (subscription.isPro && totalPlans === 2) { + return ( +
+ {visiblePlans.map((plan) => renderPlanCard(plan, 'vertical'))} +
+ ) + } + + // Default behavior for other users + const otherPlans = visiblePlans.filter((p) => p !== 'enterprise') + + // Layout logic: + // Free users (3 plans): Pro and Team vertical in grid, Enterprise horizontal below + // Team admins (1 plan): Enterprise horizontal + const enterpriseLayout = + totalPlans === 1 || totalPlans === 3 ? 'horizontal' : 'vertical' + + return ( + <> + {otherPlans.length > 0 && ( +
+ {otherPlans.map((plan) => renderPlanCard(plan, 'vertical'))} +
+ )} + + {/* Enterprise plan */} + {hasEnterprise && renderPlanCard('enterprise', enterpriseLayout)} + + ) + })()} +
)} {subscription.isEnterprise && ( -
-

- Enterprise plan - Contact support for changes +

+

+ Contact enterprise for support usage limit changes

)} {/* Cancel Subscription */} - - - {/* Team Seats Dialog */} - + {permissions.canCancelSubscription && ( +
+ +
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts index 3a6d760567..91e4106026 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts @@ -1,8 +1,11 @@ export { MemberInvitationCard } from './member-invitation-card' +export { MemberLimit } from './member-limit' export { NoOrganizationView } from './no-organization-view' export { OrganizationCreationDialog } from './organization-creation-dialog' export { OrganizationSettingsTab } from './organization-settings-tab' export { PendingInvitationsList } from './pending-invitations-list' export { RemoveMemberDialog } from './remove-member-dialog' export { TeamMembersList } from './team-members-list' +export { TeamSeats } from './team-seats' export { TeamSeatsOverview } from './team-seats-overview' +export { TeamUsage } from './team-usage' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts new file mode 100644 index 0000000000..2a0bb699f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts @@ -0,0 +1 @@ +export { MemberInvitationCard } from './member-invitation-card' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx similarity index 88% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 3ecf49d7ca..a30e59dddd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -98,14 +98,14 @@ export function MemberInvitationCard({ const selectedCount = selectedWorkspaces.length return ( - - - Invite Team Members + + + Invite Team Members Add new members to your team and optionally give them access to specific workspaces - +
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces {selectedCount > 0 && ( - + {selectedCount} )} @@ -142,7 +145,7 @@ export function MemberInvitationCard({ size='sm' onClick={onInviteMember} disabled={!inviteEmail || isInviting} - className='shrink-0 gap-2' + className='h-9 shrink-0 gap-2 rounded-[8px]' > {isInviting ? : } Invite @@ -153,8 +156,8 @@ export function MemberInvitationCard({
-
Workspace Access
- +
Workspace Access
+ Optional
@@ -174,7 +177,7 @@ export function MemberInvitationCard({

) : ( -
+
{userWorkspaces.map((workspace) => { const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id) const selectedWorkspace = selectedWorkspaces.find( @@ -185,13 +188,13 @@ export function MemberInvitationCard({
-
+
{workspace.isOwner && ( - + Owner )} @@ -242,7 +248,7 @@ export function MemberInvitationCard({ )} {inviteSuccess && ( - + Invitation sent successfully diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts new file mode 100644 index 0000000000..f09d182a32 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts @@ -0,0 +1 @@ +export { MemberLimit } from './member-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx index 19470105e5..d78038fc67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx @@ -14,7 +14,7 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -interface EditMemberLimitDialogProps { +interface MemberLimitProps { open: boolean onOpenChange: (open: boolean) => void member: { @@ -30,14 +30,14 @@ interface EditMemberLimitDialogProps { planType?: string } -export function EditMemberLimitDialog({ +export function MemberLimit({ open, onOpenChange, member, onSave, isLoading, planType = 'team', -}: EditMemberLimitDialogProps) { +}: MemberLimitProps) { const [limitValue, setLimitValue] = useState('') const [error, setError] = useState(null) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts new file mode 100644 index 0000000000..2d540c4f7e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts @@ -0,0 +1 @@ +export { NoOrganizationView } from './no-organization-view' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx similarity index 85% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index 6ec678b5ad..614b25b63a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -2,7 +2,7 @@ import { RefreshCw } from 'lucide-react' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { OrganizationCreationDialog } from './' +import { OrganizationCreationDialog } from '../organization-creation-dialog' interface NoOrganizationViewProps { hasTeamPlan: boolean @@ -35,11 +35,11 @@ export function NoOrganizationView({ }: NoOrganizationViewProps) { if (hasTeamPlan || hasEnterprisePlan) { return ( -
+
-

Create Your Team Workspace

+

Create Your Team Workspace

-
+

You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your workspace to start collaborating with your team. @@ -47,7 +47,7 @@ export function NoOrganizationView({

-