feat(files): folders, multiselect, vfs update (#4572)

* v0.6.29: login improvements, posthog telemetry (#4026)

* feat(posthog): Add tracking on mothership abort (#4023)

Co-authored-by: Theodore Li <theo@sim.ai>

* fix(login): fix captcha headers for manual login  (#4025)

* fix(signup): fix turnstile key loading

* fix(login): fix captcha header passing

* Catch user already exists, remove login form captcha

* feat(files): folders + vfs update

* address comments

* address comments

* cleanup unnused code

* address comments

* perf improvements

* address next set

* cycle detect

* error handling

* path improvements

* cleanup, best practices

* react query best practices: targeted invalidation, optimistic updates, key factory hierarchy

- Add workspaceLists(workspaceId) intermediate key level to both workspaceFilesKeys
  and workspaceFileFolderKeys so invalidation targets only the affected workspace
  instead of all workspaces
- Replace all lists() invalidation calls with workspaceLists(workspaceId) across
  every mutation (upload, rename, delete, restore, update content, folder mutations)
- Add optimistic updates to useRenameWorkspaceFile and useUpdateWorkspaceFileFolder
  with onMutate snapshot, onError rollback, onSettled reconciliation
- Move storage key into the content() factory as optional param so query keys are
  always built through the factory (useWorkspaceFileContent, useWorkspaceFileBinary)
- Fix AnimatePresence wrapping in FilesActionBar so exit animation fires on deselect
- Fix ResourceColGroup to use percentage weights instead of pixel widths to prevent
  horizontal scroll on narrow viewports

* add shift-click range selection and selection-aware context menu for files

- Extend SelectableConfig.onSelectRow with optional shiftKey param; DataRow captures shiftKey before onCheckedChange fires via a ref so the Radix Checkbox interaction chain stays intact
- Implement shift-click range selection in files.tsx using lastSelectedIndexRef; tracks last-selected index in visibleRowIds to compute the range
- Reset lastSelectedIndexRef on deselect and select-all
- Add selectedCount prop to FileRowContextMenu; hide Open and Rename when multiple items are selected, show "Delete N items" / "Download N items" labels in multi-select mode

* add Move submenu to file context menu and fix shift-click anchor update

- Add nested Move submenu to FileRowContextMenu using DropdownMenuSub/SubTrigger/SubContent; shows available folders filtered by selection, converts '__root__' -> null for moving to the root level
- Add handleContextMenuMove in files.tsx that calls moveItems.mutateAsync directly (no modal) and clears selection on success
- Fix shift-click range selection: update lastSelectedIndexRef after range select so chained shift-clicks extend from the new anchor point correctly

* fix move submenu: use folder names with tree-ordered indentation instead of stale paths

- Compute folder depth from parentId chain client-side (avoids stale server-computed path field)
- Tree-order folders so parents appear before their children, sorted by sortOrder then name
- Show folder.name instead of folder.path so optimistic renames are reflected immediately
- Indent each folder by depth * 12px in the submenu so po/shit renders as 'shit' indented under 'po'
- MoveOption gains optional depth field; contextMenuMoveOptions is a separate memo from moveFolderOptions (modal keeps its existing path-label behavior)

* fix shift-click anchor drift and remove dead stopPropagation constant

- Remove dead stopPropagation const in resource.tsx (replaced by handleSelectRowClick)
- Reset lastSelectedIndexRef when visibleRowIds changes so search/filter/folder navigation doesn't leave a stale anchor that produces wrong ranges on the next shift-click
- Update lastSelectedIndexRef in handleRowContextMenu when right-clicking resets selection to a single item, so the anchor matches the newly-selected row
- Add visibleRowIds to handleRowContextMenu deps (now reads it to compute anchor index)
- Remove moveItems.mutateAsync from handleContextMenuMove deps per project convention (.mutateAsync is stable in TanStack v5)

* complete workspace files feature: audit logs, posthog events, folder restore, empty state, keyboard shortcuts, storage indicator, breadcrumb rename

- Audit + PostHog: wire file_renamed, file_deleted, file_moved, file_bulk_deleted, folder_created, folder_renamed, folder_deleted, folder_moved events to all file/folder API routes
- Add AuditAction.FOLDER_UPDATED, FILE_MOVED, FOLDER_MOVED to audit types
- Folder restore: server function, contract, API route (POST /files/folders/[folderId]/restore), hook (useRestoreWorkspaceFileFolder), Recently Deleted integration with new File Folders tab
- Empty state: contextual emptyMessage passed to <Resource> based on search/filters/folder context
- Keyboard shortcuts: Delete/Backspace deletes selection, Escape deselects, Cmd+A selects all (list view only, input-aware guard)
- Storage indicator: useStorageInfo drives compact "used / limit" display in file list header via leadingActions
- Breadcrumb rename: current folder breadcrumb gains Rename dropdown + inline editing via breadcrumbRename (useInlineRename)
- Resource: thread leadingActions prop from ResourceProps to ResourceHeader

* cleanup: accessibility, emcn design tokens, react best practices across workspace UI

- Add sr-only ModalDescription to dialogs/modals for accessibility
- Replace hardcoded colors and z-indices with design token CSS variables
- Apply emcn design review fixes across tables, knowledge, logs, settings, workflows

* fix audit and posthog: FOLDER_RESTORED action on restore, fire folder_moved event separately from file_moved

* sidebar: add Files section with nested folder tree; polish move UX and cleanup

- Files section in sidebar shows folder/file tree with expand/collapse,
  matching Workflows section structure; collapsed sidebar shows flyout menu
- Move action bar now uses nested DropdownMenuSub tree instead of flat modal
- Context menu and action bar share renderMoveOption from move-options.tsx
- FolderInput added to emcn icons barrel; all FolderInput imports migrated
- Drag ghost uses CSS vars (--border, --shadow-medium, --z-toast)
- Selection pruning converted from useEffect to render-time comparison
- Keyboard listener stabilized with handleBulkDeleteRef pattern
- toError() used consistently in restore and move route handlers

* remove Files section from sidebar

* restore Files nav item in sidebar workspace section

* fix infinite re-render on files page - revert selection pruning to useEffect

* add filefolder resource type for ingesting workspace file folders

* export filefolder tree types; add toast feedback for file/folder mutations

* regenerate migration as 0208 after rebase onto staging

* add workspaceFileFolder to schema mock

* add FILE_MOVED, FOLDER_MOVED, FOLDER_UPDATED to audit mock

* add filefolder ChatContext kind and wire through schema and resolver

* add filefolder to AgentContextType

* add filefolder to chat context kind registry; fix resolver to use workspaceFiles table

* add .deepsec to gitignore

* cleanup: effect, emcn tokens, mutation error handling

- Replace selection-pruning useEffect with inline state adjustment during render
- Fix drag overlay using invalid --accent HSL token → --brand-secondary; z-50 → z-[var(--z-dropdown)]
- Move static inline styles on context menu trigger div to className
- Add missing onError toast to useUpdateWorkspaceFileFolder, useRestoreWorkspaceFileFolder, useRestoreWorkspaceFile

* lint

* fix: remove duplicate handleCopilotStopGeneration from rebase

* feat(copilot): folder-aware file context in WORKSPACE.md

* feat(copilot): add move operation to file manage API

* fix(files): make targetFolder optional in move file contract

* perf(files): parallelize buffer fetches, fix N+1 folder queries, stabilize drag useMemo

- download route: fan out all fetchWorkspaceFileBuffer calls with Promise.all
  before zip assembly so 100 files resolve in one round-trip instead of sequentially
- getWorkspaceFileFolder: replace per-ancestor SELECTs with a single workspace-wide
  folder load + buildWorkspaceFileFolderPathMap, making depth irrelevant to query count
- ensureWorkspaceFileFolderPath: pre-load all workspace folders in one SELECT before
  the segment loop; resolve existing segments from an in-memory map; only hit the DB
  to CREATE missing segments; conflict retry path preserved and also updates the map
- files.tsx rowDragDropConfig: move activeDropTargetId into a ref so the useMemo
  does not recompute on every drag-over event

* fix(files): remove files/ path stripping, fix stale path in optimistic update

- splitWorkspaceFilePath: remove the unconditional .replace(/^files\//, '')
  that clobbered paths for files inside a folder literally named "files"
- useUpdateWorkspaceFileFolder: when a name update is in flight, recompute
  the path field for the renamed folder (replace last segment) and propagate
  the new prefix to all descendant folders so breadcrumbs stay correct
  during the optimistic window

* fix(files): revert broken ref opt, clean 409 on restore, null parentId on orphaned restore

- files.tsx: revert the activeDropTargetId ref optimization — the ref doesn't
  trigger re-renders so the drop-target highlight never updated during drag;
  activeDropTargetId is back in state and in the rowDragDropConfig deps
- restore/route.ts: catch Postgres 23505 unique-constraint violation and
  return a clean 409 instead of leaking the raw error as 400
- restoreWorkspaceFileFolder: check if the parent folder is still archived
  before restoring; if it is, restore to root (parentId: null) so the folder
  is never orphaned under an archived parent

* feat(search): show folder path for files in cmd-k modal, strip extraneous comments

- FileItem interface with folderPath?: string[] added to search modal utils
- MemoizedFileItem component renders folder breadcrumb identically to
  MemoizedWorkflowItem — truncated path segments on the right with / separators
- FilesGroup rewritten as a dedicated memo component (was createIconGroup factory)
  so it accepts FileItem[] and includes folderPath segments in the search value
- searchModalFiles in sidebar splits f.folderPath string into string[] segments
- search-modal.tsx typed to FileItem and includes folderPath in filterAndSort
- Remove self-explanatory "Phase 1" section label from download route
- Remove redundant TSDoc on the unique index in db schema

* fix(workspace-files): audit fixes — transaction, status codes, contract refinements, guards

* fix(vfs): pass folderPath separately so buildWorkspaceMd groups files correctly

* fix(types): narrow unknown fileInput with Record cast after object guard

* fix(routes): replace instanceof Error with toError() across new workspace file routes

* improvement(files): cleanup pass — remove unnecessary useCallbacks, consolidate emcn icon imports

- Remove useCallback from 5 drag-event handlers in DataRow (passed to native <tr> elements, no observer)
- Remove stable useCallback fns from 3 useMemo deps arrays in files.tsx (editingId/editValue remain)
- Merge all @/components/emcn/icons subpath imports into barrel (files.tsx, action-bar, file-row-context-menu)

* fix(files): apply activeSort to folders, reject drop onto current parent folder

- visibleFolders now respects activeSort column (name/updated/created) and direction
  so folder ordering stays consistent with file ordering
- isInvalidDropTarget now returns true when all dragged items are already direct children
  of the target folder, preventing a no-op move mutation

* fix breadcrumb

* add new tools to rename, create, delete folders

* move more ui actions into orchestration dir

* address comments

* fix params

* fix tests

* address comments

* improve error codes

* address comments

* address more nits

* fix mcp server error code

---------

Co-authored-by: Theodore Li <theodoreqili@gmail.com>
Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2026-05-15 12:05:03 -07:00
committed by GitHub
parent bad21cb23d
commit c9118e775b
205 changed files with 11125 additions and 3436 deletions
+1
View File
@@ -84,3 +84,4 @@ i18n.cache
.claude/launch.json
.claude/worktrees/
.claude/scheduled_tasks.lock
.deepsec/
@@ -10,6 +10,7 @@ import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalTitle,
ModalTrigger,
} from '@/components/emcn'
@@ -134,6 +135,9 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
<ModalTitle className='sr-only'>
{view === 'login' ? 'Log in' : 'Create account'}
</ModalTitle>
<ModalDescription className='sr-only'>
{view === 'login' ? 'Sign in to your account' : 'Create a new account'}
</ModalDescription>
<div className='relative px-6 pt-6 pb-6'>
<ModalClose className='absolute top-6 right-6 rounded-sm opacity-70 transition-opacity hover:opacity-100'>
@@ -8,6 +8,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTrigger,
@@ -152,6 +153,9 @@ export function DemoRequestModal({ children, theme = 'dark' }: DemoRequestModalP
}
>
<ModalBody>
<ModalDescription className='sr-only'>
Fill out this form to request a demo and talk to the sales team
</ModalDescription>
<div className='space-y-3'>
<div className='grid gap-3 sm:grid-cols-2'>
<LandingField htmlFor='firstName' label='First name' error={errors.firstName}>
@@ -8,6 +8,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -83,6 +84,9 @@ export function RequestIntegrationModal() {
{status === 'success' ? (
<ModalBody>
<ModalDescription className='sr-only'>
Integration request submitted successfully
</ModalDescription>
<div className='flex flex-col items-center gap-3 py-6 text-center'>
<div className='flex size-10 items-center justify-center rounded-full bg-[#33C482]/10'>
<svg
@@ -106,6 +110,10 @@ export function RequestIntegrationModal() {
) : (
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
<ModalDescription className='sr-only'>
Submit a request for a new integration by entering the integration name and your
email
</ModalDescription>
<div className='space-y-3'>
<div className='flex flex-col gap-1'>
<Label htmlFor='integration-name'>Integration name</Label>
+34 -241
View File
@@ -1,22 +1,14 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { deleteCredential } from '@/lib/credentials/deletion'
import {
deleteWorkspaceEnvCredentials,
syncPersonalEnvCredentialsForUser,
} from '@/lib/credentials/environment'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration'
const logger = createLogger('CredentialByIdAPI')
@@ -93,93 +85,33 @@ export const PUT = withRouteHandler(
const { id } = parsed.data.params
const body = parsed.data.body
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (body.description !== undefined) {
updates.description = body.description ?? null
}
if (
body.displayName !== undefined &&
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
) {
updates.displayName = body.displayName
}
if (body.serviceAccountJson !== undefined && access.credential.type === 'service_account') {
let parsedJson: Record<string, unknown>
try {
parsedJson = JSON.parse(body.serviceAccountJson)
} catch {
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
}
if (
parsedJson.type !== 'service_account' ||
typeof parsedJson.client_email !== 'string' ||
typeof parsedJson.private_key !== 'string' ||
typeof parsedJson.project_id !== 'string'
) {
return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 })
}
const { encrypted } = await encryptSecret(body.serviceAccountJson)
updates.encryptedServiceAccountKey = encrypted
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: session.user.id,
const result = await performUpdateCredential({
credentialId: id,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_UPDATED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: id,
resourceName: access.credential.displayName,
description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`,
metadata: {
credentialType: access.credential.type,
updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'),
},
displayName: body.displayName,
description: body.description,
serviceAccountJson: body.serviceAccountJson,
request,
})
if (!result.success) {
const status =
result.errorCode === 'not_found'
? 404
: result.errorCode === 'forbidden'
? 403
: result.errorCode === 'conflict'
? 409
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
if (error instanceof Error && error.message.includes('unique')) {
return NextResponse.json(
{ error: 'A service account credential with this name already exists in the workspace' },
{ status: 409 }
)
}
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
@@ -196,163 +128,24 @@ export const DELETE = withRouteHandler(
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'env_personal',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: id,
resourceName: access.credential.displayName,
description: `Deleted personal env credential "${access.credential.envKey}"`,
metadata: { credentialType: 'env_personal', envKey: access.credential.envKey },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ??
{}) as Record<string, string>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || generateId(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await deleteWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
removedKeys: [access.credential.envKey],
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: 'env_workspace',
provider_id: access.credential.envKey,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
recordAudit({
workspaceId: access.credential.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_DELETED,
resourceType: AuditResourceType.CREDENTIAL,
resourceId: id,
resourceName: access.credential.displayName,
description: `Deleted workspace env credential "${access.credential.envKey}"`,
metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await deleteCredential({
const result = await performDeleteCredential({
credentialId: id,
actorId: session.user.id,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
reason: 'user_delete',
request,
})
captureServerEvent(
session.user.id,
'credential_deleted',
{
credential_type: access.credential.type as 'oauth' | 'service_account',
provider_id: access.credential.providerId ?? id,
workspace_id: access.credential.workspaceId,
},
{ groups: { workspace: access.credential.workspaceId } }
)
if (!result.success) {
const status =
result.errorCode === 'not_found'
? 404
: result.errorCode === 'forbidden'
? 403
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
+40 -43
View File
@@ -34,6 +34,7 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => {
})
const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder
const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
@@ -54,16 +55,6 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
/** Type for captured folder values in tests */
interface CapturedFolderValues {
name?: string
color?: string
parentId?: string | null
isExpanded?: boolean
sortOrder?: number
updatedAt?: Date
}
interface FolderDbMockOptions {
folderLookupResult?: any
updateResult?: any[]
@@ -160,6 +151,41 @@ describe('Individual Folder API Route', () => {
success: true,
deletedItems: { folders: 1, workflows: 0 },
})
mockPerformUpdateFolder.mockImplementation(async (params) => {
if (params.parentId && params.parentId === params.folderId) {
return {
success: false,
error: 'Folder cannot be its own parent',
errorCode: 'validation',
}
}
if (
params.parentId &&
(await workflowsUtilsMockFns.mockCheckForCircularReference(
params.folderId,
params.parentId
))
) {
return {
success: false,
error: 'Cannot create circular folder reference',
errorCode: 'validation',
}
}
return {
success: true,
folder: {
...mockFolder,
id: params.folderId,
name: params.name !== undefined ? params.name.trim() : 'Updated Folder',
color: params.color ?? mockFolder.color,
parentId: params.parentId ?? mockFolder.parentId,
isExpanded: params.isExpanded,
sortOrder: params.sortOrder ?? mockFolder.sortOrder,
updatedAt: new Date(),
},
}
})
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false)
})
@@ -180,7 +206,7 @@ describe('Individual Folder API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('folder')
expect(data.folder).toMatchObject({
name: 'Updated Folder',
name: 'Updated Folder Name',
})
})
@@ -285,44 +311,15 @@ describe('Individual Folder API Route', () => {
it('should trim folder name when updating', async () => {
mockAuthenticatedUser()
let capturedUpdates: CapturedFolderValues | null = null
const mockSelect = vi.fn().mockImplementation(() => ({
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
then: vi.fn().mockImplementation((callback) => {
return Promise.resolve(callback([mockFolder]))
}),
})),
})),
}))
const mockUpdate = vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation((updates) => {
capturedUpdates = updates
return {
where: vi.fn().mockImplementation(() => ({
returning: vi.fn().mockReturnValue([{ ...mockFolder, name: 'Folder With Spaces' }]),
})),
}
}),
}))
mockDbRef.current = {
select: mockSelect,
update: mockUpdate,
delete: vi.fn(),
}
const req = createMockRequest('PUT', {
name: ' Folder With Spaces ',
})
const params = Promise.resolve({ id: 'folder-1' })
await PUT(req, { params })
const response = await PUT(req, { params })
const data = await response.json()
expect(capturedUpdates).not.toBeNull()
expect(capturedUpdates!.name).toBe('Folder With Spaces')
expect(data.folder.name).toBe('Folder With Spaces')
})
it('should handle database errors gracefully', async () => {
+19 -32
View File
@@ -9,8 +9,7 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteFolder } from '@/lib/workflows/orchestration'
import { checkForCircularReference } from '@/lib/workflows/utils'
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersIDAPI')
@@ -81,39 +80,27 @@ export const PUT = withRouteHandler(
await assertFolderMutable(parentId)
}
// Prevent setting a folder as its own parent or creating circular references
if (parentId && parentId === id) {
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
const result = await performUpdateFolder({
folderId: id,
workspaceId: existingFolder.workspaceId,
userId: session.user.id,
name,
color,
isExpanded,
locked,
parentId,
sortOrder,
})
if (!result.success || !result.folder) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
// Check for circular references if parentId is provided
if (parentId) {
const wouldCreateCycle = await checkForCircularReference(id, parentId)
if (wouldCreateCycle) {
return NextResponse.json(
{ error: 'Cannot create circular folder reference' },
{ status: 400 }
)
}
}
logger.info('Updated folder:', { id, updates: parsed.data.body })
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (name !== undefined) updates.name = name.trim()
if (color !== undefined) updates.color = color
if (isExpanded !== undefined) updates.isExpanded = isExpanded
if (locked !== undefined) updates.locked = locked
if (parentId !== undefined) updates.parentId = parentId || null
if (sortOrder !== undefined) updates.sortOrder = sortOrder
const [updatedFolder] = await db
.update(workflowFolder)
.set(updates)
.where(eq(workflowFolder.id, id))
.returning()
logger.info('Updated folder:', { id, updates })
return NextResponse.json({ folder: updatedFolder })
return NextResponse.json({ folder: result.folder })
} catch (error) {
if (error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
+25 -4
View File
@@ -150,7 +150,11 @@ describe('Folders API Route', () => {
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
const defaultWhereResult = [] as Array<Record<string, unknown>> & {
orderBy: typeof mockOrderBy
}
defaultWhereResult.orderBy = mockOrderBy
mockWhere.mockReturnValue(defaultWhereResult)
mockOrderBy.mockReturnValue(mockFolders)
mockInsert.mockReturnValue({ values: mockValues })
@@ -328,6 +332,14 @@ describe('Folders API Route', () => {
},
})
)
mockWhere
.mockReturnValueOnce([{ minSortOrder: 5 }])
.mockReturnValueOnce([{ minSortOrder: 2 }])
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
capturedValues = values
return { returning: mockReturning }
})
mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }])
const req = createMockRequest('POST', {
name: 'New Test Folder',
@@ -355,6 +367,7 @@ describe('Folders API Route', () => {
insertResult: [{ ...mockFolders[1] }],
})
)
mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }])
const req = createMockRequest('POST', {
name: 'Subfolder',
@@ -478,8 +491,8 @@ describe('Folders API Route', () => {
it('should handle database errors gracefully', async () => {
mockAuthenticatedUser()
mockTransaction.mockImplementationOnce(() => {
throw new Error('Database transaction failed')
mockInsert.mockImplementationOnce(() => {
throw new Error('Database insert failed')
})
const req = createMockRequest('POST', {
@@ -493,7 +506,7 @@ describe('Folders API Route', () => {
const data = await response.json()
expect(data).toHaveProperty('error', 'Internal server error')
expect(mockLogger.error).toHaveBeenCalledWith('Error creating folder:', {
expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', {
error: expect.any(Error),
})
})
@@ -512,6 +525,10 @@ describe('Folders API Route', () => {
},
})
)
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
capturedValues = values
return { returning: mockReturning }
})
const req = createMockRequest('POST', {
name: ' Test Folder With Spaces ',
@@ -538,6 +555,10 @@ describe('Folders API Route', () => {
},
})
)
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
capturedValues = values
return { returning: mockReturning }
})
const req = createMockRequest('POST', {
name: 'Test Folder',
+28 -75
View File
@@ -1,19 +1,25 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, isNotNull, isNull, min } from 'drizzle-orm'
import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performCreateFolder } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('FoldersAPI')
function folderMutationStatus(errorCode: string | undefined): number {
if (errorCode === 'validation') return 400
if (errorCode === 'conflict') return 409
if (errorCode === 'not_found') return 404
return 500
}
// GET - Fetch folders for a workspace
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
@@ -87,59 +93,26 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
const id = clientId || generateId()
const newFolder = await db.transaction(async (tx) => {
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
const folderParentCondition = parentId
? eq(workflowFolder.parentId, parentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = parentId
? eq(workflow.folderId, parentId)
: isNull(workflow.folderId)
const [[folderResult], [workflowResult]] = await Promise.all([
tx
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
tx
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
}
const [folder] = await tx
.insert(workflowFolder)
.values({
id,
name: name.trim(),
userId: session.user.id,
workspaceId,
parentId: parentId || null,
color: color || '#6B7280',
sortOrder,
})
.returning()
return folder
const result = await performCreateFolder({
id: clientId,
userId: session.user.id,
workspaceId,
name,
parentId,
color,
sortOrder: providedSortOrder,
})
logger.info('Created new folder:', { id, name, workspaceId, parentId })
if (!result.success || !result.folder) {
return NextResponse.json(
{ error: result.error },
{ status: folderMutationStatus(result.errorCode) }
)
}
const newFolder = result.folder
logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId })
captureServerEvent(
session.user.id,
@@ -148,26 +121,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: name.trim(),
description: `Created folder "${name.trim()}"`,
metadata: {
name: name.trim(),
workspaceId,
parentId: parentId || undefined,
color: color || '#6B7280',
sortOrder: newFolder.sortOrder,
},
request,
})
return NextResponse.json({ folder: newFolder })
} catch (error) {
logger.error('Error creating folder:', { error })
@@ -1,15 +1,14 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { knowledgeBase } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service'
import {
getRestorableKnowledgeBase,
performRestoreKnowledgeBase,
} from '@/lib/knowledge/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreKnowledgeBaseAPI')
@@ -27,16 +26,7 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [kb] = await db
.select({
id: knowledgeBase.id,
name: knowledgeBase.name,
workspaceId: knowledgeBase.workspaceId,
userId: knowledgeBase.userId,
})
.from(knowledgeBase)
.where(eq(knowledgeBase.id, id))
.limit(1)
const kb = await getRestorableKnowledgeBase(id)
if (!kb) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
@@ -51,32 +41,21 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
await restoreKnowledgeBase(id, requestId)
const result = await performRestoreKnowledgeBase({
knowledgeBaseId: id,
userId: auth.userId,
requestId,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
logger.info(`[${requestId}] Restored knowledge base ${id}`)
recordAudit({
workspaceId: kb.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.KNOWLEDGE_BASE_RESTORED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: kb.name,
description: `Restored knowledge base "${kb.name}"`,
metadata: {
knowledgeBaseName: kb.name,
},
request,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
+27 -101
View File
@@ -1,22 +1,15 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { updateMcpServerBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { performUpdateMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
const logger = createLogger('McpServerAPI')
@@ -55,99 +48,32 @@ export const PATCH = withRouteHandler(
// Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body
if (updateData.url) {
try {
validateMcpDomain(updateData.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
try {
await validateMcpServerSsrf(updateData.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
}
// Get the current server to check if URL is changing
const [currentServer] = await db
.select({ url: mcpServers.url })
.from(mcpServers)
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.limit(1)
const [updatedServer] = await db
.update(mcpServers)
.set({
...updateData,
updatedAt: new Date(),
})
.where(
and(
eq(mcpServers.id, serverId),
eq(mcpServers.workspaceId, workspaceId),
isNull(mcpServers.deletedAt)
)
)
.returning()
if (!updatedServer) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
)
}
const shouldClearCache =
(body.url !== undefined && currentServer?.url !== body.url) ||
body.enabled !== undefined ||
body.headers !== undefined ||
body.timeout !== undefined ||
body.retries !== undefined
if (shouldClearCache) {
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`)
}
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
recordAudit({
const result = await performUpdateMcpServer({
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name || serverId,
description: `Updated MCP server "${updatedServer.name || serverId}"`,
metadata: {
serverName: updatedServer.name,
transport: updatedServer.transport,
url: updatedServer.url,
updatedFields: Object.keys(updateData).filter(
(k) => k !== 'workspaceId' && k !== 'updatedAt'
),
},
serverId,
name: updateData.name,
description: updateData.description,
transport: updateData.transport,
url: updateData.url,
headers: updateData.headers,
timeout: updateData.timeout,
retries: updateData.retries,
enabled: updateData.enabled,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
result.error || 'Server not found',
mcpOrchestrationStatus(result.errorCode)
)
}
const updatedServer = result.server
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
@@ -0,0 +1,89 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({
mockPerformDeleteMcpServer: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(),
},
}))
vi.mock('@/lib/mcp/middleware', () => ({
getParsedBody: () => undefined,
withMcpAuth:
() =>
(
handler: (
request: NextRequest,
context: {
userId: string
userName: string
userEmail: string
workspaceId: string
requestId: string
}
) => Promise<Response>
) =>
(request: NextRequest) =>
handler(request, {
userId: 'user-1',
userName: 'Test User',
userEmail: 'test@example.com',
workspaceId: 'workspace-1',
requestId: 'request-1',
}),
}))
vi.mock('@/lib/mcp/orchestration', () => ({
performCreateMcpServer: vi.fn(),
performDeleteMcpServer: mockPerformDeleteMcpServer,
}))
import { DELETE } from '@/app/api/mcp/servers/route'
function createDeleteRequest(serverId = 'server-1') {
return new Request(
`http://localhost:3000/api/mcp/servers?workspaceId=workspace-1&serverId=${serverId}`,
{ method: 'DELETE' }
) as NextRequest
}
describe('MCP servers DELETE route', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 404 when orchestration reports a missing server', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Server not found',
errorCode: 'not_found',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(404)
expect(body).toEqual({ success: false, error: 'Server not found' })
})
it('returns 500 when orchestration reports an internal delete failure', async () => {
mockPerformDeleteMcpServer.mockResolvedValueOnce({
success: false,
error: 'Failed to delete MCP server',
errorCode: 'internal',
})
const response = await DELETE(createDeleteRequest())
const body = await response.json()
expect(response.status).toBe(500)
expect(body).toEqual({ success: false, error: 'Failed to delete MCP server' })
})
})
+50 -176
View File
@@ -1,29 +1,19 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createMcpServerBodySchema, deleteMcpServerByQuerySchema } from '@/lib/api/contracts/mcp'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
McpDnsResolutionError,
McpDomainNotAllowedError,
McpSsrfError,
validateMcpDomain,
validateMcpServerSsrf,
} from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import { performCreateMcpServer, performDeleteMcpServer } from '@/lib/mcp/orchestration'
import {
createMcpErrorResponse,
createMcpSuccessResponse,
generateMcpServerId,
mcpOrchestrationStatus,
} from '@/lib/mcp/utils'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('McpServersAPI')
@@ -82,142 +72,50 @@ export const POST = withRouteHandler(
workspaceId,
})
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
try {
await validateMcpServerSsrf(body.url)
} catch (e) {
if (e instanceof McpDnsResolutionError) {
return createMcpErrorResponse(e, e.message, 502)
}
if (e instanceof McpSsrfError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId()
const [existingServer] = await db
.select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt })
.from(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
.limit(1)
if (existingServer) {
logger.info(
`[${requestId}] Server with ID ${serverId} already exists, updating instead of creating`
)
await db
.update(mcpServers)
.set({
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers || {},
timeout: body.timeout || 30000,
retries: body.retries || 3,
enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
updatedAt: new Date(),
deletedAt: null,
})
.where(eq(mcpServers.id, serverId))
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
)
return createMcpSuccessResponse({ serverId, updated: true }, 200)
}
await db
.insert(mcpServers)
.values({
id: serverId,
workspaceId,
createdBy: userId,
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers || {},
timeout: body.timeout || 30000,
retries: body.retries || 3,
enabled: body.enabled !== false,
connectionStatus: 'connected',
lastConnected: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
await mcpService.clearCache(workspaceId)
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.mcpServerAdded({
serverId,
serverName: body.name,
transport: body.transport,
workspaceId,
})
} catch (_e) {
// Silently fail
}
const sourceParam = body.source as string | undefined
const source =
sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined
captureServerEvent(
userId,
'mcp_server_connected',
{ workspace_id: workspaceId, server_name: body.name, transport: body.transport, source },
{
groups: { workspace: workspaceId },
setOnce: { first_mcp_connected_at: new Date().toISOString() },
}
)
recordAudit({
if (!body.url) {
return createMcpErrorResponse(
new Error('url is required'),
'Missing required parameter',
400
)
}
const result = await performCreateMcpServer({
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: body.name,
description: `Added MCP server "${body.name}"`,
metadata: {
serverName: body.name,
transport: body.transport,
url: body.url,
timeout: body.timeout || 30000,
retries: body.retries || 3,
source: source,
},
name: body.name,
description: body.description,
transport: body.transport,
url: body.url,
headers: body.headers,
timeout: body.timeout,
retries: body.retries,
enabled: body.enabled,
source,
request,
})
if (!result.success || !result.serverId) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to register MCP server'),
result.error || 'Failed to register MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
return createMcpSuccessResponse({ serverId }, 201)
logger.info(
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${result.serverId})`
)
return createMcpSuccessResponse(
result.updated
? { serverId: result.serverId, updated: true }
: { serverId: result.serverId },
result.updated ? 200 : 201
)
} catch (error) {
logger.error(`[${requestId}] Error registering MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500)
@@ -256,49 +154,25 @@ export const DELETE = withRouteHandler(
`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`
)
const [deletedServer] = await db
.delete(mcpServers)
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId)))
.returning()
if (!deletedServer) {
const result = await performDeleteMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
serverId,
source,
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error('Server not found or access denied'),
'Server not found',
404
new Error(result.error || 'Failed to delete MCP server'),
result.error || 'Failed to delete MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
captureServerEvent(
userId,
'mcp_server_disconnected',
{ workspace_id: workspaceId, server_name: deletedServer.name, source },
{ groups: { workspace: workspaceId } }
)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId!,
resourceName: deletedServer.name,
description: `Removed MCP server "${deletedServer.name}"`,
metadata: {
serverName: deletedServer.name,
transport: deletedServer.transport,
url: deletedServer.url,
source,
},
request,
})
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting MCP server:`, error)
@@ -1,4 +1,3 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -11,7 +10,10 @@ import {
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import {
performDeleteWorkflowMcpServer,
performUpdateWorkflowMcpServer,
} from '@/lib/mcp/orchestration'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
const logger = createLogger('WorkflowMcpServerAPI')
@@ -99,61 +101,29 @@ export const PATCH = withRouteHandler(
logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
const [existingServer] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!existingServer) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (body.name !== undefined) {
updateData.name = body.name.trim()
}
if (body.description !== undefined) {
updateData.description = body.description?.trim() || null
}
if (body.isPublic !== undefined) {
updateData.isPublic = body.isPublic
}
const [updatedServer] = await db
.update(workflowMcpServer)
.set(updateData)
.where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt)))
.returning()
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
recordAudit({
const result = await performUpdateWorkflowMcpServer({
serverId,
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name,
description: `Updated workflow MCP server "${updatedServer.name}"`,
metadata: {
serverName: updatedServer.name,
isPublic: updatedServer.isPublic,
updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
},
request,
name: body.name,
description: body.description,
isPublic: body.isPublic,
})
if (!result.success || !result.server) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return createMcpErrorResponse(
new Error(result.error || 'Failed to update workflow MCP server'),
result.error || 'Failed to update workflow MCP server',
status
)
}
const updatedServer = result.server
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
@@ -179,34 +149,23 @@ export const DELETE = withRouteHandler(
logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
const [deletedServer] = await db
.delete(workflowMcpServer)
.where(
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
)
.returning()
if (!deletedServer) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
const result = await performDeleteWorkflowMcpServer({
serverId,
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: deletedServer.name,
description: `Unpublished workflow MCP server "${deletedServer.name}"`,
metadata: { serverName: deletedServer.name },
request,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Server not found'),
result.error || 'Server not found',
result.errorCode === 'not_found' ? 404 : 500
)
}
const deletedServer = result.server
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
@@ -1,4 +1,3 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -11,9 +10,8 @@ import {
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool } from '@/lib/mcp/orchestration'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
const logger = createLogger('WorkflowMcpToolAPI')
@@ -99,79 +97,30 @@ export const PATCH = withRouteHandler(
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.id, toolId),
eq(workflowMcpTool.serverId, serverId),
isNull(workflowMcpTool.archivedAt)
)
)
.limit(1)
if (!existingTool) {
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
if (body.toolName !== undefined) {
updateData.toolName = sanitizeToolName(body.toolName)
}
if (body.toolDescription !== undefined) {
updateData.toolDescription = body.toolDescription?.trim() || null
}
if (body.parameterSchema !== undefined) {
updateData.parameterSchema = body.parameterSchema
}
const [updatedTool] = await db
.update(workflowMcpTool)
.set(updateData)
.where(eq(workflowMcpTool.id, toolId))
.returning()
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
const result = await performUpdateWorkflowMcpTool({
serverId,
toolId,
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
metadata: {
toolId,
toolName: updatedTool.toolName,
workflowId: updatedTool.workflowId,
updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
},
request,
toolName: body.toolName,
toolDescription: body.toolDescription,
parameterSchema: body.parameterSchema,
})
if (!result.success || !result.tool) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return createMcpErrorResponse(
new Error(result.error || 'Failed to update tool'),
result.error || 'Failed to update tool',
status
)
}
const updatedTool = result.tool
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
return createMcpSuccessResponse({ tool: updatedTool })
} catch (error) {
@@ -197,47 +146,24 @@ export const DELETE = withRouteHandler(
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const [deletedTool] = await db
.delete(workflowMcpTool)
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
.returning()
if (!deletedTool) {
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
}
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
const result = await performDeleteWorkflowMcpTool({
serverId,
toolId,
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId },
request,
})
if (!result.success || !result.tool) {
return createMcpErrorResponse(
new Error(result.error || 'Tool not found'),
result.error || 'Tool not found',
result.errorCode === 'not_found' ? 404 : 500
)
}
const deletedTool = result.tool
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
} catch (error) {
@@ -1,9 +1,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import {
@@ -12,11 +10,8 @@ import {
} from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { performCreateWorkflowMcpTool } from '@/lib/mcp/orchestration'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpToolsAPI')
@@ -110,133 +105,32 @@ export const POST = withRouteHandler(
workflowId: body.workflowId,
})
const [server] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(
and(
eq(workflowMcpServer.id, serverId),
eq(workflowMcpServer.workspaceId, workspaceId),
isNull(workflowMcpServer.deletedAt)
)
)
.limit(1)
if (!server) {
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
}
const [workflowRecord] = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt)))
.limit(1)
if (!workflowRecord) {
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
}
if (workflowRecord.workspaceId !== workspaceId) {
return createMcpErrorResponse(
new Error('Workflow does not belong to this workspace'),
'Access denied',
403
)
}
if (!workflowRecord.isDeployed) {
return createMcpErrorResponse(
new Error('Workflow must be deployed before adding as a tool'),
'Workflow not deployed',
400
)
}
const hasStartBlock = await hasValidStartBlock(body.workflowId)
if (!hasStartBlock) {
return createMcpErrorResponse(
new Error('Workflow must have a Start block to be used as an MCP tool'),
'No start block found',
400
)
}
const [existingTool] = await db
.select({ id: workflowMcpTool.id })
.from(workflowMcpTool)
.where(
and(
eq(workflowMcpTool.serverId, serverId),
eq(workflowMcpTool.workflowId, body.workflowId),
isNull(workflowMcpTool.archivedAt)
)
)
.limit(1)
if (existingTool) {
return createMcpErrorResponse(
new Error('This workflow is already added as a tool to this server'),
'Tool already exists',
409
)
}
const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name)
const toolDescription =
body.toolDescription?.trim() ||
workflowRecord.description ||
`Execute ${workflowRecord.name} workflow`
const parameterSchema =
body.parameterSchema && Object.keys(body.parameterSchema).length > 0
? body.parameterSchema
: await generateParameterSchemaForWorkflow(body.workflowId)
const toolId = generateId()
const [tool] = await db
.insert(workflowMcpTool)
.values({
id: toolId,
serverId,
workflowId: body.workflowId,
toolName,
toolDescription,
parameterSchema,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
logger.info(
`[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}`
)
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
const result = await performCreateWorkflowMcpTool({
serverId,
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Added tool "${toolName}" to MCP server`,
metadata: {
toolId,
toolName,
toolDescription,
workflowId: body.workflowId,
workflowName: workflowRecord.name,
},
request,
workflowId: body.workflowId,
toolName: body.toolName,
toolDescription: body.toolDescription,
parameterSchema: body.parameterSchema,
})
if (!result.success || !result.tool) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 400
return createMcpErrorResponse(
new Error(result.error || 'Failed to add tool'),
result.error || 'Failed to add tool',
result.errorCode === 'internal' ? 500 : status
)
}
const tool = result.tool
logger.info(
`[${requestId}] Successfully added tool ${tool.toolName} (workflow: ${body.workflowId}) to server ${serverId}`
)
return createMcpSuccessResponse({ tool }, 201)
} catch (error) {
+22 -107
View File
@@ -1,19 +1,14 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createWorkflowMcpServerBodySchema } from '@/lib/api/contracts/workflow-mcp-servers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { performCreateWorkflowMcpServer } from '@/lib/mcp/orchestration'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
const logger = createLogger('WorkflowMcpServersAPI')
@@ -112,110 +107,30 @@ export const POST = withRouteHandler(
workflowIds: body.workflowIds,
})
const serverId = generateId()
const [server] = await db
.insert(workflowMcpServer)
.values({
id: serverId,
workspaceId,
createdBy: userId,
name: body.name.trim(),
description: body.description?.trim() || null,
isPublic: body.isPublic ?? false,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
const workflowIds: string[] = body.workflowIds || []
const addedTools: Array<{ workflowId: string; toolName: string }> = []
if (workflowIds.length > 0) {
const workflows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
isDeployed: workflow.isDeployed,
workspaceId: workflow.workspaceId,
})
.from(workflow)
.where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt)))
for (const workflowRecord of workflows) {
if (workflowRecord.workspaceId !== workspaceId) {
logger.warn(
`[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace`
)
continue
}
if (!workflowRecord.isDeployed) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`)
continue
}
const hasStartBlock = await hasValidStartBlock(workflowRecord.id)
if (!hasStartBlock) {
logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`)
continue
}
const toolName = sanitizeToolName(workflowRecord.name)
const toolDescription =
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
const toolId = generateId()
await db.insert(workflowMcpTool).values({
id: toolId,
serverId,
workflowId: workflowRecord.id,
toolName,
toolDescription,
parameterSchema,
createdAt: new Date(),
updatedAt: new Date(),
})
addedTools.push({ workflowId: workflowRecord.id, toolName })
}
logger.info(
`[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`,
addedTools.map((t) => t.toolName)
)
if (addedTools.length > 0) {
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
}
}
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
recordAudit({
const result = await performCreateWorkflowMcpServer({
workspaceId,
actorId: userId,
userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: body.name.trim(),
description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`,
metadata: {
serverName: body.name.trim(),
isPublic: body.isPublic ?? false,
toolCount: addedTools.length,
toolNames: addedTools.map((t) => t.toolName),
workflowIds: addedTools.map((t) => t.workflowId),
},
request,
name: body.name,
description: body.description,
isPublic: body.isPublic,
workflowIds: body.workflowIds,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to create workflow MCP server'),
result.error || 'Failed to create workflow MCP server',
result.errorCode === 'validation' ? 400 : 500
)
}
const { server } = result
const addedTools = result.addedTools || []
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
)
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
+41 -45
View File
@@ -15,6 +15,7 @@ import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteJob, performUpdateJob } from '@/lib/workflows/schedules/orchestration'
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -168,58 +169,32 @@ export const PUT = withRouteHandler(
)
}
const updates = validatedBody
const setFields: Record<string, unknown> = { updatedAt: new Date() }
if (updates.title !== undefined) setFields.jobTitle = updates.title.trim()
if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim()
if (updates.timezone !== undefined) setFields.timezone = updates.timezone
if (updates.lifecycle !== undefined) {
setFields.lifecycle = updates.lifecycle
if (updates.lifecycle === 'persistent') {
setFields.maxRuns = null
}
}
if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns
if (updates.cronExpression !== undefined) {
const tz = updates.timezone ?? schedule.timezone ?? 'UTC'
const cronResult = validateCronExpression(updates.cronExpression, tz)
if (!cronResult.isValid) {
return NextResponse.json(
{ error: cronResult.error || 'Invalid cron expression' },
{ status: 400 }
)
}
setFields.cronExpression = updates.cronExpression
if (schedule.status === 'active' && cronResult.nextRun) {
setFields.nextRunAt = cronResult.nextRun
}
if (!workspaceId) {
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
}
await db
.update(workflowSchedule)
.set(setFields)
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
recordAudit({
const updateResult = await performUpdateJob({
jobId: scheduleId,
workspaceId,
actorId: session.user.id,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: scheduleId,
resourceName: schedule.jobTitle ?? undefined,
description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`,
metadata: {
operation: 'update',
updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'),
},
title: validatedBody.title,
prompt: validatedBody.prompt,
timezone: validatedBody.timezone,
lifecycle: validatedBody.lifecycle,
maxRuns: validatedBody.maxRuns,
cronExpression: validatedBody.cronExpression,
request,
})
if (!updateResult.success) {
return NextResponse.json(
{ error: updateResult.error || 'Failed to update schedule' },
{ status: updateResult.errorCode === 'validation' ? 400 : 500 }
)
}
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
return NextResponse.json({ message: 'Schedule updated successfully' })
}
@@ -298,6 +273,27 @@ export const DELETE = withRouteHandler(
if (result instanceof NextResponse) return result
const { schedule, workspaceId } = result
if (schedule.sourceType === 'job') {
if (!workspaceId) {
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
}
const deleteResult = await performDeleteJob({
jobId: scheduleId,
workspaceId,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
request,
})
if (!deleteResult.success) {
return NextResponse.json(
{ error: deleteResult.error || 'Failed to delete schedule' },
{ status: deleteResult.errorCode === 'not_found' ? 404 : 500 }
)
}
return NextResponse.json({ message: 'Schedule deleted successfully' })
}
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId))
logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`)
+27 -67
View File
@@ -1,8 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
import { and, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -11,8 +9,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
import { performCreateJob } from '@/lib/workflows/schedules/orchestration'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('ScheduledAPI')
@@ -228,80 +225,43 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
}
const validation = validateCronExpression(cronExpression, timezone)
if (!validation.isValid) {
const result = await performCreateJob({
workspaceId,
userId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
title,
prompt,
cronExpression,
timezone,
lifecycle,
maxRuns,
startDate,
request: req,
})
if (!result.success || !result.schedule) {
return NextResponse.json(
{ error: validation.error || 'Invalid cron expression' },
{ status: 400 }
{ error: result.error || 'Failed to create schedule' },
{ status: result.errorCode === 'validation' ? 400 : 500 }
)
}
let nextRunAt = validation.nextRun!
if (startDate) {
const start = new Date(startDate)
if (start > new Date()) {
nextRunAt = start
}
}
const now = new Date()
const id = generateId()
await db.insert(workflowSchedule).values({
id,
cronExpression,
triggerType: 'schedule',
sourceType: 'job',
status: 'active',
timezone,
nextRunAt,
createdAt: now,
updatedAt: now,
failedCount: 0,
jobTitle: title.trim(),
prompt: prompt.trim(),
lifecycle,
maxRuns: maxRuns ?? null,
runCount: 0,
sourceWorkspaceId: workspaceId,
sourceUserId: session.user.id,
})
logger.info(`[${requestId}] Created job schedule ${id}`, {
logger.info(`[${requestId}] Created job schedule ${result.schedule.id}`, {
title,
cronExpression,
timezone,
lifecycle,
})
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.SCHEDULE_CREATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: id,
resourceName: title.trim(),
description: `Created job schedule "${title.trim()}"`,
metadata: {
cronExpression,
timezone,
lifecycle,
maxRuns: maxRuns ?? null,
},
request: req,
})
captureServerEvent(
session.user.id,
'scheduled_task_created',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json(
{ schedule: { id, status: 'active', cronExpression, nextRunAt } },
{
schedule: {
id: result.schedule.id,
status: result.schedule.status,
cronExpression: result.schedule.cronExpression,
nextRunAt: result.schedule.nextRunAt,
},
},
{ status: 201 }
)
} catch (error) {
@@ -1,11 +1,11 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { tableIdParamsSchema } from '@/lib/api/contracts/tables'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getTableById, restoreTable, TableConflictError } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { performRestoreTable } from '@/lib/table/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreTableAPI')
@@ -31,33 +31,20 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
await restoreTable(tableId, requestId)
const result = await performRestoreTable({ tableId, userId: auth.userId, requestId })
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
logger.info(`[${requestId}] Restored table ${tableId}`)
recordAudit({
workspaceId: table.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.TABLE_RESTORED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Restored table "${table.name}"`,
metadata: {
tableName: table.name,
workspaceId: table.workspaceId,
},
request,
return NextResponse.json({
success: true,
data: { table: result.table },
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof TableConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring table ${tableId}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
+25 -24
View File
@@ -182,33 +182,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
const responseTables = tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
id: t.id,
name: t.name,
description: t.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: t.rowCount,
maxRows: t.maxRows,
workspaceId: t.workspaceId,
createdBy: t.createdBy,
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt: t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
archivedAt:
t.archivedAt instanceof Date
? t.archivedAt.toISOString()
: t.archivedAt
? String(t.archivedAt)
: null,
}
})
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
id: t.id,
name: t.name,
description: t.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: t.rowCount,
maxRows: t.maxRows,
createdBy: t.createdBy,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
archivedAt:
t.archivedAt instanceof Date
? t.archivedAt.toISOString()
: t.archivedAt
? String(t.archivedAt)
: null,
}
}),
tables: responseTables,
totalCount: tables.length,
},
})
+63 -10
View File
@@ -3,17 +3,20 @@ import { type NextRequest, NextResponse } from 'next/server'
import { fileManageContract } from '@/lib/api/contracts/tools/file'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
getWorkspaceFileByName,
resolveWorkspaceFileReference,
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
@@ -143,11 +146,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const selectedFileId =
fileId ||
(fileInput && typeof fileInput === 'object' && !Array.isArray(fileInput)
? typeof fileInput.id === 'string'
? fileInput.id
: typeof fileInput.fileId === 'string'
? fileInput.fileId
: ''
? (() => {
const obj = fileInput as Record<string, unknown>
return typeof obj.id === 'string'
? obj.id
: typeof obj.fileId === 'string'
? obj.fileId
: ''
})()
: '')
if (!selectedFileId) {
@@ -222,14 +228,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
case 'write': {
const { fileName, content, contentType } = body
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
const { folderSegments, leafName } = splitWorkspaceFilePath(fileName)
const folderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments: folderSegments,
})
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName))
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
const result = await uploadWorkspaceFile(
workspaceId,
userId,
fileBuffer,
fileName,
mimeType
leafName,
mimeType,
{ folderId }
)
logger.info('File created', {
@@ -249,10 +262,50 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
case 'move': {
const { fileId, targetFolder } = body
const pathSegments = targetFolder.trim()
? targetFolder
.trim()
.split('/')
.map((s) => s.trim())
.filter(Boolean)
: []
const targetFolderId = await ensureWorkspaceFileFolderPath({
workspaceId,
userId,
pathSegments,
})
const moveResult = await performMoveWorkspaceFileItems({
workspaceId,
userId,
fileIds: [fileId],
targetFolderId,
})
if (!moveResult.success) {
return NextResponse.json(
{ success: false, error: moveResult.error },
{
status:
moveResult.errorCode === 'conflict'
? 409
: moveResult.errorCode === 'not_found'
? 404
: 400,
}
)
}
logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' })
return NextResponse.json({
success: true,
data: { fileId, targetFolder: targetFolder || '(root)' },
})
}
case 'append': {
const { fileName, content } = body
const existing = await getWorkspaceFileByName(workspaceId, fileName)
const existing = await resolveWorkspaceFileReference(workspaceId, fileName)
if (!existing) {
return NextResponse.json(
{ success: false, error: `File not found: "${fileName}"` },
+10 -19
View File
@@ -1,15 +1,11 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
deleteWorkspaceFile,
fetchWorkspaceFileBuffer,
getWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
checkRateLimit,
createRateLimitResponse,
@@ -97,24 +93,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
await deleteWorkspaceFile(workspaceId, fileId)
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId,
fileIds: [fileId],
})
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 500 })
}
logger.info(
`[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FILE_DELETED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: fileRecord.name,
description: `Archived file "${fileRecord.name}" via API`,
metadata: { fileSize: fileRecord.size, fileType: fileRecord.type },
request,
})
return NextResponse.json({
success: true,
data: {
@@ -1,4 +1,3 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { assertFolderMutable, FolderLockedError, WorkflowLockedError } from '@sim/workflow-authz'
import { type NextRequest, NextResponse } from 'next/server'
@@ -8,7 +7,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { restoreWorkflow } from '@/lib/workflows/lifecycle'
import { performRestoreWorkflow } from '@/lib/workflows/orchestration'
import { getWorkflowById } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -50,31 +49,20 @@ export const POST = withRouteHandler(
}
await assertFolderMutable(workflowData.folderId)
const result = await restoreWorkflow(workflowId, { requestId })
const result = await performRestoreWorkflow({
workflowId,
userId: auth.userId,
requestId,
})
if (!result.restored) {
return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 })
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json({ error: result.error }, { status })
}
logger.info(`[${requestId}] Restored workflow ${workflowId}`)
recordAudit({
workspaceId: workflowData.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_RESTORED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name,
description: `Restored workflow "${workflowData.name}"`,
metadata: {
workflowName: workflowData.name,
workspaceId: workflowData.workspaceId || undefined,
},
request,
})
captureServerEvent(
auth.userId,
'workflow_restored',
+32 -7
View File
@@ -27,6 +27,7 @@ const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById
const mockAuthorizeWorkflowByWorkspacePermission =
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow
const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow
const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({
mockDbUpdate: vi.fn(),
@@ -85,6 +86,22 @@ describe('Workflow By ID API Route', () => {
})
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
mockPerformUpdateWorkflow.mockImplementation(async (params) => ({
success: true,
workflow: {
id: params.workflowId,
name: params.name ?? params.currentName,
description: params.description ?? null,
color: params.color ?? null,
workspaceId: params.workspaceId,
folderId: params.folderId ?? params.currentFolderId ?? null,
sortOrder: params.sortOrder ?? null,
locked: params.locked ?? null,
createdAt: new Date(),
updatedAt: new Date(),
archivedAt: null,
},
}))
mockDbTransaction.mockImplementation(async (callback) =>
callback({
execute: vi.fn().mockResolvedValue(undefined),
@@ -595,8 +612,11 @@ describe('Workflow By ID API Route', () => {
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([{ id: 'workflow-other' }])
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "Duplicate Name" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
@@ -628,8 +648,11 @@ describe('Workflow By ID API Route', () => {
workflow: mockWorkflow,
workspacePermission: 'write',
})
mockDuplicateCheck([{ id: 'workflow-other' }])
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "Duplicate Name" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
@@ -748,9 +771,11 @@ describe('Workflow By ID API Route', () => {
workflow: mockWorkflow,
workspacePermission: 'write',
})
// Duplicate exists in target folder
mockDuplicateCheck([{ id: 'workflow-other' }])
mockPerformUpdateWorkflow.mockResolvedValueOnce({
success: false,
error: 'A workflow named "My Workflow" already exists in this folder',
errorCode: 'conflict',
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'PUT',
+22 -67
View File
@@ -8,7 +8,7 @@ import {
FolderLockedError,
WorkflowLockedError,
} from '@sim/workflow-authz'
import { and, eq, isNull, ne, sql } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateWorkflowContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
@@ -16,7 +16,7 @@ import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/aut
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -338,78 +338,33 @@ export const PUT = withRouteHandler(
await assertFolderMutable(updates.folderId)
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (updates.name !== undefined) updateData.name = updates.name
if (updates.description !== undefined) updateData.description = updates.description
if (updates.color !== undefined) updateData.color = updates.color
if (updates.folderId !== undefined) updateData.folderId = updates.folderId
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder
if (updates.locked !== undefined) updateData.locked = updates.locked
if (updates.name !== undefined || updates.folderId !== undefined) {
const targetName = updates.name ?? workflowData.name
const targetFolderId =
updates.folderId !== undefined ? updates.folderId : workflowData.folderId
if (!workflowData.workspaceId) {
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const conditions = [
eq(workflow.workspaceId, workflowData.workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, targetName),
ne(workflow.id, workflowId),
]
if (targetFolderId) {
conditions.push(eq(workflow.folderId, targetFolderId))
} else {
conditions.push(isNull(workflow.folderId))
}
const [duplicate] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...conditions))
.limit(1)
if (duplicate) {
logger.warn(
`[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}`
)
return NextResponse.json(
{ error: `A workflow named "${targetName}" already exists in this folder` },
{ status: 409 }
)
}
if (!workflowData.workspaceId) {
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
const [updatedWorkflow] = await db
.update(workflow)
.set(updateData)
.where(eq(workflow.id, workflowId))
.returning({
id: workflow.id,
name: workflow.name,
description: workflow.description,
color: workflow.color,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder,
locked: workflow.locked,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
archivedAt: workflow.archivedAt,
})
const result = await performUpdateWorkflow({
workflowId,
userId,
workspaceId: workflowData.workspaceId,
currentName: workflowData.name,
currentFolderId: workflowData.folderId,
...updates,
requestId,
})
if (!result.success || !result.workflow) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, {
updates: updateData,
updates,
})
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
return NextResponse.json({ workflow: result.workflow }, { status: 200 })
} catch (error: any) {
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
+1 -1
View File
@@ -87,9 +87,9 @@ describe('Workflows API Route - POST ordering', () => {
it('uses top insertion against mixed siblings (folders + workflows)', async () => {
const minResultsQueue: Array<Array<{ minOrder: number }>> = [
[],
[{ minOrder: 5 }],
[{ minOrder: 2 }],
[],
]
mockDbSelect.mockImplementation(() => ({
+40 -137
View File
@@ -1,9 +1,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
import { permissions, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm'
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createWorkflowContract, workflowListQuerySchema } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
@@ -11,9 +9,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { performCreateWorkflow } from '@/lib/workflows/orchestration'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -183,86 +179,31 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
const workflowId = clientId || generateId()
const now = new Date()
const result = await performCreateWorkflow({
id: clientId,
name: requestedName,
description,
color,
workspaceId,
folderId,
sortOrder: providedSortOrder,
deduplicate,
userId,
requestId,
})
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
let sortOrder: number
if (providedSortOrder !== undefined) {
sortOrder = providedSortOrder
} else {
const workflowParentCondition = folderId
? eq(workflow.folderId, folderId)
: isNull(workflow.folderId)
const folderParentCondition = folderId
? eq(workflowFolder.parentId, folderId)
: isNull(workflowFolder.parentId)
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(
and(
eq(workflow.workspaceId, workspaceId),
workflowParentCondition,
isNull(workflow.archivedAt)
)
),
db
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
if (!result.success || !result.workflow) {
const status = result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
let name = requestedName
if (deduplicate) {
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
} else {
const duplicateConditions = [
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, requestedName),
]
if (folderId) {
duplicateConditions.push(eq(workflow.folderId, folderId))
} else {
duplicateConditions.push(isNull(workflow.folderId))
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...duplicateConditions))
.limit(1)
if (duplicateWorkflow) {
return NextResponse.json(
{ error: `A workflow named "${requestedName}" already exists in this folder` },
{ status: 409 }
)
}
}
const createdWorkflow = result.workflow
import('@/lib/core/telemetry')
.then(({ PlatformEvents }) => {
PlatformEvents.workflowCreated({
workflowId,
name,
workflowId: createdWorkflow.id,
name: createdWorkflow.name,
workspaceId: workspaceId || undefined,
folderId: folderId || undefined,
})
@@ -271,74 +212,36 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
// Silently fail
})
const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts()
await db.transaction(async (tx) => {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: folderId || null,
sortOrder,
name,
description,
color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
})
logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`)
logger.info(
`[${requestId}] Successfully created workflow ${createdWorkflow.id} with default blocks`
)
captureServerEvent(
userId,
'workflow_created',
{ workflow_id: workflowId, workspace_id: workspaceId ?? '', name },
{
workflow_id: createdWorkflow.id,
workspace_id: workspaceId ?? '',
name: createdWorkflow.name,
},
{
groups: workspaceId ? { workspace: workspaceId } : undefined,
setOnce: { first_workflow_created_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: name,
description: `Created workflow "${name}"`,
metadata: {
name,
description: description || undefined,
color,
workspaceId,
folderId: folderId || undefined,
sortOrder,
},
request: req,
})
return NextResponse.json({
id: workflowId,
name,
description,
color,
workspaceId,
folderId,
sortOrder,
createdAt: now,
updatedAt: now,
startBlockId,
subBlockValues,
id: createdWorkflow.id,
name: createdWorkflow.name,
description: createdWorkflow.description,
color: createdWorkflow.color,
workspaceId: createdWorkflow.workspaceId,
folderId: createdWorkflow.folderId,
sortOrder: createdWorkflow.sortOrder,
createdAt: createdWorkflow.createdAt,
updatedAt: createdWorkflow.updatedAt,
startBlockId: createdWorkflow.startBlockId,
subBlockValues: createdWorkflow.subBlockValues,
})
} catch (error) {
logger.error(`[${requestId}] Error creating workflow`, error)
@@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -10,8 +9,8 @@ import {
deleteWorkspaceApiKeysContract,
} from '@/lib/api/contracts/api-keys'
import { parseRequest } from '@/lib/api/server'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -106,60 +105,17 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { name, source } = parsed.data.body
const existingKey = await db
.select()
.from(apiKey)
.where(
and(
eq(apiKey.workspaceId, workspaceId),
eq(apiKey.name, name),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (existingKey.length > 0) {
return NextResponse.json(
{
error: `A workspace API key named "${name}" already exists. Please choose a different name.`,
},
{ status: 409 }
)
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
throw new Error('Failed to encrypt API key for storage')
}
const [newKey] = await db
.insert(apiKey)
.values({
id: generateShortId(),
workspaceId,
userId: userId,
createdBy: userId,
name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({
id: apiKey.id,
name: apiKey.name,
createdAt: apiKey.createdAt,
})
try {
PlatformEvents.apiKeyGenerated({
userId: userId,
keyName: name,
})
} catch {
// Telemetry should not fail the operation
const result = await performCreateWorkspaceApiKey({
workspaceId,
userId,
name,
source,
actorName: session.user.name,
actorEmail: session.user.email,
})
if (!result.success || !result.key) {
const status = result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
captureServerEvent(
@@ -174,25 +130,8 @@ export const POST = withRouteHandler(
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: newKey.id,
resourceName: name,
description: `Created API key "${name}"`,
metadata: { keyName: name, keyType: 'workspace', source: source ?? 'settings' },
request,
})
return NextResponse.json({
key: {
...newKey,
key: plainKey,
},
key: result.key,
})
} catch (error: unknown) {
logger.error(`[${requestId}] Workspace API key POST error`, error)
@@ -1,4 +1,3 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files'
@@ -6,7 +5,7 @@ import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreWorkspaceFileAPI')
@@ -38,28 +37,22 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
await restoreWorkspaceFile(workspaceId, fileId)
const result = await performRestoreWorkspaceFile({
workspaceId,
fileId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json(
{ error: result.error },
{ status: result.errorCode === 'conflict' ? 409 : 500 }
)
}
logger.info(`[${requestId}] Restored workspace file ${fileId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_RESTORED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: fileId,
description: `Restored workspace file ${fileId}`,
request,
})
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof FileConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
@@ -1,4 +1,3 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -9,11 +8,11 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
deleteWorkspaceFile,
FileConflictError,
renameWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
performDeleteWorkspaceFileItems,
performRenameWorkspaceFile,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic'
@@ -51,26 +50,30 @@ export const PATCH = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name)
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`)
recordAudit({
const result = await performRenameWorkspaceFile({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPDATED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
resourceName: updatedFile.name,
description: `Renamed file to "${updatedFile.name}"`,
request,
fileId,
name,
userId: session.user.id,
})
if (!result.success || !result.file) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: result.errorCode === 'conflict' ? 409 : 500 }
)
}
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`)
captureServerEvent(
session.user.id,
'file_renamed',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({
success: true,
file: updatedFile,
file: result.file,
})
} catch (error) {
logger.error(`[${requestId}] Error renaming workspace file:`, error)
@@ -79,7 +82,7 @@ export const PATCH = withRouteHandler(
success: false,
error: error instanceof Error ? error.message : 'Failed to rename file',
},
{ status: error instanceof FileConflictError ? 409 : 500 }
{ status: 500 }
)
}
}
@@ -120,22 +123,33 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
await deleteWorkspaceFile(workspaceId, fileId)
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds: [fileId],
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
logger.info(`[${requestId}] Archived workspace file: ${fileId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_DELETED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
description: `Archived file "${fileId}"`,
request,
})
captureServerEvent(
session.user.id,
'file_deleted',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({
success: true,
})
@@ -0,0 +1,70 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileBulkArchiveAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds,
folderIds,
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
if (!result.deletedItems) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workspace file items' },
{ status: 500 }
)
}
captureServerEvent(
session.user.id,
'file_bulk_deleted',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
} catch (error) {
logger.error('Failed to bulk archive workspace file items:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,150 @@
import { createLogger } from '@sim/logger'
import JSZip from 'jszip'
import { type NextRequest, NextResponse } from 'next/server'
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
buildWorkspaceFileFolderPathMap,
fetchWorkspaceFileBuffer,
listWorkspaceFileFolders,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
const logger = createLogger('WorkspaceFilesDownloadAPI')
const MAX_ZIP_DOWNLOAD_FILES = 100
const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024
function safeZipPath(path: string): string {
return path
.split('/')
.map((segment) => {
const cleaned = segment.trim().replace(/[<>:"\\|?*\x00-\x1f]/g, '_')
return cleaned === '.' || cleaned === '..' ? '_' : cleaned
})
.filter(Boolean)
.join('/')
}
function withZipPathSuffix(path: string, suffix: number): string {
const slashIndex = path.lastIndexOf('/')
const directory = slashIndex >= 0 ? `${path.slice(0, slashIndex + 1)}` : ''
const filename = slashIndex >= 0 ? path.slice(slashIndex + 1) : path
const dotIndex = filename.lastIndexOf('.')
return dotIndex > 0
? `${directory}${filename.slice(0, dotIndex)} (${suffix})${filename.slice(dotIndex)}`
: `${directory}${filename} (${suffix})`
}
function collectDescendantFolderIds(
selectedFolderIds: string[],
folders: Array<{ id: string; parentId: string | null }>
): Set<string> {
const folderIds = new Set(selectedFolderIds)
let changed = true
while (changed) {
changed = false
for (const folder of folders) {
if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) {
folderIds.add(folder.id)
changed = true
}
}
}
return folderIds
}
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds } = parsed.data.query
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const [files, folders] = await Promise.all([
listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }),
listWorkspaceFileFolders(workspaceId),
])
const folderPaths = buildWorkspaceFileFolderPathMap(folders)
const selectedFolderIds = collectDescendantFolderIds(folderIds, folders)
const requestedFileIds = new Set(fileIds)
const filesToZip = files.filter(
(file) =>
requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId))
)
if (filesToZip.length === 0) {
return NextResponse.json({ error: 'No files selected for download' }, { status: 400 })
}
if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) {
return NextResponse.json(
{
error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`,
},
{ status: 400 }
)
}
const totalBytes = filesToZip.reduce((sum, file) => sum + file.size, 0)
if (totalBytes > MAX_ZIP_DOWNLOAD_BYTES) {
return NextResponse.json(
{
error: `Selected files total ${formatFileSize(totalBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`,
},
{ status: 400 }
)
}
const buffers = await Promise.all(filesToZip.map((file) => fetchWorkspaceFileBuffer(file)))
// Assemble zip synchronously so path deduplication is deterministic.
const zip = new JSZip()
const usedPaths = new Set<string>()
for (let i = 0; i < filesToZip.length; i++) {
const file = filesToZip[i]
const buffer = buffers[i]
const folderPath = file.folderId ? folderPaths.get(file.folderId) : null
const basePath =
safeZipPath(folderPath ? `${folderPath}/${file.name}` : file.name) ||
safeZipPath(file.name) ||
file.id
let zipPath = basePath
let suffix = 2
while (usedPaths.has(zipPath)) {
zipPath = withZipPathSuffix(basePath, suffix)
suffix++
}
usedPaths.add(zipPath)
zip.file(zipPath, buffer)
}
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' })
return new NextResponse(new Uint8Array(zipBuffer), {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="workspace-files.zip"',
'Cache-Control': 'no-store',
},
})
} catch (error) {
logger.error('Failed to download workspace file selection:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,66 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performRestoreWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolderRestoreAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performRestoreWorkspaceFileFolder({
workspaceId,
folderId,
userId: session.user.id,
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
const { folder, restoredItems } = result
if (!folder || !restoredItems) {
return NextResponse.json(
{ success: false, error: 'Failed to restore workspace file folder' },
{ status: 500 }
)
}
logger.info(`Restored workspace file folder: ${folderId}`)
captureServerEvent(
session.user.id,
'folder_restored',
{ folder_id: folderId, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder, restoredItems })
} catch (error) {
logger.error('Failed to restore workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,121 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteWorkspaceFileFolderContract,
updateWorkspaceFileFolderContract,
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
performDeleteWorkspaceFileItems,
performUpdateWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolderAPI')
async function assertWritePermission(userId: string, workspaceId: string) {
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
return permission === 'admin' || permission === 'write'
}
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
if (!(await assertWritePermission(session.user.id, workspaceId))) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performUpdateWorkspaceFileFolder({
workspaceId,
folderId,
userId: session.user.id,
...parsed.data.body,
})
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
captureServerEvent(
session.user.id,
'folder_renamed',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder: result.folder })
} catch (error) {
logger.error('Failed to update workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, folderId } = parsed.data.params
if (!(await assertWritePermission(session.user.id, workspaceId))) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId: session.user.id,
folderIds: [folderId],
})
if (!result.success) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'validation'
? 400
: result.errorCode === 'not_found'
? 404
: 500,
}
)
}
if (!result.deletedItems) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workspace file folder' },
{ status: 500 }
)
}
captureServerEvent(
session.user.id,
'folder_deleted',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, deletedItems: result.deletedItems })
} catch (error) {
logger.error('Failed to delete workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,88 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
createWorkspaceFileFolderContract,
listWorkspaceFileFoldersContract,
} from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace'
import {
performCreateWorkspaceFileFolder,
workspaceFilesOrchestrationStatus,
} from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFoldersAPI')
async function getWorkspacePermission(userId: string, workspaceId: string) {
return getUserEntityPermissions(userId, 'workspace', workspaceId)
}
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { scope } = parsed.data.query
const permission = await getWorkspacePermission(session.user.id, workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
const folders = await listWorkspaceFileFolders(workspaceId, { scope })
return NextResponse.json({ success: true, folders })
}
)
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { name, parentId } = parsed.data.body
const permission = await getWorkspacePermission(session.user.id, workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performCreateWorkspaceFileFolder({
workspaceId,
userId: session.user.id,
name,
parentId,
})
if (!result.success || !result.folder) {
return NextResponse.json(
{ success: false, error: result.error },
{ status: workspaceFilesOrchestrationStatus(result.errorCode) }
)
}
captureServerEvent(
session.user.id,
'folder_created',
{ workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
return NextResponse.json({ success: true, folder: result.folder })
} catch (error) {
logger.error('Failed to create workspace file folder:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,78 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileMoveAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { fileIds, folderIds, targetFolderId } = parsed.data.body
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
try {
const result = await performMoveWorkspaceFileItems({
workspaceId,
userId: session.user.id,
fileIds,
folderIds,
targetFolderId,
})
if (!result.success || !result.movedItems) {
return NextResponse.json(
{ success: false, error: result.error },
{
status:
result.errorCode === 'conflict'
? 409
: result.errorCode === 'not_found'
? 404
: result.errorCode === 'validation'
? 400
: 500,
}
)
}
if (fileIds.length > 0) {
captureServerEvent(
session.user.id,
'file_moved',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
}
if (folderIds.length > 0) {
captureServerEvent(
session.user.id,
'folder_moved',
{ workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length },
{ groups: { workspace: workspaceId } }
)
}
return NextResponse.json({
success: true,
movedItems: result.movedItems,
})
} catch (error) {
logger.error('Failed to move workspace file items:', error)
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
import { checkStorageQuota } from '@/lib/billing/storage'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
@@ -31,7 +32,7 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { params, body } = parsed.data
const workspaceId = params.id
const { fileName, contentType, fileSize } = body
const { fileName, contentType, fileSize, folderId } = body
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
@@ -46,6 +47,16 @@ export const POST = withRouteHandler(
)
}
let targetFolderId: string | null
try {
targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId)
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Invalid target folder' },
{ status: 400 }
)
}
if (!hasCloudStorage()) {
logger.info(`Local storage detected, signaling API fallback for ${fileName}`)
return NextResponse.json({
@@ -73,7 +84,7 @@ export const POST = withRouteHandler(
userId,
customKey: key,
expirationSeconds: 3600,
metadata: { workspaceId },
metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
})
const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace`
@@ -33,7 +33,7 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { params, body } = parsed.data
const workspaceId = params.id
const { key, name, contentType } = body
const { key, name, contentType, folderId } = body
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin' && permission !== 'write') {
@@ -56,6 +56,7 @@ export const POST = withRouteHandler(
key,
originalName: name,
contentType,
folderId,
})
if (created) {
@@ -123,6 +123,9 @@ export const POST = withRouteHandler(
const formData = await request.formData()
const rawFile = formData.get('file')
const rawFolderId = formData.get('folderId')
const folderId =
typeof rawFolderId === 'string' && rawFolderId.length > 0 ? rawFolderId : null
if (!rawFile || !(rawFile instanceof File)) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
@@ -146,7 +149,8 @@ export const POST = withRouteHandler(
session.user.id,
buffer,
fileName,
rawFile.type || 'application/octet-stream'
rawFile.type || 'application/octet-stream',
{ folderId }
)
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
+7 -1
View File
@@ -41,6 +41,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTabs,
@@ -859,7 +860,9 @@ export default function PlaygroundPage() {
<ModalContent size={size}>
<ModalHeader>Modal {size.toUpperCase()}</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>This is a {size} sized modal.</p>
<ModalDescription className='text-[var(--text-secondary)]'>
This is a {size} sized modal.
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='ghost'>Cancel</Button>
@@ -882,6 +885,9 @@ export default function PlaygroundPage() {
<ModalTabsTrigger value='tab2'>Advanced</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalDescription className='sr-only'>
Modal settings with general and advanced tabs
</ModalDescription>
<ModalTabsContent value='tab1'>
<p className='text-[var(--text-secondary)]'>General settings content</p>
</ModalTabsContent>
@@ -22,6 +22,7 @@ export type {
ResourceCell,
ResourceColumn,
ResourceRow,
RowDragDropConfig,
SelectableConfig,
} from './resource/resource'
export { Resource, ResourceTable } from './resource/resource'
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -239,6 +240,9 @@ export const MessageActions = memo(function MessageActions({
<ModalContent size='sm'>
<ModalHeader>Give feedback</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Submit feedback about this response
</ModalDescription>
<div className='flex flex-col gap-2'>
<div className='flex items-start justify-between gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
@@ -11,6 +11,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -230,6 +231,9 @@ export function OAuthModal(props: OAuthModalProps) {
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Connect your {providerName} account to grant access
</ModalDescription>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex size-[40px] flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-5)]'>
@@ -1,5 +1,14 @@
'use client'
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
type DragEvent,
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -30,12 +39,25 @@ export interface ResourceRow {
export interface SelectableConfig {
selectedIds: Set<string>
onSelectRow: (id: string, checked: boolean) => void
onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void
onSelectAll: (checked: boolean) => void
isAllSelected: boolean
disabled?: boolean
}
export interface RowDragDropConfig {
activeDropTargetId?: string | null
draggedRowIds?: Set<string>
isAnyDragActive?: boolean
isRowDraggable?: (rowId: string) => boolean
isRowDropTarget?: (rowId: string) => boolean
onDragStart?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragOver?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragLeave?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDrop?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
onDragEnd?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
}
export interface PaginationConfig {
currentPage: number
totalPages: number
@@ -51,10 +73,12 @@ interface ResourceProps {
defaultSort?: string
sort?: SortConfig
headerActions?: HeaderAction[]
leadingActions?: ReactNode
columns: ResourceColumn[]
rows: ResourceRow[]
selectedRowId?: string | null
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
@@ -71,8 +95,6 @@ interface ResourceProps {
const EMPTY_CELL_PLACEHOLDER = '- - -'
const SKELETON_ROW_COUNT = 5
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation()
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules, logs).
* Renders the header, toolbar with search, and a data table from column/row definitions.
@@ -86,10 +108,12 @@ export const Resource = memo(function Resource({
defaultSort,
sort: sortOverride,
headerActions,
leadingActions,
columns,
rows,
selectedRowId,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
@@ -113,6 +137,7 @@ export const Resource = memo(function Resource({
breadcrumbs={breadcrumbs}
create={create}
actions={headerActions}
leadingActions={leadingActions}
/>
<ResourceOptionsBar
search={search}
@@ -128,6 +153,7 @@ export const Resource = memo(function Resource({
sort={sortOverride}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
@@ -148,6 +174,7 @@ export interface ResourceTableProps {
sort?: SortConfig
selectedRowId?: string | null
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
@@ -172,6 +199,7 @@ export const ResourceTable = memo(function ResourceTable({
sort: externalSort,
selectedRowId,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
@@ -327,6 +355,7 @@ export const ResourceTable = memo(function ResourceTable({
columns={columns}
selectedRowId={selectedRowId}
selectable={selectable}
rowDragDrop={rowDragDrop}
onRowClick={onRowClick}
onRowHover={onRowHover}
onRowContextMenu={onRowContextMenu}
@@ -442,6 +471,7 @@ interface DataRowProps {
columns: ResourceColumn[]
selectedRowId?: string | null
selectable?: SelectableConfig
rowDragDrop?: RowDragDropConfig
onRowClick?: (rowId: string) => void
onRowHover?: (rowId: string) => void
onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void
@@ -453,16 +483,35 @@ const DataRow = memo(function DataRow({
columns,
selectedRowId,
selectable,
rowDragDrop,
onRowClick,
onRowHover,
onRowContextMenu,
hasCheckbox,
}: DataRowProps) {
const isSelected = selectable?.selectedIds.has(row.id) ?? false
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false
const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0
const handleClick = useCallback(() => {
onRowClick?.(row.id)
}, [onRowClick, row.id])
const handleClick = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>) => {
if (
selectable &&
!selectable.disabled &&
(e.shiftKey || e.metaKey || e.ctrlKey || !onRowClick || hasActiveSelection)
) {
e.preventDefault()
selectable.onSelectRow(row.id, !isSelected, e.shiftKey)
return
}
onRowClick?.(row.id)
},
[hasActiveSelection, isSelected, onRowClick, row.id, selectable]
)
const handleMouseEnter = useCallback(() => {
onRowHover?.(row.id)
@@ -475,25 +524,65 @@ const DataRow = memo(function DataRow({
[onRowContextMenu, row.id]
)
const shiftKeyRef = useRef(false)
const handleSelectRowClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
shiftKeyRef.current = e.shiftKey
}, [])
const handleSelectRow = useCallback(
(checked: boolean | 'indeterminate') => {
selectable?.onSelectRow(row.id, checked as boolean)
selectable?.onSelectRow(row.id, checked as boolean, shiftKeyRef.current)
shiftKeyRef.current = false
},
[selectable, row.id]
)
const handleDragStart = (e: DragEvent<HTMLTableRowElement>) => {
rowDragDrop?.onDragStart?.(e, row.id)
}
const handleDragOver = (e: DragEvent<HTMLTableRowElement>) => {
rowDragDrop?.onDragOver?.(e, row.id)
}
const handleDragLeave = (e: DragEvent<HTMLTableRowElement>) => {
rowDragDrop?.onDragLeave?.(e, row.id)
}
const handleDrop = (e: DragEvent<HTMLTableRowElement>) => {
rowDragDrop?.onDrop?.(e, row.id)
}
const handleDragEnd = (e: DragEvent<HTMLTableRowElement>) => {
rowDragDrop?.onDragEnd?.(e, row.id)
}
return (
<tr
data-resource-row
data-row-id={row.id}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
'transition-colors',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]'
isDraggable && 'cursor-grab active:cursor-grabbing',
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]',
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
)}
onClick={onRowClick ? handleClick : undefined}
data-drop-target={isDropTarget || undefined}
draggable={isDraggable}
onClick={onRowClick || selectable ? handleClick : undefined}
onMouseEnter={handleMouseEnter}
onContextMenu={onRowContextMenu ? handleContextMenu : undefined}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragOver={isDropTarget ? handleDragOver : undefined}
onDragLeave={isDropTarget ? handleDragLeave : undefined}
onDrop={isDropTarget ? handleDrop : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
>
{hasCheckbox && selectable && (
<td className='w-[52px] py-2.5 pr-0 pl-5 align-middle'>
@@ -503,7 +592,7 @@ const DataRow = memo(function DataRow({
onCheckedChange={handleSelectRow}
disabled={selectable.disabled}
aria-label='Select row'
onClick={stopPropagation}
onClick={handleSelectRowClick}
/>
</td>
)}
@@ -553,22 +642,22 @@ interface ResourceColGroupProps {
hasCheckbox?: boolean
}
const CHECKBOX_WEIGHT = 0.4
const ResourceColGroup = memo(function ResourceColGroup({
columns,
hasCheckbox,
}: ResourceColGroupProps) {
const weights = columns.map(
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
)
const total = (hasCheckbox ? CHECKBOX_WEIGHT : 0) + weights.reduce((s, w) => s + w, 0)
return (
<colgroup>
{hasCheckbox && <col className='w-[52px]' />}
{hasCheckbox && <col style={{ width: `${(CHECKBOX_WEIGHT / total) * 100}%` }} />}
{columns.map((col, colIdx) => (
<col
key={col.id}
style={
colIdx === 0
? { width: 400 * (col.widthMultiplier ?? 1) }
: { width: 160 * (col.widthMultiplier ?? 1) }
}
/>
<col key={col.id} style={{ width: `${(weights[colIdx] / total) * 100}%` }} />
))}
</colgroup>
)
@@ -0,0 +1,127 @@
'use client'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import {
Button,
Download,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Folder,
Tooltip,
Trash2,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FilesActionBarProps {
selectedCount: number
onDownload?: () => void
onMove?: (optionValue: string) => void
moveOptions?: MoveOptionNode[]
onDelete?: () => void
isLoading?: boolean
className?: string
}
export function FilesActionBar({
selectedCount,
onDownload,
onMove,
moveOptions,
onDelete,
isLoading = false,
className,
}: FilesActionBarProps) {
return (
<LazyMotion features={domAnimation}>
<AnimatePresence>
{selectedCount > 0 && (
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className={cn(
'-translate-x-1/2 fixed bottom-6 left-1/2 z-[var(--z-dropdown)] transform',
className
)}
>
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
<span className='px-1 text-[var(--text-secondary)] text-small'>
{selectedCount} selected
</span>
<div className='flex items-center gap-[5px]'>
{onDownload && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDownload}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Download className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Download</Tooltip.Content>
</Tooltip.Root>
)}
{onMove && moveOptions && (
<DropdownMenu>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Folder className='size-[12px]' />
</Button>
</DropdownMenuTrigger>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Move</Tooltip.Content>
</Tooltip.Root>
<DropdownMenuContent
side='top'
align='center'
className='max-h-[240px] overflow-y-auto'
>
{moveOptions.length > 0 && (
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
)}
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuContent>
</DropdownMenu>
)}
{onDelete && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onDelete}
disabled={isLoading}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
>
<Trash2 className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Delete</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
@@ -0,0 +1 @@
export { FilesActionBar } from './action-bar'
@@ -0,0 +1,70 @@
'use client'
import { memo } from 'react'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
interface DeleteConfirmModalProps {
open: boolean
onOpenChange: (open: boolean) => void
fileName?: string
fileCount: number
folderCount: number
onDelete: () => void
isPending: boolean
}
export const DeleteConfirmModal = memo(function DeleteConfirmModal({
open,
onOpenChange,
fileName,
fileCount,
folderCount,
onDelete,
isPending,
}: DeleteConfirmModalProps) {
const totalCount = fileCount + folderCount
const hasFolders = folderCount > 0
const title = totalCount > 1 ? 'Delete Items' : hasFolders ? 'Delete Folder' : 'Delete File'
const consequence = hasFolders
? totalCount > 1
? 'This will also delete files and folders inside any selected folders.'
: 'This will also delete files and folders inside it.'
: totalCount > 1
? 'You can restore them from Recently Deleted in Settings.'
: 'You can restore it from Recently Deleted in Settings.'
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
{fileName ? (
<span className='font-medium text-[var(--text-primary)]'>{fileName}</span>
) : (
`${totalCount} item${totalCount === 1 ? '' : 's'}`
)}
? {consequence}
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
<Button variant='destructive' onClick={onDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
})
@@ -0,0 +1 @@
export { DeleteConfirmModal } from './delete-confirm-modal'
@@ -0,0 +1,114 @@
'use client'
import { memo } from 'react'
import {
Download,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Eye,
Folder,
FolderInput,
Pencil,
Trash2,
} from '@/components/emcn'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
interface FileRowContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onOpen: () => void
onDownload?: () => void
onRename: () => void
onDelete: () => void
onMove?: (optionValue: string) => void
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
}
export const FileRowContextMenu = memo(function FileRowContextMenu({
isOpen,
position,
onClose,
onOpen,
onDownload,
onRename,
onDelete,
onMove,
moveOptions,
canEdit,
selectedCount,
}: FileRowContextMenuProps) {
const isMultiSelect = selectedCount > 1
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
className='pointer-events-none fixed h-px w-px'
style={{ left: `${position.x}px`, top: `${position.y}px` }}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{!isMultiSelect && (
<DropdownMenuItem onSelect={onOpen}>
<Eye />
Open
</DropdownMenuItem>
)}
{onDownload && (
<DropdownMenuItem onSelect={onDownload}>
<Download />
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
</DropdownMenuItem>
)}
{canEdit && (
<>
<DropdownMenuSeparator />
{!isMultiSelect && (
<DropdownMenuItem onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onMove && moveOptions && moveOptions.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput />
{isMultiSelect ? `Move ${selectedCount} items` : 'Move to'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onMove(moveOptions[0].value)}>
<Folder />
{moveOptions[0].label}
</DropdownMenuItem>
{moveOptions.length > 1 && <DropdownMenuSeparator />}
{moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onSelect={onDelete}>
<Trash2 />
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
@@ -0,0 +1 @@
export { FileRowContextMenu } from './file-row-context-menu'
@@ -631,7 +631,7 @@ const STATIC_MARKDOWN_COMPONENTS = {
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className='bg-[var(--surface-2)]'>{children}</thead>
<thead className='bg-[var(--surface-3)]'>{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: React.ReactNode }) => (
@@ -7,15 +7,17 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus, Upload } from '@/components/emcn/icons'
import { FolderPlus, Plus, Upload } from '@/components/emcn/icons'
interface FilesListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCreateFile?: () => void
onCreateFolder?: () => void
onUploadFile?: () => void
disableCreate?: boolean
disableCreateFolder?: boolean
disableUpload?: boolean
}
@@ -24,22 +26,18 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
position,
onClose,
onCreateFile,
onCreateFolder,
onUploadFile,
disableCreate = false,
disableCreateFolder = false,
disableUpload = false,
}: FilesListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
className='pointer-events-none fixed size-px'
style={{ left: position.x, top: position.y }}
tabIndex={-1}
aria-hidden
/>
@@ -56,6 +54,12 @@ export const FilesListContextMenu = memo(function FilesListContextMenu({
New file
</DropdownMenuItem>
)}
{onCreateFolder && (
<DropdownMenuItem disabled={disableCreateFolder} onSelect={onCreateFolder}>
<FolderPlus />
New folder
</DropdownMenuItem>
)}
{onUploadFile && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadFile}>
<Upload />
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/emcn'
import { Folder } from '@/components/emcn/icons'
export interface MoveOptionNode {
value: string
label: string
children: MoveOptionNode[]
}
export function renderMoveOption(
option: MoveOptionNode,
onMove: (value: string) => void
): ReactNode {
if (option.children.length === 0) {
return (
<DropdownMenuItem key={option.value} onSelect={() => onMove(option.value)}>
<Folder />
{option.label}
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={option.value}>
<DropdownMenuSubTrigger>
<Folder />
{option.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onMove(option.value)}>Move here</DropdownMenuItem>
<DropdownMenuSeparator />
{option.children.map((child) => renderMoveOption(child, onMove))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
@@ -69,6 +69,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'Folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
filefolder: {
label: 'File folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
past_chat: {
label: 'Past chat',
renderIcon: ({ className }) => <Blimp className={className} />,
@@ -31,6 +31,7 @@ import { useLogsList } from '@/hooks/queries/logs'
import { useTablesList } from '@/hooks/queries/tables'
import { useTasks } from '@/hooks/queries/tasks'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
export interface AddResourceDropdownProps {
@@ -73,6 +74,7 @@ export function useAvailableResources(
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
const { data: folders = [] } = useFolders(workspaceId)
const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId)
const { data: tasks = [] } = useTasks(workspaceId)
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS)
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])
@@ -119,9 +121,19 @@ export function useAvailableResources(
items: files.map((f) => ({
id: f.id,
name: f.name,
folderId: f.folderId ?? null,
isOpen: existingKeys.has(`file:${f.id}`),
})),
},
{
type: 'filefolder' as const,
items: fileFolders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
isOpen: existingKeys.has(`filefolder:${f.id}`),
})),
},
{
type: 'knowledgebase' as const,
items: (knowledgeBases ?? []).map((kb) => ({
@@ -162,6 +174,7 @@ export function useAvailableResources(
}, [
workflows,
folders,
fileFolders,
tables,
files,
knowledgeBases,
@@ -271,6 +284,91 @@ export function WorkflowFolderTreeItems({ nodes, onSelect }: WorkflowFolderTreeI
)
}
export type FileFolderTreeNode =
| { kind: 'file'; id: string; name: string; isOpen?: boolean }
| { kind: 'folder'; id: string; name: string; isOpen?: boolean; children: FileFolderTreeNode[] }
export function buildFileFolderTree(
fileItems: AvailableItem[],
folderItems: AvailableItem[]
): FileFolderTreeNode[] {
const byFolder = new Map<string | null, AvailableItem[]>()
for (const f of fileItems) {
const key = (f.folderId as string | null | undefined) ?? null
const bucket = byFolder.get(key) ?? []
bucket.push(f)
byFolder.set(key, bucket)
}
const buildLevel = (parentId: string | null): FileFolderTreeNode[] => {
const childFolders = folderItems.filter(
(f) => ((f.parentId as string | null | undefined) ?? null) === parentId
)
const childFiles = byFolder.get(parentId) ?? []
const nodes: FileFolderTreeNode[] = []
for (const folder of childFolders) {
const children = buildLevel(folder.id)
nodes.push({
kind: 'folder',
id: folder.id,
name: folder.name,
isOpen: folder.isOpen,
children,
})
}
for (const file of childFiles) {
nodes.push({ kind: 'file', id: file.id, name: file.name, isOpen: file.isOpen })
}
return nodes
}
return buildLevel(null)
}
interface FileFolderTreeItemsProps {
nodes: FileFolderTreeNode[]
onSelect: (resource: MothershipResource, isOpen?: boolean) => void
}
export function FileFolderTreeItems({ nodes, onSelect }: FileFolderTreeItemsProps) {
return (
<>
{nodes.map((node) =>
node.kind === 'file' ? (
<DropdownMenuItem
key={node.id}
onClick={() => onSelect({ type: 'file', id: node.id, title: node.name }, node.isOpen)}
>
{getResourceConfig('file').renderDropdownItem({
item: { id: node.id, name: node.name },
})}
</DropdownMenuItem>
) : (
<DropdownMenuSub key={node.id}>
<DropdownMenuSubTrigger>
<Folder className='size-[14px]' />
<span>{node.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() =>
onSelect({ type: 'filefolder', id: node.id, title: node.name }, node.isOpen)
}
>
<Folder className='size-[14px]' />
<span>{node.name}</span>
</DropdownMenuItem>
{node.children.length > 0 && (
<FileFolderTreeItems nodes={node.children} onSelect={onSelect} />
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
)}
</>
)
}
export function AddResourceDropdown({
workspaceId,
existingKeys,
@@ -282,7 +380,6 @@ export function AddResourceDropdown({
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const available = useAvailableResources(workspaceId, existingKeys, excludeTypes)
const handleOpenChange = (next: boolean) => {
setOpen(next)
if (!next) {
@@ -308,6 +405,12 @@ export function AddResourceDropdown({
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
}, [available])
const fileFolderTree = useMemo(() => {
const fileGroup = available.find((g) => g.type === 'file')
const fileFolderGroup = available.find((g) => g.type === 'filefolder')
return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? [])
}, [available])
const filtered = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return null
@@ -407,8 +510,28 @@ export function AddResourceDropdown({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{fileFolderTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{(() => {
const Icon = getResourceConfig('file').icon
return <Icon className='size-[14px]' />
})()}
<span>Files</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<FileFolderTreeItems nodes={fileFolderTree} onSelect={select} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{available.map(({ type, items }) => {
if (type === 'workflow' || type === 'folder') return null
if (
type === 'workflow' ||
type === 'folder' ||
type === 'file' ||
type === 'filefolder'
)
return null
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
@@ -1,11 +1,14 @@
export type {
AddResourceDropdownProps,
AvailableItem,
FileFolderTreeNode,
WorkflowTreeNode,
} from './add-resource-dropdown'
export {
AddResourceDropdown,
buildFileFolderTree,
buildWorkflowFolderTree,
FileFolderTreeItems,
useAvailableResources,
WorkflowFolderTreeItems,
} from './add-resource-dropdown'
@@ -27,6 +27,7 @@ import { taskKeys } from '@/hooks/queries/tasks'
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { useWorkflows } from '@/hooks/queries/workflows'
import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
interface DropdownItemRenderProps {
@@ -180,6 +181,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
filefolder: {
type: 'filefolder',
label: 'File Folders',
icon: FolderIcon,
renderTabIcon: (_resource, className) => (
<FolderIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={FolderIcon} />,
},
task: {
type: 'task',
label: 'Tasks',
@@ -232,6 +242,9 @@ const RESOURCE_INVALIDATORS: Record<
folder: (qc) => {
qc.invalidateQueries({ queryKey: folderKeys.lists() })
},
filefolder: (qc, wId) => {
qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) })
},
task: (qc, wId) => {
qc.invalidateQueries({ queryKey: taskKeys.list(wId) })
},
@@ -90,6 +90,7 @@ const RESOURCE_TO_CONTEXT: Record<
table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }),
file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }),
folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }),
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
@@ -14,7 +14,9 @@ import {
import { Plus } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import {
buildFileFolderTree,
buildWorkflowFolderTree,
FileFolderTreeItems,
type useAvailableResources,
WorkflowFolderTreeItems,
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
@@ -75,6 +77,12 @@ export const PlusMenuDropdown = React.memo(
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
}, [availableResources])
const fileFolderTree = useMemo(() => {
const fileGroup = availableResources.find((g) => g.type === 'file')
const fileFolderGroup = availableResources.find((g) => g.type === 'filefolder')
return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? [])
}, [availableResources])
const filteredItems = useMemo(() => {
const rawQuery = isMention ? (mentionQuery ?? '') : search
const q = rawQuery.toLowerCase().trim()
@@ -293,8 +301,28 @@ export const PlusMenuDropdown = React.memo(
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{fileFolderTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{(() => {
const Icon = getResourceConfig('file').icon
return <Icon className='size-[14px]' />
})()}
<span>Files</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='max-w-[min(300px,calc(100vw-32px))]'>
<FileFolderTreeItems nodes={fileFolderTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
.filter(
({ type }) =>
type !== 'workflow' &&
type !== 'folder' &&
type !== 'file' &&
type !== 'filefolder'
)
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
@@ -1,6 +1,14 @@
'use client'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import type { ChunkData } from '@/lib/knowledge/types'
import { useDeleteChunk } from '@/hooks/queries/kb/knowledge'
@@ -34,9 +42,9 @@ export function DeleteChunkModal({
<ModalContent size='sm'>
<ModalHeader>Delete Chunk</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete this chunk? This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' disabled={isDeleting} onClick={onClose}>
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Trash,
@@ -390,6 +391,9 @@ export function DocumentTagsModal({
<span>Document Tags</span>
</div>
</ModalHeader>
<ModalDescription className='sr-only'>
View and edit tags assigned to this document
</ModalDescription>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
@@ -11,6 +11,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Trash,
@@ -72,9 +73,9 @@ function UnsavedChangesModal({
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onKeepEditing}>
@@ -1172,7 +1173,7 @@ export function Document({
<ModalContent size='sm'>
<ModalHeader>Delete Document</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{effectiveDocumentName}
@@ -1191,7 +1192,7 @@ export function Document({
) : (
<>This action cannot be undone.</>
)}
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -19,6 +19,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Tooltip,
@@ -1177,7 +1178,7 @@ export function KnowledgeBase({
<ModalContent size='sm'>
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
<span className='text-[var(--text-error)]'>
@@ -1185,7 +1186,7 @@ export function KnowledgeBase({
{pagination.total === 1 ? '' : 's'} within it will be removed.
</span>{' '}
You can restore it from Recently Deleted in Settings.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -1209,7 +1210,7 @@ export function KnowledgeBase({
{(() => {
const docToDelete = documents.find((doc) => doc.id === documentToDelete)
return (
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{docToDelete?.filename ?? 'this document'}
@@ -1228,7 +1229,7 @@ export function KnowledgeBase({
This action cannot be undone.
</>
)}
</p>
</ModalDescription>
)
})()}
</ModalBody>
@@ -1253,7 +1254,7 @@ export function KnowledgeBase({
<ModalContent size='sm'>
<ModalHeader>Delete Documents</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete {selectedDocuments.size} document
{selectedDocuments.size === 1 ? '' : 's'}?{' '}
<span className='text-[var(--text-error)]'>
@@ -1261,7 +1262,7 @@ export function KnowledgeBase({
{selectedDocuments.size === 1 ? '' : 's'}.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowBulkDeleteModal(false)}>
@@ -1306,6 +1307,9 @@ export function KnowledgeBase({
<Modal open={showConnectorsModal} onOpenChange={setShowConnectorsModal}>
<ModalContent size='lg'>
<ModalHeader>Connected Sources</ModalHeader>
<ModalDescription className='sr-only'>
Manage connected data sources for this knowledge base
</ModalDescription>
<ModalBody>
<ConnectorsSection
workspaceId={workspaceId}
@@ -16,6 +16,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Tooltip,
@@ -225,6 +226,11 @@ export function AddConnectorModal({
)}
{step === 'select-type' ? 'Connect Source' : `Configure ${connectorConfig?.name}`}
</ModalHeader>
<ModalDescription className='sr-only'>
{step === 'select-type'
? 'Select a data source to connect to this knowledge base'
: `Configure the ${connectorConfig?.name} connector settings`}
</ModalDescription>
<ModalBody>
{step === 'select-type' ? (
@@ -11,6 +11,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -222,6 +223,9 @@ export function AddDocumentsModal({
<Modal open={open} onOpenChange={handleOpenChange}>
<ModalContent size='md'>
<ModalHeader>New Documents</ModalHeader>
<ModalDescription className='sr-only'>
Upload files to add as documents to this knowledge base
</ModalDescription>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
@@ -11,6 +11,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Trash,
@@ -261,6 +262,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<span>Tags</span>
</div>
</ModalHeader>
<ModalDescription className='sr-only'>
Manage tag definitions for this knowledge base
</ModalDescription>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
@@ -415,14 +419,14 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody>
<div className='space-y-2'>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag?{' '}
<span className='text-[var(--text-error)]'>
This will remove this tag from {selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
<div className='flex flex-col gap-2'>
@@ -460,11 +464,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody>
<div className='space-y-2'>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
{selectedTagUsage?.documentCount || 0} document
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
definition.
</p>
</ModalDescription>
{selectedTagUsage?.documentCount === 0 ? (
<div className='rounded-md border p-4 text-center'>
@@ -23,6 +23,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -241,10 +242,10 @@ export function ConnectorsSection({
<ModalContent size='sm'>
<ModalHeader>Remove Connector</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)] text-sm'>
<ModalDescription className='text-[var(--text-secondary)] text-sm'>
This will disconnect the source and stop future syncs. Documents already synced will
remain in the knowledge base unless you choose to delete them.
</p>
</ModalDescription>
<div className='mt-3 flex items-center gap-2'>
<Checkbox
id='delete-docs'
@@ -14,6 +14,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTabs,
@@ -216,6 +217,9 @@ export function EditConnectorModal({
Edit {displayName}
</div>
</ModalHeader>
<ModalDescription className='sr-only'>
Configure settings and manage documents for this connector
</ModalDescription>
<ModalTabs value={activeTab} onValueChange={setActiveTab}>
<ModalTabsList>
@@ -9,6 +9,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -82,6 +83,7 @@ export function RenameDocumentModal({
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalHeader>Rename Document</ModalHeader>
<ModalDescription className='sr-only'>Enter a new name for this document</ModalDescription>
<form onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-4'>
<div className='space-y-3'>
@@ -18,6 +18,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -373,6 +374,9 @@ export const CreateBaseModal = memo(function CreateBaseModal({
<Modal open={open} onOpenChange={handleClose}>
<ModalContent size='lg'>
<ModalHeader>Create Knowledge Base</ModalHeader>
<ModalDescription className='sr-only'>
Set up a new knowledge base with documents and chunking options
</ModalDescription>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
@@ -1,7 +1,15 @@
'use client'
import { memo } from 'react'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
interface DeleteKnowledgeBaseModalProps {
/**
@@ -42,7 +50,7 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
<ModalContent size='sm'>
<ModalHeader>Delete Knowledge Base</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
{knowledgeBaseName ? (
<>
Are you sure you want to delete{' '}
@@ -60,7 +68,7 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
</>
)}{' '}
You can restore it from Recently Deleted in Settings.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose} disabled={isDeleting}>
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -103,6 +104,9 @@ export const EditKnowledgeBaseModal = memo(function EditKnowledgeBaseModal({
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent size='sm'>
<ModalHeader>Edit Knowledge Base</ModalHeader>
<ModalDescription className='sr-only'>
Update the name and description of this knowledge base
</ModalDescription>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody>
@@ -14,6 +14,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalHeader,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
@@ -216,7 +217,12 @@ export function ExecutionSnapshot({
<ModalContent size='full' className='flex h-[90vh] flex-col'>
<ModalHeader>Workflow State</ModalHeader>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>{renderContent()}</ModalBody>
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
<ModalDescription className='sr-only'>
View the workflow state snapshot for this execution
</ModalDescription>
{renderContent()}
</ModalBody>
</ModalContent>
</Modal>
{canvasContextMenu}
@@ -13,6 +13,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTabs,
@@ -1203,6 +1204,9 @@ export const NotificationSettings = memo(function NotificationSettings({
<ModalContent size='lg'>
<ModalHeader>Notifications</ModalHeader>
<ModalDescription className='sr-only'>
Manage webhook, email, and Slack notification subscriptions for this workflow
</ModalDescription>
<ModalTabs
value={activeTab}
onValueChange={(value: string) => {
@@ -1300,12 +1304,12 @@ export const NotificationSettings = memo(function NotificationSettings({
<ModalContent size='sm'>
<ModalHeader>Delete Notification</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
<span className='text-[var(--text-error)]'>
This will permanently remove the notification and stop all deliveries.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -310,6 +311,9 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
<ModalContent size='lg'>
<ModalHeader>{isEditing ? 'Edit scheduled task' : 'Create new scheduled task'}</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
{isEditing ? 'Edit the scheduled task settings' : 'Configure a new scheduled task'}
</ModalDescription>
<div className='flex flex-col gap-4.5'>
<div className='flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Title</p>
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -447,13 +448,13 @@ export function ScheduledTasks() {
<ModalContent size='sm'>
<ModalHeader>Delete Scheduled Task</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{activeTask?.jobTitle || 'this task'}
</span>
? This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -378,14 +379,14 @@ export function ApiKeys() {
<ModalContent size='sm'>
<ModalHeader>Delete Sim key</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>{deleteKey?.name}</span>{' '}
<span className='text-[var(--text-error)]'>
will immediately revoke access for any integrations using it.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
SecretReveal,
@@ -112,11 +113,11 @@ export function CreateApiKeyModal({
<ModalContent size='md'>
<ModalHeader>Create new Sim key</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</p>
</ModalDescription>
<div className='mt-4 flex flex-col gap-4.5'>
{canManageWorkspaceKeys && (
@@ -209,12 +210,12 @@ export function CreateApiKeyModal({
<ModalContent size='sm'>
<ModalHeader>Your Sim key has been created</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
This is the only time you will see your Sim key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
</ModalDescription>
{newKey && <SecretReveal value={newKey.key} className='mt-2.5' />}
</ModalBody>
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -323,10 +324,10 @@ export function BYOK() {
)}
</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '}
requests in this workspace. Your key is encrypted and stored securely.
</p>
</ModalDescription>
<div className='mt-4 flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Enter your API key</p>
@@ -406,7 +407,7 @@ export function BYOK() {
<ModalContent size='sm'>
<ModalHeader>Delete API Key</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete the{' '}
<span className='font-medium text-[var(--text-primary)]'>
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
@@ -416,7 +417,7 @@ export function BYOK() {
This workspace will revert to using platform hosted keys.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setDeleteConfirmProvider(null)}>
@@ -11,6 +11,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
SecretReveal,
@@ -256,10 +257,10 @@ export function Copilot() {
<ModalContent size='sm'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
This key will allow access to Copilot features. Make sure to copy it after creation as
you won't be able to see it again.
</p>
</ModalDescription>
<div className='mt-4 flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
@@ -316,12 +317,12 @@ export function Copilot() {
<ModalContent size='sm'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
</ModalDescription>
{newKey && <SecretReveal value={newKey} className='mt-2.5' />}
</ModalBody>
@@ -333,7 +334,7 @@ export function Copilot() {
<ModalContent size='sm'>
<ModalHeader>Delete API key</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Deleting{' '}
<span className='font-medium text-[var(--text-primary)]'>
{deleteKey?.name || 'Unnamed Key'}
@@ -342,7 +343,7 @@ export function Copilot() {
will immediately revoke access for any integrations using it.
</span>{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button
@@ -15,6 +15,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -778,6 +779,9 @@ export function CredentialSets() {
<ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Create a new polling group for email accounts
</ModalDescription>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-1'>
<Label>Name</Label>
@@ -850,13 +854,13 @@ export function CredentialSets() {
<ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to leave{' '}
<span className='font-medium text-[var(--text-primary)]'>
{leavingMembership?.name}
</span>
? Your email account will no longer be polled in workflows using this group.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setLeavingMembership(null)}>
@@ -877,11 +881,11 @@ export function CredentialSets() {
<ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingSet?.name}</span>?{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setDeletingSet(null)}>
@@ -4,7 +4,15 @@ import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { CustomToolSkeleton } from '@/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tool-skeleton'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
@@ -189,11 +197,11 @@ export function CustomTools() {
<ModalContent size='sm'>
<ModalHeader>Delete Custom Tool</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{toolToDelete?.name}</span>?{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
@@ -13,6 +13,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Switch,
@@ -509,11 +510,11 @@ export function General() {
<ModalContent size='sm'>
<ModalHeader>Reset Password</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
A password reset link will be sent to{' '}
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
Click the link in the email to create a new password.
</p>
</ModalDescription>
{resetPassword.error && (
<p className='mt-2 text-[var(--text-error)] text-small'>
{resetPassword.error.message}
@@ -9,6 +9,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Switch,
@@ -82,10 +83,10 @@ export function InboxEnableToggle() {
<ModalContent size='sm'>
<ModalHeader>Enable email inbox</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
An email address will be created for this workspace. Anyone in the allowed senders
list can email it to create tasks.
</p>
</ModalDescription>
<div className='mt-4 flex flex-col gap-2'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Custom email prefix (optional)
@@ -116,7 +117,7 @@ export function InboxEnableToggle() {
<ModalContent size='sm'>
<ModalHeader>Disable email inbox</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to disable the inbox
{config?.address && (
<>
@@ -126,7 +127,7 @@ export function InboxEnableToggle() {
)}
? Any emails sent to this address after disabling will not be delivered. This action
cannot be undone.
</p>
</ModalDescription>
<p className='mt-2 text-[var(--text-secondary)]'>
Your existing conversations and task history will be preserved.
</p>
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -239,6 +240,9 @@ export function InboxSettingsTab() {
<ModalContent size='sm'>
<ModalHeader>Add allowed sender</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Add an email address to the allowed senders list
</ModalDescription>
<div className='flex flex-col gap-3'>
<div className='flex flex-col gap-1'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>Email address</p>
@@ -287,12 +291,12 @@ export function InboxSettingsTab() {
<ModalContent size='sm'>
<ModalHeader>Change email address</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Changing your email address will create a new inbox.{' '}
<span className='font-medium text-[var(--text-primary)]'>
The old address will stop receiving emails immediately.
</span>
</p>
</ModalDescription>
<div className='mt-4 flex flex-col gap-1'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>New email prefix</p>
<EmcnInput
@@ -16,6 +16,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -663,6 +664,9 @@ export function IntegrationsManager() {
<>
<ModalHeader>Connect Integration</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Select a service to connect an integration
</ModalDescription>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2 rounded-[8px] border border-[var(--border)] bg-transparent px-2 py-[5px]'>
<Search
@@ -743,6 +747,9 @@ export function IntegrationsManager() {
</div>
</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Connect your OAuth account for this integration
</ModalDescription>
{(createError || existingOAuthDisplayName) && (
<div className='mb-3 flex flex-col gap-2'>
{createError && (
@@ -891,13 +898,13 @@ export function IntegrationsManager() {
<ModalContent size='sm'>
<ModalHeader>Disconnect Integration</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to disconnect{' '}
<span className='font-medium text-[var(--text-primary)]'>
{credentialToDelete?.displayName}
</span>
? This action cannot be undone.
</p>
</ModalDescription>
{deleteError && (
<div className='mt-3 rounded-lg border border-[color-mix(in_srgb,var(--text-error)_40%,transparent)] bg-[color-mix(in_srgb,var(--text-error)_10%,transparent)] p-3'>
<div className='flex items-start gap-2.5'>
@@ -930,9 +937,9 @@ export function IntegrationsManager() {
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
@@ -8,6 +8,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -597,6 +598,10 @@ export function McpServerFormModal({
<ModalContent>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Configure an MCP server by entering the server URL and optional headers, or paste a JSON
configuration.
</ModalDescription>
{formMode === 'json' ? (
<div className='flex flex-col gap-2'>
<Textarea
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Tooltip,
@@ -689,11 +690,11 @@ export function MCP({ initialServerId }: MCPProps) {
<ModalContent size='sm'>
<ModalHeader>Delete MCP Server</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{serverToDelete?.name}</span>
? This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
@@ -3,7 +3,7 @@
import { useMemo, useState } from 'react'
import { toError } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
import { Folder, Search } from 'lucide-react'
import { Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Button,
@@ -13,6 +13,7 @@ import {
SModalTabsList,
SModalTabsTrigger,
} from '@/components/emcn'
import { Folder } from '@/components/emcn/icons'
import { workflowBorderColor } from '@/lib/workspaces/colors'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
@@ -21,11 +22,22 @@ import { useFolders, useRestoreFolder } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import {
useRestoreWorkspaceFileFolder,
useWorkspaceFileFolders,
} from '@/hooks/queries/workspace-file-folders'
import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowFolder } from '@/stores/folders/types'
type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file' | 'folder'
type ResourceType =
| 'all'
| 'workflow'
| 'table'
| 'knowledge'
| 'file'
| 'folder'
| 'workspace_folder'
function getResourceHref(
workspaceId: string,
@@ -44,6 +56,8 @@ function getResourceHref(
return `${base}/files/${id}`
case 'folder':
return `${base}/w`
case 'workspace_folder':
return `${base}/files?folderId=${id}`
}
}
@@ -82,6 +96,11 @@ interface DeletedResource {
color?: string
}
interface RestoredResourceEntry {
resource: DeletedResource
displayIndex: number
}
const TABS: { id: ResourceType; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'workflow', label: 'Workflows' },
@@ -94,6 +113,7 @@ const TABS: { id: ResourceType; label: string }[] = [
const TYPE_LABEL: Record<Exclude<ResourceType, 'all'>, string> = {
workflow: 'Workflow',
folder: 'Folder',
workspace_folder: 'File Folder',
table: 'Table',
knowledge: 'Knowledge Base',
file: 'File',
@@ -114,7 +134,7 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
)
}
if (resource.type === 'folder') {
if (resource.type === 'folder' || resource.type === 'workspace_folder') {
const color = resource.color ?? '#6B7280'
return <Folder className={ICON_CLASS} style={{ color }} />
}
@@ -128,6 +148,12 @@ function ResourceIcon({ resource }: { resource: DeletedResource }) {
)
}
function matchesActiveTab(resource: DeletedResource, activeTab: ResourceType): boolean {
if (activeTab === 'all') return true
if (activeTab === 'file') return resource.type === 'file' || resource.type === 'workspace_folder'
return resource.type === activeTab
}
export function RecentlyDeleted() {
const params = useParams()
const router = useRouter()
@@ -136,7 +162,7 @@ export function RecentlyDeleted() {
const [searchTerm, setSearchTerm] = useState('')
const [activeSort, setActiveSort] = useState<SortConfig | null>(null)
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
const [restoredItems, setRestoredItems] = useState<Map<string, RestoredResourceEntry>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' })
const foldersQuery = useFolders(workspaceId, { scope: 'archived' })
@@ -144,26 +170,30 @@ export function RecentlyDeleted() {
const tablesQuery = useTablesList(workspaceId, 'archived')
const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
const workspaceFoldersQuery = useWorkspaceFileFolders(workspaceId, 'archived')
const restoreWorkflow = useRestoreWorkflow()
const restoreFolder = useRestoreFolder()
const restoreTable = useRestoreTable()
const restoreKnowledgeBase = useRestoreKnowledgeBase()
const restoreWorkspaceFile = useRestoreWorkspaceFile()
const restoreWorkspaceFileFolder = useRestoreWorkspaceFileFolder()
const isLoading =
workflowsQuery.isLoading ||
foldersQuery.isLoading ||
tablesQuery.isLoading ||
knowledgeQuery.isLoading ||
filesQuery.isLoading
filesQuery.isLoading ||
workspaceFoldersQuery.isLoading
const error =
workflowsQuery.error ||
foldersQuery.error ||
tablesQuery.error ||
knowledgeQuery.error ||
filesQuery.error
filesQuery.error ||
workspaceFoldersQuery.error
const resources = useMemo<DeletedResource[]>(() => {
const items: DeletedResource[] = []
@@ -220,11 +250,14 @@ export function RecentlyDeleted() {
})
}
const itemIds = new Set(items.map((i) => i.id))
for (const [id, resource] of restoredItems) {
if (!itemIds.has(id)) {
items.push(resource)
}
for (const wf of workspaceFoldersQuery.data ?? []) {
items.push({
id: wf.id,
name: wf.name,
type: 'workspace_folder',
deletedAt: wf.deletedAt ? new Date(wf.deletedAt) : new Date(wf.updatedAt),
workspaceId: wf.workspaceId,
})
}
return items
@@ -234,19 +267,19 @@ export function RecentlyDeleted() {
tablesQuery.data,
knowledgeQuery.data,
filesQuery.data,
workspaceFoldersQuery.data,
workspaceId,
restoredItems,
])
const filtered = useMemo(() => {
let items = activeTab === 'all' ? resources : resources.filter((r) => r.type === activeTab)
let items = resources.filter((resource) => matchesActiveTab(resource, activeTab))
if (searchTerm.trim()) {
const normalized = searchTerm.toLowerCase()
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
}
const col = (activeSort ?? DEFAULT_SORT).column
const dir = (activeSort ?? DEFAULT_SORT).direction
return [...items].sort((a, b) => {
items = [...items].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
@@ -261,7 +294,22 @@ export function RecentlyDeleted() {
}
return dir === 'asc' ? cmp : -cmp
})
}, [resources, activeTab, searchTerm, activeSort])
const itemIds = new Set(items.map((item) => item.id))
for (const [id, entry] of restoredItems) {
if (itemIds.has(id)) continue
if (!matchesActiveTab(entry.resource, activeTab)) continue
if (
searchTerm.trim() &&
!entry.resource.name.toLowerCase().includes(searchTerm.toLowerCase())
) {
continue
}
items.splice(Math.min(entry.displayIndex, items.length), 0, entry.resource)
}
return items
}, [resources, activeTab, searchTerm, activeSort, restoredItems])
const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0
const selectedSort = activeSort ?? DEFAULT_SORT
@@ -280,50 +328,61 @@ export function RecentlyDeleted() {
current = current.parentId ? byId.get(current.parentId) : undefined
}
}
router.push(getResourceHref(resource.workspaceId, resource.type, resource.id))
const href = getResourceHref(resource.workspaceId, resource.type, resource.id)
router.push(href)
}
function handleRestore(resource: DeletedResource) {
async function handleRestore(resource: DeletedResource) {
const displayIndex = Math.max(
0,
filtered.findIndex((item) => item.id === resource.id)
)
setRestoringIds((prev) => new Set(prev).add(resource.id))
const onSettled = () => {
try {
switch (resource.type) {
case 'workflow':
await restoreWorkflow.mutateAsync({
workflowId: resource.id,
workspaceId: resource.workspaceId,
})
break
case 'folder':
await restoreFolder.mutateAsync({
folderId: resource.id,
workspaceId: resource.workspaceId,
})
break
case 'table':
await restoreTable.mutateAsync(resource.id)
break
case 'knowledge':
await restoreKnowledgeBase.mutateAsync(resource.id)
break
case 'file':
await restoreWorkspaceFile.mutateAsync({
workspaceId: resource.workspaceId,
fileId: resource.id,
})
break
case 'workspace_folder':
await restoreWorkspaceFileFolder.mutateAsync({
workspaceId: resource.workspaceId,
folderId: resource.id,
})
break
}
setRestoredItems((prev) => new Map(prev).set(resource.id, { resource, displayIndex }))
} catch {
return
} finally {
setRestoringIds((prev) => {
const next = new Set(prev)
next.delete(resource.id)
return next
})
}
const onSuccess = () => {
setRestoredItems((prev) => new Map(prev).set(resource.id, resource))
}
switch (resource.type) {
case 'workflow':
restoreWorkflow.mutate(
{ workflowId: resource.id, workspaceId: resource.workspaceId },
{ onSettled, onSuccess }
)
break
case 'folder':
restoreFolder.mutate(
{ folderId: resource.id, workspaceId: resource.workspaceId },
{ onSettled, onSuccess }
)
break
case 'table':
restoreTable.mutate(resource.id, { onSettled, onSuccess })
break
case 'knowledge':
restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess })
break
case 'file':
restoreWorkspaceFile.mutate(
{ workspaceId: resource.workspaceId, fileId: resource.id },
{ onSettled, onSuccess }
)
break
}
}
return (
@@ -418,7 +477,11 @@ export function RecentlyDeleted() {
</span>
</div>
{isRestored ? (
{isRestoring ? (
<Button variant='primary' size='sm' disabled className='shrink-0'>
Restoring...
</Button>
) : isRestored ? (
<div className='flex shrink-0 items-center gap-2'>
<span className='text-[var(--text-tertiary)] text-small'>Restored</span>
<Button variant='primary' size='sm' onClick={() => handleView(resource)}>
@@ -429,11 +492,10 @@ export function RecentlyDeleted() {
<Button
variant='primary'
size='sm'
disabled={isRestoring}
onClick={() => handleRestore(resource)}
onClick={() => void handleRestore(resource)}
className='shrink-0'
>
{isRestoring ? 'Restoring...' : 'Restore'}
Restore
</Button>
)}
</div>
@@ -16,6 +16,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -1423,9 +1424,9 @@ export function SecretsManager() {
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDetailUnsavedChanges(false)}>
@@ -1442,9 +1443,9 @@ export function SecretsManager() {
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChanges(false)}>
@@ -1642,9 +1643,9 @@ export function SecretsManager() {
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChanges(false)}>
@@ -10,6 +10,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTabs,
@@ -239,7 +240,12 @@ export function SkillModal({
{isEditing ? (
<>
<ModalHeader>Edit Skill</ModalHeader>
<ModalBody>{createForm}</ModalBody>
<ModalBody>
<ModalDescription className='sr-only'>
Edit an existing skill definition.
</ModalDescription>
{createForm}
</ModalBody>
{footer}
</>
) : (
@@ -255,6 +261,9 @@ export function SkillModal({
<ModalTabsTrigger value='import'>Import</ModalTabsTrigger>
</ModalTabsList>
<ModalBody>
<ModalDescription className='sr-only'>
Create a new skill or import one from a file.
</ModalDescription>
<ModalTabsContent value='create'>{createForm}</ModalTabsContent>
<ModalTabsContent value='import'>
<SkillImport onImport={handleImport} />
@@ -4,7 +4,15 @@ import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { SkillModal } from '@/app/workspace/[workspaceId]/settings/components/skills/components/skill-modal'
import { SkillSkeleton } from '@/app/workspace/[workspaceId]/settings/components/skills/skill-skeleton'
@@ -170,11 +178,11 @@ export function Skills() {
<ModalContent size='sm'>
<ModalHeader>Delete Skill</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{skillToDelete?.name}</span>?{' '}
This action cannot be undone.
</p>
</ModalDescription>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowDeleteDialog(false)}>
@@ -10,6 +10,7 @@ import {
ModalBody,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTrigger,
@@ -143,6 +144,9 @@ export function CreditBalance({
<ModalContent size='sm'>
<ModalHeader>Add Credits</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Purchase additional credits by entering a dollar amount.
</ModalDescription>
{purchaseCredits.isSuccess ? (
<p className='text-center text-[var(--text-primary)] text-small'>
Credits added successfully!
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -1147,9 +1148,9 @@ function TeamPlanModal({ open, onOpenChange, isAnnual, onConfirm }: TeamPlanModa
<ModalContent size='sm'>
<ModalHeader>Get For Team</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
Choose a plan and number of seats for your team. Credits are pooled across all members.
</p>
</ModalDescription>
{/* Plan toggle */}
<div className='mt-4 flex flex-col gap-1'>
@@ -1360,7 +1361,7 @@ function ManagePlanModal({
Manage {currentTier.name} Plan{isTeamPlan ? ' (Team)' : ''}
</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
You're on the{' '}
<span className='font-medium text-[var(--text-primary)]'>{currentTier.name}</span> plan
{isTeamPlan ? ' for your team' : ''}, billed{' '}
@@ -1368,7 +1369,7 @@ function ManagePlanModal({
? `$${currentPlanDollars}/mo${perUnit}`
: `$${actualAnnualTotal}/yr${perUnit} ($${actualDiscountedMonthly}/mo${perUnit})`}
.
</p>
</ModalDescription>
{isLegacyPlan && (
<Badge variant='amber' size='lg' dot className='mt-2'>
@@ -4,6 +4,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
@@ -48,7 +49,7 @@ export function RemoveMemberDialog({
<ModalContent size='sm'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>
<ModalDescription className='text-[var(--text-secondary)]'>
{isSelfRemoval ? (
'Are you sure you want to leave this organization? You will lose access to all team resources.'
) : isExternalRemoval ? (
@@ -66,7 +67,7 @@ export function RemoveMemberDialog({
</>
)}{' '}
This action cannot be undone.
</p>
</ModalDescription>
{!isSelfRemoval && !isExternalRemoval && canReduceSeats && (
<div className='mt-4'>
@@ -7,6 +7,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Tooltip,
@@ -68,7 +69,9 @@ export function TeamSeats({
<ModalContent size='sm'>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p className='text-[var(--text-secondary)]'>{description}</p>
<ModalDescription className='text-[var(--text-secondary)]'>
{description}
</ModalDescription>
<div className='mt-4 flex flex-col gap-1'>
<Label htmlFor='seats' className='text-small'>
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Skeleton,
@@ -90,6 +91,9 @@ export function TransferOwnershipDialog({
<ModalContent size='md'>
<ModalHeader>Leave organization</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Transfer ownership to another member before leaving the organization.
</ModalDescription>
{isLoadingMembers ? (
<div className='space-y-3'>
<Skeleton className='h-4 w-3/4' />
@@ -12,6 +12,7 @@ import {
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
Textarea,
@@ -80,6 +81,10 @@ export function CreateWorkflowMcpServerModal({
<ModalContent>
<ModalHeader>Add New MCP Server</ModalHeader>
<ModalBody>
<ModalDescription className='sr-only'>
Configure a new MCP server by providing a name, description, and optional workflow
access.
</ModalDescription>
<div className='flex flex-col gap-3'>
<FormField label='Server Name'>
<EmcnInput

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