mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
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:
committed by
GitHub
parent
bad21cb23d
commit
c9118e775b
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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}"` },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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'
|
||||
+70
@@ -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'
|
||||
+114
@@ -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 }) => (
|
||||
|
||||
+13
-9
@@ -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} />,
|
||||
|
||||
+125
-2
@@ -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
|
||||
|
||||
+3
@@ -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'
|
||||
|
||||
+13
@@ -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) })
|
||||
},
|
||||
|
||||
+1
@@ -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 }),
|
||||
|
||||
+29
-1
@@ -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)
|
||||
|
||||
+11
-3
@@ -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}>
|
||||
|
||||
+4
@@ -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}
|
||||
|
||||
+6
@@ -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' ? (
|
||||
|
||||
+4
@@ -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'>
|
||||
|
||||
+8
-4
@@ -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'>
|
||||
|
||||
+3
-2
@@ -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'
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+2
@@ -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'>
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+11
-3
@@ -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}>
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+7
-1
@@ -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}
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+8
-4
@@ -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)}>
|
||||
|
||||
+11
-3
@@ -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}
|
||||
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+11
-4
@@ -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)}>
|
||||
|
||||
+5
@@ -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)}>
|
||||
|
||||
+115
-53
@@ -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>
|
||||
|
||||
+7
-6
@@ -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
-1
@@ -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)}>
|
||||
|
||||
+4
@@ -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!
|
||||
|
||||
+5
-4
@@ -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'>
|
||||
|
||||
+3
-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'>
|
||||
|
||||
+4
-1
@@ -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'>
|
||||
|
||||
+4
@@ -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' />
|
||||
|
||||
+5
@@ -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
Reference in New Issue
Block a user