Compare commits

..

40 Commits

Author SHA1 Message Date
Vikhyath Mondreti
e1f04f42f8 v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
2025-08-14 14:17:25 -05:00
Vikhyath Mondreti
fd9e61f85a improvement(credentials-security): use clear credentials sharing helper, fix google sheets block url split bug (#968)
* improvement(credentials-sharing-security): cleanup and reuse helper to determine credential access

* few more routes

* fix google sheets block

* fix test mocks

* fix calendar route
2025-08-14 14:13:18 -05:00
Waleed Latif
f1934fe76b fix(billing): separate client side and server side envvars for billing (#966) 2025-08-14 11:29:02 -07:00
Vikhyath Mondreti
ac41bf8c17 Revert "fix(workflow-block): revert change bubbling up error for workflow block" (#965)
* Revert "fix(workflow-block): revert change bubbling up error for workflow blo…"

This reverts commit 9f0993ed57.

* revert test changes
2025-08-14 12:18:47 -05:00
Vikhyath Mondreti
56ffb538a0 Merge pull request #964 from simstudioai/staging
v0.3.25: oauth credentials sharing mechanism, workflow block error handling changes
2025-08-14 02:36:19 -05:00
Vikhyath Mondreti
2e8f051e58 fix workflow block test 2025-08-14 02:28:17 -05:00
Vikhyath Mondreti
9f0993ed57 fix(workflow-block): revert change bubbling up error for workflow block (#963) 2025-08-14 02:18:18 -05:00
Waleed Latif
472a22cc94 improvement(helm): added template for external db secret (#957) 2025-08-13 21:21:46 -07:00
Waleed Latif
da04ea0e9f fix(subflows): added change detection for parallels, updated deploy and status schemas to match parallel/loop (#956) 2025-08-13 21:18:07 -07:00
Waleed Latif
d4f412af92 fix(api): fix api post and get without stringifying (#955) 2025-08-13 18:49:22 -05:00
Siddharth Ganesan
70fa628a2a improvement(uploads): add multipart upload + batching + retries (#938)
* File upload retries + multipart uploads

* Lint

* FIle uploads

* File uploads 2

* Lint

* Fix file uploads

* Add auth to file upload routes

* Lint
2025-08-13 15:18:14 -07:00
Vikhyath Mondreti
b159d63fbb improvement(oauth): credentials sharing for workflows (#939)
* improvement(oauth): credential UX while sharing workflows

* fix tests

* address greptile comments

* fix linear, jira, folder selectors

* fix routes

* fix linear

* jira fix attempt

* jira fix attempt

* jira fixes

* fix

* fix

* fix jira

* fix selector disable behaviour

* minor fixes

* clear selectors correctly

* fix project selector jira

* fix gdrive

* fix labels dropdown

* fix webhook realtime collab

* fix

* fix webhooks persistence

* fix folders route

* fix lint

* test webhook intermittent error

* fix

* fix display
2025-08-13 16:51:46 -05:00
Adam Gough
5dfe9330bb added file for microsoft verification (#946)
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-08-13 12:18:31 -05:00
Waleed Latif
4107948554 Merge pull request #954 from simstudioai/staging
fix
2025-08-12 21:12:18 -07:00
Vikhyath Mondreti
7ebc87564d fix(double-read): API Block (#950)
* fix(double-read-http): double reading body json

* fix

* fix tests
2025-08-12 23:08:31 -05:00
Vikhyath Mondreti
8aa0ed19f1 Revert "fix(api): fix api block (#951)" (#953)
This reverts commit 8016af60f4.
2025-08-12 23:05:08 -05:00
Waleed Latif
f7573fadb1 v0.3.24: api block fixes 2025-08-12 20:35:07 -07:00
Waleed Latif
8016af60f4 fix(api): fix api block (#951) 2025-08-12 20:31:41 -07:00
Vikhyath Mondreti
8fccd5c20d Merge pull request #948 from simstudioai/staging
v0.3.24: revert redis session management change
2025-08-12 17:56:16 -05:00
Vikhyath Mondreti
8de06b63d1 Revert "improvement(performance): use redis for session data (#934)" (#947)
This reverts commit 3c7b3e1a4b.
2025-08-12 17:30:21 -05:00
Vikhyath Mondreti
1c818b2e3e v0.3.23: multiplayer variables, api key fixes, kb improvements, triggers fixes
v0.3.23: multiplayer variables, api key fixes, kb improvements, triggers fixes
2025-08-12 15:23:09 -05:00
Vikhyath Mondreti
1a7de84c7a fix(tag-dropdown): last char dropped bug (#945) 2025-08-12 11:48:34 -05:00
Waleed Latif
a2dea384a4 fix(kb): kb-level deletion should reflect in doc level kb tags sidebar registry (#944) 2025-08-12 09:26:28 -07:00
Waleed Latif
1c3e923f1b fix(kb-ui): fixed tags hover effect (#942) 2025-08-12 08:49:19 -07:00
Waleed Latif
e1d5e38528 fix(chunks): instantaneous search + server side searching instead of client-side (#940)
* fix(chunks): instantaneous search + server side searching instead of client-side

* add knowledge tags component to sidebar, replace old knowledge tags UI

* add types, remove extraneous comments

* added knowledge-base level tag definitions viewer, ability to create/delete slots in sidebar and respective routes

* ui

* fix stale tag issue

* use logger
2025-08-12 01:53:47 -07:00
Waleed Latif
3c7b3e1a4b improvement(performance): use redis for session data (#934) 2025-08-11 22:42:22 -05:00
Waleed Latif
bc455d5bf4 feat(variables): multiplayer variables through sockets, persist server side (#933)
* feat(variables): multiplayer variables through sockets, persist server side

* remove extraneous comments

* breakout variables handler in sockets
2025-08-11 18:32:21 -05:00
Waleed Latif
2a333c7cf7 fix(kb): added proper pagination for documents in kb (#937) 2025-08-11 14:16:15 -07:00
Adam Gough
41cc0cdadc fix(webhooks): fixed all webhook structures (#935)
* fix for variable format + trig

* fixed slack variable

* microsoft teams working

* fixed outlook, plus added other minor documentation changes and fixed subblock

* removed discord webhook logic

* added airtable logic

* bun run lint

* test

* test again

* test again 2

* test again 3

* test again 4

* test again 4

* test again 4

* bun run lint

* test 5

* test 6

* test 7

* test 7

* test 7

* test 7

* test 7

* test 7

* test 8

* test 9

* test 9

* test 9

* test 10

* test 10

* bun run lint, plus github fixed

* removed some debug statements #935

* testing resolver removing

* testing trig

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-11 12:50:55 -07:00
Waleed Latif
70aeb0c298 fix(sidebar-ui): fix small ui bug to close gap when creating new workflow (#932) 2025-08-10 18:33:01 -07:00
Emir Karabeg
83f113984d feat(usage-indicator): added ability to see current usage (#925)
* feat(usage-indicator): added ability to see current usage

* feat(billing): added billing ennabled flag for usage indicator, enforcement of billing usage

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-10 17:20:53 -07:00
Waleed Latif
56ede1c980 improvement(tools): removed transformError, isInternalRoute, directExecution (#928)
* standardized response format for transformError

* removed trasnformError, moved error handling to executeTool for all different error formats

* remove isInternalRoute, make it implicit in executeTool

* removed directExecution, everything on the server nothing on the client

* fix supabase

* fix(tag-dropdown): fix values for parallel & loop blocks (#929)

* fix(search-modal): add parallel and loop blocks to search modal

* reordered tool params

* update docs
2025-08-10 17:19:46 -07:00
Waleed Latif
df16382a19 improvement(subflow): consolidated parallel/loop tags and collaborativeUpdate (#931)
* fix(console): fix typo

* improvement(subflows): consolidated subflows tags
2025-08-10 17:19:21 -07:00
Waleed Latif
e271ed86b6 improvement(console): added iteration info to console entry for parallel/loop (#930) 2025-08-10 16:27:39 -07:00
Waleed Latif
785b86a32e fix(tag-dropdown): fix values for parallel & loop blocks (#929) 2025-08-10 11:55:56 -07:00
Waleed Latif
e5e8082de4 fix(workflow-block): improvements to pulsing effect, active execution state, and running workflow blocks in parallel (#927)
* fix: same child workflow executing in parallel with workflow block

* fixed run button prematurely showing completion before child workflows completed

* prevent child worklfows from touching the activeBlocks & layer logic in the parent executor

* surface child workflow errors to main workfow

* ack PR comments
2025-08-09 16:57:56 -07:00
Waleed Latif
8a08afd733 improvement(control-bar): standardize styling across all control bar buttons (#926) 2025-08-09 12:32:37 -07:00
Vikhyath Mondreti
ebb25469ab fix(apikeys): pinned api key to track API key a workflow is deployed with (#924)
* fix(apikeys): pinned api key to track API key a workflow is deployed with

* remove deprecated behaviour tests
2025-08-09 01:37:27 -05:00
Waleed Latif
a2040322e7 fix(chat): fix chat attachments style in dark mode (#923) 2025-08-08 20:12:30 -07:00
Waleed Latif
a8be7e9fb3 fix(help): fix email for help route (#922) 2025-08-08 20:06:19 -07:00
335 changed files with 19028 additions and 11863 deletions

View File

@@ -416,8 +416,8 @@ In addition, you will need to update the registries:
Your tool should export a constant with a naming convention of `{toolName}Tool`. The tool ID should follow the format `{provider}_{tool_name}`. For example:
```typescript:/apps/sim/tools/pinecone/fetch.ts
import { ToolConfig, ToolResponse } from '../types'
import { PineconeParams, PineconeResponse } from './types'
import { ToolConfig, ToolResponse } from '@/tools/types'
import { PineconeParams, PineconeResponse } from '@/tools/pinecone/types'
export const fetchTool: ToolConfig<PineconeParams, PineconeResponse> = {
id: 'pinecone_fetch', // Follow the {provider}_{tool_name} format
@@ -448,9 +448,6 @@ In addition, you will need to update the registries:
transformResponse: async (response: Response) => {
// Transform response
},
transformError: (error) => {
// Handle errors
},
}
```
@@ -458,7 +455,7 @@ In addition, you will need to update the registries:
Update the tools registry in `/apps/sim/tools/index.ts` to include your new tool:
```typescript:/apps/sim/tools/index.ts
import { fetchTool, generateEmbeddingsTool, searchTextTool } from './pinecone'
import { fetchTool, generateEmbeddingsTool, searchTextTool } from '/@tools/pinecone'
// ... other imports
export const tools: Record<string, ToolConfig> = {

View File

@@ -151,8 +151,6 @@ Update multiple existing records in an Airtable table
| `baseId` | string | Yes | ID of the Airtable base |
| `tableId` | string | Yes | ID or name of the table |
| `records` | json | Yes | Array of records to update, each with an `id` and a `fields` object |
| `fields` | string | No | No description |
| `fields` | string | No | No description |
#### Output

View File

@@ -82,9 +82,10 @@ Runs a browser automation task using BrowserUse
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | json | Browser automation task results including task ID, success status, output data, and execution steps |
| `error` | string | Error message if the operation failed |
| `id` | string | Task execution identifier |
| `success` | boolean | Task completion status |
| `output` | json | Task output data |
| `steps` | json | Execution steps taken |

View File

@@ -62,7 +62,7 @@ Convert TTS using ElevenLabs voices
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `audioUrl` | string | Generated audio URL |
| `audioUrl` | string | The URL of the generated audio |

View File

@@ -71,8 +71,8 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | json | Array of parsed file objects with content, metadata, and file properties |
| `combinedContent` | string | All file contents merged into a single text string |
| `files` | array | Array of parsed files |
| `combinedContent` | string | Combined content of all parsed files |

View File

@@ -101,8 +101,8 @@ Query data from a Supabase table
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Query operation results |
| `message` | string | Operation status message |
| `results` | array | Array of records returned from the query |
### `supabase_insert`
@@ -121,8 +121,8 @@ Insert data into a Supabase table
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Insert operation results |
| `message` | string | Operation status message |
| `results` | array | Array of inserted records |
### `supabase_get_row`
@@ -141,8 +141,8 @@ Get a single row from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Get row operation results |
| `message` | string | Operation status message |
| `results` | object | The row data if found, null if not found |
### `supabase_update`
@@ -162,8 +162,8 @@ Update rows in a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Update operation results |
| `message` | string | Operation status message |
| `results` | array | Array of updated records |
### `supabase_delete`
@@ -182,8 +182,8 @@ Delete rows from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Delete operation results |
| `message` | string | Operation status message |
| `results` | array | Array of deleted records |

View File

@@ -125,7 +125,7 @@ describe('OAuth Credentials API Route', () => {
})
expect(data.credentials[1]).toMatchObject({
id: 'credential-2',
provider: 'google-email',
provider: 'google-default',
isDefault: true,
})
})
@@ -158,7 +158,7 @@ describe('OAuth Credentials API Route', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
expect(data.error).toBe('Provider or credentialId is required')
expect(mockLogger.warn).toHaveBeenCalled()
})

View File

@@ -1,12 +1,13 @@
import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthService } from '@/lib/oauth/oauth'
import { parseProvider } from '@/lib/oauth/oauth'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { account, user } from '@/db/schema'
import { account, user, workflow } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -25,36 +26,96 @@ export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Get query params
const { searchParams } = new URL(request.url)
const providerParam = searchParams.get('provider') as OAuthService | null
const workflowId = searchParams.get('workflowId')
const credentialId = searchParams.get('credentialId')
// Check if the user is authenticated
if (!session?.user?.id) {
// Authenticate requester (supports session, API key, internal JWT)
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const requesterUserId = authResult.userId
// Get the provider from the query params
const { searchParams } = new URL(request.url)
const provider = searchParams.get('provider') as OAuthService | null
// Resolve effective user id: workflow owner if workflowId provided (with access check); else requester
let effectiveUserId: string
if (workflowId) {
// Load workflow owner and workspace for access control
const rows = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!provider) {
logger.warn(`[${requestId}] Missing provider parameter`)
return NextResponse.json({ error: 'Provider is required' }, { status: 400 })
if (!rows.length) {
logger.warn(`[${requestId}] Workflow not found for credentials request`, { workflowId })
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const wf = rows[0]
if (requesterUserId !== wf.userId) {
if (!wf.workspaceId) {
logger.warn(
`[${requestId}] Forbidden - workflow has no workspace and requester is not owner`,
{
requesterUserId,
}
)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const perm = await getUserEntityPermissions(requesterUserId, 'workspace', wf.workspaceId)
if (perm === null) {
logger.warn(`[${requestId}] Forbidden credentials request - no workspace access`, {
requesterUserId,
workspaceId: wf.workspaceId,
})
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
effectiveUserId = wf.userId
} else {
effectiveUserId = requesterUserId
}
// Parse the provider to get base provider and feature type
const { baseProvider } = parseProvider(provider)
if (!providerParam && !credentialId) {
logger.warn(`[${requestId}] Missing provider parameter`)
return NextResponse.json({ error: 'Provider or credentialId is required' }, { status: 400 })
}
// Get all accounts for this user and provider
const accounts = await db
.select()
.from(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, provider)))
// Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider(providerParam || 'google-default')
let accountsData
if (credentialId) {
// Foreign-aware lookup for a specific credential by id
// If workflowId is provided and requester has access (checked above), allow fetching by id only
if (workflowId) {
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else {
// Fallback: constrain to requester's own credentials when not in a workflow context
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
}
} else {
// Fetch all credentials for provider and effective user
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
}
// Transform accounts into credentials
const credentials = await Promise.all(
accounts.map(async (acc) => {
accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')
@@ -109,7 +170,7 @@ export async function GET(request: NextRequest) {
return {
id: acc.id,
name: displayName,
provider,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
}

View File

@@ -10,6 +10,8 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn()
const mockLogger = {
info: vi.fn(),
@@ -37,6 +39,14 @@ describe('OAuth Token API Routes', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
})
afterEach(() => {
@@ -48,7 +58,12 @@ describe('OAuth Token API Routes', () => {
*/
describe('POST handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -78,13 +93,18 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('accessToken', 'fresh-token')
// Verify mocks were called correctly
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, undefined)
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle workflowId for server-side authentication', async () => {
mockGetUserId.mockResolvedValueOnce('workflow-owner-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -110,12 +130,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, 'workflow-id')
expect(mockGetCredential).toHaveBeenCalledWith(
mockRequestId,
'credential-id',
'workflow-owner-id'
)
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
@@ -132,7 +148,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -143,12 +162,12 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle workflow not found', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -160,12 +179,16 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Workflow not found')
expect(response.status).toBe(403)
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
@@ -177,12 +200,17 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Credential not found')
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -211,7 +239,11 @@ describe('OAuth Token API Routes', () => {
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -236,7 +268,7 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId)
expect(mockCheckHybridAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
@@ -255,7 +287,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = new Request(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
@@ -267,11 +302,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = new Request(
@@ -284,11 +323,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Credential not found')
expect(data).toHaveProperty('error')
})
it('should handle missing access token', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: null,
@@ -306,12 +349,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'No access token available')
expect(mockLogger.warn).toHaveBeenCalled()
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -331,7 +377,7 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token')
expect(data).toHaveProperty('error')
})
})
})

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { getCredential, getUserId, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -26,23 +28,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Determine the user ID based on the context
const userId = await getUserId(requestId, workflowId)
if (!userId) {
return NextResponse.json(
{ error: workflowId ? 'Workflow not found' : 'User not authenticated' },
{ status: workflowId ? 404 : 401 }
)
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, userId)
if (!credential) {
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
try {
// Refresh the token if needed
@@ -75,14 +67,13 @@ export async function GET(request: NextRequest) {
}
// For GET requests, we only support session-based authentication
const userId = await getUserId(requestId)
if (!userId) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, userId)
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })

View File

@@ -245,6 +245,8 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
const validData = {
@@ -287,6 +289,8 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
const validData = {

View File

@@ -0,0 +1,164 @@
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { S3_KB_CONFIG } from '@/lib/uploads/setup'
const logger = createLogger('MultipartUploadAPI')
interface InitiateMultipartRequest {
fileName: string
contentType: string
fileSize: number
}
interface GetPartUrlsRequest {
uploadId: string
key: string
partNumbers: number[]
}
interface CompleteMultipartRequest {
uploadId: string
key: string
parts: Array<{
ETag: string
PartNumber: number
}>
}
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const action = request.nextUrl.searchParams.get('action')
if (!isUsingCloudStorage() || getStorageProvider() !== 's3') {
return NextResponse.json(
{ error: 'Multipart upload is only available with S3 storage' },
{ status: 400 }
)
}
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
const s3Client = getS3Client()
switch (action) {
case 'initiate': {
const data: InitiateMultipartRequest = await request.json()
const { fileName, contentType } = data
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const uniqueKey = `kb/${uuidv4()}-${safeFileName}`
const command = new CreateMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: {
originalName: fileName,
uploadedAt: new Date().toISOString(),
purpose: 'knowledge-base',
},
})
const response = await s3Client.send(command)
logger.info(`Initiated multipart upload for ${fileName}: ${response.UploadId}`)
return NextResponse.json({
uploadId: response.UploadId,
key: uniqueKey,
})
}
case 'get-part-urls': {
const data: GetPartUrlsRequest = await request.json()
const { uploadId, key, partNumbers } = data
const presignedUrls = await Promise.all(
partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
})
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
return { partNumber, url }
})
)
return NextResponse.json({ presignedUrls })
}
case 'complete': {
const data: CompleteMultipartRequest = await request.json()
const { uploadId, key, parts } = data
const command = new CompleteMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
},
})
const response = await s3Client.send(command)
logger.info(`Completed multipart upload for key ${key}`)
const finalPath = `/api/files/serve/s3/${encodeURIComponent(key)}`
return NextResponse.json({
success: true,
location: response.Location,
path: finalPath,
key,
})
}
case 'abort': {
const data = await request.json()
const { uploadId, key } = data
const command = new AbortMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
UploadId: uploadId,
})
await s3Client.send(command)
logger.info(`Aborted multipart upload for key ${key}`)
return NextResponse.json({ success: true })
}
default:
return NextResponse.json(
{ error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' },
{ status: 400 }
)
}
} catch (error) {
logger.error('Multipart upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Multipart upload failed' },
{ status: 500 }
)
}
}

View File

@@ -2,6 +2,7 @@ import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
// Dynamic imports for storage clients to avoid client-side bundling
@@ -54,6 +55,11 @@ class ValidationError extends PresignedUrlError {
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let data: PresignedUrlRequest
try {
data = await request.json()
@@ -61,7 +67,7 @@ export async function POST(request: NextRequest) {
throw new ValidationError('Invalid JSON in request body')
}
const { fileName, contentType, fileSize, userId, chatId } = data
const { fileName, contentType, fileSize } = data
if (!fileName?.trim()) {
throw new ValidationError('fileName is required and cannot be empty')
@@ -90,10 +96,13 @@ export async function POST(request: NextRequest) {
? 'copilot'
: 'general'
// Validate copilot-specific requirements
// Evaluate user id from session for copilot uploads
const sessionUserId = session.user.id
// Validate copilot-specific requirements (use session user)
if (uploadType === 'copilot') {
if (!userId?.trim()) {
throw new ValidationError('userId is required for copilot uploads')
if (!sessionUserId?.trim()) {
throw new ValidationError('Authenticated user session is required for copilot uploads')
}
}
@@ -108,9 +117,21 @@ export async function POST(request: NextRequest) {
switch (storageProvider) {
case 's3':
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType, userId)
return await handleS3PresignedUrl(
fileName,
contentType,
fileSize,
uploadType,
sessionUserId
)
case 'blob':
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId)
return await handleBlobPresignedUrl(
fileName,
contentType,
fileSize,
uploadType,
sessionUserId
)
default:
throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
}

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getPresignedUrl, isUsingCloudStorage, uploadFile } from '@/lib/uploads'
import '@/lib/uploads/setup.server'
import { getSession } from '@/lib/auth'
import {
createErrorResponse,
createOptionsResponse,
@@ -14,6 +15,11 @@ const logger = createLogger('FilesUploadAPI')
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
// Check if multiple files are being uploaded or a single file

View File

@@ -98,8 +98,8 @@ ${message}
// Send email using Resend
const { data, error } = await resend.emails.send({
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
from: `Sim <noreply@${getEmailDomain()}>`,
to: [`help@${getEmailDomain()}`],
subject: `[${type.toUpperCase()}] ${subject}`,
replyTo: email,
text: emailText,
@@ -121,7 +121,7 @@ ${message}
// Send confirmation email to the user
await resend.emails
.send({
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
from: `Sim <noreply@${getEmailDomain()}>`,
to: [email],
subject: `Your ${type} request has been received: ${subject}`,
text: `
@@ -137,7 +137,7 @@ ${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
Best regards,
The Sim Team
`,
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
replyTo: `help@${getEmailDomain()}`,
})
.catch((err) => {
logger.warn(`[${requestId}] Failed to send confirmation email`, err)

View File

@@ -0,0 +1,118 @@
import { randomUUID } from 'crypto'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { db } from '@/db'
import { document, embedding, knowledgeBaseTagDefinitions } from '@/db/schema'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagDefinitionAPI')
// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string; tagId: string }> }
) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId, tagId } = await params
try {
logger.info(
`[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}`
)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get the tag definition to find which slot it uses
const tagDefinition = await db
.select({
id: knowledgeBaseTagDefinitions.id,
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
})
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.id, tagId),
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)
)
)
.limit(1)
if (tagDefinition.length === 0) {
return NextResponse.json({ error: 'Tag definition not found' }, { status: 404 })
}
const tagDef = tagDefinition[0]
// Delete the tag definition and clear all document tags in a transaction
await db.transaction(async (tx) => {
logger.info(`[${requestId}] Starting transaction to delete ${tagDef.tagSlot}`)
try {
// Clear the tag from documents that actually have this tag set
logger.info(`[${requestId}] Clearing tag from documents...`)
await tx
.update(document)
.set({ [tagDef.tagSlot]: null })
.where(
and(
eq(document.knowledgeBaseId, knowledgeBaseId),
isNotNull(document[tagDef.tagSlot as keyof typeof document.$inferSelect])
)
)
logger.info(`[${requestId}] Documents updated successfully`)
// Clear the tag from embeddings that actually have this tag set
logger.info(`[${requestId}] Clearing tag from embeddings...`)
await tx
.update(embedding)
.set({ [tagDef.tagSlot]: null })
.where(
and(
eq(embedding.knowledgeBaseId, knowledgeBaseId),
isNotNull(embedding[tagDef.tagSlot as keyof typeof embedding.$inferSelect])
)
)
logger.info(`[${requestId}] Embeddings updated successfully`)
// Delete the tag definition
logger.info(`[${requestId}] Deleting tag definition...`)
await tx
.delete(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.id, tagId))
logger.info(`[${requestId}] Tag definition deleted successfully`)
} catch (error) {
logger.error(`[${requestId}] Error in transaction:`, error)
throw error
}
})
logger.info(
`[${requestId}] Successfully deleted tag definition ${tagDef.displayName} (${tagDef.tagSlot})`
)
return NextResponse.json({
success: true,
message: `Tag definition "${tagDef.displayName}" deleted successfully`,
})
} catch (error) {
logger.error(`[${requestId}] Error deleting tag definition`, error)
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
}
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
@@ -55,3 +55,89 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
}
}
// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json()
const { tagSlot, displayName, fieldType } = body
if (!tagSlot || !displayName || !fieldType) {
return NextResponse.json(
{ error: 'tagSlot, displayName, and fieldType are required' },
{ status: 400 }
)
}
// Check if tag slot is already used
const existingTag = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
eq(knowledgeBaseTagDefinitions.tagSlot, tagSlot)
)
)
.limit(1)
if (existingTag.length > 0) {
return NextResponse.json({ error: 'Tag slot is already in use' }, { status: 409 })
}
// Check if display name is already used
const existingName = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
eq(knowledgeBaseTagDefinitions.displayName, displayName)
)
)
.limit(1)
if (existingName.length > 0) {
return NextResponse.json({ error: 'Tag name is already in use' }, { status: 409 })
}
// Create the new tag definition
const newTagDefinition = {
id: randomUUID(),
knowledgeBaseId,
tagSlot,
displayName,
fieldType,
createdAt: new Date(),
updatedAt: new Date(),
}
await db.insert(knowledgeBaseTagDefinitions).values(newTagDefinition)
logger.info(`[${requestId}] Successfully created tag definition ${displayName} (${tagSlot})`)
return NextResponse.json({
success: true,
data: newTagDefinition,
})
} catch (error) {
logger.error(`[${requestId}] Error creating tag definition`, error)
return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { randomUUID } from 'crypto'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { db } from '@/db'
import { document, knowledgeBaseTagDefinitions } from '@/db/schema'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagUsageAPI')
// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
logger.info(`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get all tag definitions for the knowledge base
const tagDefinitions = await db
.select({
id: knowledgeBaseTagDefinitions.id,
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
})
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
// Get usage statistics for each tag definition
const usageStats = await Promise.all(
tagDefinitions.map(async (tagDef) => {
// Count documents using this tag slot
const tagSlotColumn = tagDef.tagSlot as keyof typeof document.$inferSelect
const documentsWithTag = await db
.select({
id: document.id,
filename: document.filename,
[tagDef.tagSlot]: document[tagSlotColumn as keyof typeof document.$inferSelect] as any,
})
.from(document)
.where(
and(
eq(document.knowledgeBaseId, knowledgeBaseId),
isNotNull(document[tagSlotColumn as keyof typeof document.$inferSelect])
)
)
return {
tagName: tagDef.displayName,
tagSlot: tagDef.tagSlot,
documentCount: documentsWithTag.length,
documents: documentsWithTag.map((doc) => ({
id: doc.id,
name: doc.filename,
tagValue: doc[tagDef.tagSlot],
})),
}
})
)
logger.info(
`[${requestId}] Retrieved usage statistics for ${tagDefinitions.length} tag definitions`
)
return NextResponse.json({
success: true,
data: usageStats,
})
} catch (error) {
logger.error(`[${requestId}] Error getting tag usage statistics`, error)
return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 })
}
}

View File

@@ -30,6 +30,8 @@ vi.mock('@/lib/env', () => ({
env: {
OPENAI_API_KEY: 'test-api-key',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({

View File

@@ -15,7 +15,11 @@ vi.mock('drizzle-orm', () => ({
sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }),
}))
vi.mock('@/lib/env', () => ({ env: { OPENAI_API_KEY: 'test-key' } }))
vi.mock('@/lib/env', () => ({
env: { OPENAI_API_KEY: 'test-key' },
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(),

View File

@@ -235,42 +235,8 @@ export async function POST(request: Request) {
error: result.error || 'Unknown error',
})
if (tool.transformError) {
try {
const errorResult = tool.transformError(result)
// Handle both string and Promise return types
if (typeof errorResult === 'string') {
throw new Error(errorResult)
}
// It's a Promise, await it
const transformedError = await errorResult
// If it's a string or has an error property, use it
if (typeof transformedError === 'string') {
throw new Error(transformedError)
}
if (
transformedError &&
typeof transformedError === 'object' &&
'error' in transformedError
) {
throw new Error(transformedError.error || 'Tool returned an error')
}
// Fallback
throw new Error('Tool returned an error')
} catch (transformError) {
logger.error(`[${requestId}] Error transformation failed for ${toolId}`, {
error:
transformError instanceof Error ? transformError.message : String(transformError),
})
if (transformError instanceof Error) {
throw transformError
}
throw new Error('Tool returned an error')
}
} else {
throw new Error('Tool returned an error')
}
// Let the main executeTool handle error transformation to avoid double transformation
throw new Error(result.error || 'Tool execution failed')
}
const endTime = new Date()

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -18,46 +15,28 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Drive file request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID and file ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`)
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId: credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -40,16 +40,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db
// Get the credential from the database. Prefer session-owned credential, but
// if not found, resolve by credential ID to support collaborator-owned credentials.
let credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
}
const credential = credentials[0]
@@ -60,7 +64,7 @@ export async function GET(request: NextRequest) {
)
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -28,45 +25,26 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Calendar calendars request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -110,6 +110,7 @@ export async function GET(request: Request) {
const accessToken = url.searchParams.get('accessToken')
const providedCloudId = url.searchParams.get('cloudId')
const query = url.searchParams.get('query') || ''
const projectId = url.searchParams.get('projectId') || ''
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -131,36 +132,70 @@ export async function GET(request: Request) {
params.append('query', query)
}
// Use the correct Jira Cloud OAuth endpoint structure
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
let data: any
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage = errorData.message || `Failed to fetch issue suggestions (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
if (query) {
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage =
errorData.message || `Failed to fetch issue suggestions (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
data = await response.json()
} else if (projectId) {
// When no query, list latest issues for the selected project using Search API
const searchParams = new URLSearchParams()
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
searchParams.append('maxResults', '25')
searchParams.append('fields', 'summary,key')
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
const response = await fetch(searchUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
let errorMessage
try {
const errorData = await response.json()
logger.error('Jira Search API error details:', errorData)
errorMessage =
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const searchData = await response.json()
const issues = (searchData.issues || []).map((it: any) => ({
key: it.key,
summary: it.fields?.summary || it.key,
}))
data = { sections: [{ issues }], cloudId }
} else {
data = { sections: [], cloudId }
}
const data = await response.json()
return NextResponse.json({
...data,
cloudId, // Return the cloudId so it can be cached

View File

@@ -0,0 +1,147 @@
import { NextResponse } from 'next/server'
import { Logger } from '@/lib/logs/console/logger'
import { getJiraCloudId } from '@/tools/jira/utils'
export const dynamic = 'force-dynamic'
const logger = new Logger('JiraUpdateAPI')
export async function PUT(request: Request) {
try {
const {
domain,
accessToken,
issueKey,
summary,
title, // Support both summary and title for backwards compatibility
description,
status,
priority,
assignee,
cloudId: providedCloudId,
} = await request.json()
// Validate required parameters
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!issueKey) {
logger.error('Missing issue key in request')
return NextResponse.json({ error: 'Issue key is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId)
// Build the URL using cloudId for Jira API
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}`
logger.info('Updating Jira issue at:', url)
// Map the summary from either summary or title field
const summaryValue = summary || title
const fields: Record<string, any> = {}
if (summaryValue) {
fields.summary = summaryValue
}
if (description) {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
}
if (status) {
fields.status = {
name: status,
}
}
if (priority) {
fields.priority = {
name: priority,
}
}
if (assignee) {
fields.assignee = {
id: assignee,
}
}
const body = { fields }
// Make the request to Jira API
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Jira API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `Jira API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
// Note: Jira update API typically returns 204 No Content on success
const responseData = response.status === 204 ? {} : await response.json()
logger.info('Successfully updated Jira issue:', issueKey)
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueKey: responseData.key || issueKey,
summary: responseData.fields?.summary || 'Issue updated',
success: true,
},
})
} catch (error: any) {
logger.error('Error updating Jira issue:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,162 @@
import { NextResponse } from 'next/server'
import { Logger } from '@/lib/logs/console/logger'
import { getJiraCloudId } from '@/tools/jira/utils'
export const dynamic = 'force-dynamic'
const logger = new Logger('JiraWriteAPI')
export async function POST(request: Request) {
try {
const {
domain,
accessToken,
projectId,
summary,
description,
priority,
assignee,
cloudId: providedCloudId,
issueType,
parent,
} = await request.json()
// Validate required parameters
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectId) {
logger.error('Missing project ID in request')
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
}
if (!summary) {
logger.error('Missing summary in request')
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
}
if (!issueType) {
logger.error('Missing issue type in request')
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId)
// Build the URL using cloudId for Jira API
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue`
logger.info('Creating Jira issue at:', url)
// Construct fields object with only the necessary fields
const fields: Record<string, any> = {
project: {
id: projectId,
},
issuetype: {
name: issueType,
},
summary: summary,
}
// Only add description if it exists
if (description) {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
}
// Only add parent if it exists
if (parent) {
fields.parent = parent
}
// Only add priority if it exists
if (priority) {
fields.priority = {
name: priority,
}
}
// Only add assignee if it exists
if (assignee) {
fields.assignee = {
id: assignee,
}
}
const body = { fields }
// Make the request to Jira API
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Jira API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `Jira API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const responseData = await response.json()
logger.info('Successfully created Jira issue:', responseData.key)
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueKey: responseData.key || 'unknown',
summary: responseData.fields?.summary || 'Issue created',
success: true,
url: `https://${domain}/browse/${responseData.key}`,
},
})
} catch (error: any) {
logger.error('Error creating Jira issue:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -1,7 +1,7 @@
import type { Project } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -11,7 +11,6 @@ const logger = createLogger('LinearProjectsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -20,15 +19,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 })
}
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,7 +1,7 @@
import type { Team } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -11,7 +11,7 @@ const logger = createLogger('LinearTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -20,15 +20,24 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -9,7 +9,6 @@ const logger = createLogger('TeamsChannelsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -25,18 +24,24 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsChannelsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -115,10 +115,10 @@ const getChatDisplayName = async (
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential } = body
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -126,18 +126,24 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, body.workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsChatsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -9,7 +9,6 @@ const logger = createLogger('TeamsTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, workflowId } = body
@@ -20,18 +19,26 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsTeamsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,7 +1,10 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -26,22 +29,30 @@ export async function GET(request: Request) {
}
try {
// Get the userId from the session
const userId = session?.user?.id || ''
// Ensure we have a session for permission checks
const sessionUserId = session?.user?.id || ''
if (!userId) {
if (!sessionUserId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Resolve the credential owner to support collaborator-owned credentials
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credentialOwnerUserId = creds[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
userId,
credentialOwnerUserId,
crypto.randomUUID().slice(0, 8)
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId, userId })
logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId })
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -17,7 +17,7 @@ interface SlackChannel {
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -34,15 +34,23 @@ export async function POST(request: Request) {
isBotToken = true
logger.info('Using direct bot token for Slack API')
} else {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const resolvedToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!resolvedToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -0,0 +1,53 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import type { ThinkingToolParams, ThinkingToolResponse } from '@/tools/thinking/types'
const logger = createLogger('ThinkingToolAPI')
export const dynamic = 'force-dynamic'
/**
* POST - Process a thinking tool request
* Simply acknowledges the thought by returning it in the output
*/
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body: ThinkingToolParams = await request.json()
logger.info(`[${requestId}] Processing thinking tool request`)
// Validate the required parameter
if (!body.thought || typeof body.thought !== 'string') {
logger.warn(`[${requestId}] Missing or invalid 'thought' parameter`)
return NextResponse.json(
{
success: false,
error: 'The thought parameter is required and must be a string',
},
{ status: 400 }
)
}
// Simply acknowledge the thought by returning it in the output
const response: ThinkingToolResponse = {
success: true,
output: {
acknowledgedThought: body.thought,
},
}
logger.info(`[${requestId}] Thinking tool processed successfully`)
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] Error processing thinking tool:`, error)
return NextResponse.json(
{
success: false,
error: 'Failed to process thinking tool request',
},
{ status: 500 }
)
}
}

View File

@@ -29,29 +29,63 @@ export async function GET(request: NextRequest) {
const workflowId = searchParams.get('workflowId')
const blockId = searchParams.get('blockId')
if (workflowId && blockId) {
// Collaborative-aware path: allow collaborators with read access to view webhooks
// Fetch workflow to verify access
const wf = await db
.select({ id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf.length) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const wfRecord = wf[0]
let canRead = wfRecord.userId === session.user.id
if (!canRead && wfRecord.workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
wfRecord.workspaceId
)
canRead = permission === 'read' || permission === 'write' || permission === 'admin'
}
if (!canRead) {
logger.warn(
`[${requestId}] User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}`
)
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
logger.info(
`[${requestId}] Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}`
)
return NextResponse.json({ webhooks }, { status: 200 })
}
if (workflowId && !blockId) {
// For now, allow the call but return empty results to avoid breaking the UI
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
logger.debug(`[${requestId}] Fetching webhooks for user ${session.user.id}`, {
filteredByWorkflow: !!workflowId,
filteredByBlock: !!blockId,
})
// Create where condition
const conditions = [eq(workflow.userId, session.user.id)]
if (workflowId) {
conditions.push(eq(webhook.workflowId, workflowId))
}
if (blockId) {
conditions.push(eq(webhook.blockId, blockId))
}
const whereCondition = conditions.length > 1 ? and(...conditions) : conditions[0]
// Default: list webhooks owned by the session user
logger.debug(`[${requestId}] Fetching user-owned webhooks for ${session.user.id}`)
const webhooks = await db
.select({
webhook: webhook,
@@ -62,9 +96,9 @@ export async function GET(request: NextRequest) {
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(whereCondition)
.where(eq(workflow.userId, session.user.id))
logger.info(`[${requestId}] Retrieved ${webhooks.length} webhooks for user ${session.user.id}`)
logger.info(`[${requestId}] Retrieved ${webhooks.length} user-owned webhooks`)
return NextResponse.json({ webhooks }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching webhooks`, error)
@@ -95,17 +129,36 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// For credential-based providers (those that use polling instead of webhooks),
// generate a dummy path if none provided since they don't use actual webhook URLs
// but still need database entries for the polling services to find them
// Determine final path with special handling for credential-based providers
// to avoid generating a new path on every save.
let finalPath = path
if (!path || path.trim() === '') {
// List of providers that use credential-based polling instead of webhooks
const credentialBasedProviders = ['gmail', 'outlook']
const credentialBasedProviders = ['gmail', 'outlook']
const isCredentialBased = credentialBasedProviders.includes(provider)
if (credentialBasedProviders.includes(provider)) {
finalPath = `${provider}-${crypto.randomUUID()}`
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
// If path is missing
if (!finalPath || finalPath.trim() === '') {
if (isCredentialBased) {
// Try to reuse existing path for this workflow+block if one exists
if (blockId) {
const existingForBlock = await db
.select({ id: webhook.id, path: webhook.path })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
finalPath = existingForBlock[0].path
logger.info(
`[${requestId}] Reusing existing dummy path for ${provider} trigger: ${finalPath}`
)
}
}
// If still no path, generate a new dummy path (first-time save)
if (!finalPath || finalPath.trim() === '') {
finalPath = `${provider}-${crypto.randomUUID()}`
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
}
} else {
logger.warn(`[${requestId}] Missing path for webhook creation`, {
hasWorkflowId: !!workflowId,
@@ -160,29 +213,43 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if a webhook with the same path already exists
const existingWebhooks = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
.from(webhook)
.where(eq(webhook.path, finalPath))
.limit(1)
// Determine existing webhook to update (prefer by workflow+block for credential-based providers)
let targetWebhookId: string | null = null
if (isCredentialBased && blockId) {
const existingForBlock = await db
.select({ id: webhook.id })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
targetWebhookId = existingForBlock[0].id
}
}
if (!targetWebhookId) {
const existingByPath = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
.from(webhook)
.where(eq(webhook.path, finalPath))
.limit(1)
if (existingByPath.length > 0) {
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingByPath[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
targetWebhookId = existingByPath[0].id
}
}
let savedWebhook: any = null // Variable to hold the result of save/update
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
const finalProviderConfig = providerConfig
// If a webhook with the same path and workflowId exists, update it
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId === workflowId) {
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`)
const updatedResult = await db
.update(webhook)
@@ -193,7 +260,7 @@ export async function POST(request: NextRequest) {
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, existingWebhooks[0].id))
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
} else {
@@ -262,7 +329,8 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
const success = await configureGmailPolling(userId, savedWebhook, requestId)
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure Gmail polling`)
@@ -296,7 +364,12 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
const success = await configureOutlookPolling(userId, savedWebhook, requestId)
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,
requestId
)
if (!success) {
logger.error(`[${requestId}] Failed to configure Outlook polling`)
@@ -323,7 +396,7 @@ export async function POST(request: NextRequest) {
}
// --- End Outlook specific logic ---
const status = existingWebhooks.length > 0 ? 200 : 201
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
logger.error(`[${requestId}] Error creating/updating webhook`, {
@@ -352,12 +425,15 @@ async function createAirtableWebhookSubscription(
return // Cannot proceed without base/table IDs
}
const accessToken = await getOAuthToken(userId, 'airtable') // Use 'airtable' as the providerId key
const accessToken = await getOAuthToken(userId, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
)
return
// Instead of silently returning, throw an error with clear user guidance
throw new Error(
'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.'
)
}
const requestOrigin = new URL(request.url).origin

View File

@@ -100,20 +100,41 @@ export async function POST(
return new NextResponse('Failed to read request body', { status: 400 })
}
// Parse the body as JSON
// Parse the body - handle both JSON and form-encoded payloads
let body: any
try {
body = JSON.parse(rawBody)
// Check content type to handle both JSON and form-encoded payloads
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/x-www-form-urlencoded')) {
// GitHub sends form-encoded data with JSON in the 'payload' field
const formData = new URLSearchParams(rawBody)
const payloadString = formData.get('payload')
if (!payloadString) {
logger.warn(`[${requestId}] No payload field found in form-encoded data`)
return new NextResponse('Missing payload field', { status: 400 })
}
body = JSON.parse(payloadString)
logger.debug(`[${requestId}] Parsed form-encoded GitHub webhook payload`)
} else {
// Default to JSON parsing
body = JSON.parse(rawBody)
logger.debug(`[${requestId}] Parsed JSON webhook payload`)
}
if (Object.keys(body).length === 0) {
logger.warn(`[${requestId}] Rejecting empty JSON object`)
return new NextResponse('Empty JSON payload', { status: 400 })
}
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse JSON body`, {
logger.error(`[${requestId}] Failed to parse webhook body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
contentType: request.headers.get('content-type'),
bodyPreview: `${rawBody?.slice(0, 100)}...`,
})
return new NextResponse('Invalid JSON payload', { status: 400 })
return new NextResponse('Invalid payload format', { status: 400 })
}
// Handle Slack challenge

View File

@@ -118,44 +118,49 @@ describe('Workflow Deployment API Route', () => {
db: {
select: vi.fn().mockImplementation(() => {
selectCallCount++
const buildLimitResponse = () => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
})
return {
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
...buildLimitResponse(),
orderBy: vi.fn().mockReturnValue(buildLimitResponse()),
})),
})),
}
@@ -216,160 +221,7 @@ describe('Workflow Deployment API Route', () => {
expect(data).toHaveProperty('deployedAt', null)
})
/**
* Test POST deployment with no existing API key
* This should generate a new API key
*/
it('should create new API key when deploying workflow for user with no API key', async () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345')
expect(data).toHaveProperty('isDeployed', true)
expect(data).toHaveProperty('deployedAt')
})
/**
* Test POST deployment with existing API key
* This should use the existing API key
*/
it('should use existing API key when deploying workflow', async () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ key: 'sim_existingtestapikey12345' }]), // Existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345')
expect(data).toHaveProperty('isDeployed', true)
})
// Removed two POST deployment tests by request
/**
* Test DELETE undeployment

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
deployedAt: workflow.deployedAt,
userId: workflow.userId,
deployedState: workflow.deployedState,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -56,37 +57,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
// Fetch the user's API key
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.limit(1)
let userKey: string | null = null
let userKey = null
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKey,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKey
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
if (workflowData.pinnedApiKey) {
userKey = workflowData.pinnedApiKey
} else {
userKey = userApiKey[0].key
// Fetch the user's API key, preferring the most recently used
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKeyVal = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKeyVal,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKeyVal
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
} else {
userKey = userApiKey[0].key
}
}
// Check if the workflow has meaningful changes that would require redeployment
@@ -139,10 +145,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Get the workflow to find the user (removed deprecated state column)
// Get the workflow to find the user and existing pin (removed deprecated state column)
const workflowData = await db
.select({
userId: workflow.userId,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -155,6 +162,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = workflowData[0].userId
// Parse request body to capture selected API key (if provided)
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {
// Body may be empty; ignore
}
// Get the current live state from normalized tables instead of stale JSON
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
@@ -193,16 +211,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
loops[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterationCount: config.iterationCount || 1,
iterationType: config.iterationType || 'fixed',
collection: config.collection || '',
iterations: config.iterations || 1,
loopType: config.loopType || 'for',
forEachItems: config.forEachItems || '',
}
} else if (subflow.type === 'parallel') {
parallels[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
parallelCount: config.parallelCount || 2,
collection: config.collection || '',
count: config.count || 2,
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
}
})
@@ -241,13 +262,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
// Check if the user already has an API key
// Check if the user already has API keys
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
let userKey = null
@@ -274,15 +296,42 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
userKey = userApiKey[0].key
}
// If client provided a specific API key and it belongs to the user, prefer it
if (providedApiKey) {
const [owned] = await db
.select({ key: apiKey.key })
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.key, providedApiKey)))
.limit(1)
if (owned) {
userKey = providedApiKey
}
}
// Update the workflow deployment status and save current state as deployed state
await db
.update(workflow)
.set({
isDeployed: true,
deployedAt,
deployedState: currentState,
})
.where(eq(workflow.id, id))
const updateData: any = {
isDeployed: true,
deployedAt,
deployedState: currentState,
}
// Only pin when the client explicitly provided a key in this request
if (providedApiKey) {
updateData.pinnedApiKey = userKey
}
await db.update(workflow).set(updateData).where(eq(workflow.id, id))
// Update lastUsed for the key we returned
if (userKey) {
try {
await db
.update(apiKey)
.set({ lastUsed: new Date(), updatedAt: new Date() })
.where(eq(apiKey.key, userKey))
} catch (e) {
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt })

View File

@@ -57,16 +57,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
loops[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterationCount: config.iterationCount || 1,
iterationType: config.iterationType || 'fixed',
collection: config.collection || '',
iterations: config.iterations || 1,
loopType: config.loopType || 'for',
forEachItems: config.forEachItems || '',
}
} else if (subflow.type === 'parallel') {
parallels[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
parallelCount: config.parallelCount || 2,
collection: config.collection || '',
count: config.count || 2,
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
}
})

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -56,22 +56,31 @@ export async function validateWorkflowAccess(
}
}
// Verify API key belongs to the user who owns the workflow
const userApiKeys = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflow.userId))
// If a pinned key exists, only accept that specific key
if (workflow.pinnedApiKey) {
if (workflow.pinnedApiKey !== apiKeyHeader) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
} else {
// Otherwise, verify the key belongs to the workflow owner
const [owned] = await db
.select({ key: apiKey.key })
.from(apiKey)
.where(and(eq(apiKey.userId, workflow.userId), eq(apiKey.key, apiKeyHeader)))
.limit(1)
const validApiKey = userApiKeys.some((k) => k.key === apiKeyHeader)
if (!validApiKey) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
if (!owned) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
}
}

View File

@@ -89,6 +89,10 @@
/* Base Component Properties */
--base-muted-foreground: #737373;
/* Gradient Colors */
--gradient-primary: 263 85% 70%; /* More vibrant purple */
--gradient-secondary: 336 95% 65%; /* More vibrant pink */
}
/* Dark Mode Theme */
@@ -145,6 +149,10 @@
/* Base Component Properties */
--base-muted-foreground: #a3a3a3;
/* Gradient Colors - Adjusted for dark mode */
--gradient-primary: 263 90% 75%; /* More vibrant purple for dark mode */
--gradient-secondary: 336 100% 72%; /* More vibrant pink for dark mode */
}
}
@@ -325,6 +333,13 @@ input[type="search"]::-ms-clear {
background: transparent;
}
/* Gradient Text Utility - Use with Tailwind gradient directions */
.gradient-text {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation Classes */
.animate-pulse-ring {
animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;

View File

@@ -680,19 +680,6 @@ export function KnowledgeBase({
/>
<div className='flex items-center gap-3'>
{/* Clear Search Button */}
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setCurrentPage(1)
}}
className='text-muted-foreground text-sm hover:text-foreground'
>
Clear search
</button>
)}
{/* Add Documents Button */}
<Tooltip>
<TooltipTrigger asChild>
@@ -1121,7 +1108,7 @@ export function KnowledgeBase({
key={page}
onClick={() => goToPage(page)}
disabled={isLoadingDocuments}
className={`font-medium text-sm transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50 ${
className={`font-medium text-sm transition-colors hover:text-foreground disabled:opacity-50 ${
page === currentPage ? 'text-foreground' : 'text-muted-foreground'
}`}
>

View File

@@ -1,10 +1,11 @@
'use client'
import { useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Check, Loader2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { createLogger } from '@/lib/logs/console/logger'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
@@ -151,9 +152,15 @@ export function UploadModal({
}
}
// Calculate progress percentage
const progressPercentage =
uploadProgress.totalFiles > 0
? Math.round((uploadProgress.filesCompleted / uploadProgress.totalFiles) * 100)
: 0
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='flex max-h-[90vh] max-w-2xl flex-col overflow-hidden'>
<DialogContent className='flex max-h-[95vh] max-w-2xl flex-col overflow-hidden'>
<DialogHeader>
<DialogTitle>Upload Documents</DialogTitle>
</DialogHeader>
@@ -218,30 +225,55 @@ export function UploadModal({
</p>
</div>
<div className='max-h-40 space-y-2 overflow-auto'>
{files.map((file, index) => (
<div
key={index}
className='flex items-center justify-between rounded-md border p-3'
>
<div className='min-w-0 flex-1'>
<p className='truncate font-medium text-sm'>{file.name}</p>
<p className='text-muted-foreground text-xs'>
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
<div className='max-h-60 space-y-1.5 overflow-auto'>
{files.map((file, index) => {
const fileStatus = uploadProgress.fileStatuses?.[index]
const isCurrentlyUploading = fileStatus?.status === 'uploading'
const isCompleted = fileStatus?.status === 'completed'
const isFailed = fileStatus?.status === 'failed'
return (
<div key={index} className='space-y-1.5 rounded-md border p-2'>
<div className='flex items-center justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
{isCurrentlyUploading && (
<Loader2 className='h-4 w-4 animate-spin text-blue-500' />
)}
{isCompleted && <Check className='h-4 w-4 text-green-500' />}
{isFailed && <X className='h-4 w-4 text-red-500' />}
{!isCurrentlyUploading && !isCompleted && !isFailed && (
<div className='h-4 w-4' />
)}
<p className='truncate text-sm'>
<span className='font-medium'>{file.name}</span>
<span className='text-muted-foreground'>
{' '}
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
</p>
</div>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0'
>
<X className='h-4 w-4' />
</Button>
</div>
{isCurrentlyUploading && (
<Progress value={fileStatus?.progress || 0} className='h-1' />
)}
{isFailed && fileStatus?.error && (
<p className='text-red-500 text-xs'>{fileStatus.error}</p>
)}
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeFile(index)}
disabled={isUploading}
className='h-8 w-8 p-0'
>
<X className='h-4 w-4' />
</Button>
</div>
))}
)
})}
</div>
</div>
)}

View File

@@ -26,10 +26,13 @@ import {
TooltipTrigger,
} from '@/components/ui'
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
import { createLogger } from '@/lib/logs/console/logger'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
const logger = createLogger('DocumentTagEntry')
export interface DocumentTag {
slot: string
displayName: string
@@ -246,7 +249,7 @@ export function DocumentTagEntry({
setModalOpen(false)
} catch (error) {
console.error('Error saving tag:', error)
logger.error('Error saving tag:', error)
}
}

View File

@@ -8,6 +8,7 @@ interface SearchInputProps {
placeholder: string
disabled?: boolean
className?: string
isLoading?: boolean
}
export function SearchInput({
@@ -16,6 +17,7 @@ export function SearchInput({
placeholder,
disabled = false,
className = 'max-w-md flex-1',
isLoading = false,
}: SearchInputProps) {
return (
<div className={`relative ${className}`}>
@@ -29,13 +31,20 @@ export function SearchInput({
disabled={disabled}
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
{value && !disabled && (
<button
onClick={() => onChange('')}
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
>
<X className='h-[18px] w-[18px]' />
</button>
{isLoading ? (
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[#701FFC]' />
</div>
) : (
value &&
!disabled && (
<button
onClick={() => onChange('')}
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
>
<X className='h-[18px] w-[18px]' />
</button>
)
)}
</div>
</div>

View File

@@ -18,11 +18,21 @@ export interface UploadedFile {
tag7?: string
}
export interface FileUploadStatus {
fileName: string
fileSize: number
status: 'pending' | 'uploading' | 'completed' | 'failed'
progress?: number // 0-100 percentage
error?: string
}
export interface UploadProgress {
stage: 'idle' | 'uploading' | 'processing' | 'completing'
filesCompleted: number
totalFiles: number
currentFile?: string
currentFileProgress?: number // 0-100 percentage for current file
fileStatuses?: FileUploadStatus[] // Track each file's status
}
export interface UploadError {
@@ -73,6 +83,19 @@ class ProcessingError extends KnowledgeUploadError {
}
}
// Upload configuration constants
// Vercel has a 4.5MB body size limit for API routes
const UPLOAD_CONFIG = {
BATCH_SIZE: 5, // Upload 5 files in parallel
MAX_RETRIES: 3, // Retry failed uploads up to 3 times
RETRY_DELAY: 1000, // Initial retry delay in ms
CHUNK_SIZE: 5 * 1024 * 1024,
VERCEL_MAX_BODY_SIZE: 4.5 * 1024 * 1024, // Vercel's 4.5MB limit
DIRECT_UPLOAD_THRESHOLD: 4 * 1024 * 1024, // Files > 4MB must use presigned URLs
LARGE_FILE_THRESHOLD: 50 * 1024 * 1024, // Files > 50MB need multipart upload
UPLOAD_TIMEOUT: 60000, // 60 second timeout per upload
} as const
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
@@ -126,6 +149,523 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
}
}
/**
* Upload a single file with retry logic
*/
const uploadSingleFileWithRetry = async (
file: File,
retryCount = 0,
fileIndex?: number
): Promise<UploadedFile> => {
try {
// Create abort controller for timeout
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_CONFIG.UPLOAD_TIMEOUT)
try {
// Get presigned URL
const presignedResponse = await fetch('/api/files/presigned?type=knowledge-base', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!presignedResponse.ok) {
let errorDetails: any = null
try {
errorDetails = await presignedResponse.json()
} catch {
// Ignore JSON parsing errors
}
logger.error('Presigned URL request failed', {
status: presignedResponse.status,
fileSize: file.size,
retryCount,
})
throw new PresignedUrlError(
`Failed to get presigned URL for ${file.name}: ${presignedResponse.status} ${presignedResponse.statusText}`,
errorDetails
)
}
const presignedData = await presignedResponse.json()
if (presignedData.directUploadSupported) {
// Use presigned URLs for all uploads when cloud storage is available
// Check if file needs multipart upload for large files
if (file.size > UPLOAD_CONFIG.LARGE_FILE_THRESHOLD) {
return await uploadFileInChunks(file, presignedData, fileIndex)
}
return await uploadFileDirectly(file, presignedData, fileIndex)
}
// Fallback to traditional upload through API route
// This is only used when cloud storage is not configured
// Must check file size due to Vercel's 4.5MB limit
if (file.size > UPLOAD_CONFIG.DIRECT_UPLOAD_THRESHOLD) {
throw new DirectUploadError(
`File ${file.name} is too large (${(file.size / 1024 / 1024).toFixed(2)}MB) for upload. Cloud storage must be configured for files over 4MB.`,
{ fileSize: file.size, limit: UPLOAD_CONFIG.DIRECT_UPLOAD_THRESHOLD }
)
}
logger.warn(`Using API upload fallback for ${file.name} - cloud storage not configured`)
return await uploadFileThroughAPI(file)
} finally {
clearTimeout(timeoutId)
}
} catch (error) {
const isTimeout = error instanceof Error && error.name === 'AbortError'
const isNetwork =
error instanceof Error &&
(error.message.includes('fetch') ||
error.message.includes('network') ||
error.message.includes('Failed to fetch'))
// Retry logic
if (retryCount < UPLOAD_CONFIG.MAX_RETRIES) {
const delay = UPLOAD_CONFIG.RETRY_DELAY * 2 ** retryCount // Exponential backoff
// Only log essential info for debugging
if (isTimeout || isNetwork) {
logger.warn(`Upload failed (${isTimeout ? 'timeout' : 'network'}), retrying...`, {
attempt: retryCount + 1,
fileSize: file.size,
})
}
// Reset progress to 0 before retry to indicate restart
if (fileIndex !== undefined) {
setUploadProgress((prev) => ({
...prev,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex ? { ...fs, progress: 0, status: 'uploading' as const } : fs
),
}))
}
await new Promise((resolve) => setTimeout(resolve, delay))
return uploadSingleFileWithRetry(file, retryCount + 1, fileIndex)
}
logger.error('Upload failed after retries', {
fileSize: file.size,
errorType: isTimeout ? 'timeout' : isNetwork ? 'network' : 'unknown',
attempts: UPLOAD_CONFIG.MAX_RETRIES + 1,
})
throw error
}
}
/**
* Upload file directly with timeout and progress tracking
*/
const uploadFileDirectly = async (
file: File,
presignedData: any,
fileIndex?: number
): Promise<UploadedFile> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
let isCompleted = false // Track if this upload has completed to prevent duplicate state updates
const timeoutId = setTimeout(() => {
if (!isCompleted) {
isCompleted = true
xhr.abort()
reject(new Error('Upload timeout'))
}
}, UPLOAD_CONFIG.UPLOAD_TIMEOUT)
// Track upload progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && fileIndex !== undefined && !isCompleted) {
const percentComplete = Math.round((event.loaded / event.total) * 100)
setUploadProgress((prev) => {
// Only update if this file is still uploading
if (prev.fileStatuses?.[fileIndex]?.status === 'uploading') {
return {
...prev,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex ? { ...fs, progress: percentComplete } : fs
),
}
}
return prev
})
}
})
xhr.addEventListener('load', () => {
if (!isCompleted) {
isCompleted = true
clearTimeout(timeoutId)
if (xhr.status >= 200 && xhr.status < 300) {
const fullFileUrl = presignedData.fileInfo.path.startsWith('http')
? presignedData.fileInfo.path
: `${window.location.origin}${presignedData.fileInfo.path}`
resolve(createUploadedFile(file.name, fullFileUrl, file.size, file.type, file))
} else {
logger.error('S3 PUT request failed', {
status: xhr.status,
fileSize: file.size,
})
reject(
new DirectUploadError(
`Direct upload failed for ${file.name}: ${xhr.status} ${xhr.statusText}`,
{ uploadResponse: xhr.statusText }
)
)
}
}
})
xhr.addEventListener('error', () => {
if (!isCompleted) {
isCompleted = true
clearTimeout(timeoutId)
reject(new DirectUploadError(`Network error uploading ${file.name}`, {}))
}
})
xhr.addEventListener('abort', () => {
if (!isCompleted) {
isCompleted = true
clearTimeout(timeoutId)
reject(new DirectUploadError(`Upload aborted for ${file.name}`, {}))
}
})
// Start the upload
xhr.open('PUT', presignedData.presignedUrl)
// Set headers
xhr.setRequestHeader('Content-Type', file.type)
if (presignedData.uploadHeaders) {
Object.entries(presignedData.uploadHeaders).forEach(([key, value]) => {
xhr.setRequestHeader(key, value as string)
})
}
xhr.send(file)
})
}
/**
* Upload large file in chunks (multipart upload)
*/
const uploadFileInChunks = async (
file: File,
presignedData: any,
fileIndex?: number
): Promise<UploadedFile> => {
logger.info(
`Uploading large file ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB) using multipart upload`
)
try {
// Step 1: Initiate multipart upload
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (!initiateResponse.ok) {
throw new Error(`Failed to initiate multipart upload: ${initiateResponse.statusText}`)
}
const { uploadId, key } = await initiateResponse.json()
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
// Step 2: Calculate parts
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
const numParts = Math.ceil(file.size / chunkSize)
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
// Step 3: Get presigned URLs for all parts
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
key,
partNumbers,
}),
})
if (!partUrlsResponse.ok) {
// Abort the multipart upload if we can't get URLs
await fetch('/api/files/multipart?action=abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, key }),
})
throw new Error(`Failed to get part URLs: ${partUrlsResponse.statusText}`)
}
const { presignedUrls } = await partUrlsResponse.json()
// Step 4: Upload parts in parallel (batch them to avoid overwhelming the browser)
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []
const PARALLEL_UPLOADS = 3 // Upload 3 parts at a time
for (let i = 0; i < presignedUrls.length; i += PARALLEL_UPLOADS) {
const batch = presignedUrls.slice(i, i + PARALLEL_UPLOADS)
const batchPromises = batch.map(async ({ partNumber, url }: any) => {
const start = (partNumber - 1) * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const uploadResponse = await fetch(url, {
method: 'PUT',
body: chunk,
headers: {
'Content-Type': file.type,
},
})
if (!uploadResponse.ok) {
throw new Error(`Failed to upload part ${partNumber}: ${uploadResponse.statusText}`)
}
// Get ETag from response headers
const etag = uploadResponse.headers.get('ETag') || ''
logger.info(`Uploaded part ${partNumber}/${numParts}`)
return { ETag: etag.replace(/"/g, ''), PartNumber: partNumber }
})
const batchResults = await Promise.all(batchPromises)
uploadedParts.push(...batchResults)
}
// Step 5: Complete multipart upload
const completeResponse = await fetch('/api/files/multipart?action=complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
key,
parts: uploadedParts,
}),
})
if (!completeResponse.ok) {
throw new Error(`Failed to complete multipart upload: ${completeResponse.statusText}`)
}
const { path } = await completeResponse.json()
logger.info(`Completed multipart upload for ${file.name}`)
const fullFileUrl = path.startsWith('http') ? path : `${window.location.origin}${path}`
return createUploadedFile(file.name, fullFileUrl, file.size, file.type, file)
} catch (error) {
logger.error(`Multipart upload failed for ${file.name}:`, error)
// Fall back to direct upload if multipart fails
logger.info('Falling back to direct upload')
return uploadFileDirectly(file, presignedData)
}
}
/**
* Fallback upload through API
*/
const uploadFileThroughAPI = async (file: File): Promise<UploadedFile> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_CONFIG.UPLOAD_TIMEOUT)
try {
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
signal: controller.signal,
})
if (!uploadResponse.ok) {
let errorData: any = null
try {
errorData = await uploadResponse.json()
} catch {
// Ignore JSON parsing errors
}
throw new DirectUploadError(
`Failed to upload ${file.name}: ${errorData?.error || 'Unknown error'}`,
errorData
)
}
const uploadResult = await uploadResponse.json()
// Validate upload result structure
if (!uploadResult.path) {
throw new DirectUploadError(
`Invalid upload response for ${file.name}: missing file path`,
uploadResult
)
}
return createUploadedFile(
file.name,
uploadResult.path.startsWith('http')
? uploadResult.path
: `${window.location.origin}${uploadResult.path}`,
file.size,
file.type,
file
)
} finally {
clearTimeout(timeoutId)
}
}
/**
* Upload files with a constant pool of concurrent uploads
*/
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
const uploadedFiles: UploadedFile[] = []
const failedFiles: Array<{ file: File; error: Error }> = []
// Initialize file statuses
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
fileName: file.name,
fileSize: file.size,
status: 'pending' as const,
progress: 0,
}))
setUploadProgress((prev) => ({
...prev,
fileStatuses,
}))
// Create a queue of files to upload
const fileQueue = files.map((file, index) => ({ file, index }))
const activeUploads = new Map<number, Promise<any>>()
logger.info(
`Starting upload of ${files.length} files with concurrency ${UPLOAD_CONFIG.BATCH_SIZE}`
)
// Function to start an upload for a file
const startUpload = async (file: File, fileIndex: number) => {
// Mark file as uploading (only if not already processing)
setUploadProgress((prev) => {
const currentStatus = prev.fileStatuses?.[fileIndex]?.status
// Don't re-upload files that are already completed or currently uploading
if (currentStatus === 'completed' || currentStatus === 'uploading') {
return prev
}
return {
...prev,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex ? { ...fs, status: 'uploading' as const, progress: 0 } : fs
),
}
})
try {
const result = await uploadSingleFileWithRetry(file, 0, fileIndex)
// Mark file as completed (with atomic update)
setUploadProgress((prev) => {
// Only mark as completed if still uploading (prevent race conditions)
if (prev.fileStatuses?.[fileIndex]?.status === 'uploading') {
return {
...prev,
filesCompleted: prev.filesCompleted + 1,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex ? { ...fs, status: 'completed' as const, progress: 100 } : fs
),
}
}
return prev
})
uploadedFiles.push(result)
return { success: true, file, result }
} catch (error) {
// Mark file as failed (with atomic update)
setUploadProgress((prev) => {
// Only mark as failed if still uploading
if (prev.fileStatuses?.[fileIndex]?.status === 'uploading') {
return {
...prev,
fileStatuses: prev.fileStatuses?.map((fs, idx) =>
idx === fileIndex
? {
...fs,
status: 'failed' as const,
error: error instanceof Error ? error.message : 'Upload failed',
}
: fs
),
}
}
return prev
})
failedFiles.push({
file,
error: error instanceof Error ? error : new Error(String(error)),
})
return {
success: false,
file,
error: error instanceof Error ? error : new Error(String(error)),
}
}
}
// Process files with constant concurrency pool
while (fileQueue.length > 0 || activeUploads.size > 0) {
// Start new uploads up to the batch size limit
while (fileQueue.length > 0 && activeUploads.size < UPLOAD_CONFIG.BATCH_SIZE) {
const { file, index } = fileQueue.shift()!
const uploadPromise = startUpload(file, index).finally(() => {
activeUploads.delete(index)
})
activeUploads.set(index, uploadPromise)
}
// Wait for at least one upload to complete if we're at capacity or done with queue
if (activeUploads.size > 0) {
await Promise.race(Array.from(activeUploads.values()))
}
}
// Report failed files
if (failedFiles.length > 0) {
logger.error(`Failed to upload ${failedFiles.length} files:`, failedFiles)
const errorMessage = `Failed to upload ${failedFiles.length} file(s): ${failedFiles.map((f) => f.file.name).join(', ')}`
throw new KnowledgeUploadError(errorMessage, 'PARTIAL_UPLOAD_FAILURE', {
failedFiles,
uploadedFiles,
})
}
return uploadedFiles
}
const uploadFiles = async (
files: File[],
knowledgeBaseId: string,
@@ -144,129 +684,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
setUploadError(null)
setUploadProgress({ stage: 'uploading', filesCompleted: 0, totalFiles: files.length })
const uploadedFiles: UploadedFile[] = []
// Upload all files using presigned URLs
for (const [index, file] of files.entries()) {
setUploadProgress((prev) => ({
...prev,
currentFile: file.name,
filesCompleted: index,
}))
try {
// Get presigned URL
const presignedResponse = await fetch('/api/files/presigned?type=knowledge-base', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
fileSize: file.size,
}),
})
if (!presignedResponse.ok) {
let errorDetails: any = null
try {
errorDetails = await presignedResponse.json()
} catch {
// Ignore JSON parsing errors
}
throw new PresignedUrlError(
`Failed to get presigned URL for ${file.name}: ${presignedResponse.status} ${presignedResponse.statusText}`,
errorDetails
)
}
const presignedData = await presignedResponse.json()
if (presignedData.directUploadSupported) {
// Use presigned URL for direct upload
const uploadHeaders: Record<string, string> = {
'Content-Type': file.type,
}
// Add Azure-specific headers if provided
if (presignedData.uploadHeaders) {
Object.assign(uploadHeaders, presignedData.uploadHeaders)
}
const uploadResponse = await fetch(presignedData.presignedUrl, {
method: 'PUT',
headers: uploadHeaders,
body: file,
})
if (!uploadResponse.ok) {
throw new DirectUploadError(
`Direct upload failed for ${file.name}: ${uploadResponse.status} ${uploadResponse.statusText}`,
{ uploadResponse: uploadResponse.statusText }
)
}
// Convert relative path to full URL for schema validation
const fullFileUrl = presignedData.fileInfo.path.startsWith('http')
? presignedData.fileInfo.path
: `${window.location.origin}${presignedData.fileInfo.path}`
uploadedFiles.push(
createUploadedFile(file.name, fullFileUrl, file.size, file.type, file)
)
} else {
// Fallback to traditional upload through API route
const formData = new FormData()
formData.append('file', file)
const uploadResponse = await fetch('/api/files/upload', {
method: 'POST',
body: formData,
})
if (!uploadResponse.ok) {
let errorData: any = null
try {
errorData = await uploadResponse.json()
} catch {
// Ignore JSON parsing errors
}
throw new DirectUploadError(
`Failed to upload ${file.name}: ${errorData?.error || 'Unknown error'}`,
errorData
)
}
const uploadResult = await uploadResponse.json()
// Validate upload result structure
if (!uploadResult.path) {
throw new DirectUploadError(
`Invalid upload response for ${file.name}: missing file path`,
uploadResult
)
}
uploadedFiles.push(
createUploadedFile(
file.name,
uploadResult.path.startsWith('http')
? uploadResult.path
: `${window.location.origin}${uploadResult.path}`,
file.size,
file.type,
file
)
)
}
} catch (fileError) {
logger.error(`Error uploading file ${file.name}:`, fileError)
throw fileError // Re-throw to be caught by outer try-catch
}
}
// Upload files in batches with retry logic
const uploadedFiles = await uploadFilesInBatches(files)
setUploadProgress((prev) => ({ ...prev, stage: 'processing' }))

View File

@@ -59,6 +59,8 @@ interface DeployFormProps {
onSubmit: (data: DeployFormValues) => void
getInputFormatExample: () => string
onApiKeyCreated?: () => void
// Optional id to bind an external submit button via the `form` attribute
formId?: string
}
export function DeployForm({
@@ -69,6 +71,7 @@ export function DeployForm({
onSubmit,
getInputFormatExample,
onApiKeyCreated,
formId,
}: DeployFormProps) {
// State
const [isCreatingKey, setIsCreatingKey] = useState(false)
@@ -148,6 +151,7 @@ export function DeployForm({
return (
<Form {...form}>
<form
id={formId}
onSubmit={(e) => {
e.preventDefault()
onSubmit(form.getValues())

View File

@@ -178,6 +178,7 @@ export function DeployModal({
useEffect(() => {
async function fetchDeploymentInfo() {
// If not open or not deployed, clear info and stop
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
if (!open) {
@@ -186,6 +187,12 @@ export function DeployModal({
return
}
// If we already have deploymentInfo (e.g., just deployed and set locally), avoid overriding it
if (deploymentInfo?.isDeployed && !needsRedeployment) {
setIsLoading(false)
return
}
try {
setIsLoading(true)
@@ -215,7 +222,7 @@ export function DeployModal({
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment])
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async (data: DeployFormValues) => {
setApiDeployError(null)
@@ -239,13 +246,13 @@ export function DeployModal({
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
data.apiKey
apiKey || data.apiKey
)
setNeedsRedeployment(false)
@@ -258,9 +265,9 @@ export function DeployModal({
const newDeploymentInfo = {
isDeployed: true,
deployedAt: deployedAt,
apiKey: data.apiKey,
apiKey: apiKey || data.apiKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
exampleCommand: `curl -X POST -H "X-API-Key: ${apiKey || data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: false,
}
@@ -331,6 +338,9 @@ export function DeployModal({
}
await refetchDeployedState()
// Ensure modal status updates immediately
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
} finally {
@@ -437,7 +447,9 @@ export function DeployModal({
{isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={deploymentInfo}
deploymentInfo={
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
@@ -464,6 +476,7 @@ export function DeployModal({
onSubmit={onDeploy}
getInputFormatExample={getInputFormatExample}
onApiKeyCreated={fetchApiKeys}
formId='deploy-api-form'
/>
</div>
</>
@@ -494,8 +507,8 @@ export function DeployModal({
</Button>
<Button
type='button'
onClick={() => onDeploy({ apiKey: apiKeys.length > 0 ? apiKeys[0].key : '' })}
type='submit'
form='deploy-api-form'
disabled={isSubmitting || (!keysLoaded && !apiKeys.length)}
className={cn(
'gap-2 font-medium',

View File

@@ -90,12 +90,12 @@ export function DeploymentControls({
onClick={handleDeployClick}
disabled={isDisabled}
className={cn(
'h-12 w-12 rounded-[11px] border-[hsl(var(--card-border))] bg-[hsl(var(--card-background))] text-[hsl(var(--card-text))] shadow-xs',
'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs',
'hover:border-[#701FFC] hover:bg-[#701FFC] hover:text-white',
'transition-all duration-200',
isDeployed && 'text-[#802FFF]',
isDisabled &&
'cursor-not-allowed opacity-50 hover:border-[hsl(var(--card-border))] hover:bg-[hsl(var(--card-background))] hover:text-[hsl(var(--card-text))] hover:shadow-xs'
'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs'
)}
>
{isDeploying ? (

View File

@@ -70,7 +70,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Download className='h-5 w-5' />
</div>
) : (

View File

@@ -412,7 +412,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Trash2 className='h-5 w-5' />
</div>
</TooltipTrigger>
@@ -497,7 +497,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Copy className='h-5 w-5' />
</div>
) : (
@@ -561,7 +561,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
{isAutoLayouting ? (
<RefreshCw className='h-5 w-5 animate-spin' />
) : (
@@ -720,7 +720,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-[11px] border bg-card font-medium text-card-foreground text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Store className='h-5 w-5' />
</div>
) : (
@@ -771,7 +771,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
className={cn(
'inline-flex h-12 w-12 cursor-not-allowed items-center justify-center',
'rounded-[11px] border bg-card text-card-foreground opacity-50',
'transition-colors [&_svg]:size-4 [&_svg]:shrink-0',
'shadow-xs transition-colors',
isDebugging && 'text-amber-500'
)}
>

View File

@@ -1,9 +1,9 @@
export { ControlBar } from './control-bar/control-bar'
export { ErrorBoundary } from './error/index'
export { LoopNodeComponent } from './loop-node/loop-node'
export { Panel } from './panel/panel'
export { ParallelNodeComponent } from './parallel-node/parallel-node'
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
export { LoopNodeComponent } from './subflows/loop/loop-node'
export { ParallelNodeComponent } from './subflows/parallel/parallel-node'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowEdge } from './workflow-edge/workflow-edge'

View File

@@ -1,57 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
// Mock the store
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: vi.fn(),
}))
describe('LoopBadges Store Integration', () => {
const mockUpdateLoopType = vi.fn()
const mockUpdateLoopCount = vi.fn()
const mockUpdateLoopCollection = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
;(useWorkflowStore as any).mockImplementation((selector: any) => {
const state = {
updateLoopType: mockUpdateLoopType,
updateLoopCount: mockUpdateLoopCount,
updateLoopCollection: mockUpdateLoopCollection,
}
return selector(state)
})
})
it('should call updateLoopType when changing loop type', () => {
// When we update loop type in the UI, it should call the store method
const nodeId = 'loop1'
const newType = 'forEach'
// Simulate the handler being called
mockUpdateLoopType(nodeId, newType)
expect(mockUpdateLoopType).toHaveBeenCalledWith(nodeId, newType)
})
it('should call updateLoopCount when changing loop count', () => {
const nodeId = 'loop1'
const newCount = 15
// Simulate the handler being called
mockUpdateLoopCount(nodeId, newCount)
expect(mockUpdateLoopCount).toHaveBeenCalledWith(nodeId, newCount)
})
it('should call updateLoopCollection when changing collection', () => {
const nodeId = 'loop1'
const newCollection = '["item1", "item2", "item3"]'
// Simulate the handler being called
mockUpdateLoopCollection(nodeId, newCollection)
expect(mockUpdateLoopCollection).toHaveBeenCalledWith(nodeId, newCollection)
})
})

View File

@@ -187,17 +187,19 @@ export function ChatFileUpload({
{files.map((file) => (
<div
key={file.id}
className='flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1 text-sm'
className='flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1 text-sm dark:bg-gray-800'
>
{getFileIcon(file.type)}
<span className='flex-1 truncate' title={file.name}>
<span className='flex-1 truncate dark:text-white' title={file.name}>
{file.name}
</span>
<span className='text-gray-500 text-xs'>{formatFileSize(file.size)}</span>
<span className='text-gray-500 text-xs dark:text-gray-400'>
{formatFileSize(file.size)}
</span>
<button
type='button'
onClick={() => handleRemoveFile(file.id)}
className='p-0.5 text-gray-400 transition-colors hover:text-red-500'
className='p-0.5 text-gray-400 transition-colors hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400'
title='Remove file'
>
<X className='h-3 w-3' />

View File

@@ -417,6 +417,14 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
{entry.startedAt ? format(new Date(entry.startedAt), 'HH:mm:ss') : 'N/A'}
</span>
</div>
{/* Iteration tag - only show if iteration context exists */}
{entry.iterationCurrent !== undefined && entry.iterationTotal !== undefined && (
<div className='flex h-5 items-center rounded-lg bg-secondary px-2'>
<span className='font-normal text-muted-foreground text-xs leading-normal'>
{entry.iterationCurrent}/{entry.iterationTotal}
</span>
</div>
)}
{/* Input/Output tags - only show if input data exists */}
{hasInputData && (
<>

View File

@@ -19,6 +19,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { validateName } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable, VariableType } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -34,19 +35,17 @@ export function Variables() {
deleteVariable,
duplicateVariable,
getVariablesByWorkflowId,
loadVariables,
} = useVariablesStore()
const {
collaborativeUpdateVariable,
collaborativeAddVariable,
collaborativeDeleteVariable,
collaborativeDuplicateVariable,
} = useCollaborativeWorkflow()
// Get variables for the current workflow
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
// Load variables when active workflow changes
useEffect(() => {
if (activeWorkflowId) {
loadVariables(activeWorkflowId)
}
}, [activeWorkflowId, loadVariables])
// Track editor references
const editorRefs = useRef<Record<string, HTMLDivElement | null>>({})
@@ -56,16 +55,14 @@ export function Variables() {
// Handle variable name change with validation
const handleVariableNameChange = (variableId: string, newName: string) => {
const validatedName = validateName(newName)
updateVariable(variableId, { name: validatedName })
collaborativeUpdateVariable(variableId, 'name', validatedName)
}
// Auto-save when variables are added/edited
const handleAddVariable = () => {
if (!activeWorkflowId) return
// Create a default variable - naming is handled in the store
const id = addVariable({
name: '', // Store will generate an appropriate name
const id = collaborativeAddVariable({
name: '',
type: 'string',
value: '',
workflowId: activeWorkflowId,
@@ -125,17 +122,10 @@ export function Variables() {
}
}
// Handle editor value changes - store exactly what user types
const handleEditorChange = (variable: Variable, newValue: string) => {
// Store the raw value directly, no parsing or formatting
updateVariable(variable.id, {
value: newValue,
// Clear any previous validation errors so they'll be recalculated
validationError: undefined,
})
collaborativeUpdateVariable(variable.id, 'value', newValue)
}
// Only track focus state for UI purposes
const handleEditorBlur = (variableId: string) => {
setActiveEditors((prev) => ({
...prev,
@@ -143,7 +133,6 @@ export function Variables() {
}))
}
// Track when editor becomes active
const handleEditorFocus = (variableId: string) => {
setActiveEditors((prev) => ({
...prev,
@@ -151,20 +140,14 @@ export function Variables() {
}))
}
// Always return raw value without any formatting
const formatValue = (variable: Variable) => {
if (variable.value === '') return ''
// Always return raw value exactly as typed
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
}
// Get validation status based on type and value
const getValidationStatus = (variable: Variable): string | undefined => {
// Empty values don't need validation
if (variable.value === '') return undefined
// Otherwise validate based on type
switch (variable.type) {
case 'number':
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
@@ -174,49 +157,38 @@ export function Variables() {
: undefined
case 'object':
try {
// Handle both JavaScript and JSON syntax
const valueToEvaluate = String(variable.value).trim()
// Basic security check to prevent arbitrary code execution
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
// Use Function constructor to safely evaluate the object expression
// This is safer than eval() and handles all JS object literal syntax
const parsed = new Function(`return ${valueToEvaluate}`)()
// Verify it's actually an object (not array or null)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined // Valid object
return undefined
} catch (e) {
logger.info('Object parsing error:', e)
return 'Invalid object syntax'
}
case 'array':
try {
// Use actual JavaScript evaluation instead of trying to convert to JSON
// This properly handles all valid JS array syntax including mixed types
const valueToEvaluate = String(variable.value).trim()
// Basic security check to prevent arbitrary code execution
if (!valueToEvaluate.startsWith('[') || !valueToEvaluate.endsWith(']')) {
return 'Not a valid array format'
}
// Use Function constructor to safely evaluate the array expression
// This is safer than eval() and handles all JS array syntax correctly
const parsed = new Function(`return ${valueToEvaluate}`)()
// Verify it's actually an array
if (!Array.isArray(parsed)) {
return 'Not a valid array'
}
return undefined // Valid array
return undefined
} catch (e) {
logger.info('Array parsing error:', e)
return 'Invalid array syntax'
@@ -226,9 +198,7 @@ export function Variables() {
}
}
// Clear editor refs when variables change
useEffect(() => {
// Clean up any references to deleted variables
Object.keys(editorRefs.current).forEach((id) => {
if (!workflowVariables.some((v) => v.id === id)) {
delete editorRefs.current[id]
@@ -276,35 +246,35 @@ export function Variables() {
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'plain' })}
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'plain')}
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>Abc</div>
<span className='font-[380]'>Plain</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'number' })}
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'number')}
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>123</div>
<span className='font-[380]'>Number</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'boolean' })}
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'boolean')}
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>0/1</div>
<span className='font-[380]'>Boolean</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'object' })}
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'object')}
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>{'{}'}</div>
<span className='font-[380]'>Object</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateVariable(variable.id, { type: 'array' })}
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'array')}
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='mr-2 w-5 text-center font-[380] text-sm'>[]</div>
@@ -329,14 +299,14 @@ export function Variables() {
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'
>
<DropdownMenuItem
onClick={() => duplicateVariable(variable.id)}
onClick={() => collaborativeDuplicateVariable(variable.id)}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => deleteVariable(variable.id)}
onClick={() => collaborativeDeleteVariable(variable.id)}
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
>
<Trash className='mr-2 h-4 w-4' />

View File

@@ -1,329 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import Editor from 'react-simple-code-editor'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { cn } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
interface ParallelNodeData {
width?: number
height?: number
parentId?: string
state?: string
type?: string
extent?: 'parent'
parallelType?: 'count' | 'collection'
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentExecution: number
isExecuting: boolean
startTime: number | null
endTime: number | null
}
}
interface ParallelBadgesProps {
nodeId: string
data: ParallelNodeData
}
export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Get parallel configuration from the workflow store (single source of truth)
const { parallels } = useWorkflowStore()
const parallelConfig = parallels[nodeId]
// Use parallel config as primary source, fallback to data for backward compatibility
const configCount = parallelConfig?.count ?? data?.count ?? 5
const configDistribution = parallelConfig?.distribution ?? data?.collection ?? ''
// For parallel type, use the block's parallelType data property as the source of truth
// Don't infer it from whether distribution exists, as that causes unwanted switching
const configParallelType = data?.parallelType || 'collection'
// Derive values directly from props - no useState needed for synchronized data
const parallelType = configParallelType
const iterations = configCount
const distributionString =
typeof configDistribution === 'string'
? configDistribution
: JSON.stringify(configDistribution) || ''
// Use actual values directly for display, temporary state only for active editing
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = distributionString
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const [showTagDropdown, setShowTagDropdown] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const editorContainerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Get collaborative functions
const {
collaborativeUpdateParallelCount,
collaborativeUpdateParallelCollection,
collaborativeUpdateParallelType,
} = useCollaborativeWorkflow()
// Handle parallel type change
const handleParallelTypeChange = useCallback(
(newType: 'count' | 'collection') => {
if (isPreview) return // Don't allow changes in preview mode
// Use single collaborative function that handles all the state changes atomically
collaborativeUpdateParallelType(nodeId, newType)
setTypePopoverOpen(false)
},
[nodeId, collaborativeUpdateParallelType, isPreview]
)
// Handle iterations input change
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setTempInputValue(Math.min(20, numValue).toString())
} else {
setTempInputValue(sanitizedValue)
}
},
[isPreview]
)
// Handle iterations save
const handleIterationsSave = useCallback(() => {
if (isPreview) return // Don't allow changes in preview mode
const value = Number.parseInt(inputValue)
if (!Number.isNaN(value)) {
const newValue = Math.min(20, Math.max(1, value))
// Update the collaborative state - this will cause iterations to be derived from props
collaborativeUpdateParallelCount(nodeId, newValue)
}
// Clear temporary input state to show the actual value
setTempInputValue(null)
setConfigPopoverOpen(false)
}, [inputValue, nodeId, collaborativeUpdateParallelCount, isPreview])
// Handle editor change and check for tag trigger
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
// Update collaborative state directly - no local state needed
collaborativeUpdateParallelCollection(nodeId, value)
// Get the textarea element and cursor position
const textarea = editorContainerRef.current?.querySelector('textarea')
if (textarea) {
textareaRef.current = textarea
const position = textarea.selectionStart || 0
setCursorPosition(position)
// Check for tag trigger
const tagTrigger = checkTagTrigger(value, position)
setShowTagDropdown(tagTrigger.show)
}
},
[nodeId, collaborativeUpdateParallelCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
// Update collaborative state directly - no local state needed
collaborativeUpdateParallelCollection(nodeId, newValue)
setShowTagDropdown(false)
// Focus back on the editor after selection
setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
}
}, 0)
},
[nodeId, collaborativeUpdateParallelCollection, isPreview]
)
// Handle key events
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setShowTagDropdown(false)
}
}, [])
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Parallel Type Badge */}
<Popover
open={!isPreview && typePopoverOpen}
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{parallelType === 'count' ? 'Parallel Count' : 'Parallel Each'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Parallel Type</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'count' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('count')}
>
<span className='text-sm'>Parallel Count</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
parallelType === 'collection' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleParallelTypeChange('collection')}
>
<span className='text-sm'>Parallel Each</span>
</div>
</div>
</div>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
<Popover
open={!isPreview && configPopoverOpen}
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
<Badge
variant='outline'
className={cn(
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{parallelType === 'count' ? `Iterations: ${iterations}` : 'Items'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent
className={cn('p-3', parallelType !== 'count' ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'}
</div>
{parallelType === 'count' ? (
// Number input for count-based parallel
<div className='flex items-center gap-2'>
<Input
type='text'
value={inputValue}
onChange={handleIterationsChange}
onBlur={handleIterationsSave}
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
className='h-8 text-sm'
autoFocus
/>
</div>
) : (
// Code editor for collection-based parallel
<div className='relative'>
<div
ref={editorContainerRef}
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
>
{editorValue === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
['item1', 'item2', 'item3']
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className='w-full focus:outline-none'
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTagDropdown(false)
}
}}
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to use for parallel execution. Type "{'<'}" to reference other
blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
{parallelType === 'count' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 20
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)
}

View File

@@ -12,50 +12,80 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
interface LoopNodeData {
type IterationType = 'loop' | 'parallel'
type LoopType = 'for' | 'forEach'
type ParallelType = 'count' | 'collection'
interface IterationNodeData {
width?: number
height?: number
parentId?: string
state?: string
type?: string
extent?: 'parent'
loopType?: 'for' | 'forEach'
loopType?: LoopType
parallelType?: ParallelType
// Common
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentIteration: number
currentIteration?: number
currentExecution?: number
isExecuting: boolean
startTime: number | null
endTime: number | null
}
}
interface LoopBadgesProps {
interface IterationBadgesProps {
nodeId: string
data: LoopNodeData
data: IterationNodeData
iterationType: IterationType
}
export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Check if this is preview mode
const CONFIG = {
loop: {
typeLabels: { for: 'For Loop', forEach: 'For Each' },
typeKey: 'loopType' as const,
storeKey: 'loops' as const,
maxIterations: 100,
configKeys: {
iterations: 'iterations' as const,
items: 'forEachItems' as const,
},
},
parallel: {
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
typeKey: 'parallelType' as const,
storeKey: 'parallels' as const,
maxIterations: 20,
configKeys: {
iterations: 'count' as const,
items: 'distribution' as const,
},
},
} as const
export function IterationBadges({ nodeId, data, iterationType }: IterationBadgesProps) {
const config = CONFIG[iterationType]
const isPreview = data?.isPreview || false
// Get loop configuration from the workflow store (single source of truth)
const { loops } = useWorkflowStore()
const loopConfig = loops[nodeId]
// Get configuration from the workflow store
const store = useWorkflowStore()
const nodeConfig = store[config.storeKey][nodeId]
// Use loop config as primary source, fallback to data for backward compatibility
const configIterations = loopConfig?.iterations ?? data?.count ?? 5
const configLoopType = loopConfig?.loopType ?? data?.loopType ?? 'for'
const configCollection = loopConfig?.forEachItems ?? data?.collection ?? ''
// Determine current type and values
const currentType = (data?.[config.typeKey] ||
(iterationType === 'loop' ? 'for' : 'count')) as any
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
// Derive values directly from props - no useState needed for synchronized data
const loopType = configLoopType
const iterations = configIterations
const collectionString =
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
// Use actual values directly for display, temporary state only for active editing
// State management
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const inputValue = tempInputValue ?? iterations.toString()
const editorValue = collectionString
@@ -69,88 +99,91 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Get collaborative functions
const {
collaborativeUpdateLoopType,
collaborativeUpdateLoopCount,
collaborativeUpdateLoopCollection,
collaborativeUpdateParallelType,
collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection,
} = useCollaborativeWorkflow()
// Handle loop type change
const handleLoopTypeChange = useCallback(
(newType: 'for' | 'forEach') => {
if (isPreview) return // Don't allow changes in preview mode
// Update the collaborative state - this will cause the component to re-render with new derived values
collaborativeUpdateLoopType(nodeId, newType)
// Handle type change
const handleTypeChange = useCallback(
(newType: any) => {
if (isPreview) return
if (iterationType === 'loop') {
collaborativeUpdateLoopType(nodeId, newType)
} else {
collaborativeUpdateParallelType(nodeId, newType)
}
setTypePopoverOpen(false)
},
[nodeId, collaborativeUpdateLoopType, isPreview]
[nodeId, iterationType, collaborativeUpdateLoopType, collaborativeUpdateParallelType, isPreview]
)
// Handle iterations input change
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
if (isPreview) return
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setTempInputValue(Math.min(100, numValue).toString())
setTempInputValue(Math.min(config.maxIterations, numValue).toString())
} else {
setTempInputValue(sanitizedValue)
}
},
[isPreview]
[isPreview, config.maxIterations]
)
// Handle iterations save
const handleIterationsSave = useCallback(() => {
if (isPreview) return // Don't allow changes in preview mode
if (isPreview) return
const value = Number.parseInt(inputValue)
if (!Number.isNaN(value)) {
const newValue = Math.min(100, Math.max(1, value))
// Update the collaborative state - this will cause iterations to be derived from props
collaborativeUpdateLoopCount(nodeId, newValue)
const newValue = Math.min(config.maxIterations, Math.max(1, value))
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
}
// Clear temporary input state to show the actual value
setTempInputValue(null)
setConfigPopoverOpen(false)
}, [inputValue, nodeId, collaborativeUpdateLoopCount, isPreview])
}, [
inputValue,
nodeId,
iterationType,
collaborativeUpdateIterationCount,
isPreview,
config.maxIterations,
])
// Handle editor change with tag dropdown support
// Handle editor change
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
if (isPreview) return
// Update collaborative state directly - no local state needed
collaborativeUpdateLoopCollection(nodeId, value)
// Get the textarea element from the editor
const textarea = editorContainerRef.current?.querySelector('textarea')
// Capture cursor first to minimize staleness in dropdown logic
const textarea = editorContainerRef.current?.querySelector(
'textarea'
) as HTMLTextAreaElement | null
const cursorPos = textarea?.selectionStart ?? cursorPosition
if (textarea) {
textareaRef.current = textarea
const cursorPos = textarea.selectionStart || 0
setCursorPosition(cursorPos)
// Check for tag trigger
const triggerCheck = checkTagTrigger(value, cursorPos)
setShowTagDropdown(triggerCheck.show)
}
setCursorPosition(cursorPos)
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
const triggerCheck = checkTagTrigger(value, cursorPos)
setShowTagDropdown(triggerCheck.show)
},
[nodeId, collaborativeUpdateLoopCollection, isPreview]
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview, cursorPosition]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
// Update collaborative state directly - no local state needed
collaborativeUpdateLoopCollection(nodeId, newValue)
if (isPreview) return
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
setShowTagDropdown(false)
// Focus back on the editor after a short delay
setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
@@ -158,12 +191,20 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
}
}, 0)
},
[nodeId, collaborativeUpdateLoopCollection, isPreview]
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
)
// Determine if we're in count mode or collection mode
const isCountMode =
(iterationType === 'loop' && currentType === 'for') ||
(iterationType === 'parallel' && currentType === 'count')
// Get type options
const typeOptions = Object.entries(config.typeLabels)
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Loop Type Badge */}
{/* Type Badge */}
<Popover
open={!isPreview && typePopoverOpen}
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
@@ -178,40 +219,36 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? 'For Loop' : 'For Each'}
{config.typeLabels[currentType as keyof typeof config.typeLabels]}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>Loop Type</div>
<div className='font-medium text-muted-foreground text-xs'>
{iterationType === 'loop' ? 'Loop Type' : 'Parallel Type'}
</div>
<div className='space-y-1'>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'for' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('for')}
>
<span className='text-sm'>For Loop</span>
</div>
<div
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
loopType === 'forEach' ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleLoopTypeChange('forEach')}
>
<span className='text-sm'>For Each</span>
</div>
{typeOptions.map(([typeValue, typeLabel]) => (
<div
key={typeValue}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
currentType === typeValue ? 'bg-accent' : 'hover:bg-accent/50'
)}
onClick={() => handleTypeChange(typeValue)}
>
<span className='text-sm'>{typeLabel}</span>
</div>
))}
</div>
</div>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
{/* Configuration Badge */}
<Popover
open={!isPreview && configPopoverOpen}
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
@@ -226,23 +263,25 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? `Iterations: ${iterations}` : 'Items'}
{isCountMode ? `Iterations: ${iterations}` : 'Items'}
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
{!isPreview && (
<PopoverContent
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
className={cn('p-3', !isCountMode ? 'w-72' : 'w-48')}
align='center'
onClick={(e) => e.stopPropagation()}
>
<div className='space-y-2'>
<div className='font-medium text-muted-foreground text-xs'>
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
{isCountMode
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
{isCountMode ? (
// Number input for count-based mode
<div className='flex items-center gap-2'>
<Input
type='text'
@@ -255,7 +294,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
/>
</div>
) : (
// Code editor for 'forEach' loops
// Code editor for collection-based mode
<div ref={editorContainerRef} className='relative'>
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
{editorValue === '' && (
@@ -293,9 +332,9 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
</div>
)}
{loopType === 'for' && (
{isCountMode && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
Enter a number between 1 and {config.maxIterations}
</div>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
vi.mock('@/stores/workflows/workflow/store', () => ({

View File

@@ -6,9 +6,9 @@ import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCurrentWorkflow } from '../../hooks'
import { LoopBadges } from './components/loop-badges'
// Add these styles to your existing global CSS file or create a separate CSS module
const LoopNodeStyles: React.FC = () => {
@@ -245,7 +245,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
/>
{/* Loop Configuration Badges */}
<LoopBadges nodeId={id} data={data} />
<IterationBadges nodeId={id} data={data} iterationType='loop' />
</Card>
</div>
</>

View File

@@ -9,7 +9,7 @@ export const ParallelTool = {
bgColor: '#FEE12B',
data: {
label: 'Parallel',
parallelType: 'collection' as 'collection' | 'count',
parallelType: 'count' as 'collection' | 'count',
count: 5,
collection: '',
extent: 'parent',

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
vi.mock('@/stores/workflows/workflow/store', () => ({

View File

@@ -6,9 +6,9 @@ import { StartIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCurrentWorkflow } from '../../hooks'
import { ParallelBadges } from './components/parallel-badges'
const ParallelNodeStyles: React.FC = () => {
return (
@@ -263,7 +263,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
/>
{/* Parallel Configuration Badges */}
<ParallelBadges nodeId={id} data={data} />
<IterationBadges nodeId={id} data={data} iterationType='parallel' />
</Card>
</div>
</>

View File

@@ -446,24 +446,25 @@ export function Code({
value={code}
onValueChange={(newCode) => {
if (!isCollapsed && !isAiStreaming && !isPreview && !disabled) {
// Capture cursor first to minimize staleness in dropdown logic
const textarea = editorRef.current?.querySelector(
'textarea'
) as HTMLTextAreaElement | null
const pos = textarea?.selectionStart ?? cursorPosition
setCursorPosition(pos)
setCode(newCode)
setStoreValue(newCode)
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}}
onKeyDown={(e) => {

View File

@@ -150,13 +150,14 @@ export function ComboBox({
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
// Update cursor first to reduce staleness for dropdown logic
setCursorPosition(newCursorPosition)
// Update store value immediately (allow free text)
if (!isPreview) {
setStoreValue(newValue)
}
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(envVarTrigger.show)

View File

@@ -24,6 +24,9 @@ import {
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
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'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
const logger = createLogger('CredentialSelector')
@@ -47,6 +50,9 @@ export function CredentialSelector({
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const [hasForeignMeta, setHasForeignMeta] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Use collaborative state management via useSubBlockValue hook
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
@@ -81,45 +87,42 @@ export function CredentialSelector({
const response = await fetch(`/api/auth/oauth/credentials?provider=${effectiveProviderId}`)
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
const creds = data.credentials as Credential[]
let foreignMetaFound = false
// If we have a value but it's not in the credentials, reset it
if (selectedId && !data.credentials.some((cred: Credential) => cred.id === selectedId)) {
setSelectedId('')
if (!isPreview) {
setStoreValue('')
}
}
// Auto-select logic:
// 1. If we already have a valid selection, keep it
// 2. If there's a default credential, select it
// 3. If there's only one credential, select it
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
if (
(!selectedId || !data.credentials.some((cred: Credential) => cred.id === selectedId)) &&
data.credentials.length > 0
selectedId &&
!(creds || []).some((cred: Credential) => cred.id === selectedId) &&
activeWorkflowId
) {
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
setSelectedId(defaultCred.id)
if (!isPreview) {
setStoreValue(defaultCred.id)
}
} else if (data.credentials.length === 1) {
// If only one credential, select it
setSelectedId(data.credentials[0].id)
if (!isPreview) {
setStoreValue(data.credentials[0].id)
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
// Mark as foreign, but do NOT merge into list to avoid leaking owner email
foreignMetaFound = true
}
}
} catch {
// ignore meta errors
}
}
setHasForeignMeta(foreignMetaFound)
setCredentials(creds)
// Do not auto-select or reset. We only show what's persisted.
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
} finally {
setIsLoading(false)
}
}, [effectiveProviderId, selectedId, isPreview, setStoreValue])
}, [effectiveProviderId, selectedId, activeWorkflowId])
// Fetch credentials on initial mount
useEffect(() => {
@@ -128,6 +131,38 @@ export function CredentialSelector({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
useEffect(() => {
let aborted = false
;(async () => {
try {
if (!selectedId) {
setHasForeignMeta(false)
return
}
// If the selected credential exists in viewer's list, it's not foreign
if ((credentials || []).some((cred) => cred.id === selectedId)) {
setHasForeignMeta(false)
return
}
if (!activeWorkflowId) return
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${selectedId}&workflowId=${activeWorkflowId}`
)
if (aborted) return
if (metaResp.ok) {
const meta = await metaResp.json()
setHasForeignMeta(!!meta.credentials?.length)
}
} catch {
// ignore
}
})()
return () => {
aborted = true
}
}, [selectedId, credentials, activeWorkflowId])
// This effect is no longer needed since we're using effectiveValue directly
// Listen for visibility changes to update credentials when user returns from settings
@@ -156,12 +191,25 @@ export function CredentialSelector({
// Get the selected credential
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
// Handle selection
const handleSelect = (credentialId: string) => {
const previousId = selectedId || (effectiveValue as string) || ''
setSelectedId(credentialId)
if (!isPreview) {
setStoreValue(credentialId)
// If credential changed, clear other sub-block fields for a clean state
if (previousId && previousId !== credentialId) {
const wfId = (activeWorkflowId as string) || ''
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
const blockValues = workflowValues[blockId] || {}
Object.keys(blockValues).forEach((key) => {
if (key !== subBlock.id) {
collaborativeSetSubblockValue(blockId, key, '')
}
})
}
}
setOpen(false)
}
@@ -214,11 +262,17 @@ export function CredentialSelector({
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
{getProviderIcon(provider)}
{selectedCredential ? (
<span className='truncate font-normal'>{selectedCredential.name}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
<span
className={
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
}
>
{selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: label}
</span>
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>

View File

@@ -45,6 +45,7 @@ interface ConfluenceFileSelectorProps {
domain: string
showPreview?: boolean
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
credentialId?: string
}
export function ConfluenceFileSelector({
@@ -58,11 +59,12 @@ export function ConfluenceFileSelector({
domain,
showPreview = true,
onFileInfoChange,
credentialId,
}: ConfluenceFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [files, setFiles] = useState<ConfluenceFileInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedFileId, setSelectedFileId] = useState(value)
const [selectedFile, setSelectedFile] = useState<ConfluenceFileInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -120,25 +122,6 @@ export function ConfluenceFileSelector({
if (response.ok) {
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)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', error)

View File

@@ -35,6 +35,7 @@ interface GoogleCalendarSelectorProps {
showPreview?: boolean
onCalendarInfoChange?: (info: GoogleCalendarInfo | null) => void
credentialId: string
workflowId?: string
}
export function GoogleCalendarSelector({
@@ -45,6 +46,7 @@ export function GoogleCalendarSelector({
showPreview = true,
onCalendarInfoChange,
credentialId,
workflowId,
}: GoogleCalendarSelectorProps) {
const [open, setOpen] = useState(false)
const [calendars, setCalendars] = useState<GoogleCalendarInfo[]>([])
@@ -62,6 +64,9 @@ export function GoogleCalendarSelector({
const queryParams = new URLSearchParams({
credentialId: credentialId,
})
if (workflowId) {
queryParams.set('workflowId', workflowId)
}
const response = await fetch(`/api/tools/google_calendar/calendars?${queryParams.toString()}`)

View File

@@ -54,6 +54,8 @@ interface GoogleDrivePickerProps {
onFileInfoChange?: (fileInfo: FileInfo | null) => void
clientId: string
apiKey: string
credentialId?: string
workflowId?: string
}
export function GoogleDrivePicker({
@@ -69,6 +71,8 @@ export function GoogleDrivePicker({
onFileInfoChange,
clientId,
apiKey,
credentialId,
workflowId,
}: GoogleDrivePickerProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
@@ -105,25 +109,7 @@ export function GoogleDrivePicker({
if (response.ok) {
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)
}
}
}
// Do not auto-select. Respect persisted credential via prop when provided.
}
} catch (error) {
logger.error('Error fetching credentials:', { error })
@@ -133,6 +119,13 @@ export function GoogleDrivePicker({
}
}, [provider, getProviderId, selectedCredentialId])
// Prefer persisted credentialId if provided
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Fetch a single file by ID when we have a selectedFileId but no metadata
const fetchFileById = useCallback(
async (fileId: string) => {
@@ -145,6 +138,7 @@ export function GoogleDrivePicker({
credentialId: selectedCredentialId,
fileId: fileId,
})
if (workflowId) queryParams.set('workflowId', workflowId)
const response = await fetch(`/api/tools/drive/file?${queryParams.toString()}`)
@@ -251,7 +245,10 @@ export function GoogleDrivePicker({
setIsLoading(true)
try {
const response = await fetch(`/api/auth/oauth/token?credentialId=${selectedCredentialId}`)
const url = new URL('/api/auth/oauth/token', window.location.origin)
url.searchParams.set('credentialId', selectedCredentialId)
// include workflowId if available via global registry (server adds session owner otherwise)
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch access token: ${response.status}`)
@@ -500,10 +497,7 @@ export function GoogleDrivePicker({
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Ready to select files.</p>
<p className='text-muted-foreground text-xs'>
Click the button below to open the file picker.
</p>
<p className='font-medium text-sm'>No documents available.</p>
</div>
)}
</CommandEmpty>

View File

@@ -46,6 +46,8 @@ interface JiraIssueSelectorProps {
showPreview?: boolean
onIssueInfoChange?: (issueInfo: JiraIssueInfo | null) => void
projectId?: string
credentialId?: string
isForeignCredential?: boolean
}
export function JiraIssueSelector({
@@ -60,11 +62,12 @@ export function JiraIssueSelector({
showPreview = true,
onIssueInfoChange,
projectId,
credentialId,
}: JiraIssueSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [issues, setIssues] = useState<JiraIssueInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedIssueId, setSelectedIssueId] = useState(value)
const [selectedIssue, setSelectedIssue] = useState<JiraIssueInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -73,6 +76,15 @@ export function JiraIssueSelector({
const [error, setError] = useState<string | null>(null)
const [cloudId, setCloudId] = useState<string | null>(null)
// Keep local credential state in sync with persisted credentialId prop
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
} else if (!credentialId && selectedCredentialId) {
setSelectedCredentialId('')
}
}, [credentialId, selectedCredentialId])
// Handle search with debounce
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -124,25 +136,6 @@ export function JiraIssueSelector({
if (response.ok) {
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)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', error)
@@ -242,6 +235,11 @@ export function JiraIssueSelector({
const fetchIssues = useCallback(
async (searchQuery?: string) => {
if (!selectedCredentialId || !domain) return
// If no search query is provided, require a projectId before fetching
if (!searchQuery && !projectId) {
setIssues([])
return
}
// Validate domain format
const trimmedDomain = domain.trim().toLowerCase()
@@ -370,13 +368,12 @@ export function JiraIssueSelector({
]
)
// Fetch credentials on initial mount
// Fetch credentials when the dropdown opens (avoid fetching on mount with no credential)
useEffect(() => {
if (!initialFetchRef.current) {
if (open) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
}, [open, fetchCredentials])
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
@@ -384,7 +381,10 @@ export function JiraIssueSelector({
// Only fetch recent/default issues when opening the dropdown
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
fetchIssues('') // Pass empty string to get recent or default issues
// Only fetch on open when a project is selected; otherwise wait for user search
if (projectId) {
fetchIssues('')
}
}
}
@@ -406,6 +406,14 @@ export function JiraIssueSelector({
if (value !== selectedIssueId) {
setSelectedIssueId(value)
}
// When the upstream value is cleared (e.g., project changed or remote user cleared),
// clear local selection and preview immediately
if (!value) {
setSelectedIssue(null)
setIssues([])
setError(null)
onIssueInfoChange?.(null)
}
}, [value])
// Handle issue selection
@@ -443,7 +451,7 @@ export function JiraIssueSelector({
role='combobox'
aria-expanded={open}
className='h-10 w-full min-w-0 justify-between'
disabled={disabled || !domain}
disabled={disabled || !domain || !selectedCredentialId}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedIssue ? (

View File

@@ -60,6 +60,7 @@ export function TeamsMessageSelector({
serviceId,
showPreview = true,
onMessageInfoChange,
credential,
selectionType = 'team',
initialTeamId,
workflowId,
@@ -69,7 +70,7 @@ export function TeamsMessageSelector({
const [teams, setTeams] = useState<TeamsMessageInfo[]>([])
const [channels, setChannels] = useState<TeamsMessageInfo[]>([])
const [chats, setChats] = useState<TeamsMessageInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credential || '')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [selectedChatId, setSelectedChatId] = useState<string>('')
@@ -102,25 +103,6 @@ export function TeamsMessageSelector({
if (response.ok) {
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)
}
}
}
}
} catch (error) {
logger.error('Error fetching credentials:', error)
@@ -144,6 +126,7 @@ export function TeamsMessageSelector({
},
body: JSON.stringify({
credential: selectedCredentialId,
workflowId,
}),
})
@@ -205,6 +188,7 @@ export function TeamsMessageSelector({
body: JSON.stringify({
credential: selectedCredentialId,
teamId,
workflowId,
}),
})
@@ -341,7 +325,6 @@ export function TeamsMessageSelector({
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch data when opening the dropdown
if (isOpen && selectedCredentialId) {
if (selectionStage === 'team') {
@@ -671,9 +654,16 @@ export function TeamsMessageSelector({
}
}, [fetchCredentials])
// Restore selection based on selectionType and value
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (value && selectedCredentialId && !selectedMessage) {
if (credential && credential !== selectedCredentialId) {
setSelectedCredentialId(credential)
}
}, [credential, selectedCredentialId])
// Restore selection whenever the canonical value changes
useEffect(() => {
if (value && selectedCredentialId) {
if (selectionType === 'team') {
restoreTeamSelection(value)
} else if (selectionType === 'chat') {
@@ -681,11 +671,12 @@ export function TeamsMessageSelector({
} else if (selectionType === 'channel') {
restoreChannelSelection(value)
}
} else {
setSelectedMessage(null)
}
}, [
value,
selectedCredentialId,
selectedMessage,
selectionType,
restoreTeamSelection,
restoreChatSelection,

View File

@@ -43,6 +43,7 @@ interface WealthboxFileSelectorProps {
showPreview?: boolean
onFileInfoChange?: (itemInfo: WealthboxItemInfo | null) => void
itemType?: 'contact'
credentialId?: string
}
export function WealthboxFileSelector({
@@ -56,10 +57,11 @@ export function WealthboxFileSelector({
showPreview = true,
onFileInfoChange,
itemType = 'contact',
credentialId,
}: WealthboxFileSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedItemId, setSelectedItemId] = useState(value)
const [selectedItem, setSelectedItem] = useState<WealthboxItemInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -94,23 +96,6 @@ export function WealthboxFileSelector({
if (response.ok) {
const data = await response.json()
setCredentials(data.credentials)
// Auto-select logic for credentials
if (data.credentials.length > 0) {
if (
selectedCredentialId &&
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
) {
// Keep the current selection
} else {
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) {
logger.error('Error fetching credentials:', { error })
@@ -120,6 +105,13 @@ export function WealthboxFileSelector({
}
}, [provider, getProviderId, selectedCredentialId])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Debounced search function
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null)

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getEnv } from '@/lib/env'
import {
@@ -45,6 +46,8 @@ export function FileSelectorInput({
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const params = useParams()
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
@@ -61,6 +64,36 @@ export function FileSelectorInput({
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
// Determine if the persisted credential belongs to the current viewer
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(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')])
// Get provider-specific values
const provider = subBlock.provider || 'google-drive'
const isConfluence = provider === 'confluence'
@@ -76,6 +109,7 @@ export function FileSelectorInput({
const isMicrosoftPlanner = provider === 'microsoft-planner'
// For Confluence and Jira, we need the domain and credentials
const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : ''
const jiraCredential = isJira ? (getValue(blockId, 'credential') as string) || '' : ''
// For Discord, we need the bot token and server ID
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : ''
@@ -83,59 +117,53 @@ export function FileSelectorInput({
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
// Get the current value from the store or prop value if in preview mode
// Keep local selection in sync with store value (and preview)
useEffect(() => {
if (isPreview && previewValue !== undefined) {
const value = previewValue
if (value && typeof value === 'string') {
if (isJira) {
setSelectedIssueId(value)
} else if (isDiscord) {
setSelectedChannelId(value)
} else if (isMicrosoftTeams) {
setSelectedMessageId(value)
} else if (isGoogleCalendar) {
setSelectedCalendarId(value)
} else if (isWealthbox) {
setSelectedWealthboxItemId(value)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(value)
} else {
setSelectedFileId(value)
}
const effective = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof effective === 'string' && effective !== '') {
if (isJira) {
setSelectedIssueId(effective)
} else if (isDiscord) {
setSelectedChannelId(effective)
} else if (isMicrosoftTeams) {
setSelectedMessageId(effective)
} else if (isGoogleCalendar) {
setSelectedCalendarId(effective)
} else if (isWealthbox) {
setSelectedWealthboxItemId(effective)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(effective)
} else {
setSelectedFileId(effective)
}
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
if (isJira) {
setSelectedIssueId(value)
} else if (isDiscord) {
setSelectedChannelId(value)
} else if (isMicrosoftTeams) {
setSelectedMessageId(value)
} else if (isGoogleCalendar) {
setSelectedCalendarId(value)
} else if (isWealthbox) {
setSelectedWealthboxItemId(value)
} else if (isMicrosoftSharePoint) {
setSelectedFileId(value)
} else {
setSelectedFileId(value)
}
// Clear when value becomes empty
if (isJira) {
setSelectedIssueId('')
} else if (isDiscord) {
setSelectedChannelId('')
} else if (isMicrosoftTeams) {
setSelectedMessageId('')
} else if (isGoogleCalendar) {
setSelectedCalendarId('')
} else if (isWealthbox) {
setSelectedWealthboxItemId('')
} else if (isMicrosoftSharePoint) {
setSelectedFileId('')
} else {
setSelectedFileId('')
}
}
}, [
blockId,
subBlock.id,
getValue,
isPreview,
previewValue,
storeValue,
isJira,
isDiscord,
isMicrosoftTeams,
isGoogleCalendar,
isWealthbox,
isMicrosoftSharePoint,
isPreview,
previewValue,
])
// Handle file selection
@@ -155,6 +183,10 @@ export function FileSelectorInput({
if (isJira) {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
// Also clear the manual issue key when cleared
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}
}
@@ -193,13 +225,22 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<GoogleCalendarSelector
value={selectedCalendarId}
onChange={handleCalendarChange}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedCalendarId(val)
setCalendarInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
label={subBlock.placeholder || 'Select Google Calendar'}
disabled={disabled || !credential}
showPreview={true}
onCalendarInfoChange={setCalendarInfo}
credentialId={credential}
workflowId={workflowIdFromUrl}
/>
</div>
</TooltipTrigger>
@@ -243,14 +284,23 @@ export function FileSelectorInput({
// Render the appropriate picker based on provider
if (isConfluence) {
const credential = (getValue(blockId, 'credential') as string) || ''
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<ConfluenceFileSelector
value={selectedFileId}
onChange={handleFileChange}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
provider='confluence'
requiredScopes={subBlock.requiredScopes || []}
@@ -259,6 +309,7 @@ export function FileSelectorInput({
disabled={disabled || !domain}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
credentialId={credential}
/>
</div>
</TooltipTrigger>
@@ -273,30 +324,52 @@ export function FileSelectorInput({
}
if (isJira) {
const credential = jiraCredential
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<JiraIssueSelector
value={selectedIssueId}
onChange={handleIssueChange}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedIssueId(val)
setIssueInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
domain={domain}
provider='jira'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={disabled || !domain}
disabled={
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
credentialId={credential}
projectId={(getValue(blockId, 'projectId') as string) || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!domain && (
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
)}
) : !credential ? (
<TooltipContent side='top'>
<p>Please select Jira credentials first</p>
</TooltipContent>
) : !(getValue(blockId, 'projectId') as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira project first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)
@@ -502,7 +575,11 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<TeamsMessageSelector
value={selectedMessageId}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(value, info) => {
setSelectedMessageId(value)
setMessageInfo(info || null)
@@ -547,8 +624,16 @@ export function FileSelectorInput({
<TooltipTrigger asChild>
<div className='w-full'>
<WealthboxFileSelector
value={selectedWealthboxItemId}
onChange={handleWealthboxItemChange}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedWealthboxItemId(val)
setWealthboxItemInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider='wealthbox'
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
@@ -556,6 +641,7 @@ export function FileSelectorInput({
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setWealthboxItemInfo}
credentialId={credential}
itemType={itemType}
/>
</div>
@@ -576,8 +662,16 @@ export function FileSelectorInput({
// Default to Google Drive picker
return (
<GoogleDrivePicker
value={selectedFileId}
onChange={handleFileChange}
value={
(isPreview && previewValue !== undefined
? (previewValue as string)
: (storeValue as string)) || ''
}
onChange={(val, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
collaborativeSetSubblockValue(blockId, subBlock.id, val)
}}
provider={provider}
requiredScopes={subBlock.requiredScopes || []}
label={subBlock.placeholder || 'Select file'}
@@ -588,6 +682,8 @@ export function FileSelectorInput({
onFileInfoChange={setFileInfo}
clientId={clientId}
apiKey={apiKey}
credentialId={(getValue(blockId, 'credential') as string) || ''}
workflowId={workflowIdFromUrl}
/>
)
}

View File

@@ -5,8 +5,9 @@ import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
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 { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
interface FolderSelectorInputProps {
blockId: string
@@ -23,7 +24,8 @@ export function FolderSelectorInput({
isPreview = false,
previewValue,
}: FolderSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
@@ -31,26 +33,27 @@ export function FolderSelectorInput({
useEffect(() => {
if (isPreview && previewValue !== undefined) {
setSelectedFolderId(previewValue)
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedFolderId(value)
} else {
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
if (!isPreview) {
setValue(blockId, subBlock.id, defaultValue)
}
}
return
}
}, [blockId, subBlock.id, getValue, setValue, isPreview, previewValue])
const current = storeValue as string | undefined
if (current && typeof current === 'string') {
setSelectedFolderId(current)
return
}
// Set default INBOX if empty
const defaultValue = 'INBOX'
setSelectedFolderId(defaultValue)
if (!isPreview) {
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
}
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
// Handle folder selection
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
setSelectedFolderId(folderId)
setFolderInfo(info || null)
if (!isPreview) {
setValue(blockId, subBlock.id, folderId)
collaborativeSetSubblockValue(blockId, subBlock.id, folderId)
}
}

View File

@@ -148,6 +148,9 @@ export function LongInput({
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
// Update cursor first to minimize state staleness for dropdown selection logic
setCursorPosition(newCursorPosition)
// Update local content immediately
setLocalContent(newValue)
@@ -158,8 +161,6 @@ export function LongInput({
setStoreValue(newValue)
}
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(envVarTrigger.show)

View File

@@ -48,6 +48,8 @@ interface JiraProjectSelectorProps {
domain: string
showPreview?: boolean
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
credentialId?: string
isForeignCredential?: boolean
}
export function JiraProjectSelector({
@@ -61,11 +63,12 @@ export function JiraProjectSelector({
domain,
showPreview = true,
onProjectInfoChange,
credentialId,
}: JiraProjectSelectorProps) {
const [open, setOpen] = useState(false)
const [credentials, setCredentials] = useState<Credential[]>([])
const [projects, setProjects] = useState<JiraProjectInfo[]>([])
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
const [selectedProjectId, setSelectedProjectId] = useState(value)
const [selectedProject, setSelectedProject] = useState<JiraProjectInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -124,25 +127,7 @@ export function JiraProjectSelector({
if (response.ok) {
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)
}
}
}
// Do not auto-select credentials. Only use the credentialId provided by the parent.
}
} catch (error) {
logger.error('Error fetching credentials:', error)
@@ -187,30 +172,34 @@ export function JiraProjectSelector({
return
}
// Build query parameters for the project endpoint
const queryParams = new URLSearchParams({
domain,
accessToken,
projectId,
...(cloudId && { cloudId }),
// Use POST /api/tools/jira/projects to fetch a single project by id
const response = await fetch(`/api/tools/jira/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, accessToken, projectId, cloudId }),
})
const response = await fetch(`/api/tools/jira/project?${queryParams.toString()}`)
if (!response.ok) {
const errorData = await response.json()
logger.error('Jira API error:', errorData)
throw new Error(errorData.error || 'Failed to fetch project details')
}
const projectInfo = await response.json()
const json = await response.json()
const projectInfo = json?.project
const newCloudId = json?.cloudId
if (projectInfo.cloudId) {
setCloudId(projectInfo.cloudId)
if (newCloudId) {
setCloudId(newCloudId)
}
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
if (projectInfo) {
setSelectedProject(projectInfo)
onProjectInfoChange?.(projectInfo)
} else {
setSelectedProject(null)
onProjectInfoChange?.(null)
}
} catch (error) {
logger.error('Error fetching project details:', error)
setError((error as Error).message)
@@ -329,17 +318,29 @@ export function JiraProjectSelector({
]
)
// Fetch credentials on initial mount
// Fetch credentials list when dropdown opens (for account switching UI), not on mount
useEffect(() => {
if (!initialFetchRef.current) {
if (open) {
fetchCredentials()
initialFetchRef.current = true
}
}, [fetchCredentials])
}, [open, fetchCredentials])
// Keep local credential state in sync with persisted credential
useEffect(() => {
if (credentialId && credentialId !== selectedCredentialId) {
setSelectedCredentialId(credentialId)
}
}, [credentialId, selectedCredentialId])
// Fetch the selected project metadata once credentials are ready or changed
useEffect(() => {
if (value && selectedCredentialId && !selectedProject && domain && domain.includes('.')) {
if (
value &&
selectedCredentialId &&
domain &&
domain.includes('.') &&
(!selectedProject || selectedProject.id !== value)
) {
fetchProjectInfo(value)
}
}, [value, selectedCredentialId, selectedProject, domain, fetchProjectInfo])
@@ -354,8 +355,9 @@ export function JiraProjectSelector({
// Handle open change
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
// Only fetch projects when a credential is present; otherwise, do nothing
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
fetchProjects('') // Pass empty string to get all projects
fetchProjects('')
}
}
@@ -394,13 +396,18 @@ export function JiraProjectSelector({
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !domain}
disabled={disabled || !domain || !selectedCredentialId}
>
{selectedProject ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProject.name}</span>
</div>
) : selectedProjectId ? (
<div className='flex items-center gap-2 overflow-hidden'>
<JiraIcon className='h-4 w-4' />
<span className='truncate font-normal'>{selectedProjectId}</span>
</div>
) : (
<div className='flex items-center gap-2'>
<JiraIcon className='h-4 w-4' />

View File

@@ -24,6 +24,7 @@ interface LinearProjectSelectorProps {
teamId: string
label?: string
disabled?: boolean
workflowId?: string
}
export function LinearProjectSelector({
@@ -33,6 +34,7 @@ export function LinearProjectSelector({
teamId,
label = 'Select Linear project',
disabled = false,
workflowId,
}: LinearProjectSelectorProps) {
const [projects, setProjects] = useState<LinearProjectInfo[]>([])
const [loading, setLoading] = useState(false)
@@ -49,7 +51,7 @@ export function LinearProjectSelector({
fetch('/api/tools/linear/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential, teamId }),
body: JSON.stringify({ credential, teamId, workflowId }),
signal: controller.signal,
})
.then(async (res) => {
@@ -80,7 +82,7 @@ export function LinearProjectSelector({
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, teamId, value])
}, [credential, teamId, value, workflowId])
// Sync selected project with value prop
useEffect(() => {

View File

@@ -23,6 +23,7 @@ interface LinearTeamSelectorProps {
credential: string
label?: string
disabled?: boolean
workflowId?: string
showPreview?: boolean
}
@@ -32,6 +33,7 @@ export function LinearTeamSelector({
credential,
label = 'Select Linear team',
disabled = false,
workflowId,
}: LinearTeamSelectorProps) {
const [teams, setTeams] = useState<LinearTeamInfo[]>([])
const [loading, setLoading] = useState(false)
@@ -48,7 +50,7 @@ export function LinearTeamSelector({
fetch('/api/tools/linear/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential }),
body: JSON.stringify({ credential, workflowId }),
signal: controller.signal,
})
.then((res) => {
@@ -76,7 +78,7 @@ export function LinearTeamSelector({
})
.finally(() => setLoading(false))
return () => controller.abort()
}, [credential, value])
}, [credential, value, workflowId])
// Sync selected team with value prop
useEffect(() => {

View File

@@ -21,7 +21,7 @@ import {
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 { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ProjectSelectorInputProps {
blockId: string
@@ -40,34 +40,73 @@ export function ProjectSelectorInput({
isPreview = false,
previewValue,
}: ProjectSelectorInputProps) {
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
// Local setters for related Jira fields to ensure immediate UI clearing
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
blockId,
'manualIssueKey'
)
// Reactive dependencies from store for Linear
const [linearCredential] = useSubBlockValue(blockId, 'credential')
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
// Get provider-specific values
const provider = subBlock.provider || 'jira'
const isDiscord = provider === 'discord'
const isLinear = provider === 'linear'
// For Jira, we need the domain
const domain = !isDiscord ? (getValue(blockId, 'domain') as string) || '' : ''
const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : ''
// Jira/Discord upstream fields
const [jiraDomain] = useSubBlockValue(blockId, 'domain')
const [jiraCredential] = useSubBlockValue(blockId, 'credential')
const domain = (jiraDomain as string) || ''
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(() => {
if (isPreview && previewValue !== undefined) {
setSelectedProjectId(previewValue)
} else if (typeof storeValue === 'string') {
setSelectedProjectId(storeValue)
} else {
const value = getValue(blockId, subBlock.id)
if (value && typeof value === 'string') {
setSelectedProjectId(value)
}
setSelectedProjectId('')
}
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
}, [isPreview, previewValue, storeValue])
// Handle project selection
const handleProjectChange = (
@@ -82,7 +121,12 @@ export function ProjectSelectorInput({
if (provider === 'jira') {
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
// Clear both the basic and advanced issue key fields to ensure UI resets
collaborativeSetSubblockValue(blockId, 'issueKey', '')
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
// Also clear locally for immediate UI feedback on this client
setIssueKeyValue('')
setManualIssueKeyValue('')
} else if (provider === 'discord') {
collaborativeSetSubblockValue(blockId, 'channelId', '')
} else if (provider === 'linear') {
@@ -139,15 +183,16 @@ export function ProjectSelectorInput({
onChange={(teamId: string, teamInfo?: LinearTeamInfo) => {
handleProjectChange(teamId, teamInfo)
}}
credential={getValue(blockId, 'credential') as string}
credential={(linearCredential as string) || ''}
label={subBlock.placeholder || 'Select Linear team'}
disabled={disabled || !getValue(blockId, 'credential')}
disabled={disabled || !(linearCredential as string)}
showPreview={true}
workflowId={activeWorkflowId || ''}
/>
) : (
(() => {
const credential = getValue(blockId, 'credential') as string
const teamId = getValue(blockId, 'teamId') as string
const credential = (linearCredential as string) || ''
const teamId = (linearTeamId as string) || ''
const isDisabled = disabled || !credential || !teamId
return (
<LinearProjectSelector
@@ -159,13 +204,14 @@ export function ProjectSelectorInput({
teamId={teamId}
label={subBlock.placeholder || 'Select Linear project'}
disabled={isDisabled}
workflowId={activeWorkflowId || ''}
/>
)
})()
)}
</div>
</TooltipTrigger>
{!getValue(blockId, 'credential') && (
{!(linearCredential as string) && (
<TooltipContent side='top'>
<p>Please select a Linear account first</p>
</TooltipContent>
@@ -189,17 +235,23 @@ export function ProjectSelectorInput({
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled}
disabled={disabled || !domain || !(jiraCredential as string)}
showPreview={true}
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
{!domain && (
{!domain ? (
<TooltipContent side='top'>
<p>Please enter a Jira domain first</p>
</TooltipContent>
)}
) : !(jiraCredential as string) ? (
<TooltipContent side='top'>
<p>Please select a Jira account first</p>
</TooltipContent>
) : null}
</Tooltip>
</TooltipProvider>
)

View File

@@ -106,6 +106,9 @@ export function ShortInput({
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart ?? 0
// Update cursor first to minimize state staleness for dropdown selection logic
setCursorPosition(newCursorPosition)
if (onChange) {
onChange(newValue)
} else if (!isPreview) {
@@ -113,8 +116,6 @@ export function ShortInput({
setStoreValue(newValue)
}
setCursorPosition(newCursorPosition)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)

View File

@@ -18,6 +18,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ToolCredentialSelector')
@@ -72,6 +73,7 @@ export function ToolCredentialSelector({
const [isLoading, setIsLoading] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [selectedId, setSelectedId] = useState('')
const { activeWorkflowId } = useWorkflowRegistry()
// Update selected ID when value changes
useEffect(() => {
@@ -86,26 +88,24 @@ export function ToolCredentialSelector({
const data = await response.json()
setCredentials(data.credentials || [])
// If we have a value but it's not in the credentials, reset it
if (value && !data.credentials?.some((cred: Credential) => cred.id === value)) {
onChange('')
}
// Auto-selection logic (like credential-selector):
// 1. If we already have a valid selection, keep it
// 2. If there's a default credential, select it
// 3. If there's only one credential, select it
// If persisted selection is not among viewer's credentials, attempt to fetch its metadata
if (
(!value || !data.credentials?.some((cred: Credential) => cred.id === value)) &&
data.credentials &&
data.credentials.length > 0
value &&
!(data.credentials || []).some((cred: Credential) => cred.id === value) &&
activeWorkflowId
) {
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
if (defaultCred) {
onChange(defaultCred.id)
} else if (data.credentials.length === 1) {
// If only one credential, select it
onChange(data.credentials[0].id)
try {
const metaResp = await fetch(
`/api/auth/oauth/credentials?credentialId=${value}&workflowId=${activeWorkflowId}`
)
if (metaResp.ok) {
const meta = await metaResp.json()
if (meta.credentials?.length) {
setCredentials([meta.credentials[0], ...(data.credentials || [])])
}
}
} catch {
// ignore
}
}
} else {
@@ -164,6 +164,7 @@ export function ToolCredentialSelector({
}
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
const isForeign = !!(selectedId && !selectedCredential)
return (
<>
@@ -177,17 +178,18 @@ export function ToolCredentialSelector({
disabled={disabled}
>
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
{selectedCredential ? (
<>
{getProviderIcon(provider)}
<span className='truncate font-normal'>{selectedCredential.name}</span>
</>
) : (
<>
{getProviderIcon(provider)}
<span className='truncate text-muted-foreground'>{label}</span>
</>
)}
{getProviderIcon(provider)}
<span
className={
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
}
>
{selectedCredential
? selectedCredential.name
: isForeign
? 'Saved by collaborator'
: label}
</span>
</div>
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>

View File

@@ -94,6 +94,8 @@ export function TriggerModal({
setSelectedCredentialId(credentialValue)
if (triggerDef.provider === 'gmail') {
loadGmailLabels(credentialValue)
} else if (triggerDef.provider === 'outlook') {
loadOutlookFolders(credentialValue)
}
}
}
@@ -139,6 +141,30 @@ export function TriggerModal({
}
}
// Load Outlook folders for the selected credential
const loadOutlookFolders = async (credentialId: string) => {
try {
const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`)
if (response.ok) {
const data = await response.json()
if (data.folders && Array.isArray(data.folders)) {
const folderOptions = data.folders.map((folder: any) => ({
id: folder.id,
name: folder.name,
}))
setDynamicOptions((prev) => ({
...prev,
folderIds: folderOptions,
}))
}
} else {
logger.error('Failed to load Outlook folders:', response.statusText)
}
} catch (error) {
logger.error('Error loading Outlook folders:', error)
}
}
// Generate webhook path and URL
useEffect(() => {
// For triggers that don't use webhooks (like Gmail polling), skip URL generation
@@ -152,15 +178,14 @@ export function TriggerModal({
// If no path exists, generate one automatically
if (!finalPath) {
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 8)
finalPath = `/${triggerDef.provider}/${timestamp}-${randomId}`
// Use UUID format consistent with other webhooks
finalPath = crypto.randomUUID()
setGeneratedPath(finalPath)
}
if (finalPath) {
const baseUrl = window.location.origin
setWebhookUrl(`${baseUrl}/api/webhooks/trigger${finalPath}`)
setWebhookUrl(`${baseUrl}/api/webhooks/trigger/${finalPath}`)
}
}, [triggerPath, triggerDef.provider, triggerDef.requiresCredentials, triggerDef.webhook])

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
@@ -66,59 +66,78 @@ export function TriggerConfig({
const [actualTriggerId, setActualTriggerId] = useState<string | null>(null)
// Check if webhook exists in the database (using existing webhook API)
useEffect(() => {
const refreshWebhookState = useCallback(async () => {
// Skip API calls in preview mode
if (isPreview) {
if (isPreview || !effectiveTriggerId) {
setIsLoading(false)
return
}
const checkWebhook = async () => {
setIsLoading(true)
try {
// Check if there's a webhook for this specific block
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
setTriggerId(webhook.id)
setActualTriggerId(webhook.provider)
setIsLoading(true)
try {
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
setTriggerId(webhook.id)
setActualTriggerId(webhook.provider)
// Update the path in the block state if it's different
if (webhook.path && webhook.path !== triggerPath) {
setTriggerPath(webhook.path)
}
if (webhook.path && webhook.path !== triggerPath) {
setTriggerPath(webhook.path)
}
// Update trigger config (from webhook providerConfig)
if (webhook.providerConfig) {
setTriggerConfig(webhook.providerConfig)
}
} else {
setTriggerId(null)
setActualTriggerId(null)
if (webhook.providerConfig) {
setTriggerConfig(webhook.providerConfig)
}
} else {
setTriggerId(null)
setActualTriggerId(null)
// Clear stale trigger data from store when no webhook found in database
if (triggerPath) {
setTriggerPath('')
logger.info('Cleared stale trigger path on page refresh - no webhook in database', {
blockId,
clearedPath: triggerPath,
})
}
if (triggerPath) {
setTriggerPath('')
logger.info('Cleared stale trigger path on page refresh - no webhook in database', {
blockId,
clearedPath: triggerPath,
})
}
}
} catch (error) {
logger.error('Error checking webhook:', { error })
} finally {
setIsLoading(false)
}
} catch (error) {
logger.error('Error checking webhook:', { error })
} finally {
setIsLoading(false)
}
}, [
isPreview,
effectiveTriggerId,
workflowId,
blockId,
triggerPath,
setTriggerPath,
setTriggerConfig,
])
if (effectiveTriggerId) {
checkWebhook()
// Initial load
useEffect(() => {
refreshWebhookState()
}, [refreshWebhookState])
// Re-check when collaborative store updates trigger fields (so other users' changes reflect)
// Avoid overriding local edits while the modal is open or when saving/deleting
useEffect(() => {
if (!isModalOpen && !isSaving && !isDeleting) {
refreshWebhookState()
}
}, [workflowId, blockId, isPreview, effectiveTriggerId])
}, [
storeTriggerId,
storeTriggerPath,
storeTriggerConfig,
isModalOpen,
isSaving,
isDeleting,
refreshWebhookState,
])
const handleOpenModal = () => {
if (isPreview || disabled) return

View File

@@ -1,6 +1,5 @@
export {
AirtableConfig,
DiscordConfig,
GenericConfig,
GithubConfig,
GmailConfig,

View File

@@ -1,125 +0,0 @@
import { Terminal } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle, CodeBlock, Input } from '@/components/ui'
import {
ConfigField,
ConfigSection,
InstructionsSection,
TestResultDisplay,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
interface DiscordConfigProps {
webhookName: string
setWebhookName: (name: string) => void
avatarUrl: string
setAvatarUrl: (url: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
}
const examplePayload = JSON.stringify(
{
content: 'Hello from Sim!',
username: 'Optional Custom Name',
avatar_url: 'https://example.com/avatar.png',
},
null,
2
)
export function DiscordConfig({
webhookName,
setWebhookName,
avatarUrl,
setAvatarUrl,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook, // Passed to TestResultDisplay
}: DiscordConfigProps) {
return (
<div className='space-y-4'>
<ConfigSection title='Discord Appearance (Optional)'>
<ConfigField
id='discord-webhook-name'
label='Webhook Name'
description='This name will be displayed as the sender of messages in Discord.'
>
<Input
id='discord-webhook-name'
value={webhookName}
onChange={(e) => setWebhookName(e.target.value)}
placeholder='Sim Bot'
disabled={isLoadingToken}
/>
</ConfigField>
<ConfigField
id='discord-avatar-url'
label='Avatar URL'
description="URL to an image that will be used as the webhook's avatar."
>
<Input
id='discord-avatar-url'
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder='https://example.com/avatar.png'
disabled={isLoadingToken}
type='url'
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true} // Discord can be tested via curl
/>
<InstructionsSection
title='Receiving Events from Discord (Incoming Webhook)'
tip='Create a webhook in Discord and paste its URL into the Webhook URL field above.'
>
<ol className='list-inside list-decimal space-y-1'>
<li>Go to Discord Server Settings {'>'} Integrations.</li>
<li>Click "Webhooks" then "New Webhook".</li>
<li>Customize the name and channel.</li>
<li>Click "Copy Webhook URL".</li>
<li>
Paste the copied Discord URL into the main <strong>Webhook URL</strong> field above.
</li>
<li>Your workflow triggers when Discord sends an event to that URL.</li>
</ol>
</InstructionsSection>
<InstructionsSection title='Sending Messages to Discord (Outgoing via this URL)'>
<p>
To send messages <i>to</i> Discord using the Sim Webhook URL (above), make a POST request
with a JSON body like this:
</p>
<CodeBlock language='json' code={examplePayload} className='mt-2 text-sm' />
<ul className='mt-3 list-outside list-disc space-y-1 pl-4'>
<li>Customize message appearance with embeds (see Discord docs).</li>
<li>Override the default username/avatar per request if needed.</li>
</ul>
</InstructionsSection>
<Alert>
<Terminal className='h-4 w-4' />
<AlertTitle>Security Note</AlertTitle>
<AlertDescription>
The Sim Webhook URL allows sending messages <i>to</i> Discord. Treat it like a password.
Don't share it publicly.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,5 +1,4 @@
export { AirtableConfig } from './airtable'
export { DiscordConfig } from './discord'
export { GenericConfig } from './generic'
export { GithubConfig } from './github'
export { GmailConfig } from './gmail'

View File

@@ -137,6 +137,10 @@ export function SlackConfig({
<li>Paste the Webhook URL (from above) into the "Request URL" field</li>
</ol>
</li>
<li>
Go to <strong>Install App</strong> in the left sidebar and install the app into your
desired Slack workspace and channel.
</li>
<li>Save changes in both Slack and here.</li>
</ol>
</InstructionsSection>

View File

@@ -12,7 +12,6 @@ import { createLogger } from '@/lib/logs/console/logger'
import {
AirtableConfig,
DeleteConfirmDialog,
DiscordConfig,
GenericConfig,
GithubConfig,
GmailConfig,
@@ -83,8 +82,7 @@ export function WebhookModal({
// Provider-specific state
const [whatsappVerificationToken, setWhatsappVerificationToken] = useState('')
const [githubContentType, setGithubContentType] = useState('application/json')
const [discordWebhookName, setDiscordWebhookName] = useState('')
const [discordAvatarUrl, setDiscordAvatarUrl] = useState('')
const [slackSigningSecret, setSlackSigningSecret] = useState('')
const [telegramBotToken, setTelegramBotToken] = useState('')
// Microsoft Teams-specific state
@@ -106,8 +104,7 @@ export function WebhookModal({
secretHeaderName: '',
requireAuth: false,
allowedIps: '',
discordWebhookName: '',
discordAvatarUrl: '',
airtableWebhookSecret: '',
airtableBaseId: '',
airtableTableId: '',
@@ -184,18 +181,6 @@ export function WebhookModal({
const contentType = config.contentType || 'application/json'
setGithubContentType(contentType)
setOriginalValues((prev) => ({ ...prev, githubContentType: contentType }))
} else if (webhookProvider === 'discord') {
const webhookName = config.webhookName || ''
const avatarUrl = config.avatarUrl || ''
setDiscordWebhookName(webhookName)
setDiscordAvatarUrl(avatarUrl)
setOriginalValues((prev) => ({
...prev,
discordWebhookName: webhookName,
discordAvatarUrl: avatarUrl,
}))
} else if (webhookProvider === 'generic') {
// Set general webhook configuration
const token = config.token || ''
@@ -328,9 +313,6 @@ export function WebhookModal({
(webhookProvider === 'whatsapp' &&
whatsappVerificationToken !== originalValues.whatsappVerificationToken) ||
(webhookProvider === 'github' && githubContentType !== originalValues.githubContentType) ||
(webhookProvider === 'discord' &&
(discordWebhookName !== originalValues.discordWebhookName ||
discordAvatarUrl !== originalValues.discordAvatarUrl)) ||
(webhookProvider === 'generic' &&
(generalToken !== originalValues.generalToken ||
secretHeaderName !== originalValues.secretHeaderName ||
@@ -357,8 +339,6 @@ export function WebhookModal({
webhookProvider,
whatsappVerificationToken,
githubContentType,
discordWebhookName,
discordAvatarUrl,
generalToken,
secretHeaderName,
requireAuth,
@@ -393,9 +373,7 @@ export function WebhookModal({
case 'github':
isValid = generalToken.trim() !== ''
break
case 'discord':
isValid = discordWebhookName.trim() !== ''
break
case 'telegram':
isValid = telegramBotToken.trim() !== ''
break
@@ -442,11 +420,6 @@ export function WebhookModal({
return { verificationToken: whatsappVerificationToken }
case 'github':
return { contentType: githubContentType }
case 'discord':
return {
webhookName: discordWebhookName || undefined,
avatarUrl: discordAvatarUrl || undefined,
}
case 'stripe':
return {}
case 'gmail':
@@ -539,8 +512,6 @@ export function WebhookModal({
secretHeaderName,
requireAuth,
allowedIps,
discordWebhookName,
discordAvatarUrl,
slackSigningSecret,
airtableWebhookSecret,
airtableBaseId,
@@ -738,20 +709,7 @@ export function WebhookModal({
setIncludeRawEmail={setIncludeRawEmail}
/>
)
case 'discord':
return (
<DiscordConfig
webhookName={discordWebhookName}
setWebhookName={setDiscordWebhookName}
avatarUrl={discordAvatarUrl}
setAvatarUrl={setDiscordAvatarUrl}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
/>
)
case 'stripe':
return (
<StripeConfig

View File

@@ -3,7 +3,6 @@ import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AirtableIcon,
DiscordIcon,
GithubIcon,
GmailIcon,
MicrosoftTeamsIcon,
@@ -46,11 +45,6 @@ export interface GitHubConfig {
contentType: string
}
export interface DiscordConfig {
webhookName?: string
avatarUrl?: string
}
export type StripeConfig = Record<string, never>
export interface GeneralWebhookConfig {
@@ -103,7 +97,6 @@ export interface MicrosoftTeamsConfig {
export type ProviderConfig =
| WhatsAppConfig
| GitHubConfig
| DiscordConfig
| StripeConfig
| GeneralWebhookConfig
| SlackConfig
@@ -219,25 +212,7 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
},
},
},
discord: {
id: 'discord',
name: 'Discord',
icon: (props) => <DiscordIcon {...props} />,
configFields: {
webhookName: {
type: 'string',
label: 'Webhook Name',
placeholder: 'Enter a name for the webhook',
description: 'Custom name that will appear as the message sender in Discord.',
},
avatarUrl: {
type: 'string',
label: 'Avatar URL',
placeholder: 'https://example.com/avatar.png',
description: 'URL to an image that will be used as the webhook avatar.',
},
},
},
stripe: {
id: 'stripe',
name: 'Stripe',

View File

@@ -157,8 +157,25 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
collaborativeToggleBlockWide,
collaborativeToggleBlockAdvancedMode,
collaborativeToggleBlockTriggerMode,
collaborativeSetSubblockValue,
} = useCollaborativeWorkflow()
// Clear credential-dependent fields when credential changes
const prevCredRef = useRef<string | undefined>(undefined)
useEffect(() => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id]
if (!current) return
const cred = current.credential?.value as string | undefined
if (prevCredRef.current !== cred) {
prevCredRef.current = cred
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
}, [id, collaborativeSetSubblockValue])
// Workflow store actions
const updateBlockHeight = useWorkflowStore((state) => state.updateBlockHeight)

View File

@@ -17,9 +17,20 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
getNodeAbsolutePosition,
getNodeDepth,
getNodeHierarchy,
isPointInLoopNode,
resizeLoopNodes,
updateNodeParent as updateNodeParentUtil,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { getBlock } from '@/blocks'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
@@ -31,17 +42,6 @@ import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { WorkflowBlock } from './components/workflow-block/workflow-block'
import { WorkflowEdge } from './components/workflow-edge/workflow-edge'
import { useCurrentWorkflow } from './hooks'
import {
getNodeAbsolutePosition,
getNodeDepth,
getNodeHierarchy,
isPointInLoopNode,
resizeLoopNodes,
updateNodeParent as updateNodeParentUtil,
} from './utils'
const logger = createLogger('Workflow')

View File

@@ -3,7 +3,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { BookOpen, Building2, LibraryBig, ScrollText, Search, Shapes, Workflow } from 'lucide-react'
import {
BookOpen,
Building2,
LibraryBig,
RepeatIcon,
ScrollText,
Search,
Shapes,
SplitIcon,
Workflow,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
@@ -119,7 +129,7 @@ export function SearchModal({
if (!isOnWorkflowPage) return []
const allBlocks = getAllBlocks()
return allBlocks
const regularBlocks = allBlocks
.filter(
(block) =>
block.type !== 'starter' &&
@@ -137,7 +147,28 @@ export function SearchModal({
type: block.type,
})
)
.sort((a, b) => a.name.localeCompare(b.name))
// Add special blocks (loop and parallel)
const specialBlocks: BlockItem[] = [
{
id: 'loop',
name: 'Loop',
description: 'Create a Loop',
icon: RepeatIcon,
bgColor: '#2FB3FF',
type: 'loop',
},
{
id: 'parallel',
name: 'Parallel',
description: 'Parallel Execution',
icon: SplitIcon,
bgColor: '#FEE12B',
type: 'parallel',
},
]
return [...regularBlocks, ...specialBlocks].sort((a, b) => a.name.localeCompare(b.name))
}, [isOnWorkflowPage])
// Get all available tools - only when on workflow page

View File

@@ -1,9 +1,13 @@
export { CreateMenu } from './create-menu/create-menu'
export { FolderTree } from './folder-tree/folder-tree'
export { HelpModal } from './help-modal/help-modal'
export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags'
export { KnowledgeTags } from './knowledge-tags/knowledge-tags'
export { LogsFilters } from './logs-filters/logs-filters'
export { SettingsModal } from './settings-modal/settings-modal'
export { SubscriptionModal } from './subscription-modal/subscription-modal'
export { Toolbar } from './toolbar/toolbar'
export { UsageIndicator } from './usage-indicator/usage-indicator'
export { WorkflowContextMenu } from './workflow-context-menu/workflow-context-menu'
export { WorkflowList } from './workflow-list/workflow-list'
export { WorkspaceHeader } from './workspace-header/workspace-header'

View File

@@ -0,0 +1,565 @@
'use client'
import { useEffect, useState } from 'react'
import { Eye, MoreHorizontal, Plus, Trash2, X } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
import { createLogger } from '@/lib/logs/console/logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
const logger = createLogger('KnowledgeBaseTags')
// Predetermined colors for each tag slot (same as document tags)
const TAG_SLOT_COLORS = [
'#701FFC', // Purple
'#FF6B35', // Orange
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#96CEB4', // Green
'#FFEAA7', // Yellow
'#DDA0DD', // Plum
'#FF7675', // Red
'#74B9FF', // Light Blue
'#A29BFE', // Lavender
] as const
interface KnowledgeBaseTagsProps {
knowledgeBaseId: string
}
interface TagUsageData {
tagName: string
tagSlot: string
documentCount: number
documents: Array<{ id: string; name: string; tagValue: string }>
}
export function KnowledgeBaseTags({ knowledgeBaseId }: KnowledgeBaseTagsProps) {
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const userPermissions = useUserPermissionsContext()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
const [isLoadingUsage, setIsLoadingUsage] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [createForm, setCreateForm] = useState({
displayName: '',
fieldType: 'text',
})
// Get color for a tag based on its slot
const getTagColor = (slot: string) => {
const slotMatch = slot.match(/tag(\d+)/)
const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0
return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length]
}
// Fetch tag usage data from API
const fetchTagUsage = async () => {
if (!knowledgeBaseId) return
setIsLoadingUsage(true)
try {
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
if (!response.ok) {
throw new Error('Failed to fetch tag usage')
}
const result = await response.json()
if (result.success) {
setTagUsageData(result.data)
}
} catch (error) {
logger.error('Error fetching tag usage:', error)
} finally {
setIsLoadingUsage(false)
}
}
// Load tag usage data when component mounts or knowledge base changes
useEffect(() => {
fetchTagUsage()
}, [knowledgeBaseId])
// Get usage data for a tag
const getTagUsage = (tagName: string): TagUsageData => {
return (
tagUsageData.find((usage) => usage.tagName === tagName) || {
tagName,
tagSlot: '',
documentCount: 0,
documents: [],
}
)
}
const handleDeleteTag = async (tag: TagDefinition) => {
setSelectedTag(tag)
// Fetch fresh usage data before showing the delete dialog
await fetchTagUsage()
setDeleteDialogOpen(true)
}
const handleViewDocuments = async (tag: TagDefinition) => {
setSelectedTag(tag)
// Fetch fresh usage data before showing the view documents dialog
await fetchTagUsage()
setViewDocumentsDialogOpen(true)
}
const openTagCreator = () => {
setCreateForm({
displayName: '',
fieldType: 'text',
})
setIsCreating(true)
}
const cancelCreating = () => {
setCreateForm({
displayName: '',
fieldType: 'text',
})
setIsCreating(false)
}
const hasNameConflict = (name: string) => {
if (!name.trim()) return false
return kbTagDefinitions.some(
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
)
}
// Check for conflicts in real-time during creation (but not while saving)
const nameConflict = isCreating && !isSaving && hasNameConflict(createForm.displayName)
const canSave = () => {
return createForm.displayName.trim() && !hasNameConflict(createForm.displayName)
}
const saveTagDefinition = async () => {
if (!canSave()) return
setIsSaving(true)
try {
// Find next available slot
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
const availableSlot = (
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
).find((slot) => !usedSlots.has(slot))
if (!availableSlot) {
throw new Error('No available tag slots')
}
// Create the tag definition
const newTagDefinition = {
tagSlot: availableSlot,
displayName: createForm.displayName.trim(),
fieldType: createForm.fieldType,
}
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTagDefinition),
})
if (!response.ok) {
throw new Error('Failed to create tag definition')
}
// Refresh tag definitions and usage data
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
// Reset form and close creator
setCreateForm({
displayName: '',
fieldType: 'text',
})
setIsCreating(false)
} catch (error) {
logger.error('Error creating tag definition:', error)
} finally {
setIsSaving(false)
}
}
const confirmDeleteTag = async () => {
if (!selectedTag) return
logger.info('Starting delete operation for:', selectedTag.displayName)
setIsDeleting(true)
try {
logger.info('Calling delete API for tag:', selectedTag.displayName)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
{
method: 'DELETE',
}
)
logger.info('Delete API response status:', response.status)
if (!response.ok) {
const errorText = await response.text()
logger.error('Delete API failed:', errorText)
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
}
logger.info('Delete API successful, refreshing data...')
// Refresh both tag definitions and usage data
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
logger.info('Data refresh complete, closing dialog')
// Only close dialog and reset state after successful deletion and refresh
setDeleteDialogOpen(false)
setSelectedTag(null)
logger.info('Delete operation completed successfully')
} catch (error) {
logger.error('Error deleting tag definition:', error)
// Don't close dialog on error - let user see the error and try again or cancel
} finally {
logger.info('Setting isDeleting to false')
setIsDeleting(false)
}
}
// Don't show if user can't edit
if (!userPermissions.canEdit) {
return null
}
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.displayName) : null
return (
<>
<div className='h-full w-full overflow-hidden'>
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='px-2 py-2'>
{/* KB Tag Definitions Section */}
<div className='mb-1 space-y-1'>
<div className='font-medium text-muted-foreground text-xs'>Knowledge Base Tags</div>
<div>
{/* Existing Tag Definitions */}
<div>
{kbTagDefinitions.length === 0 && !isCreating ? (
<div className='mb-1 rounded-[10px] border border-dashed bg-card p-3 text-center'>
<p className='text-muted-foreground text-xs'>
No tag definitions yet.
<br />
</p>
</div>
) : (
kbTagDefinitions.length > 0 &&
kbTagDefinitions.map((tag, index) => {
const usage = getTagUsage(tag.displayName)
return (
<div key={tag.id} className='mb-1'>
<div className='cursor-default rounded-[10px] border bg-card p-2 transition-colors'>
<div className='flex items-center justify-between text-sm'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='h-2 w-2 rounded-full'
style={{ backgroundColor: getTagColor(tag.tagSlot) }}
/>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{tag.displayName}</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
>
<MoreHorizontal className='h-3 w-3' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='w-[180px] rounded-lg border bg-card shadow-xs'
>
<DropdownMenuItem
onClick={() => handleViewDocuments(tag)}
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
>
<Eye className='mr-2 h-3 w-3 flex-shrink-0' />
<span className='whitespace-nowrap'>View Docs</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteTag(tag)}
className='cursor-pointer rounded-md px-3 py-2 text-red-600 text-sm hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950'
>
<Trash2 className='mr-2 h-3 w-3' />
Delete Tag
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
})
)}
</div>
{/* Add New Tag Button or Inline Creator */}
{!isCreating && userPermissions.canEdit && (
<div className='mb-1'>
<Button
variant='outline'
size='sm'
onClick={openTagCreator}
className='w-full justify-start gap-2 rounded-[10px] border border-dashed bg-card text-muted-foreground hover:text-foreground'
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
>
<Plus className='h-4 w-4' />
Add Tag Definition
</Button>
</div>
)}
{/* Inline Tag Creation Form */}
{isCreating && (
<div className='mb-1 w-full max-w-full space-y-2 rounded-[10px] border bg-card p-2'>
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<Label className='font-medium text-xs'>Tag Name</Label>
<Button
variant='ghost'
size='sm'
onClick={cancelCreating}
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-3 w-3' />
</Button>
</div>
<Input
value={createForm.displayName}
onChange={(e) =>
setCreateForm({ ...createForm, displayName: e.target.value })
}
placeholder='Enter tag name'
className='h-8 w-full rounded-md text-sm'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSave()) {
e.preventDefault()
saveTagDefinition()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelCreating()
}
}}
/>
{nameConflict && (
<div className='text-red-600 text-xs'>
A tag with this name already exists
</div>
)}
</div>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Type</Label>
<Select
value={createForm.fieldType}
onValueChange={(value) =>
setCreateForm({ ...createForm, fieldType: value })
}
>
<SelectTrigger className='h-8 w-full text-sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
</div>
{/* Action buttons */}
<div className='flex pt-1.5'>
<Button
size='sm'
onClick={saveTagDefinition}
className='h-7 w-full text-xs'
disabled={!canSave() || isSaving}
>
{isSaving ? 'Creating...' : 'Save'}
</Button>
</div>
</div>
)}
<div className='mt-2 text-muted-foreground text-xs'>
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<div className='mb-2'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
<span className='text-red-500 dark:text-red-500'>
This action cannot be undone.
</span>
</div>
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
<div className='mt-4'>
<div className='mb-2 font-medium text-sm'>Affected documents:</div>
<div className='rounded-md border border-border bg-background'>
<div className='max-h-32 overflow-y-auto'>
{selectedTagUsage.documents.slice(0, 5).map((doc, index) => {
const DocumentIcon = getDocumentIcon('', doc.name)
return (
<div
key={doc.id}
className='flex items-center gap-3 border-border/50 border-b p-3 last:border-b-0'
>
<DocumentIcon className='h-4 w-4 flex-shrink-0' />
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-sm'>{doc.name}</div>
{doc.tagValue && (
<div className='mt-1 text-muted-foreground text-xs'>
Tag value: <span className='font-medium'>{doc.tagValue}</span>
</div>
)}
</div>
</div>
)
})}
{selectedTagUsage.documentCount > 5 && (
<div className='flex items-center gap-3 p-3 text-muted-foreground text-sm'>
<div className='h-4 w-4' />
<div className='font-medium'>
and {selectedTagUsage.documentCount - 5} more documents...
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='flex'>
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<Button
onClick={confirmDeleteTag}
disabled={isDeleting}
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'
>
{isDeleting ? 'Deleting...' : 'Delete Tag'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* View Documents Dialog */}
<AlertDialog open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Documents using "{selectedTag?.displayName}"</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<div className='mb-4 text-muted-foreground'>
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
definition.
</div>
{selectedTagUsage?.documentCount === 0 ? (
<div className='rounded-md bg-muted/30 p-6 text-center'>
<div className='text-muted-foreground text-sm'>
This tag definition is not being used by any documents. You can safely delete
it to free up the tag slot.
</div>
</div>
) : (
<div className='rounded-md border border-border bg-background'>
<div className='max-h-80 overflow-y-auto'>
{selectedTagUsage?.documents.map((doc, index) => {
const DocumentIcon = getDocumentIcon('', doc.name)
return (
<div
key={doc.id}
className='flex items-center gap-3 border-border/50 border-b p-3 transition-colors last:border-b-0 hover:bg-muted/30'
>
<DocumentIcon className='h-4 w-4 flex-shrink-0' />
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-sm'>{doc.name}</div>
{doc.tagValue && (
<div className='mt-1 text-muted-foreground text-xs'>
Tag value: <span className='font-medium'>{doc.tagValue}</span>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,796 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, Plus, X } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
import { createLogger } from '@/lib/logs/console/logger'
import type { DocumentTag } from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('KnowledgeTags')
interface KnowledgeTagsProps {
knowledgeBaseId: string
documentId: string
}
// Predetermined colors for each tag slot
const TAG_SLOT_COLORS = [
'#701FFC', // Purple
'#FF6B35', // Orange
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#96CEB4', // Green
'#FFEAA7', // Yellow
'#DDA0DD', // Plum
'#FF7675', // Red
'#74B9FF', // Light Blue
'#A29BFE', // Lavender
] as const
export function KnowledgeTags({ knowledgeBaseId, documentId }: KnowledgeTagsProps) {
const { getCachedDocuments, updateDocument: updateDocumentInStore } = useKnowledgeStore()
const userPermissions = useUserPermissionsContext()
// Use different hooks based on whether we have a documentId
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
// Use the document-level hook since we have documentId
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
const [documentData, setDocumentData] = useState<DocumentData | null>(null)
const [isLoadingDocument, setIsLoadingDocument] = useState(true)
const [error, setError] = useState<string | null>(null)
// Inline editing state
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [editForm, setEditForm] = useState({
displayName: '',
fieldType: 'text',
value: '',
})
// Function to build document tags from data and definitions
const buildDocumentTags = useCallback(
(docData: DocumentData, definitions: TagDefinition[], currentTags?: DocumentTag[]) => {
const tags: DocumentTag[] = []
const tagSlots = TAG_SLOTS
tagSlots.forEach((slot) => {
const value = docData[slot] as string | null | undefined
const definition = definitions.find((def) => def.tagSlot === slot)
const currentTag = currentTags?.find((tag) => tag.slot === slot)
// Only include tag if the document has a value AND a corresponding KB tag definition exists
if (value?.trim() && definition) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: value.trim(),
})
}
})
return tags
},
[]
)
// Handle tag updates (local state only, no API calls)
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
// Only update local state, don't save to API
setDocumentTags(newTags)
}, [])
// Handle saving document tag values to the API
const handleSaveDocumentTags = useCallback(
async (tagsToSave: DocumentTag[]) => {
if (!documentData) return
try {
// Convert DocumentTag array to tag data for API
const tagData: Record<string, string> = {}
const tagSlots = TAG_SLOTS
// Clear all tags first
tagSlots.forEach((slot) => {
tagData[slot] = ''
})
// Set values from tagsToSave
tagsToSave.forEach((tag) => {
if (tag.value.trim()) {
tagData[tag.slot] = tag.value.trim()
}
})
// Update document via API
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(tagData),
})
if (!response.ok) {
throw new Error('Failed to update document tags')
}
// Update the document in the store and local state
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
setDocumentData((prev) => (prev ? { ...prev, ...tagData } : null))
// Refresh tag definitions to update the display
await fetchTagDefinitions()
} catch (error) {
logger.error('Error updating document tags:', error)
throw error // Re-throw so the component can handle it
}
},
[documentData, knowledgeBaseId, documentId, updateDocumentInStore, fetchTagDefinitions]
)
// Handle removing a tag
const handleRemoveTag = async (index: number) => {
const updatedTags = documentTags.filter((_, i) => i !== index)
handleTagsChange(updatedTags)
// Persist the changes
try {
await handleSaveDocumentTags(updatedTags)
} catch (error) {
// Handle error silently - the UI will show the optimistic update
// but the user can retry if needed
}
}
// Toggle inline editor for existing tag
const toggleTagEditor = (index: number) => {
if (editingTagIndex === index) {
// Already editing this tag - collapse it
cancelEditing()
} else {
// Start editing this tag
const tag = documentTags[index]
setEditingTagIndex(index)
setEditForm({
displayName: tag.displayName,
fieldType: tag.fieldType,
value: tag.value,
})
setIsCreating(false)
}
}
// Open inline creator for new tag
const openTagCreator = () => {
setEditingTagIndex(null)
setEditForm({
displayName: '',
fieldType: 'text',
value: '',
})
setIsCreating(true)
}
// Save tag (create or edit)
const saveTag = async () => {
if (!editForm.displayName.trim() || !editForm.value.trim()) return
// Close the edit form immediately and set saving flag
const formData = { ...editForm }
const currentEditingIndex = editingTagIndex
// Capture original tag data before updating
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
setEditingTagIndex(null)
setIsCreating(false)
setIsSaving(true)
try {
let targetSlot: string
if (currentEditingIndex !== null && originalTag) {
// EDIT MODE: Editing existing tag - use existing slot
targetSlot = originalTag.slot
} else {
// CREATE MODE: Check if using existing definition or creating new one
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (existingDefinition) {
// Using existing definition - use its slot
targetSlot = existingDefinition.tagSlot
} else {
// Creating new definition - get next available slot from server
const serverSlot = await getServerNextSlot(formData.fieldType)
if (!serverSlot) {
throw new Error(`No available slots for new tag of type '${formData.fieldType}'`)
}
targetSlot = serverSlot
}
}
// Update the tags array
let updatedTags: DocumentTag[]
if (currentEditingIndex !== null) {
// Editing existing tag
updatedTags = [...documentTags]
updatedTags[currentEditingIndex] = {
...updatedTags[currentEditingIndex],
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
} else {
// Creating new tag
const newTag: DocumentTag = {
slot: targetSlot,
displayName: formData.displayName,
fieldType: formData.fieldType,
value: formData.value,
}
updatedTags = [...documentTags, newTag]
}
handleTagsChange(updatedTags)
// Handle tag definition creation/update based on edit mode
if (currentEditingIndex !== null && originalTag) {
// EDIT MODE: Always update existing definition, never create new slots
const currentDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
)
if (currentDefinition) {
const updatedDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
tagSlot: currentDefinition.tagSlot, // Keep existing slot
_originalDisplayName: originalTag.displayName, // Tell server which definition to update
}
if (saveTagDefinitions) {
await saveTagDefinitions([updatedDefinition])
}
await refreshTagDefinitions()
}
} else {
// CREATE MODE: Adding new tag
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
)
if (!existingDefinition) {
// Create new definition
const newDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: formData.fieldType,
tagSlot: targetSlot as TagSlot,
}
if (saveTagDefinitions) {
await saveTagDefinitions([newDefinition])
}
await refreshTagDefinitions()
}
}
// Save the actual document tags
await handleSaveDocumentTags(updatedTags)
// Reset form
setEditForm({
displayName: '',
fieldType: 'text',
value: '',
})
} catch (error) {
logger.error('Error saving tag:', error)
} finally {
setIsSaving(false)
}
}
// Check if tag name already exists on this document
const hasNameConflict = (name: string) => {
if (!name.trim()) return false
return documentTags.some((tag, index) => {
// When editing, don't consider the current tag being edited as a conflict
if (editingTagIndex !== null && index === editingTagIndex) {
return false
}
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
})
}
// Get color for a tag based on its slot
const getTagColor = (slot: string) => {
// Extract slot number from slot string (e.g., "tag1" -> 1, "tag2" -> 2, etc.)
const slotMatch = slot.match(/tag(\d+)/)
const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0
return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length]
}
const cancelEditing = () => {
setEditForm({
displayName: '',
fieldType: 'text',
value: '',
})
setEditingTagIndex(null)
setIsCreating(false)
}
// Filter available tag definitions - exclude all used tag names on this document
const availableDefinitions = kbTagDefinitions.filter((def) => {
// Always exclude all already used tag names (including current tag being edited)
return !documentTags.some(
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
)
})
useEffect(() => {
const fetchDocument = async () => {
try {
setIsLoadingDocument(true)
setError(null)
const cachedDocuments = getCachedDocuments(knowledgeBaseId)
const cachedDoc = cachedDocuments?.documents?.find((d) => d.id === documentId)
if (cachedDoc) {
setDocumentData(cachedDoc)
// Initialize tags from cached document
const initialTags = buildDocumentTags(cachedDoc, tagDefinitions)
setDocumentTags(initialTags)
setIsLoadingDocument(false)
return
}
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Document not found')
}
throw new Error(`Failed to fetch document: ${response.statusText}`)
}
const result = await response.json()
if (result.success) {
setDocumentData(result.data)
// Initialize tags from fetched document
const initialTags = buildDocumentTags(result.data, tagDefinitions, [])
setDocumentTags(initialTags)
} else {
throw new Error(result.error || 'Failed to fetch document')
}
} catch (err) {
logger.error('Error fetching document:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsLoadingDocument(false)
}
}
if (knowledgeBaseId && documentId) {
fetchDocument()
}
}, [knowledgeBaseId, documentId, getCachedDocuments, buildDocumentTags])
// Separate effect to rebuild tags when tag definitions change (without re-fetching document)
useEffect(() => {
if (documentData && !isSaving) {
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions, documentTags)
setDocumentTags(rebuiltTags)
}
}, [documentData, tagDefinitions, buildDocumentTags, isSaving])
if (isLoadingDocument) {
return (
<div className='h-full'>
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='px-2 py-2'>
<div className='h-20 animate-pulse rounded-md bg-muted' />
</div>
</ScrollArea>
</div>
)
}
if (error || !documentData) {
return null // Don't show anything if there's an error or no document
}
const isEditing = editingTagIndex !== null || isCreating
const nameConflict = hasNameConflict(editForm.displayName)
// Check if there are actual changes (for editing mode)
const hasChanges = () => {
if (editingTagIndex === null) return true // Creating new tag always has changes
const originalTag = documentTags[editingTagIndex]
if (!originalTag) return true
return (
originalTag.displayName !== editForm.displayName ||
originalTag.value !== editForm.value ||
originalTag.fieldType !== editForm.fieldType
)
}
// Check if save should be enabled
const canSave =
editForm.displayName.trim() && editForm.value.trim() && !nameConflict && hasChanges()
return (
<div className='h-full w-full overflow-hidden'>
<ScrollArea className='h-full' hideScrollbar={true}>
<div className='px-2 py-2'>
{/* Document Tags Section */}
<div className='mb-1 space-y-1'>
<div className='font-medium text-muted-foreground text-xs'>Document Tags</div>
<div>
{/* Existing Tags */}
<div>
{documentTags.map((tag, index) => {
return (
<div key={index} className='mb-1'>
<div
className={`cursor-pointer rounded-[10px] border bg-card transition-colors ${editingTagIndex === index ? 'space-y-2 p-2' : 'p-2'}`}
onClick={() => userPermissions.canEdit && toggleTagEditor(index)}
>
{/* Always show the tag display */}
<div className='flex items-center justify-between text-sm'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div
className='h-2 w-2 rounded-full'
style={{ backgroundColor: getTagColor(tag.slot) }}
/>
<div className='truncate font-medium'>{tag.displayName}</div>
</div>
{userPermissions.canEdit && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleRemoveTag(index)
}}
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-3 w-3' />
</Button>
)}
</div>
{/* Show edit form when this tag is being edited */}
{editingTagIndex === index && (
<div className='space-y-1.5' onClick={(e) => e.stopPropagation()}>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Tag Name</Label>
<div className='flex gap-1.5'>
<Input
value={editForm.displayName}
onChange={(e) =>
setEditForm({ ...editForm, displayName: e.target.value })
}
placeholder='Enter tag name'
className='h-8 min-w-0 flex-1 rounded-md text-sm'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSave) {
e.preventDefault()
saveTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditing()
}
}}
/>
{availableDefinitions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-8 w-7 flex-shrink-0 p-0'
>
<ChevronDown className='h-3 w-3' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='w-[160px] rounded-lg border bg-card shadow-xs'
>
{availableDefinitions.map((def) => (
<DropdownMenuItem
key={def.id}
onClick={() =>
setEditForm({
...editForm,
displayName: def.displayName,
fieldType: def.fieldType,
})
}
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
>
{def.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{nameConflict && (
<div className='text-red-600 text-xs'>
A tag with this name already exists on this document
</div>
)}
</div>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Type</Label>
<Select
value={editForm.fieldType}
onValueChange={(value) =>
setEditForm({ ...editForm, fieldType: value })
}
disabled={editingTagIndex !== null} // Disable in edit mode
>
<SelectTrigger className='h-8 w-full text-sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Value</Label>
<Input
value={editForm.value}
onChange={(e) =>
setEditForm({ ...editForm, value: e.target.value })
}
placeholder='Enter tag value'
className='h-8 w-full rounded-md text-sm'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSave) {
e.preventDefault()
saveTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditing()
}
}}
/>
</div>
<div className='pt-1'>
<Button
onClick={saveTag}
size='sm'
className='h-7 w-full text-xs'
disabled={!canSave}
>
Save Changes
</Button>
</div>
</div>
)}
</div>
</div>
)
})}
</div>
{documentTags.length === 0 && !isCreating && (
<div className='mb-1 rounded-[10px] border border-dashed bg-card p-3 text-center'>
<p className='text-muted-foreground text-xs'>No tags added yet.</p>
</div>
)}
{/* Add New Tag Button or Inline Creator */}
{!isEditing && userPermissions.canEdit && (
<div className='mb-1'>
<Button
variant='outline'
size='sm'
onClick={openTagCreator}
className='w-full justify-start gap-2 rounded-[10px] border border-dashed bg-card text-muted-foreground hover:text-foreground'
disabled={
kbTagDefinitions.length >= MAX_TAG_SLOTS && availableDefinitions.length === 0
}
>
<Plus className='h-4 w-4' />
Add Tag
</Button>
</div>
)}
{/* Inline Tag Creation Form */}
{isCreating && (
<div className='mb-1 w-full max-w-full space-y-2 rounded-[10px] border bg-card p-2'>
<div className='space-y-1.5'>
<div className='flex items-center justify-between'>
<Label className='font-medium text-xs'>Tag Name</Label>
<Button
variant='ghost'
size='sm'
onClick={cancelEditing}
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-3 w-3' />
</Button>
</div>
<div className='flex gap-1.5'>
<Input
value={editForm.displayName}
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
placeholder='Enter tag name'
className='h-8 min-w-0 flex-1 rounded-md text-sm'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSave) {
e.preventDefault()
saveTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditing()
}
}}
/>
{availableDefinitions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='h-8 w-7 flex-shrink-0 p-0'
>
<ChevronDown className='h-3 w-3' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className='w-[160px] rounded-lg border bg-card shadow-xs'
>
{availableDefinitions.map((def) => (
<DropdownMenuItem
key={def.id}
onClick={() =>
setEditForm({
...editForm,
displayName: def.displayName,
fieldType: def.fieldType,
})
}
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
>
{def.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{nameConflict && (
<div className='text-red-600 text-xs'>
A tag with this name already exists on this document
</div>
)}
</div>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Type</Label>
<Select
value={editForm.fieldType}
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
>
<SelectTrigger className='h-8 w-full text-sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-1.5'>
<Label className='font-medium text-xs'>Value</Label>
<Input
value={editForm.value}
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
placeholder='Enter tag value'
className='h-8 w-full rounded-md text-sm'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSave) {
e.preventDefault()
saveTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditing()
}
}}
/>
</div>
{/* Warning when at max slots */}
{kbTagDefinitions.length >= MAX_TAG_SLOTS && (
<div className='rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-950'>
<div className='text-amber-800 text-xs dark:text-amber-200'>
<span className='font-medium'>Maximum tag definitions reached</span>
</div>
<p className='text-amber-700 text-xs dark:text-amber-300'>
You can still use existing tag definitions, but cannot create new ones.
</p>
</div>
)}
<div className='pt-2'>
<Button
onClick={saveTag}
size='sm'
className='h-7 w-full text-xs'
disabled={
!canSave ||
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
!kbTagDefinitions.find(
(def) =>
def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
))
}
>
Create Tag
</Button>
</div>
</div>
)}
<div className='mt-2 text-muted-foreground text-xs'>
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
</div>
</div>
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import {
UserCircle,
Users,
} from 'lucide-react'
import { isDev } from '@/lib/environment'
import { getEnv } from '@/lib/env'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -40,7 +40,7 @@ type NavigationItem = {
| 'privacy'
label: string
icon: React.ComponentType<{ className?: string }>
hideInDev?: boolean
hideWhenBillingDisabled?: boolean
requiresTeam?: boolean
}
@@ -79,13 +79,13 @@ const allNavigationItems: NavigationItem[] = [
id: 'subscription',
label: 'Subscription',
icon: CreditCard,
hideInDev: true,
hideWhenBillingDisabled: true,
},
{
id: 'team',
label: 'Team',
icon: Users,
hideInDev: true,
hideWhenBillingDisabled: true,
requiresTeam: true,
},
]
@@ -98,8 +98,11 @@ export function SettingsNavigation({
const { getSubscriptionStatus } = useSubscriptionStore()
const subscription = getSubscriptionStatus()
// Get billing status
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
const navigationItems = allNavigationItems.filter((item) => {
if (item.hideInDev && isDev) {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { client } from '@/lib/auth-client'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
@@ -44,6 +44,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
// Get billing status
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
useEffect(() => {
async function loadAllSettings() {
if (!open) return
@@ -82,7 +85,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}, [onOpenChange])
const isSubscriptionEnabled = !!client.subscription
// Redirect away from billing tabs if billing is disabled
useEffect(() => {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
setActiveSection('general')
}
}, [activeSection])
const isSubscriptionEnabled = isBillingEnabled
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -134,9 +144,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<Subscription onOpenChange={onOpenChange} />
</div>
)}
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
{isBillingEnabled && (
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
<TeamManagement />
</div>
)}
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
<Privacy />
</div>

Some files were not shown because too many files have changed in this diff Show More