mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
v0.3.44: removing unused routes, whitelabeling terms & policy URLs, e2b remote code execution, copilot improvements
This commit is contained in:
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -2,8 +2,7 @@ name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -56,7 +55,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -70,10 +69,7 @@ jobs:
|
||||
images: ${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,suffix=-${{ matrix.arch }}
|
||||
type=semver,pattern={{version}},suffix=-${{ matrix.arch }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.arch }}
|
||||
type=semver,pattern={{major}}.{{minor}}.{{patch}},suffix=-${{ matrix.arch }}
|
||||
type=raw,value=staging-${{ github.sha }}-${{ matrix.arch }},enable=${{ github.ref == 'refs/heads/staging' }}
|
||||
type=sha,format=long,suffix=-${{ matrix.arch }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -82,7 +78,7 @@ jobs:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=build-v3
|
||||
@@ -93,7 +89,7 @@ jobs:
|
||||
create-manifests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
@@ -119,10 +115,6 @@ jobs:
|
||||
images: ${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}.{{minor}}.{{patch}}
|
||||
type=sha,format=long
|
||||
|
||||
- name: Create and push manifest
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"microsoft_planner",
|
||||
"microsoft_teams",
|
||||
"mistral_parse",
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"notion",
|
||||
"onedrive",
|
||||
|
||||
264
apps/docs/content/docs/tools/mongodb.mdx
Normal file
264
apps/docs/content/docs/tools/mongodb.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
title: MongoDB
|
||||
description: Connect to MongoDB database
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mongodb"
|
||||
color="#E0E0E0"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='currentColor'
|
||||
d='M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#45A538'
|
||||
d='M61.729 110.054c-1.69-1.453-3.439-2.842-5.059-4.37-8.717-8.222-15.093-17.899-18.233-29.566-.865-3.211-1.442-6.474-1.627-9.792-.13-2.322-.318-4.665-.154-6.975.437-6.144 1.325-12.229 3.127-18.147l.099-.138c.175.233.427.439.516.702 1.759 5.18 3.505 10.364 5.242 15.551 5.458 16.3 10.909 32.604 16.376 48.9.107.318.384.579.583.866l-.87 2.969z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#46A037'
|
||||
d='M88.038 42.812c-1.384.206-2.768.403-4.149.616-3.131.485-6.263.968-9.392 1.465-2.622.417-5.242.852-7.862 1.281l-1.602.275-.012-1.045c-.053-.859-.144-1.717-.154-2.576-.069-5.478-.112-10.956-.18-16.434-.042-3.429-.105-6.857-.175-10.285-.043-2.13-.089-4.261-.185-6.388-.052-1.143-.236-2.28-.311-3.423-.042-.657.016-1.319.029-1.979.817 1.583 1.616 3.178 2.456 4.749 1.327 2.484 3.441 4.314 5.344 6.311 7.523 7.892 12.864 17.068 16.193 27.433z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#409433'
|
||||
d='M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#4FAA41'
|
||||
d='M65.036 80.753c-.118 2.982-.23 5.964-.357 8.947-.035.83-.111 1.657-.168 2.485l-.765.289c-1.699-5.002-3.399-9.951-5.062-14.913-2.75-8.209-5.467-16.431-8.213-24.642a4498.887 4498.887 0 00-6.7-19.867c-.105-.31-.407-.552-.617-.826l4.896-9.002c.168.292.39.565.496.879a6167.476 6167.476 0 016.768 20.118c2.916 8.73 5.814 17.467 8.728 26.198.116.349.308.671.491 1.062l.67-.78-.167 10.052z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#4AA73C'
|
||||
d='M43.155 32.227c.21.274.511.516.617.826a4498.887 4498.887 0 016.7 19.867c2.746 8.211 5.463 16.433 8.213 24.642 1.662 4.961 3.362 9.911 5.062 14.913l.765-.289-.171 2.426-.155.559c-.266 2.656-.49 5.318-.814 7.968-.163 1.328-.509 2.632-.772 3.947-.198-.287-.476-.548-.583-.866-5.467-16.297-10.918-32.6-16.376-48.9a3888.972 3888.972 0 00-5.242-15.551c-.089-.263-.34-.469-.516-.702l3.272-8.84z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#57AE47'
|
||||
d='M65.202 70.702l-.67.78c-.183-.391-.375-.714-.491-1.062-2.913-8.731-5.812-17.468-8.728-26.198a6167.476 6167.476 0 00-6.768-20.118c-.105-.314-.327-.588-.496-.879l6.055-7.965c.191.255.463.482.562.769 1.681 4.921 3.347 9.848 5.003 14.778 1.547 4.604 3.071 9.215 4.636 13.813.105.308.47.526.714.786l.012 1.045c.058 8.082.115 16.167.171 24.251z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#60B24F'
|
||||
d='M65.021 45.404c-.244-.26-.609-.478-.714-.786-1.565-4.598-3.089-9.209-4.636-13.813-1.656-4.93-3.322-9.856-5.003-14.778-.099-.287-.371-.514-.562-.769 1.969-1.928 3.877-3.925 5.925-5.764 1.821-1.634 3.285-3.386 3.352-5.968.003-.107.059-.214.145-.514l.519 1.306c-.013.661-.072 1.322-.029 1.979.075 1.143.259 2.28.311 3.423.096 2.127.142 4.258.185 6.388.069 3.428.132 6.856.175 10.285.067 5.478.111 10.956.18 16.434.008.861.098 1.718.152 2.577z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#A9AA88'
|
||||
d='M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#B6B598'
|
||||
d='M62.476 112.621c.196-.246.475-.464.574-.744.731-2.05 1.417-4.115 2.143-6.166.093-.262.341-.469.518-.701l1.255 2.754c-.248.352-.59.669-.728 1.061l-2.404 7.059c-.099.283-.437.483-.663.722l-.695-3.985z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#C2C1A7'
|
||||
d='M63.171 116.605c.227-.238.564-.439.663-.722l2.404-7.059c.137-.391.48-.709.728-1.061l1.215 1.037c-.587.58-.913 1.25-.717 2.097l-.369 1.208c-.168.207-.411.387-.494.624-.839 2.403-1.64 4.819-2.485 7.222-.107.305-.404.544-.614.812-.109-1.387-.22-2.771-.331-4.158z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#CECDB7'
|
||||
d='M63.503 120.763c.209-.269.506-.508.614-.812.845-2.402 1.646-4.818 2.485-7.222.083-.236.325-.417.494-.624l-.509 5.545c-.136.157-.333.294-.398.477-.575 1.614-1.117 3.24-1.694 4.854-.119.333-.347.627-.525.938-.158-.207-.441-.407-.454-.623-.051-.841-.016-1.688-.013-2.533z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#DBDAC7'
|
||||
d='M63.969 123.919c.178-.312.406-.606.525-.938.578-1.613 1.119-3.239 1.694-4.854.065-.183.263-.319.398-.477l.012 3.64-1.218 3.124-1.411-.495z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#EBE9DC'
|
||||
d='M65.38 124.415l1.218-3.124.251 3.696-1.469-.572z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#CECDB7'
|
||||
d='M67.464 110.898c-.196-.847.129-1.518.717-2.097l.337.23-1.054 1.867z'
|
||||
/>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
fill='#4FAA41'
|
||||
d='M64.316 95.172l-.066-.011-.066.01.155-.559-.023.56z'
|
||||
/>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any MongoDB database to execute queries, manage data, and perform database operations. Supports find, insert, update, delete, and aggregation operations with secure connection handling.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `mongodb_query`
|
||||
|
||||
Execute find operation on MongoDB collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | MongoDB server hostname or IP address |
|
||||
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | No | MongoDB username |
|
||||
| `password` | string | No | MongoDB password |
|
||||
| `authSource` | string | No | Authentication database |
|
||||
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
|
||||
| `collection` | string | Yes | Collection name to query |
|
||||
| `query` | string | No | MongoDB query filter as JSON string |
|
||||
| `limit` | number | No | Maximum number of documents to return |
|
||||
| `sort` | string | No | Sort criteria as JSON string |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `documents` | array | Array of documents returned from the query |
|
||||
| `documentCount` | number | Number of documents returned |
|
||||
|
||||
### `mongodb_insert`
|
||||
|
||||
Insert documents into MongoDB collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | MongoDB server hostname or IP address |
|
||||
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | No | MongoDB username |
|
||||
| `password` | string | No | MongoDB password |
|
||||
| `authSource` | string | No | Authentication database |
|
||||
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
|
||||
| `collection` | string | Yes | Collection name to insert into |
|
||||
| `documents` | array | Yes | Array of documents to insert |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `documentCount` | number | Number of documents inserted |
|
||||
| `insertedId` | string | ID of inserted document \(single insert\) |
|
||||
| `insertedIds` | array | Array of inserted document IDs \(multiple insert\) |
|
||||
|
||||
### `mongodb_update`
|
||||
|
||||
Update documents in MongoDB collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | MongoDB server hostname or IP address |
|
||||
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | No | MongoDB username |
|
||||
| `password` | string | No | MongoDB password |
|
||||
| `authSource` | string | No | Authentication database |
|
||||
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
|
||||
| `collection` | string | Yes | Collection name to update |
|
||||
| `filter` | string | Yes | Filter criteria as JSON string |
|
||||
| `update` | string | Yes | Update operations as JSON string |
|
||||
| `upsert` | boolean | No | Create document if not found |
|
||||
| `multi` | boolean | No | Update multiple documents |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `matchedCount` | number | Number of documents matched by filter |
|
||||
| `modifiedCount` | number | Number of documents modified |
|
||||
| `documentCount` | number | Total number of documents affected |
|
||||
| `insertedId` | string | ID of inserted document \(if upsert\) |
|
||||
|
||||
### `mongodb_delete`
|
||||
|
||||
Delete documents from MongoDB collection
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | MongoDB server hostname or IP address |
|
||||
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | No | MongoDB username |
|
||||
| `password` | string | No | MongoDB password |
|
||||
| `authSource` | string | No | Authentication database |
|
||||
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
|
||||
| `collection` | string | Yes | Collection name to delete from |
|
||||
| `filter` | string | Yes | Filter criteria as JSON string |
|
||||
| `multi` | boolean | No | Delete multiple documents |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `deletedCount` | number | Number of documents deleted |
|
||||
| `documentCount` | number | Total number of documents affected |
|
||||
|
||||
### `mongodb_execute`
|
||||
|
||||
Execute MongoDB aggregation pipeline
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Yes | MongoDB server hostname or IP address |
|
||||
| `port` | number | Yes | MongoDB server port \(default: 27017\) |
|
||||
| `database` | string | Yes | Database name to connect to |
|
||||
| `username` | string | No | MongoDB username |
|
||||
| `password` | string | No | MongoDB password |
|
||||
| `authSource` | string | No | Authentication database |
|
||||
| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) |
|
||||
| `collection` | string | Yes | Collection name to execute pipeline on |
|
||||
| `pipeline` | string | Yes | Aggregation pipeline as JSON string |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `documents` | array | Array of documents returned from aggregation |
|
||||
| `documentCount` | number | Number of documents returned |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `mongodb`
|
||||
@@ -304,6 +304,15 @@ export default function LoginPage({
|
||||
return
|
||||
}
|
||||
|
||||
const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase())
|
||||
if (!emailValidation.isValid) {
|
||||
setResetStatus({
|
||||
type: 'error',
|
||||
message: 'Please enter a valid email address',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingReset(true)
|
||||
setResetStatus({ type: null, message: '' })
|
||||
@@ -321,7 +330,23 @@ export default function LoginPage({
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to request password reset')
|
||||
let errorMessage = errorData.message || 'Failed to request password reset'
|
||||
|
||||
if (
|
||||
errorMessage.includes('Invalid body parameters') ||
|
||||
errorMessage.includes('invalid email')
|
||||
) {
|
||||
errorMessage = 'Please enter a valid email address'
|
||||
} else if (errorMessage.includes('Email is required')) {
|
||||
errorMessage = 'Please enter your email address'
|
||||
} else if (
|
||||
errorMessage.includes('user not found') ||
|
||||
errorMessage.includes('User not found')
|
||||
) {
|
||||
errorMessage = 'No account found with this email address'
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
setResetStatus({
|
||||
@@ -497,7 +522,8 @@ export default function LoginPage({
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-neutral-300 text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
@@ -512,14 +538,20 @@ export default function LoginPage({
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
type='email'
|
||||
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20'
|
||||
className={cn(
|
||||
'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20',
|
||||
resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{resetStatus.type && (
|
||||
<div
|
||||
className={`text-sm ${resetStatus.type === 'success' ? 'text-[#4CAF50]' : 'text-red-500'}`}
|
||||
>
|
||||
{resetStatus.message}
|
||||
{resetStatus.type === 'success' && (
|
||||
<div className='mt-1 space-y-1 text-[#4CAF50] text-xs'>
|
||||
<p>{resetStatus.message}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -166,8 +166,9 @@ describe('SignupPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should prevent submission with invalid name validation', async () => {
|
||||
it('should automatically trim spaces from name input', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
@@ -176,22 +177,20 @@ describe('SignupPage', () => {
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
// Use name with leading/trailing spaces which should fail validation
|
||||
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
// Should not call signUp because validation failed
|
||||
expect(mockSignUp).not.toHaveBeenCalled()
|
||||
|
||||
// Should show validation error
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Name cannot contain consecutive spaces|Name cannot start or end with spaces/
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'John Doe',
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -49,10 +49,6 @@ const NAME_VALIDATIONS = {
|
||||
regex: /^(?!.*\s\s).*$/,
|
||||
message: 'Name cannot contain consecutive spaces.',
|
||||
},
|
||||
noLeadingTrailingSpaces: {
|
||||
test: (value: string) => value === value.trim(),
|
||||
message: 'Name cannot start or end with spaces.',
|
||||
},
|
||||
}
|
||||
|
||||
const validateEmailField = (emailValue: string): string[] => {
|
||||
@@ -175,10 +171,6 @@ function SignupFormContent({
|
||||
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.noLeadingTrailingSpaces.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.noLeadingTrailingSpaces.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -193,11 +185,10 @@ function SignupFormContent({
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
setName(newName)
|
||||
const rawValue = e.target.value
|
||||
setName(rawValue)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateName(newName)
|
||||
const errors = validateName(rawValue)
|
||||
setNameErrors(errors)
|
||||
setShowNameValidationError(false)
|
||||
}
|
||||
@@ -224,23 +215,21 @@ function SignupFormContent({
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValue = formData.get('email') as string
|
||||
const passwordValue = formData.get('password') as string
|
||||
const name = formData.get('name') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
// Validate name on submit
|
||||
const nameValidationErrors = validateName(name)
|
||||
const trimmedName = nameValue.trim()
|
||||
|
||||
const nameValidationErrors = validateName(trimmedName)
|
||||
setNameErrors(nameValidationErrors)
|
||||
setShowNameValidationError(nameValidationErrors.length > 0)
|
||||
|
||||
// Validate email on submit
|
||||
const emailValidationErrors = validateEmailField(emailValue)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
// Validate password on submit
|
||||
const errors = validatePassword(passwordValue)
|
||||
setPasswordErrors(errors)
|
||||
|
||||
// Only show validation errors if there are any
|
||||
setShowValidationError(errors.length > 0)
|
||||
|
||||
try {
|
||||
@@ -249,7 +238,6 @@ function SignupFormContent({
|
||||
emailValidationErrors.length > 0 ||
|
||||
errors.length > 0
|
||||
) {
|
||||
// Prioritize name errors first, then email errors, then password errors
|
||||
if (nameValidationErrors.length > 0) {
|
||||
setNameErrors([nameValidationErrors[0]])
|
||||
setShowNameValidationError(true)
|
||||
@@ -266,8 +254,6 @@ function SignupFormContent({
|
||||
return
|
||||
}
|
||||
|
||||
// Check if name will be truncated and warn user
|
||||
const trimmedName = name.trim()
|
||||
if (trimmedName.length > 100) {
|
||||
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
|
||||
setShowNameValidationError(true)
|
||||
@@ -337,7 +323,6 @@ function SignupFormContent({
|
||||
logger.info('Session refreshed after successful signup')
|
||||
} catch (sessionError) {
|
||||
logger.error('Failed to refresh session after signup:', sessionError)
|
||||
// Continue anyway - the verification flow will handle this
|
||||
}
|
||||
|
||||
// For new signups, always require verification
|
||||
|
||||
@@ -215,20 +215,28 @@ export function useVerification({
|
||||
setOtp(value)
|
||||
}
|
||||
|
||||
// Auto-submit when OTP is complete
|
||||
useEffect(() => {
|
||||
if (otp.length === 6 && email && !isLoading && !isVerified) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
verifyCode()
|
||||
}, 300) // Small delay to ensure UI is ready
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [otp, email, isLoading, isVerified])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!isProduction || !hasResendKey) {
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
logger.info('Auto-verifying user', { email: storedEmail })
|
||||
}
|
||||
|
||||
const isDevOrDocker = !isProduction || isTruthy(env.DOCKER_BUILD)
|
||||
|
||||
// Auto-verify and redirect in development/docker environments
|
||||
if (isDevOrDocker || !hasResendKey) {
|
||||
setIsVerified(true)
|
||||
|
||||
// Clear verification requirement cookie (same as manual verification)
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -12,7 +13,7 @@ import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { db } from '@/db'
|
||||
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
|
||||
import { chat, userStats, workflow } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -453,18 +454,21 @@ export async function executeWorkflowForChat(
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get user environment variables for this workflow
|
||||
// Get user environment variables with workspace precedence
|
||||
let envVars: Record<string, string> = {}
|
||||
try {
|
||||
const envResult = await db
|
||||
.select()
|
||||
.from(envTable)
|
||||
.where(eq(envTable.userId, deployment.userId))
|
||||
const wfWorkspaceRow = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (envResult.length > 0 && envResult[0].variables) {
|
||||
envVars = envResult[0].variables as Record<string, string>
|
||||
}
|
||||
const workspaceId = wfWorkspaceRow[0]?.workspaceId || undefined
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
deployment.userId,
|
||||
workspaceId
|
||||
)
|
||||
envVars = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
@@ -286,6 +287,7 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
@@ -337,6 +339,7 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'agent',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
@@ -425,6 +428,7 @@ describe('Copilot Chat API Route', () => {
|
||||
stream: true,
|
||||
streamToolCalls: true,
|
||||
mode: 'ask',
|
||||
messageId: 'mock-uuid-1234-5678',
|
||||
depth: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -50,13 +50,25 @@ const ChatMessageSchema = z.object({
|
||||
contexts: z
|
||||
.array(
|
||||
z.object({
|
||||
kind: z.enum(['past_chat', 'workflow', 'blocks', 'logs', 'knowledge', 'templates']),
|
||||
kind: z.enum([
|
||||
'past_chat',
|
||||
'workflow',
|
||||
'current_workflow',
|
||||
'blocks',
|
||||
'logs',
|
||||
'workflow_block',
|
||||
'knowledge',
|
||||
'templates',
|
||||
'docs',
|
||||
]),
|
||||
label: z.string(),
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
knowledgeId: z.string().optional(),
|
||||
blockId: z.string().optional(),
|
||||
templateId: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
// For workflow_block, provide both workflowId and blockId
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -96,6 +108,8 @@ export async function POST(req: NextRequest) {
|
||||
conversationId,
|
||||
contexts,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
// Ensure we have a consistent user message ID for this request
|
||||
const userMessageIdToUse = userMessageId || crypto.randomUUID()
|
||||
try {
|
||||
logger.info(`[${tracker.requestId}] Received chat POST`, {
|
||||
hasContexts: Array.isArray(contexts),
|
||||
@@ -105,6 +119,7 @@ export async function POST(req: NextRequest) {
|
||||
kind: c?.kind,
|
||||
chatId: c?.chatId,
|
||||
workflowId: c?.workflowId,
|
||||
executionId: (c as any)?.executionId,
|
||||
label: c?.label,
|
||||
}))
|
||||
: undefined,
|
||||
@@ -115,13 +130,18 @@ export async function POST(req: NextRequest) {
|
||||
if (Array.isArray(contexts) && contexts.length > 0) {
|
||||
try {
|
||||
const { processContextsServer } = await import('@/lib/copilot/process-contents')
|
||||
const processed = await processContextsServer(contexts as any, authenticatedUserId)
|
||||
const processed = await processContextsServer(contexts as any, authenticatedUserId, message)
|
||||
agentContexts = processed
|
||||
logger.info(`[${tracker.requestId}] Contexts processed for request`, {
|
||||
processedCount: agentContexts.length,
|
||||
kinds: agentContexts.map((c) => c.type),
|
||||
lengthPreview: agentContexts.map((c) => c.content?.length ?? 0),
|
||||
})
|
||||
if (Array.isArray(contexts) && contexts.length > 0 && agentContexts.length === 0) {
|
||||
logger.warn(
|
||||
`[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.`
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[${tracker.requestId}] Failed to process contexts`, e)
|
||||
}
|
||||
@@ -351,6 +371,7 @@ export async function POST(req: NextRequest) {
|
||||
stream: stream,
|
||||
streamToolCalls: true,
|
||||
mode: mode,
|
||||
messageId: userMessageIdToUse,
|
||||
...(providerConfig ? { provider: providerConfig } : {}),
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
|
||||
@@ -396,7 +417,7 @@ export async function POST(req: NextRequest) {
|
||||
if (stream && simAgentResponse.body) {
|
||||
// Create user message to save
|
||||
const userMessage = {
|
||||
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -474,16 +495,6 @@ export async function POST(req: NextRequest) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check if client disconnected before processing chunk
|
||||
try {
|
||||
// Forward the chunk to client immediately
|
||||
controller.enqueue(value)
|
||||
} catch (error) {
|
||||
// Client disconnected - stop reading from sim agent
|
||||
reader.cancel() // Stop reading from sim agent
|
||||
break
|
||||
}
|
||||
|
||||
// Decode and parse SSE events for logging and capturing content
|
||||
const decodedChunk = decoder.decode(value, { stream: true })
|
||||
buffer += decodedChunk
|
||||
@@ -583,6 +594,47 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
// Emit to client: rewrite 'error' events into user-friendly assistant message
|
||||
if (event?.type === 'error') {
|
||||
try {
|
||||
const displayMessage: string =
|
||||
(event?.data && (event.data.displayMessage as string)) ||
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
const formatted = `_${displayMessage}_`
|
||||
// Accumulate so it persists to DB as assistant content
|
||||
assistantContent += formatted
|
||||
// Send as content chunk
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
|
||||
)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
// Then close this response cleanly for the client
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
// Do not forward the original error event
|
||||
} else {
|
||||
// Forward original event to client
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Enhanced error handling for large payloads and parsing issues
|
||||
const lineLength = line.length
|
||||
@@ -615,10 +667,37 @@ export async function POST(req: NextRequest) {
|
||||
logger.debug(`[${tracker.requestId}] Processing remaining buffer: "${buffer}"`)
|
||||
if (buffer.startsWith('data: ')) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6))
|
||||
const jsonStr = buffer.slice(6)
|
||||
const event = JSON.parse(jsonStr)
|
||||
if (event.type === 'content' && event.data) {
|
||||
assistantContent += event.data
|
||||
}
|
||||
// Forward remaining event, applying same error rewrite behavior
|
||||
if (event?.type === 'error') {
|
||||
const displayMessage: string =
|
||||
(event?.data && (event.data.displayMessage as string)) ||
|
||||
'Sorry, I encountered an error. Please try again.'
|
||||
const formatted = `_${displayMessage}_`
|
||||
assistantContent += formatted
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'content', data: formatted })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
|
||||
)
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${jsonStr}\n\n`))
|
||||
} catch (enqueueErr) {
|
||||
reader.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[${tracker.requestId}] Failed to parse final buffer: "${buffer}"`)
|
||||
}
|
||||
@@ -734,7 +813,7 @@ export async function POST(req: NextRequest) {
|
||||
// Save messages if we have a chat
|
||||
if (currentChat && responseData.content) {
|
||||
const userMessage = {
|
||||
id: userMessageId || crypto.randomUUID(), // Use frontend ID if provided
|
||||
id: userMessageIdToUse, // Consistent ID used for request and persistence
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
80
apps/sim/app/api/copilot/stats/route.ts
Normal file
80
apps/sim/app/api/copilot/stats/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createRequestTracker,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
|
||||
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
const BodySchema = z
|
||||
.object({
|
||||
// Do NOT send id; messageId is the unique correlator
|
||||
userId: z.string().optional(),
|
||||
chatId: z.string().uuid().optional(),
|
||||
messageId: z.string().optional(),
|
||||
depth: z.number().int().nullable().optional(),
|
||||
maxEnabled: z.boolean().nullable().optional(),
|
||||
createdAt: z.union([z.string().datetime(), z.date()]).optional(),
|
||||
diffCreated: z.boolean().nullable().optional(),
|
||||
diffAccepted: z.boolean().nullable().optional(),
|
||||
duration: z.number().int().nullable().optional(),
|
||||
inputTokens: z.number().int().nullable().optional(),
|
||||
outputTokens: z.number().int().nullable().optional(),
|
||||
aborted: z.boolean().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const tracker = createRequestTracker()
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => ({}))
|
||||
const parsed = BodySchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
return createBadRequestResponse('Invalid request body for copilot stats')
|
||||
}
|
||||
const body = parsed.data as any
|
||||
|
||||
// Build outgoing payload for Sim Agent; do not include id
|
||||
const payload: Record<string, any> = {
|
||||
...body,
|
||||
userId: body.userId || userId,
|
||||
createdAt: body.createdAt || new Date().toISOString(),
|
||||
}
|
||||
payload.id = undefined
|
||||
|
||||
const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/stats`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
// Prefer not to block clients; still relay status
|
||||
let agentJson: any = null
|
||||
try {
|
||||
agentJson = await agentRes.json()
|
||||
} catch {}
|
||||
|
||||
if (!agentRes.ok) {
|
||||
const message = (agentJson && (agentJson.error || agentJson.message)) || 'Upstream error'
|
||||
return NextResponse.json({ success: false, error: message }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return createInternalServerErrorResponse('Failed to forward copilot stats')
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import type { EnvironmentVariable } from '@/stores/settings/environment/types'
|
||||
|
||||
const logger = createLogger('EnvironmentAPI')
|
||||
|
||||
// Schema for environment variable updates
|
||||
const EnvVarSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
@@ -30,17 +29,13 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { variables } = EnvVarSchema.parse(body)
|
||||
|
||||
// Encrypt all variables
|
||||
const encryptedVariables = await Object.entries(variables).reduce(
|
||||
async (accPromise, [key, value]) => {
|
||||
const acc = await accPromise
|
||||
const encryptedVariables = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return { ...acc, [key]: encrypted }
|
||||
},
|
||||
Promise.resolve({})
|
||||
)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Replace all environment variables for user
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
@@ -80,7 +75,6 @@ export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
@@ -99,18 +93,15 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ data: {} }, { status: 200 })
|
||||
}
|
||||
|
||||
// Decrypt the variables for client-side use
|
||||
const encryptedVariables = result[0].variables as Record<string, string>
|
||||
const decryptedVariables: Record<string, EnvironmentVariable> = {}
|
||||
|
||||
// Decrypt each variable
|
||||
for (const [key, encryptedValue] of Object.entries(encryptedVariables)) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(encryptedValue)
|
||||
decryptedVariables[key] = { key, value: decrypted }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error decrypting variable ${key}`, error)
|
||||
// If decryption fails, provide a placeholder
|
||||
decryptedVariables[key] = { key, value: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getEnvironmentVariableKeys } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/utils'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('EnvironmentVariablesAPI')
|
||||
|
||||
// Schema for environment variable updates
|
||||
const EnvVarSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// For GET requests, check for workflowId in query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId || undefined)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get only the variable names (keys), not values
|
||||
const result = await getEnvironmentVariableKeys(userId)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: result,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables fetch error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to get environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { workflowId, variables } = body
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables set attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { variables: validatedVariables } = EnvVarSchema.parse({ variables })
|
||||
|
||||
// Get existing environment variables for this user
|
||||
const existingData = await db
|
||||
.select()
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
// Start with existing encrypted variables or empty object
|
||||
const existingEncryptedVariables =
|
||||
(existingData[0]?.variables as Record<string, string>) || {}
|
||||
|
||||
// Determine which variables are new or changed by comparing with decrypted existing values
|
||||
const variablesToEncrypt: Record<string, string> = {}
|
||||
const addedVariables: string[] = []
|
||||
const updatedVariables: string[] = []
|
||||
|
||||
for (const [key, newValue] of Object.entries(validatedVariables)) {
|
||||
if (!(key in existingEncryptedVariables)) {
|
||||
// New variable
|
||||
variablesToEncrypt[key] = newValue
|
||||
addedVariables.push(key)
|
||||
} else {
|
||||
// Check if the value has actually changed by decrypting the existing value
|
||||
try {
|
||||
const { decrypted: existingValue } = await decryptSecret(
|
||||
existingEncryptedVariables[key]
|
||||
)
|
||||
|
||||
if (existingValue !== newValue) {
|
||||
// Value changed, needs re-encryption
|
||||
variablesToEncrypt[key] = newValue
|
||||
updatedVariables.push(key)
|
||||
}
|
||||
// If values are the same, keep the existing encrypted value
|
||||
} catch (decryptError) {
|
||||
// If we can't decrypt the existing value, treat as changed and re-encrypt
|
||||
logger.warn(
|
||||
`[${requestId}] Could not decrypt existing variable ${key}, re-encrypting`,
|
||||
{
|
||||
error: decryptError,
|
||||
}
|
||||
)
|
||||
variablesToEncrypt[key] = newValue
|
||||
updatedVariables.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only encrypt the variables that are new or changed
|
||||
const newlyEncryptedVariables = await Object.entries(variablesToEncrypt).reduce(
|
||||
async (accPromise, [key, value]) => {
|
||||
const acc = await accPromise
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return { ...acc, [key]: encrypted }
|
||||
},
|
||||
Promise.resolve({})
|
||||
)
|
||||
|
||||
// Merge existing encrypted variables with newly encrypted ones
|
||||
const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables }
|
||||
|
||||
// Update or insert environment variables for user
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
variables: finalEncryptedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [environment.userId],
|
||||
set: {
|
||||
variables: finalEncryptedVariables,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`,
|
||||
variableCount: Object.keys(validatedVariables).length,
|
||||
variableNames: Object.keys(validatedVariables),
|
||||
totalVariableCount: Object.keys(finalEncryptedVariables).length,
|
||||
addedVariables,
|
||||
updatedVariables,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid environment variables data`, {
|
||||
errors: validationError.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: validationError.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
throw validationError
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables set error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to set environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { workflowId } = body
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get only the variable names (keys), not values
|
||||
const result = await getEnvironmentVariableKeys(userId)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: result,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables fetch error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to get environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -553,22 +553,11 @@ function handleGenericBuffer(
|
||||
*/
|
||||
async function parseBufferAsPdf(buffer: Buffer) {
|
||||
try {
|
||||
try {
|
||||
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
|
||||
const parser = new PdfParser()
|
||||
logger.info('Using main PDF parser for buffer')
|
||||
const { PdfParser } = await import('@/lib/file-parsers/pdf-parser')
|
||||
const parser = new PdfParser()
|
||||
logger.info('Using main PDF parser for buffer')
|
||||
|
||||
if (parser.parseBuffer) {
|
||||
return await parser.parseBuffer(buffer)
|
||||
}
|
||||
throw new Error('PDF parser does not support buffer parsing')
|
||||
} catch (error) {
|
||||
logger.warn('Main PDF parser failed, using raw parser for buffer:', error)
|
||||
const { RawPdfParser } = await import('@/lib/file-parsers/raw-pdf-parser')
|
||||
const rawParser = new RawPdfParser()
|
||||
|
||||
return await rawParser.parseBuffer(buffer)
|
||||
}
|
||||
return await parser.parseBuffer(buffer)
|
||||
} catch (error) {
|
||||
throw new Error(`PDF parsing failed: ${(error as Error).message}`)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
/**
|
||||
* Tests for file presigned API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('/api/files/presigned', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -19,7 +25,7 @@ describe('/api/files/presigned', () => {
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
test('should return error when cloud storage is not enabled', async () => {
|
||||
it('should return error when cloud storage is not enabled', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 's3',
|
||||
@@ -39,7 +45,7 @@ describe('/api/files/presigned', () => {
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
|
||||
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
|
||||
expect(data.directUploadSupported).toBe(false)
|
||||
|
||||
@@ -32,6 +32,14 @@ describe('Function Execute API Route', () => {
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn().mockResolvedValue({
|
||||
result: 'e2b success',
|
||||
stdout: 'e2b output',
|
||||
sandboxId: 'test-sandbox-id',
|
||||
}),
|
||||
}))
|
||||
|
||||
mockRunInContext.mockResolvedValue('vm success')
|
||||
mockCreateContext.mockReturnValue({})
|
||||
})
|
||||
@@ -45,6 +53,7 @@ describe('Function Execute API Route', () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "Hello World"',
|
||||
timeout: 5000,
|
||||
useLocalVM: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
@@ -74,6 +83,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should use default timeout when not provided', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
useLocalVM: true,
|
||||
})
|
||||
|
||||
const { POST } = await import('@/app/api/function/execute/route')
|
||||
@@ -93,6 +103,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should resolve environment variables with {{var_name}} syntax', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return {{API_KEY}}',
|
||||
useLocalVM: true,
|
||||
envVars: {
|
||||
API_KEY: 'secret-key-123',
|
||||
},
|
||||
@@ -108,6 +119,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should resolve tag variables with <tag_name> syntax', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
useLocalVM: true,
|
||||
params: {
|
||||
email: { id: '123', subject: 'Test Email' },
|
||||
},
|
||||
@@ -123,6 +135,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should NOT treat email addresses as template variables', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "Email sent to user"',
|
||||
useLocalVM: true,
|
||||
params: {
|
||||
email: {
|
||||
from: 'Waleed Latif <waleed@sim.ai>',
|
||||
@@ -141,6 +154,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should only match valid variable names in angle brackets', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <validVar> + "<invalid@email.com>" + <another_valid>',
|
||||
useLocalVM: true,
|
||||
params: {
|
||||
validVar: 'hello',
|
||||
another_valid: 'world',
|
||||
@@ -178,6 +192,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
useLocalVM: true,
|
||||
params: gmailData,
|
||||
})
|
||||
|
||||
@@ -200,6 +215,7 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <email>',
|
||||
useLocalVM: true,
|
||||
params: complexEmailData,
|
||||
})
|
||||
|
||||
@@ -214,6 +230,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should handle custom tool execution with direct parameter access', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return location + " weather is sunny"',
|
||||
useLocalVM: true,
|
||||
params: {
|
||||
location: 'San Francisco',
|
||||
},
|
||||
@@ -245,6 +262,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should handle timeout parameter', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
useLocalVM: true,
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
@@ -262,6 +280,7 @@ describe('Function Execute API Route', () => {
|
||||
it('should handle empty parameters object', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "no params"',
|
||||
useLocalVM: true,
|
||||
params: {},
|
||||
})
|
||||
|
||||
@@ -295,6 +314,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -338,6 +358,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = null;\nreturn obj.someMethod();',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -379,6 +400,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const x = 42;\nreturn undefinedVariable + x;',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -409,6 +431,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test";',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -445,6 +468,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -476,6 +500,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = {\n name: "test"\n// Missing closing brace',
|
||||
useLocalVM: true,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -496,6 +521,7 @@ SyntaxError: Invalid or unexpected token
|
||||
// This tests the escapeRegExp function indirectly
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return {{special.chars+*?}}',
|
||||
useLocalVM: true,
|
||||
envVars: {
|
||||
'special.chars+*?': 'escaped-value',
|
||||
},
|
||||
@@ -512,6 +538,7 @@ SyntaxError: Invalid or unexpected token
|
||||
// Test with complex but not circular data first
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return <complexData>',
|
||||
useLocalVM: true,
|
||||
params: {
|
||||
complexData: {
|
||||
special: 'chars"with\'quotes',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createContext, Script } from 'vm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -8,6 +11,10 @@ export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('FunctionExecuteAPI')
|
||||
|
||||
// Constants for E2B code wrapping line counts
|
||||
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
||||
|
||||
/**
|
||||
* Enhanced error information interface
|
||||
*/
|
||||
@@ -124,6 +131,103 @@ function extractEnhancedError(
|
||||
return enhanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and format E2B error message
|
||||
* Removes E2B-specific line references and adds correct user line numbers
|
||||
*/
|
||||
function formatE2BError(
|
||||
errorMessage: string,
|
||||
errorOutput: string,
|
||||
language: CodeLanguage,
|
||||
userCode: string,
|
||||
prologueLineCount: number
|
||||
): { formattedError: string; cleanedOutput: string } {
|
||||
// Calculate line offset based on language and prologue
|
||||
const wrapperLines =
|
||||
language === CodeLanguage.Python ? E2B_PYTHON_WRAPPER_LINES : E2B_JS_WRAPPER_LINES
|
||||
const totalOffset = prologueLineCount + wrapperLines
|
||||
|
||||
let userLine: number | undefined
|
||||
let cleanErrorType = ''
|
||||
let cleanErrorMsg = ''
|
||||
|
||||
if (language === CodeLanguage.Python) {
|
||||
// Python error format: "Cell In[X], line Y" followed by error details
|
||||
// Extract line number from the Cell reference
|
||||
const cellMatch = errorOutput.match(/Cell In\[\d+\], line (\d+)/)
|
||||
if (cellMatch) {
|
||||
const originalLine = Number.parseInt(cellMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
|
||||
// Extract clean error message from the error string
|
||||
// Remove file references like "(detected at line X) (file.py, line Y)"
|
||||
cleanErrorMsg = errorMessage
|
||||
.replace(/\s*\(detected at line \d+\)/g, '')
|
||||
.replace(/\s*\([^)]+\.py, line \d+\)/g, '')
|
||||
.trim()
|
||||
} else if (language === CodeLanguage.JavaScript) {
|
||||
// JavaScript error format from E2B: "SyntaxError: /path/file.ts: Message. (line:col)\n\n 9 | ..."
|
||||
// First, extract the error type and message from the first line
|
||||
const firstLineEnd = errorMessage.indexOf('\n')
|
||||
const firstLine = firstLineEnd > 0 ? errorMessage.substring(0, firstLineEnd) : errorMessage
|
||||
|
||||
// Parse: "SyntaxError: /home/user/index.ts: Missing semicolon. (11:9)"
|
||||
const jsErrorMatch = firstLine.match(/^(\w+Error):\s*[^:]+:\s*([^(]+)\.\s*\((\d+):(\d+)\)/)
|
||||
if (jsErrorMatch) {
|
||||
cleanErrorType = jsErrorMatch[1]
|
||||
cleanErrorMsg = jsErrorMatch[2].trim()
|
||||
const originalLine = Number.parseInt(jsErrorMatch[3], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
} else {
|
||||
// Fallback: look for line number in the arrow pointer line (> 11 |)
|
||||
const arrowMatch = errorMessage.match(/^>\s*(\d+)\s*\|/m)
|
||||
if (arrowMatch) {
|
||||
const originalLine = Number.parseInt(arrowMatch[1], 10)
|
||||
userLine = originalLine - totalOffset
|
||||
}
|
||||
// Try to extract error type and message
|
||||
const errorMatch = firstLine.match(/^(\w+Error):\s*(.+)/)
|
||||
if (errorMatch) {
|
||||
cleanErrorType = errorMatch[1]
|
||||
cleanErrorMsg = errorMatch[2]
|
||||
.replace(/^[^:]+:\s*/, '') // Remove file path
|
||||
.replace(/\s*\(\d+:\d+\)\s*$/, '') // Remove line:col at end
|
||||
.trim()
|
||||
} else {
|
||||
cleanErrorMsg = firstLine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final clean error message
|
||||
const finalErrorMsg =
|
||||
cleanErrorType && cleanErrorMsg
|
||||
? `${cleanErrorType}: ${cleanErrorMsg}`
|
||||
: cleanErrorMsg || errorMessage
|
||||
|
||||
// Format with line number if available
|
||||
let formattedError = finalErrorMsg
|
||||
if (userLine && userLine > 0) {
|
||||
const codeLines = userCode.split('\n')
|
||||
// Clamp userLine to the actual user code range
|
||||
const actualUserLine = Math.min(userLine, codeLines.length)
|
||||
if (actualUserLine > 0 && actualUserLine <= codeLines.length) {
|
||||
const lineContent = codeLines[actualUserLine - 1]?.trim()
|
||||
if (lineContent) {
|
||||
formattedError = `Line ${actualUserLine}: \`${lineContent}\` - ${finalErrorMsg}`
|
||||
} else {
|
||||
formattedError = `Line ${actualUserLine} - ${finalErrorMsg}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For stdout, just return the clean error message without the full traceback
|
||||
const cleanedOutput = finalErrorMsg
|
||||
|
||||
return { formattedError, cleanedOutput }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a detailed error message for users
|
||||
*/
|
||||
@@ -442,6 +546,8 @@ export async function POST(req: NextRequest) {
|
||||
code,
|
||||
params = {},
|
||||
timeout = 5000,
|
||||
language = DEFAULT_CODE_LANGUAGE,
|
||||
useLocalVM = false,
|
||||
envVars = {},
|
||||
blockData = {},
|
||||
blockNameMapping = {},
|
||||
@@ -474,19 +580,163 @@ export async function POST(req: NextRequest) {
|
||||
resolvedCode = codeResolution.resolvedCode
|
||||
const contextVariables = codeResolution.contextVariables
|
||||
|
||||
const executionMethod = 'vm' // Default execution method
|
||||
const e2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
const useE2B =
|
||||
e2bEnabled &&
|
||||
!useLocalVM &&
|
||||
(lang === CodeLanguage.JavaScript || lang === CodeLanguage.Python)
|
||||
|
||||
logger.info(`[${requestId}] Using VM for code execution`, {
|
||||
hasEnvVars: Object.keys(envVars).length > 0,
|
||||
hasWorkflowVariables: Object.keys(workflowVariables).length > 0,
|
||||
})
|
||||
if (useE2B) {
|
||||
logger.info(`[${requestId}] E2B status`, {
|
||||
enabled: e2bEnabled,
|
||||
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
||||
language: lang,
|
||||
})
|
||||
let prologue = ''
|
||||
const epilogue = ''
|
||||
|
||||
// Create a secure context with console logging
|
||||
if (lang === CodeLanguage.JavaScript) {
|
||||
// Track prologue lines for error adjustment
|
||||
let prologueLineCount = 0
|
||||
prologue += `const params = JSON.parse(${JSON.stringify(JSON.stringify(executionParams))});\n`
|
||||
prologueLineCount++
|
||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
const wrapped = [
|
||||
';(async () => {',
|
||||
' try {',
|
||||
' const __sim_result = await (async () => {',
|
||||
` ${resolvedCode.split('\n').join('\n ')}`,
|
||||
' })();',
|
||||
" console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));",
|
||||
' } catch (error) {',
|
||||
' console.log(String((error && (error.stack || error.message)) || error));',
|
||||
' throw error;',
|
||||
' }',
|
||||
'})();',
|
||||
].join('\n')
|
||||
const codeForE2B = prologue + wrapped + epilogue
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
result: e2bResult,
|
||||
stdout: e2bStdout,
|
||||
sandboxId,
|
||||
error: e2bError,
|
||||
} = await executeInE2B({
|
||||
code: codeForE2B,
|
||||
language: CodeLanguage.JavaScript,
|
||||
timeoutMs: timeout,
|
||||
})
|
||||
const executionTime = Date.now() - execStart
|
||||
stdout += e2bStdout
|
||||
|
||||
logger.info(`[${requestId}] E2B JS sandbox`, {
|
||||
sandboxId,
|
||||
stdoutPreview: e2bStdout?.slice(0, 200),
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
e2bStdout,
|
||||
lang,
|
||||
resolvedCode,
|
||||
prologueLineCount
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: formattedError,
|
||||
output: { result: null, stdout: cleanedOutput, executionTime },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { result: e2bResult ?? null, stdout, executionTime },
|
||||
})
|
||||
}
|
||||
// Track prologue lines for error adjustment
|
||||
let prologueLineCount = 0
|
||||
prologue += 'import json\n'
|
||||
prologueLineCount++
|
||||
prologue += `params = json.loads(${JSON.stringify(JSON.stringify(executionParams))})\n`
|
||||
prologueLineCount++
|
||||
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
|
||||
prologueLineCount++
|
||||
}
|
||||
const wrapped = [
|
||||
'def __sim_main__():',
|
||||
...resolvedCode.split('\n').map((l) => ` ${l}`),
|
||||
'__sim_result__ = __sim_main__()',
|
||||
"print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
|
||||
].join('\n')
|
||||
const codeForE2B = prologue + wrapped + epilogue
|
||||
|
||||
const execStart = Date.now()
|
||||
const {
|
||||
result: e2bResult,
|
||||
stdout: e2bStdout,
|
||||
sandboxId,
|
||||
error: e2bError,
|
||||
} = await executeInE2B({
|
||||
code: codeForE2B,
|
||||
language: CodeLanguage.Python,
|
||||
timeoutMs: timeout,
|
||||
})
|
||||
const executionTime = Date.now() - execStart
|
||||
stdout += e2bStdout
|
||||
|
||||
logger.info(`[${requestId}] E2B Py sandbox`, {
|
||||
sandboxId,
|
||||
stdoutPreview: e2bStdout?.slice(0, 200),
|
||||
error: e2bError,
|
||||
})
|
||||
|
||||
// If there was an execution error, format it properly
|
||||
if (e2bError) {
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
e2bStdout,
|
||||
lang,
|
||||
resolvedCode,
|
||||
prologueLineCount
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: formattedError,
|
||||
output: { result: null, stdout: cleanedOutput, executionTime },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { result: e2bResult ?? null, stdout, executionTime },
|
||||
})
|
||||
}
|
||||
|
||||
const executionMethod = 'vm'
|
||||
const context = createContext({
|
||||
params: executionParams,
|
||||
environmentVariables: envVars,
|
||||
...contextVariables, // Add resolved variables directly to context
|
||||
fetch: globalThis.fetch || require('node-fetch').default,
|
||||
...contextVariables,
|
||||
fetch: (globalThis as any).fetch || require('node-fetch').default,
|
||||
console: {
|
||||
log: (...args: any[]) => {
|
||||
const logMessage = `${args
|
||||
@@ -504,23 +754,17 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
|
||||
// Calculate line offset for user code to provide accurate error reporting
|
||||
const wrapperLines = ['(async () => {', ' try {']
|
||||
|
||||
// Add custom tool parameter declarations if needed
|
||||
if (isCustomTool) {
|
||||
wrapperLines.push(' // For custom tools, make parameters directly accessible')
|
||||
Object.keys(executionParams).forEach((key) => {
|
||||
wrapperLines.push(` const ${key} = params.${key};`)
|
||||
})
|
||||
}
|
||||
|
||||
userCodeStartLine = wrapperLines.length + 1 // +1 because user code starts on next line
|
||||
|
||||
// Build the complete script with proper formatting for line numbers
|
||||
userCodeStartLine = wrapperLines.length + 1
|
||||
const fullScript = [
|
||||
...wrapperLines,
|
||||
` ${resolvedCode.split('\n').join('\n ')}`, // Indent user code
|
||||
` ${resolvedCode.split('\n').join('\n ')}`,
|
||||
' } catch (error) {',
|
||||
' console.error(error);',
|
||||
' throw error;',
|
||||
@@ -529,33 +773,26 @@ export async function POST(req: NextRequest) {
|
||||
].join('\n')
|
||||
|
||||
const script = new Script(fullScript, {
|
||||
filename: 'user-function.js', // This filename will appear in stack traces
|
||||
lineOffset: 0, // Start line numbering from 0
|
||||
columnOffset: 0, // Start column numbering from 0
|
||||
filename: 'user-function.js',
|
||||
lineOffset: 0,
|
||||
columnOffset: 0,
|
||||
})
|
||||
|
||||
const result = await script.runInContext(context, {
|
||||
timeout,
|
||||
displayErrors: true,
|
||||
breakOnSigint: true, // Allow breaking on SIGINT for better debugging
|
||||
breakOnSigint: true,
|
||||
})
|
||||
// }
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
|
||||
executionTime,
|
||||
})
|
||||
|
||||
const response = {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
result,
|
||||
stdout,
|
||||
executionTime,
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
output: { result, stdout, executionTime },
|
||||
})
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Function execution failed`, {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('FrozenCanvasAPI')
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
try {
|
||||
const { executionId } = await params
|
||||
|
||||
logger.debug(`Fetching frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
|
||||
// Get the workflow execution log to find the snapshot
|
||||
const [workflowLog] = await db
|
||||
@@ -50,14 +50,14 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching frozen canvas data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 })
|
||||
logger.error('Error fetching execution data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitation')
|
||||
|
||||
// Get invitation details
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const orgInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!orgInvitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const org = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
invitation: orgInvitation,
|
||||
organization: org,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organization invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { status } = await req.json()
|
||||
|
||||
if (!status || !['accepted', 'rejected', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const orgInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!orgInvitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email mismatch. You can only accept invitations sent to your email address.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
const isAdmin = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, organizationId),
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.role, 'admin')
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only organization admins can cancel invitations' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
|
||||
|
||||
if (status === 'accepted') {
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const linkedWorkspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of linkedWorkspaceInvitations) {
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
userId: session.user.id,
|
||||
email: orgInvitation.email,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Invitation ${status} successfully`,
|
||||
invitation: { ...orgInvitation, status },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error updating organization invitation:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getEmailSubject,
|
||||
@@ -17,9 +17,17 @@ import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationsAPI')
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
@@ -40,7 +48,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -61,7 +68,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all pending invitations for the organization
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
@@ -118,10 +124,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const body = await request.json()
|
||||
const { email, emails, role = 'member', workspaceInvitations } = body
|
||||
|
||||
// Handle single invitation vs batch
|
||||
const invitationEmails = email ? [email] : emails
|
||||
|
||||
// Validate input
|
||||
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
|
||||
}
|
||||
@@ -130,7 +134,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -148,7 +151,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Handle validation-only requests
|
||||
if (validateOnly) {
|
||||
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
|
||||
|
||||
@@ -167,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
// Validate seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
|
||||
|
||||
if (!seatValidation.canInvite) {
|
||||
@@ -185,7 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization details
|
||||
const organizationEntry = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
@@ -196,7 +196,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate and normalize emails
|
||||
const processedEmails = invitationEmails
|
||||
.map((email: string) => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
@@ -209,11 +208,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Handle batch workspace invitations if provided
|
||||
const validWorkspaceInvitations: WorkspaceInvitation[] = []
|
||||
if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) {
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if user has admin permission on this workspace
|
||||
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
|
||||
|
||||
if (!canInvite) {
|
||||
@@ -229,7 +226,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
@@ -239,7 +235,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail)
|
||||
const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email))
|
||||
|
||||
// Check for existing pending invitations
|
||||
const existingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
@@ -265,7 +260,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Create invitations
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
const invitationsToCreate = emailsToInvite.map((email: string) => ({
|
||||
id: randomUUID(),
|
||||
@@ -280,10 +274,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
await db.insert(invitation).values(invitationsToCreate)
|
||||
|
||||
// Create workspace invitations if batch mode
|
||||
const workspaceInvitationIds: string[] = []
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
for (const email of emailsToInvite) {
|
||||
const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email)
|
||||
for (const wsInvitation of validWorkspaceInvitations) {
|
||||
const wsInvitationId = randomUUID()
|
||||
const token = randomUUID()
|
||||
@@ -297,6 +291,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
status: 'pending',
|
||||
token,
|
||||
permissions: wsInvitation.permission,
|
||||
orgInvitationId: orgInviteForEmail?.id,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -307,7 +302,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Send invitation emails
|
||||
const inviter = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
@@ -320,7 +314,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
let emailResult
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
// Get workspace details for batch email
|
||||
const workspaceDetails = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
@@ -346,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
@@ -359,7 +352,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
@@ -446,7 +439,6 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -464,12 +456,9 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Cancel the invitation
|
||||
const result = await db
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
})
|
||||
.set({ status: 'cancelled' })
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.id, invitationId),
|
||||
@@ -486,6 +475,23 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
|
||||
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(
|
||||
and(
|
||||
isNull(workspaceInvitation.orgInvitationId),
|
||||
eq(workspaceInvitation.email, result[0].email),
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus),
|
||||
eq(workspaceInvitation.inviterId, session.user.id)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info('Organization invitation cancelled', {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -260,7 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${invitationId}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
)
|
||||
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, permissions, user, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
|
||||
|
||||
// Accept an organization invitation and any associated workspace invitations
|
||||
export async function GET(req: NextRequest) {
|
||||
const invitationId = req.nextUrl.searchParams.get('id')
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-invitation-id',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// Redirect to login, user will be redirected back after login
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/organization?id=${invitationId}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the organization invitation
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-invitation',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
// Check if invitation has expired
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation is still pending
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get user data to check email verification status
|
||||
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (userData.length === 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=user-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData[0].emailVerified) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData[0].email}) before accepting invitations.`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the email matches the current user
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${orgInvitation.email}, but you're logged in as ${userData[0].email}`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member of the organization
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-member',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Start transaction to accept both organization and workspace invitations
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation - add user as member
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark organization invitation as accepted
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Find and accept any pending workspace invitations for the same email
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if invitation hasn't expired
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
// Check if user doesn't already have permissions on the workspace
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
// Add workspace permissions
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark workspace invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
logger.info('Accepted workspace invitation', {
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permission: wsInvitation.permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Successfully accepted batch invitation', {
|
||||
organizationId: orgInvitation.organizationId,
|
||||
userId: session.user.id,
|
||||
role: orgInvitation.role,
|
||||
})
|
||||
|
||||
// Redirect to success page or main app
|
||||
return NextResponse.redirect(
|
||||
new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation', {
|
||||
invitationId,
|
||||
userId: session.user.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST endpoint for programmatic acceptance (for API use)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { invitationId } = await req.json()
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Similar logic to GET but return JSON response
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 })
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.json({ error: 'Invitation expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get user data to check email verification status
|
||||
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (userData.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData[0].emailVerified) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Email not verified',
|
||||
message: `You must verify your email address (${userData[0].email}) before accepting invitations.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.json({ error: 'Already a member' }, { status: 400 })
|
||||
}
|
||||
|
||||
let acceptedWorkspaces = 0
|
||||
|
||||
// Accept invitations in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Accept workspace invitations
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
acceptedWorkspaces++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
workspacesJoined: acceptedWorkspaces,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation via API', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -17,13 +18,7 @@ import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
environment as environmentTable,
|
||||
subscription,
|
||||
userStats,
|
||||
workflow,
|
||||
workflowSchedule,
|
||||
} from '@/db/schema'
|
||||
import { subscription, userStats, workflow, workflowSchedule } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
@@ -236,20 +231,15 @@ export async function GET() {
|
||||
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Retrieve environment variables for this user (if any).
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, workflowRecord.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userEnv) {
|
||||
logger.debug(
|
||||
`[${requestId}] No environment record found for user ${workflowRecord.userId}. Proceeding with empty variables.`
|
||||
)
|
||||
}
|
||||
|
||||
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
|
||||
// Retrieve environment variables with workspace precedence
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
workflowRecord.userId,
|
||||
workflowRecord.workspaceId || undefined
|
||||
)
|
||||
const variables = EnvVarsSchema.parse({
|
||||
...personalEncrypted,
|
||||
...workspaceEncrypted,
|
||||
})
|
||||
|
||||
const currentBlockStates = await Object.entries(mergedStates).reduce(
|
||||
async (accPromise, [id, block]) => {
|
||||
|
||||
114
apps/sim/app/api/tools/mongodb/delete/route.ts
Normal file
114
apps/sim/app/api/tools/mongodb/delete/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
|
||||
|
||||
const logger = createLogger('MongoDBDeleteAPI')
|
||||
|
||||
const DeleteSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive('Port must be a positive integer'),
|
||||
database: z.string().min(1, 'Database name is required'),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
authSource: z.string().optional(),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
collection: z.string().min(1, 'Collection name is required'),
|
||||
filter: z
|
||||
.union([z.string(), z.object({}).passthrough()])
|
||||
.transform((val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val
|
||||
})
|
||||
.refine((val) => val && val.trim() !== '' && val !== '{}', {
|
||||
message: 'Filter is required for MongoDB Delete',
|
||||
}),
|
||||
multi: z
|
||||
.union([z.boolean(), z.string(), z.undefined()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === 'true' || val === true) return true
|
||||
if (val === 'false' || val === false) return false
|
||||
return false // Default to false
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
let client = null
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const params = DeleteSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleting document(s) from ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi})`
|
||||
)
|
||||
|
||||
const sanitizedCollection = sanitizeCollectionName(params.collection)
|
||||
|
||||
const filterValidation = validateFilter(params.filter)
|
||||
if (!filterValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Filter validation failed: ${filterValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Filter validation failed: ${filterValidation.error}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let filterDoc
|
||||
try {
|
||||
filterDoc = JSON.parse(params.filter)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Invalid filter JSON: ${params.filter}`)
|
||||
return NextResponse.json({ error: 'Invalid JSON format in filter' }, { status: 400 })
|
||||
}
|
||||
|
||||
client = await createMongoDBConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
authSource: params.authSource,
|
||||
ssl: params.ssl,
|
||||
})
|
||||
|
||||
const db = client.db(params.database)
|
||||
const coll = db.collection(sanitizedCollection)
|
||||
|
||||
let result
|
||||
if (params.multi) {
|
||||
result = await coll.deleteMany(filterDoc)
|
||||
} else {
|
||||
result = await coll.deleteOne(filterDoc)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Delete completed: ${result.deletedCount} documents deleted`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `${result.deletedCount} documents deleted`,
|
||||
deletedCount: result.deletedCount,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] MongoDB delete failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `MongoDB delete failed: ${errorMessage}` }, { status: 500 })
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
102
apps/sim/app/api/tools/mongodb/execute/route.ts
Normal file
102
apps/sim/app/api/tools/mongodb/execute/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils'
|
||||
|
||||
const logger = createLogger('MongoDBExecuteAPI')
|
||||
|
||||
const ExecuteSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive('Port must be a positive integer'),
|
||||
database: z.string().min(1, 'Database name is required'),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
authSource: z.string().optional(),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
collection: z.string().min(1, 'Collection name is required'),
|
||||
pipeline: z
|
||||
.union([z.string(), z.array(z.object({}).passthrough())])
|
||||
.transform((val) => {
|
||||
if (Array.isArray(val)) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val
|
||||
})
|
||||
.refine((val) => val && val.trim() !== '', {
|
||||
message: 'Pipeline is required',
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
let client = null
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const params = ExecuteSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Executing aggregation pipeline on ${params.host}:${params.port}/${params.database}.${params.collection}`
|
||||
)
|
||||
|
||||
const sanitizedCollection = sanitizeCollectionName(params.collection)
|
||||
|
||||
const pipelineValidation = validatePipeline(params.pipeline)
|
||||
if (!pipelineValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Pipeline validation failed: ${pipelineValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Pipeline validation failed: ${pipelineValidation.error}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipelineDoc = JSON.parse(params.pipeline)
|
||||
|
||||
client = await createMongoDBConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
authSource: params.authSource,
|
||||
ssl: params.ssl,
|
||||
})
|
||||
|
||||
const db = client.db(params.database)
|
||||
const coll = db.collection(sanitizedCollection)
|
||||
|
||||
const cursor = coll.aggregate(pipelineDoc)
|
||||
const documents = await cursor.toArray()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Aggregation completed successfully, returned ${documents.length} documents`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Aggregation completed, returned ${documents.length} documents`,
|
||||
documents,
|
||||
documentCount: documents.length,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] MongoDB aggregation failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `MongoDB aggregation failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
98
apps/sim/app/api/tools/mongodb/insert/route.ts
Normal file
98
apps/sim/app/api/tools/mongodb/insert/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
|
||||
|
||||
const logger = createLogger('MongoDBInsertAPI')
|
||||
|
||||
const InsertSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive('Port must be a positive integer'),
|
||||
database: z.string().min(1, 'Database name is required'),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
authSource: z.string().optional(),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
collection: z.string().min(1, 'Collection name is required'),
|
||||
documents: z
|
||||
.union([z.array(z.record(z.unknown())), z.string()])
|
||||
.transform((val) => {
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(val)
|
||||
return Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch {
|
||||
throw new Error('Invalid JSON in documents field')
|
||||
}
|
||||
}
|
||||
return val
|
||||
})
|
||||
.refine((val) => Array.isArray(val) && val.length > 0, {
|
||||
message: 'At least one document is required',
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
let client = null
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const params = InsertSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Inserting ${params.documents.length} document(s) into ${params.host}:${params.port}/${params.database}.${params.collection}`
|
||||
)
|
||||
|
||||
const sanitizedCollection = sanitizeCollectionName(params.collection)
|
||||
client = await createMongoDBConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
authSource: params.authSource,
|
||||
ssl: params.ssl,
|
||||
})
|
||||
|
||||
const db = client.db(params.database)
|
||||
const coll = db.collection(sanitizedCollection)
|
||||
|
||||
let result
|
||||
if (params.documents.length === 1) {
|
||||
result = await coll.insertOne(params.documents[0] as Record<string, unknown>)
|
||||
logger.info(`[${requestId}] Single document inserted successfully`)
|
||||
return NextResponse.json({
|
||||
message: 'Document inserted successfully',
|
||||
insertedId: result.insertedId.toString(),
|
||||
documentCount: 1,
|
||||
})
|
||||
}
|
||||
result = await coll.insertMany(params.documents as Record<string, unknown>[])
|
||||
const insertedCount = Object.keys(result.insertedIds).length
|
||||
logger.info(`[${requestId}] ${insertedCount} documents inserted successfully`)
|
||||
return NextResponse.json({
|
||||
message: `${insertedCount} documents inserted successfully`,
|
||||
insertedIds: Object.values(result.insertedIds).map((id) => id.toString()),
|
||||
documentCount: insertedCount,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] MongoDB insert failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `MongoDB insert failed: ${errorMessage}` }, { status: 500 })
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
136
apps/sim/app/api/tools/mongodb/query/route.ts
Normal file
136
apps/sim/app/api/tools/mongodb/query/route.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
|
||||
|
||||
const logger = createLogger('MongoDBQueryAPI')
|
||||
|
||||
const QuerySchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive('Port must be a positive integer'),
|
||||
database: z.string().min(1, 'Database name is required'),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
authSource: z.string().optional(),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
collection: z.string().min(1, 'Collection name is required'),
|
||||
query: z
|
||||
.union([z.string(), z.object({}).passthrough()])
|
||||
.optional()
|
||||
.default('{}')
|
||||
.transform((val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val || '{}'
|
||||
}),
|
||||
limit: z
|
||||
.union([z.coerce.number().int().positive(), z.literal(''), z.undefined()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === '' || val === undefined || val === null) {
|
||||
return 100
|
||||
}
|
||||
return val
|
||||
}),
|
||||
sort: z
|
||||
.union([z.string(), z.object({}).passthrough(), z.null()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
let client = null
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const params = QuerySchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Executing MongoDB query on ${params.host}:${params.port}/${params.database}.${params.collection}`
|
||||
)
|
||||
|
||||
const sanitizedCollection = sanitizeCollectionName(params.collection)
|
||||
|
||||
let filter = {}
|
||||
if (params.query?.trim()) {
|
||||
const validation = validateFilter(params.query)
|
||||
if (!validation.isValid) {
|
||||
logger.warn(`[${requestId}] Filter validation failed: ${validation.error}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Filter validation failed: ${validation.error}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
filter = JSON.parse(params.query)
|
||||
}
|
||||
|
||||
let sortCriteria = {}
|
||||
if (params.sort?.trim()) {
|
||||
try {
|
||||
sortCriteria = JSON.parse(params.sort)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Invalid sort JSON: ${params.sort}`)
|
||||
return NextResponse.json({ error: 'Invalid JSON format in sort criteria' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
client = await createMongoDBConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
authSource: params.authSource,
|
||||
ssl: params.ssl,
|
||||
})
|
||||
|
||||
const db = client.db(params.database)
|
||||
const coll = db.collection(sanitizedCollection)
|
||||
|
||||
let cursor = coll.find(filter)
|
||||
|
||||
if (Object.keys(sortCriteria).length > 0) {
|
||||
cursor = cursor.sort(sortCriteria)
|
||||
}
|
||||
|
||||
const limit = params.limit || 100
|
||||
cursor = cursor.limit(limit)
|
||||
|
||||
const documents = await cursor.toArray()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Query executed successfully, returned ${documents.length} documents`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${documents.length} documents`,
|
||||
documents,
|
||||
documentCount: documents.length,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] MongoDB query failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `MongoDB query failed: ${errorMessage}` }, { status: 500 })
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
143
apps/sim/app/api/tools/mongodb/update/route.ts
Normal file
143
apps/sim/app/api/tools/mongodb/update/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
|
||||
|
||||
const logger = createLogger('MongoDBUpdateAPI')
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
host: z.string().min(1, 'Host is required'),
|
||||
port: z.coerce.number().int().positive('Port must be a positive integer'),
|
||||
database: z.string().min(1, 'Database name is required'),
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
authSource: z.string().optional(),
|
||||
ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'),
|
||||
collection: z.string().min(1, 'Collection name is required'),
|
||||
filter: z
|
||||
.union([z.string(), z.object({}).passthrough()])
|
||||
.transform((val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val
|
||||
})
|
||||
.refine((val) => val && val.trim() !== '' && val !== '{}', {
|
||||
message: 'Filter is required for MongoDB Update',
|
||||
}),
|
||||
update: z
|
||||
.union([z.string(), z.object({}).passthrough()])
|
||||
.transform((val) => {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return val
|
||||
})
|
||||
.refine((val) => val && val.trim() !== '', {
|
||||
message: 'Update is required',
|
||||
}),
|
||||
upsert: z
|
||||
.union([z.boolean(), z.string(), z.undefined()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === 'true' || val === true) return true
|
||||
if (val === 'false' || val === false) return false
|
||||
return false
|
||||
}),
|
||||
multi: z
|
||||
.union([z.boolean(), z.string(), z.undefined()])
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === 'true' || val === true) return true
|
||||
if (val === 'false' || val === false) return false
|
||||
return false
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
let client = null
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const params = UpdateSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updating document(s) in ${params.host}:${params.port}/${params.database}.${params.collection} (multi: ${params.multi}, upsert: ${params.upsert})`
|
||||
)
|
||||
|
||||
const sanitizedCollection = sanitizeCollectionName(params.collection)
|
||||
|
||||
const filterValidation = validateFilter(params.filter)
|
||||
if (!filterValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Filter validation failed: ${filterValidation.error}`)
|
||||
return NextResponse.json(
|
||||
{ error: `Filter validation failed: ${filterValidation.error}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let filterDoc
|
||||
let updateDoc
|
||||
try {
|
||||
filterDoc = JSON.parse(params.filter)
|
||||
updateDoc = JSON.parse(params.update)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Invalid JSON in filter or update`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON format in filter or update' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
client = await createMongoDBConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
authSource: params.authSource,
|
||||
ssl: params.ssl,
|
||||
})
|
||||
|
||||
const db = client.db(params.database)
|
||||
const coll = db.collection(sanitizedCollection)
|
||||
|
||||
let result
|
||||
if (params.multi) {
|
||||
result = await coll.updateMany(filterDoc, updateDoc, { upsert: params.upsert })
|
||||
} else {
|
||||
result = await coll.updateOne(filterDoc, updateDoc, { upsert: params.upsert })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Update completed: ${result.modifiedCount} modified, ${result.matchedCount} matched${result.upsertedCount ? `, ${result.upsertedCount} upserted` : ''}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `${result.modifiedCount} documents updated${result.upsertedCount ? `, ${result.upsertedCount} documents upserted` : ''}`,
|
||||
matchedCount: result.matchedCount,
|
||||
modifiedCount: result.modifiedCount,
|
||||
documentCount: result.modifiedCount + (result.upsertedCount || 0),
|
||||
...(result.upsertedId && { insertedId: result.upsertedId.toString() }),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error(`[${requestId}] MongoDB update failed:`, error)
|
||||
|
||||
return NextResponse.json({ error: `MongoDB update failed: ${errorMessage}` }, { status: 500 })
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
123
apps/sim/app/api/tools/mongodb/utils.ts
Normal file
123
apps/sim/app/api/tools/mongodb/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { MongoClient } from 'mongodb'
|
||||
import type { MongoDBConnectionConfig } from '@/tools/mongodb/types'
|
||||
|
||||
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
|
||||
const credentials =
|
||||
config.username && config.password
|
||||
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
|
||||
: ''
|
||||
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
if (config.authSource) {
|
||||
queryParams.append('authSource', config.authSource)
|
||||
}
|
||||
|
||||
if (config.ssl === 'required') {
|
||||
queryParams.append('ssl', 'true')
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString()
|
||||
const uri = `mongodb://${credentials}${config.host}:${config.port}/${config.database}${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const client = new MongoClient(uri, {
|
||||
connectTimeoutMS: 10000,
|
||||
socketTimeoutMS: 10000,
|
||||
maxPoolSize: 1,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
export function validateFilter(filter: string): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(filter)
|
||||
|
||||
const dangerousOperators = ['$where', '$regex', '$expr', '$function', '$accumulator', '$let']
|
||||
|
||||
const checkForDangerousOps = (obj: any): boolean => {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof obj[key] === 'object' && checkForDangerousOps(obj[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkForDangerousOps(parsed)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Filter contains potentially dangerous operators',
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid JSON format in filter',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePipeline(pipeline: string): { isValid: boolean; error?: string } {
|
||||
try {
|
||||
const parsed = JSON.parse(pipeline)
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Pipeline must be an array',
|
||||
}
|
||||
}
|
||||
|
||||
const dangerousOperators = [
|
||||
'$where',
|
||||
'$function',
|
||||
'$accumulator',
|
||||
'$let',
|
||||
'$merge',
|
||||
'$out',
|
||||
'$currentOp',
|
||||
'$listSessions',
|
||||
'$listLocalSessions',
|
||||
]
|
||||
|
||||
const checkPipelineStage = (stage: any): boolean => {
|
||||
if (typeof stage !== 'object' || stage === null) return false
|
||||
|
||||
for (const key of Object.keys(stage)) {
|
||||
if (dangerousOperators.includes(key)) return true
|
||||
if (typeof stage[key] === 'object' && checkPipelineStage(stage[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const stage of parsed) {
|
||||
if (checkPipelineStage(stage)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Pipeline contains potentially dangerous operators',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid JSON format in pipeline',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeCollectionName(name: string): string {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
throw new Error(
|
||||
'Invalid collection name. Must start with letter or underscore and contain only letters, numbers, and underscores.'
|
||||
)
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageCheckAPI')
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const session = await getSession()
|
||||
try {
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const result = await checkServerSideUsageLimits(userId)
|
||||
// Normalize to client usage shape
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
percentUsed:
|
||||
result.limit > 0
|
||||
? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100)
|
||||
: 0,
|
||||
isWarning:
|
||||
result.limit > 0
|
||||
? (result.currentUsage / result.limit) * 100 >= 80 &&
|
||||
(result.currentUsage / result.limit) * 100 < 100
|
||||
: false,
|
||||
isExceeded: result.isExceeded,
|
||||
currentUsage: result.currentUsage,
|
||||
limit: result.limit,
|
||||
message: result.message,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed usage check', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
} from '@/lib/billing/core/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
const logger = createLogger('UnifiedUsageAPI')
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|organization&userId=<id>&organizationId=<id>
|
||||
* Unified Usage Endpoint
|
||||
* GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id>
|
||||
*
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -11,15 +11,12 @@ const logger = createLogger('RateLimitAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Try session auth first (for web UI)
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
// If no session, check for API key auth
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
// Verify API key
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
@@ -36,7 +33,6 @@ export async function GET(request: NextRequest) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
// Get user subscription
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
@@ -1,7 +1,12 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { env } from '@/lib/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats, workflow } from '@/db/schema'
|
||||
import { getModelPricing } from '@/providers/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
@@ -47,9 +52,9 @@ interface RequestBody {
|
||||
systemPrompt?: string
|
||||
stream?: boolean
|
||||
history?: ChatMessage[]
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
// Helper: safe stringify for error payloads that may include circular structures
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
@@ -58,6 +63,80 @@ function safeStringify(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserStatsForWand(
|
||||
workflowId: string,
|
||||
usage: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
},
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug(`[${requestId}] Billing is disabled, skipping wand usage cost update`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!usage.total_tokens || usage.total_tokens <= 0) {
|
||||
logger.debug(`[${requestId}] No tokens to update in user stats`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const [workflowRecord] = await db
|
||||
.select({ userId: workflow.userId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord?.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] No user found for workflow ${workflowId}, cannot update user stats`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const userId = workflowRecord.userId
|
||||
const totalTokens = usage.total_tokens || 0
|
||||
const promptTokens = usage.prompt_tokens || 0
|
||||
const completionTokens = usage.completion_tokens || 0
|
||||
|
||||
const modelName = useWandAzure ? wandModelName : 'gpt-4o'
|
||||
const pricing = getModelPricing(modelName)
|
||||
|
||||
const costMultiplier = getCostMultiplier()
|
||||
let modelCost = 0
|
||||
|
||||
if (pricing) {
|
||||
const inputCost = (promptTokens / 1000000) * pricing.input
|
||||
const outputCost = (completionTokens / 1000000) * pricing.output
|
||||
modelCost = inputCost + outputCost
|
||||
} else {
|
||||
modelCost = (promptTokens / 1000000) * 0.005 + (completionTokens / 1000000) * 0.015
|
||||
}
|
||||
|
||||
const costToStore = modelCost * costMultiplier
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
lastActive: new Date(),
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.debug(`[${requestId}] Updated user stats for wand usage`, {
|
||||
userId,
|
||||
tokensUsed: totalTokens,
|
||||
costAdded: costToStore,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to update user stats for wand usage`, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
logger.info(`[${requestId}] Received wand generation request`)
|
||||
@@ -73,7 +152,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = (await req.json()) as RequestBody
|
||||
|
||||
const { prompt, systemPrompt, stream = false, history = [] } = body
|
||||
const { prompt, systemPrompt, stream = false, history = [], workflowId } = body
|
||||
|
||||
if (!prompt) {
|
||||
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
|
||||
@@ -83,18 +162,14 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Use provided system prompt or default
|
||||
const finalSystemPrompt =
|
||||
systemPrompt ||
|
||||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
|
||||
|
||||
// Prepare messages for OpenAI API
|
||||
const messages: ChatMessage[] = [{ role: 'system', content: finalSystemPrompt }]
|
||||
|
||||
// Add previous messages from history
|
||||
messages.push(...history.filter((msg) => msg.role !== 'system'))
|
||||
|
||||
// Add the current user prompt
|
||||
messages.push({ role: 'user', content: prompt })
|
||||
|
||||
logger.debug(
|
||||
@@ -108,7 +183,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
)
|
||||
|
||||
// For streaming responses
|
||||
if (stream) {
|
||||
try {
|
||||
logger.debug(
|
||||
@@ -119,7 +193,6 @@ export async function POST(req: NextRequest) {
|
||||
`[${requestId}] About to create stream with model: ${useWandAzure ? wandModelName : 'gpt-4o'}`
|
||||
)
|
||||
|
||||
// Use native fetch for streaming to avoid OpenAI SDK issues with Node.js runtime
|
||||
const apiUrl = useWandAzure
|
||||
? `${azureEndpoint}/openai/deployments/${wandModelName}/chat/completions?api-version=${azureApiVersion}`
|
||||
: 'https://api.openai.com/v1/chat/completions'
|
||||
@@ -142,7 +215,7 @@ export async function POST(req: NextRequest) {
|
||||
body: JSON.stringify({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
temperature: 0.2,
|
||||
max_tokens: 10000,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
@@ -161,7 +234,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Stream response received, starting processing`)
|
||||
|
||||
// Create a TransformStream to process the SSE data
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
@@ -176,6 +248,7 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
let buffer = ''
|
||||
let chunkCount = 0
|
||||
let finalUsage: any = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
@@ -187,12 +260,10 @@ export async function POST(req: NextRequest) {
|
||||
break
|
||||
}
|
||||
|
||||
// Decode the chunk
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process complete SSE messages
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || '' // Keep incomplete line in buffer
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
@@ -217,25 +288,22 @@ export async function POST(req: NextRequest) {
|
||||
logger.info(`[${requestId}] Received first content chunk`)
|
||||
}
|
||||
|
||||
// Forward the content
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
|
||||
)
|
||||
}
|
||||
|
||||
// Log usage if present
|
||||
if (parsed.usage) {
|
||||
finalUsage = parsed.usage
|
||||
logger.info(
|
||||
`[${requestId}] Received usage data: ${JSON.stringify(parsed.usage)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Log progress periodically
|
||||
if (chunkCount % 10 === 0) {
|
||||
logger.debug(`[${requestId}] Processed ${chunkCount} chunks`)
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip invalid JSON lines
|
||||
logger.debug(
|
||||
`[${requestId}] Skipped non-JSON line: ${data.substring(0, 100)}`
|
||||
)
|
||||
@@ -245,6 +313,10 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Wand generation streaming completed successfully`)
|
||||
|
||||
if (finalUsage && workflowId) {
|
||||
await updateUserStatsForWand(workflowId, finalUsage, requestId)
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
logger.error(`[${requestId}] Streaming error`, {
|
||||
name: streamError?.name,
|
||||
@@ -252,7 +324,6 @@ export async function POST(req: NextRequest) {
|
||||
stack: streamError?.stack,
|
||||
})
|
||||
|
||||
// Send error to client
|
||||
const errorData = `data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
|
||||
controller.enqueue(encoder.encode(errorData))
|
||||
controller.close()
|
||||
@@ -262,14 +333,12 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
})
|
||||
|
||||
// Return Response with proper headers for Node.js runtime
|
||||
return new Response(readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Disable Nginx buffering
|
||||
'Transfer-Encoding': 'chunked', // Important for Node.js runtime
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -294,7 +363,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
const completion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
@@ -315,6 +383,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Wand generation successful`)
|
||||
|
||||
if (completion.usage && workflowId) {
|
||||
await updateUserStatsForWand(workflowId, completion.usage, requestId)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, content: generatedContent })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Wand generation failed`, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment as environmentTable, subscription, userStats } from '@/db/schema'
|
||||
import { subscription, userStats } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import {
|
||||
@@ -64,7 +65,12 @@ class UsageLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflow(workflow: any, requestId: string, input?: any): Promise<any> {
|
||||
async function executeWorkflow(
|
||||
workflow: any,
|
||||
requestId: string,
|
||||
input?: any,
|
||||
executingUserId?: string
|
||||
): Promise<any> {
|
||||
const workflowId = workflow.id
|
||||
const executionId = uuidv4()
|
||||
|
||||
@@ -127,23 +133,15 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
|
||||
// Use the same execution flow as in scheduled executions
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Fetch the user's environment variables (if any)
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, workflow.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userEnv) {
|
||||
logger.debug(
|
||||
`[${requestId}] No environment record found for user ${workflow.userId}. Proceeding with empty variables.`
|
||||
)
|
||||
}
|
||||
|
||||
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
|
||||
// Load personal (for the executing user) and workspace env (workspace overrides personal)
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
executingUserId || workflow.userId,
|
||||
workflow.workspaceId || undefined
|
||||
)
|
||||
const variables = EnvVarsSchema.parse({ ...personalEncrypted, ...workspaceEncrypted })
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: workflow.userId,
|
||||
userId: executingUserId || workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
variables,
|
||||
})
|
||||
@@ -400,7 +398,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, undefined)
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
undefined,
|
||||
// Executing user (manual run): if session present, use that user for fallback
|
||||
(await getSession())?.user?.id || undefined
|
||||
)
|
||||
|
||||
// Check if the workflow execution contains a response block output
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
@@ -589,7 +593,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, input)
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
input,
|
||||
authenticatedUserId
|
||||
)
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import crypto from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks } from '@/db/schema'
|
||||
import { workflow, workflowBlocks, workspace } from '@/db/schema'
|
||||
import { verifyWorkspaceMembership } from './utils'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
@@ -16,6 +18,68 @@ const CreateWorkflowSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const url = new URL(request.url)
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!workspaceExists) {
|
||||
logger.warn(
|
||||
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
|
||||
if (!userRole) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let workflows
|
||||
|
||||
if (workspaceId) {
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: workflows }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workflows - Create a new workflow
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
@@ -36,114 +100,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
|
||||
|
||||
// Create initial state with start block
|
||||
const initialState = {
|
||||
blocks: {
|
||||
[starterId]: {
|
||||
id: starterId,
|
||||
type: 'starter',
|
||||
name: 'Start',
|
||||
position: { x: 100, y: 100 },
|
||||
subBlocks: {
|
||||
startWorkflow: {
|
||||
id: 'startWorkflow',
|
||||
type: 'dropdown',
|
||||
value: 'manual',
|
||||
},
|
||||
webhookPath: {
|
||||
id: 'webhookPath',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
webhookSecret: {
|
||||
id: 'webhookSecret',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
scheduleType: {
|
||||
id: 'scheduleType',
|
||||
type: 'dropdown',
|
||||
value: 'daily',
|
||||
},
|
||||
minutesInterval: {
|
||||
id: 'minutesInterval',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
minutesStartingAt: {
|
||||
id: 'minutesStartingAt',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
hourlyMinute: {
|
||||
id: 'hourlyMinute',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
dailyTime: {
|
||||
id: 'dailyTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
weeklyDay: {
|
||||
id: 'weeklyDay',
|
||||
type: 'dropdown',
|
||||
value: 'MON',
|
||||
},
|
||||
weeklyDayTime: {
|
||||
id: 'weeklyDayTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
monthlyDay: {
|
||||
id: 'monthlyDay',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
monthlyTime: {
|
||||
id: 'monthlyTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
cronExpression: {
|
||||
id: 'cronExpression',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
timezone: {
|
||||
id: 'timezone',
|
||||
type: 'dropdown',
|
||||
value: 'UTC',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
input: 'any',
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 95,
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
subflows: {},
|
||||
variables: {},
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Create the workflow and start block in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
@@ -163,7 +120,6 @@ export async function POST(req: NextRequest) {
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
// Insert the start block into workflow_blocks table
|
||||
await tx.insert(workflowBlocks).values({
|
||||
id: starterId,
|
||||
workflowId: workflowId,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
/**
|
||||
* Verifies user's workspace permissions using the permissions table
|
||||
* @param userId User ID to check
|
||||
* @param workspaceId Workspace ID to check
|
||||
* @returns Permission type if user has access, null otherwise
|
||||
*/
|
||||
async function verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
|
||||
return permission
|
||||
} catch (error) {
|
||||
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const url = new URL(request.url)
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// If workspaceId is provided, verify it exists and user is a member
|
||||
if (workspaceId) {
|
||||
// Check workspace exists first
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!workspaceExists) {
|
||||
logger.warn(
|
||||
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the user is a member of the workspace using our optimized function
|
||||
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
|
||||
if (!userRole) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Migrate any orphaned workflows to this workspace (in background)
|
||||
migrateOrphanedWorkflows(userId, workspaceId).catch((error) => {
|
||||
logger.error(`[${requestId}] Error migrating orphaned workflows:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch workflows for the user
|
||||
let workflows
|
||||
|
||||
if (workspaceId) {
|
||||
// Filter by workspace ID only, not user ID
|
||||
// This allows sharing workflows across workspace members
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
// Filter by user ID only, including workflows without workspace IDs
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// Return the workflows
|
||||
return NextResponse.json({ data: workflows }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to migrate orphaned workflows to a workspace
|
||||
async function migrateOrphanedWorkflows(userId: string, workspaceId: string) {
|
||||
try {
|
||||
// Find workflows without workspace IDs for this user
|
||||
const orphanedWorkflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
if (orphanedWorkflows.length === 0) {
|
||||
return // No orphaned workflows to migrate
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Migrating ${orphanedWorkflows.length} orphaned workflows to workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
// Update workflows in batch if possible
|
||||
try {
|
||||
// Batch update all orphaned workflows
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
workspaceId: workspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
logger.info(
|
||||
`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`
|
||||
)
|
||||
} catch (batchError) {
|
||||
logger.warn('Batch migration failed, falling back to individual updates:', batchError)
|
||||
|
||||
// Fallback to individual updates if batch update fails
|
||||
for (const { id } of orphanedWorkflows) {
|
||||
try {
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
workspaceId: workspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, id))
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to migrate workflow ${id}:`, updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error migrating orphaned workflows:', error)
|
||||
// Continue execution even if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
// POST method removed - workflow operations now handled by:
|
||||
// - POST /api/workflows (create)
|
||||
// - DELETE /api/workflows/[id] (delete)
|
||||
// - Socket.IO collaborative operations (real-time updates)
|
||||
@@ -1,4 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
export function createErrorResponse(error: string, status: number, code?: string) {
|
||||
return NextResponse.json(
|
||||
@@ -13,3 +17,23 @@ export function createErrorResponse(error: string, status: number, code?: string
|
||||
export function createSuccessResponse(data: any) {
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies user's workspace permissions using the permissions table
|
||||
* @param userId User ID to check
|
||||
* @param workspaceId Workspace ID to check
|
||||
* @returns Permission type if user has access, null otherwise
|
||||
*/
|
||||
export async function verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
|
||||
return permission
|
||||
} catch (error) {
|
||||
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment, workspace, workspaceEnvironment } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkspaceEnvironmentAPI')
|
||||
|
||||
const UpsertSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
|
||||
const DeleteSchema = z.object({
|
||||
keys: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Validate workspace exists
|
||||
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
|
||||
if (!ws.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Require any permission to read
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Workspace env (encrypted)
|
||||
const wsEnvRow = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
|
||||
|
||||
// Personal env (encrypted)
|
||||
const personalRow = await db
|
||||
.select()
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
|
||||
|
||||
// Decrypt both for UI
|
||||
const decryptAll = async (src: Record<string, string>) => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(v)
|
||||
out[k] = decrypted
|
||||
} catch {
|
||||
out[k] = ''
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
|
||||
decryptAll(wsEncrypted),
|
||||
decryptAll(personalEncrypted),
|
||||
])
|
||||
|
||||
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
workspace: workspaceDecrypted,
|
||||
personal: personalDecrypted,
|
||||
conflicts,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to load environment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { variables } = UpsertSchema.parse(body)
|
||||
|
||||
// Read existing encrypted ws vars
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const existingEncrypted: Record<string, string> = (existingRows[0]?.variables as any) || {}
|
||||
|
||||
// Encrypt incoming
|
||||
const encryptedIncoming = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
const merged = { ...existingEncrypted, ...encryptedIncoming }
|
||||
|
||||
// Upsert by unique workspace_id
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
variables: merged,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: merged, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to update environment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteSchema.parse(body)
|
||||
|
||||
const wsRows = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const current: Record<string, string> = (wsRows[0]?.variables as any) || {}
|
||||
let changed = false
|
||||
for (const k of keys) {
|
||||
if (k in current) {
|
||||
delete current[k]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: wsRows[0]?.id || crypto.randomUUID(),
|
||||
workspaceId,
|
||||
variables: current,
|
||||
createdAt: wsRows[0]?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to remove environment keys' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { DELETE } from '@/app/api/workspaces/invitations/[id]/route'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/db/schema', () => ({
|
||||
workspaceInvitation: {
|
||||
id: 'id',
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
inviterId: 'inviterId',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
}))
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[id]', () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
image: null,
|
||||
stripeCustomerId: null,
|
||||
},
|
||||
session: {
|
||||
id: 'session123',
|
||||
token: 'token123',
|
||||
userId: 'user123',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
activeOrganizationId: null,
|
||||
},
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation123',
|
||||
workspaceId: 'workspace456',
|
||||
email: 'invited@example.com',
|
||||
inviterId: 'inviter789',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation not found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Simulate empty rows array
|
||||
return Promise.resolve(callback([]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'non-existent' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found' })
|
||||
})
|
||||
|
||||
it('should return 403 when user does not have admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user does not have admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation with accepted status
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([acceptedInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Can only delete pending invitations' })
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
// Mock successful deletion
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockResolvedValue({ rowCount: 1 }),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ success: true })
|
||||
expect(db.delete).toHaveBeenCalledWith(workspaceInvitation)
|
||||
expect(mockDelete.where).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 500 when database error occurs', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock database error
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toEqual({ error: 'Failed to delete invitation' })
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the invitation to delete
|
||||
const invitation = await db
|
||||
.select({
|
||||
id: workspaceInvitation.id,
|
||||
workspaceId: workspaceInvitation.workspaceId,
|
||||
email: workspaceInvitation.email,
|
||||
inviterId: workspaceInvitation.inviterId,
|
||||
status: workspaceInvitation.status,
|
||||
})
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if current user has admin access to the workspace
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Only allow deleting pending invitations
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete the invitation
|
||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
/**
|
||||
* Tests for workspace invitation by ID API route
|
||||
* Tests GET (details + token acceptance), DELETE (cancellation)
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
}
|
||||
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-456',
|
||||
name: 'Test Workspace',
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation-789',
|
||||
workspaceId: 'workspace-456',
|
||||
email: 'invited@example.com',
|
||||
inviterId: 'inviter-321',
|
||||
status: 'pending',
|
||||
token: 'token-abc123',
|
||||
permissions: 'read',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
let mockDbResults: any[] = []
|
||||
let mockGetSession: any
|
||||
let mockHasWorkspaceAdminAccess: any
|
||||
let mockTransaction: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockDbResults = []
|
||||
mockConsoleLogger()
|
||||
mockAuth(mockUser)
|
||||
|
||||
vi.doMock('crypto', () => ({
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'),
|
||||
}))
|
||||
|
||||
mockGetSession = vi.fn()
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
mockHasWorkspaceAdminAccess = vi.fn()
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
},
|
||||
}))
|
||||
|
||||
mockTransaction = vi.fn()
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockImplementation((callback: any) => {
|
||||
const result = mockDbResults.shift() || []
|
||||
return callback ? callback(result) : Promise.resolve(result)
|
||||
}),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
transaction: mockTransaction,
|
||||
}
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDbChain,
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
workspaceInvitation: {
|
||||
id: 'id',
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
inviterId: 'inviterId',
|
||||
status: 'status',
|
||||
token: 'token',
|
||||
permissions: 'permissions',
|
||||
expiresAt: 'expiresAt',
|
||||
},
|
||||
workspace: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
user: {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
},
|
||||
permissions: {
|
||||
id: 'id',
|
||||
entityType: 'entityType',
|
||||
entityId: 'entityId',
|
||||
userId: 'userId',
|
||||
permissionType: 'permissionType',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
and: vi.fn((...args) => ({ type: 'and', args })),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('GET /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return invitation details when called without token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({
|
||||
id: 'invitation-789',
|
||||
email: 'invited@example.com',
|
||||
status: 'pending',
|
||||
workspaceName: 'Test Workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to login when unauthenticated with token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/token-abc123?token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept invitation when called with valid token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
})
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }])
|
||||
mockDbResults.push([])
|
||||
|
||||
mockTransaction.mockImplementation(async (callback: any) => {
|
||||
await callback({
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
})
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
|
||||
})
|
||||
|
||||
it('should redirect to error page when invitation expired', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
})
|
||||
|
||||
const expiredInvitation = {
|
||||
...mockInvitation,
|
||||
expiresAt: new Date(Date.now() - 86400000), // 1 day ago
|
||||
}
|
||||
|
||||
mockDbResults.push([expiredInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page when email mismatch', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'wrong@example.com' },
|
||||
})
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
mockDbResults.push([{ ...mockUser, email: 'wrong@example.com' }])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
|
||||
mockDbResults.push([])
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ invitationId: 'non-existent' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found' })
|
||||
})
|
||||
|
||||
it('should return 403 when user lacks admin access', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-123', 'workspace-456')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
mockDbResults.push([acceptedInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Can only delete pending invitations' })
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('should return 500 when database error occurs', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const mockErrorDb = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}
|
||||
|
||||
vi.doMock('@/db', () => ({ db: mockErrorDb }))
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({ user: mockUser }),
|
||||
}))
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
workspaceInvitation: { id: 'id' },
|
||||
}))
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toEqual({ error: 'Failed to delete invitation' })
|
||||
})
|
||||
})
|
||||
})
|
||||
236
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
236
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
permissions,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
// GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
const session = await getSession()
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// For token-based acceptance flows, redirect to login
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitationId}?token=${token}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const whereClause = token
|
||||
? eq(workspaceInvitation.token, token)
|
||||
: eq(workspaceInvitation.id, invitationId)
|
||||
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(whereClause)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=expired`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=workspace-not-found`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (isAcceptFlow) {
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=already-processed`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const userEmail = session.user.email.toLowerCase()
|
||||
const invitationEmail = invitation.email.toLowerCase()
|
||||
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=user-not-found`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const isValidMatch = userEmail === invitationEmail
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=email-mismatch`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, invitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const invitation = await db
|
||||
.select({
|
||||
id: workspaceInvitation.id,
|
||||
workspaceId: workspaceInvitation.workspaceId,
|
||||
email: workspaceInvitation.email,
|
||||
inviterId: workspaceInvitation.inviterId,
|
||||
status: workspaceInvitation.status,
|
||||
})
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, invitationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { db } from '@/db'
|
||||
import { permissions, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// Accept an invitation via token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-token',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// No need to encode API URL as callback, just redirect to invite page
|
||||
// The middleware will handle proper login flow and return to invite page
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${token}?token=${token}`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-token',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation is already accepted
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const userEmail = session.user.email.toLowerCase()
|
||||
const invitationEmail = invitation.email.toLowerCase()
|
||||
|
||||
// Get user data to check email verification status and for error messages
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=user-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData.emailVerified) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData.email}) before accepting invitations.`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the logged-in user's email matches the invitation
|
||||
const isValidMatch = userEmail === invitationEmail
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData.email}`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get the workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=workspace-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user already has permissions for this workspace
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, invitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
// User already has permissions, just mark the invitation as accepted and redirect
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Add user permissions and mark invitation as accepted in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Create permissions for the user
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
})
|
||||
|
||||
// Redirect to the workspace
|
||||
return NextResponse.redirect(
|
||||
new URL(`/workspace/${invitation.workspaceId}/w`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error accepting invitation:', error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// Get invitation details by token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Return the invitation with workspace name
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
permissions,
|
||||
type permissionTypeEnum,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
@@ -162,7 +163,7 @@ export async function POST(req: NextRequest) {
|
||||
and(
|
||||
eq(workspaceInvitation.workspaceId, workspaceId),
|
||||
eq(workspaceInvitation.email, email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
@@ -189,7 +190,7 @@ export async function POST(req: NextRequest) {
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
role,
|
||||
status: 'pending',
|
||||
status: 'pending' as WorkspaceInvitationStatus,
|
||||
token,
|
||||
permissions: permission,
|
||||
expiresAt,
|
||||
@@ -205,6 +206,7 @@ export async function POST(req: NextRequest) {
|
||||
to: email,
|
||||
inviterName: session.user.name || session.user.email || 'A user',
|
||||
workspaceName: workspaceDetails.name,
|
||||
invitationId: invitationData.id,
|
||||
token: token,
|
||||
})
|
||||
|
||||
@@ -220,17 +222,19 @@ async function sendInvitationEmail({
|
||||
to,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
invitationId,
|
||||
token,
|
||||
}: {
|
||||
to: string
|
||||
inviterName: string
|
||||
workspaceName: string
|
||||
invitationId: string
|
||||
token: string
|
||||
}) {
|
||||
try {
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
// Always use the client-side invite route with token parameter
|
||||
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
|
||||
// Use invitation ID in path, token in query parameter for security
|
||||
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}`
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
|
||||
@@ -161,6 +161,12 @@
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Tooltip overrides - keep tooltips black with white text for consistency */
|
||||
.chat-light-wrapper [data-radix-tooltip-content] {
|
||||
background-color: hsl(0 0% 3.9%) !important;
|
||||
color: hsl(0 0% 98%) !important;
|
||||
}
|
||||
|
||||
/* Force color scheme */
|
||||
.chat-light-wrapper {
|
||||
color-scheme: light !important;
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/chat/[subdomain]/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/[subdomain]/hooks'
|
||||
} from '@/app/chat/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './chat-client.css'
|
||||
import './chat.css'
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat-client'
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
|
||||
const { subdomain } = await params
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Send, Square } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { VoiceInput } from '@/app/chat/[subdomain]/components/input/voice-input'
|
||||
import { VoiceInput } from '@/app/chat/components/input/voice-input'
|
||||
|
||||
const PLACEHOLDER_MOBILE = 'Enter a message'
|
||||
const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak'
|
||||
@@ -118,7 +118,7 @@ export const ChatInput: React.FC<{
|
||||
<VoiceInput onVoiceStart={handleVoiceStart} disabled={isStreaming} large={true} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='border border-gray-200 bg-white text-gray-900'>
|
||||
<TooltipContent side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -3,10 +3,7 @@
|
||||
import { memo, type RefObject } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type ChatMessage,
|
||||
ClientChatMessage,
|
||||
} from '@/app/chat/[subdomain]/components/message/message'
|
||||
import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message'
|
||||
|
||||
interface ChatMessageContainerProps {
|
||||
messages: ChatMessage[]
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import MarkdownRenderer from './components/markdown-renderer'
|
||||
|
||||
@@ -80,10 +79,8 @@ export const ClientChatMessage = memo(
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='flex items-center gap-1.5 px-2 py-1'
|
||||
<button
|
||||
className='text-muted-foreground transition-colors hover:bg-muted'
|
||||
onClick={() => {
|
||||
const contentToCopy =
|
||||
typeof cleanTextContent === 'string'
|
||||
@@ -95,15 +92,11 @@ export const ClientChatMessage = memo(
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||
</>
|
||||
<Check className='h-3 w-3' strokeWidth={2} />
|
||||
) : (
|
||||
<>
|
||||
<Copy className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
</>
|
||||
<Copy className='h-3 w-3' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' align='center' sideOffset={5}>
|
||||
{isCopied ? 'Copied!' : 'Copy to clipboard'}
|
||||
@@ -5,7 +5,7 @@ import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ParticlesVisualization } from '@/app/chat/[subdomain]/components/voice-interface/components/particles'
|
||||
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChatMessage } from '@/app/chat/[subdomain]/components/message/message'
|
||||
import type { ChatMessage } from '@/app/chat/components/message/message'
|
||||
// No longer need complex output extraction - backend handles this
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Mail, UserPlus, Users2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getErrorMessage } from '@/app/invite/[id]/utils'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
|
||||
const logger = createLogger('InviteById')
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
@@ -15,7 +15,6 @@ export default function Invite() {
|
||||
const inviteId = params.id as string
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending } = useSession()
|
||||
const brandConfig = useBrandConfig()
|
||||
const [invitationDetails, setInvitationDetails] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -25,12 +24,18 @@ export default function Invite() {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace')
|
||||
|
||||
// Check if this is a new user vs. existing user and get token from query
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
|
||||
if (errorReason) {
|
||||
setError(getErrorMessage(errorReason))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
// Get token from URL or use inviteId as token
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
const effectiveToken = tokenFromQuery || inviteId
|
||||
|
||||
@@ -40,20 +45,16 @@ export default function Invite() {
|
||||
}
|
||||
}, [searchParams, inviteId])
|
||||
|
||||
// Auto-fetch invitation details when logged in
|
||||
useEffect(() => {
|
||||
if (!session?.user || !token) return
|
||||
|
||||
async function fetchInvitationDetails() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// First try to fetch workspace invitation details
|
||||
const workspaceInviteResponse = await fetch(
|
||||
`/api/workspaces/invitations/details?token=${token}`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
// Fetch invitation details using the invitation ID from the URL path
|
||||
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (workspaceInviteResponse.ok) {
|
||||
const data = await workspaceInviteResponse.json()
|
||||
@@ -67,7 +68,6 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// If workspace invitation not found, try organization invitation
|
||||
try {
|
||||
const { data } = await client.organization.getInvitation({
|
||||
query: { id: inviteId },
|
||||
@@ -81,7 +81,6 @@ export default function Invite() {
|
||||
name: data.organizationName || 'an organization',
|
||||
})
|
||||
|
||||
// Get organization details
|
||||
if (data.organizationId) {
|
||||
const orgResponse = await client.organization.getFullOrganization({
|
||||
query: { organizationId: data.organizationId },
|
||||
@@ -98,11 +97,10 @@ export default function Invite() {
|
||||
throw new Error('Invitation not found or has expired')
|
||||
}
|
||||
} catch (_err) {
|
||||
// If neither workspace nor organization invitation is found
|
||||
throw new Error('Invitation not found or has expired')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching invitation:', err)
|
||||
logger.error('Error fetching invitation:', err)
|
||||
setError(err.message || 'Failed to load invitation details')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -112,36 +110,19 @@ export default function Invite() {
|
||||
fetchInvitationDetails()
|
||||
}, [session?.user, inviteId, token])
|
||||
|
||||
// Handle invitation acceptance
|
||||
const handleAcceptInvitation = async () => {
|
||||
if (!session?.user) return
|
||||
|
||||
setIsAccepting(true)
|
||||
try {
|
||||
if (invitationType === 'workspace') {
|
||||
// For workspace invites, call the API route with token
|
||||
const response = await fetch(
|
||||
`/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to accept invitation')
|
||||
}
|
||||
|
||||
setAccepted(true)
|
||||
|
||||
// Redirect to workspace after a brief delay
|
||||
setTimeout(() => {
|
||||
router.push('/workspace')
|
||||
}, 2000)
|
||||
} else {
|
||||
// For organization invites, use the client API
|
||||
if (invitationType === 'workspace') {
|
||||
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
|
||||
} else {
|
||||
try {
|
||||
const response = await client.organization.acceptInvitation({
|
||||
invitationId: inviteId,
|
||||
})
|
||||
|
||||
// Set the active organization to the one just joined
|
||||
const orgId =
|
||||
response.data?.invitation.organizationId || invitationDetails?.data?.organizationId
|
||||
|
||||
@@ -153,294 +134,147 @@ export default function Invite() {
|
||||
|
||||
setAccepted(true)
|
||||
|
||||
// Redirect to workspace after a brief delay
|
||||
setTimeout(() => {
|
||||
router.push('/workspace')
|
||||
}, 2000)
|
||||
} catch (err: any) {
|
||||
logger.error('Error accepting invitation:', err)
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
} finally {
|
||||
setIsAccepting(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error accepting invitation:', err)
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
} finally {
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the callback URL - this ensures after login, user returns to invite page
|
||||
const getCallbackUrl = () => {
|
||||
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
|
||||
}
|
||||
|
||||
// Show login/signup prompt if not logged in
|
||||
if (!session?.user && !isPending) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
|
||||
<UserPlus className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
You've been invited!
|
||||
</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
{isNewUser
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='login'
|
||||
title="You've been invited!"
|
||||
description={
|
||||
isNewUser
|
||||
? 'Create an account to join this workspace on Sim'
|
||||
: 'Sign in to your account to accept this invitation'}
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isNewUser ? (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
onClick={() =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`)
|
||||
}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
: 'Sign in to your account to accept this invitation'
|
||||
}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
...(isNewUser
|
||||
? [
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'I already have an account',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Sign in',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading || isPending) {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<LoadingAgent size='lg' />
|
||||
<p className='mt-4 text-gray-400 text-sm'>Loading invitation...</p>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isExpiredError = errorReason === 'expired'
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
|
||||
<AlertCircle className='h-8 w-8 text-red-500 dark:text-red-400' />
|
||||
</div>
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
Invitation Error
|
||||
</h1>
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>{error}</p>
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='error'
|
||||
title='Invitation Error'
|
||||
description={error}
|
||||
icon='error'
|
||||
isExpiredError={isExpiredError}
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show success state
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-green-50 p-3 dark:bg-green-950/20'>
|
||||
<CheckCircle2 className='h-8 w-8 text-green-500 dark:text-green-400' />
|
||||
</div>
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Welcome!</h1>
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting
|
||||
to your workspace...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='success'
|
||||
title='Welcome!'
|
||||
description={`You have successfully joined ${invitationDetails?.name || 'the workspace'}. Redirecting to your workspace...`}
|
||||
icon='success'
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show invitation details
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src='/logo/b&w/medium.png'
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
|
||||
{invitationType === 'organization' ? (
|
||||
<Users2 className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
) : (
|
||||
<Mail className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
{invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'}
|
||||
</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
You've been invited to join{' '}
|
||||
<span className='font-medium text-black dark:text-white'>
|
||||
{invitationDetails?.name || `a ${invitationType}`}
|
||||
</span>
|
||||
. Click accept below to join.
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<Button
|
||||
onClick={handleAcceptInvitation}
|
||||
disabled={isAccepting}
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
'Accept Invitation'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full text-gray-600 hover:bg-gray-200 hover:text-black dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='invitation'
|
||||
title={
|
||||
invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'
|
||||
}
|
||||
description={`You've been invited to join ${invitationDetails?.name || `a ${invitationType}`}. Click accept below to join.`}
|
||||
icon={invitationType === 'organization' ? 'users' : 'mail'}
|
||||
actions={[
|
||||
{
|
||||
label: 'Accept Invitation',
|
||||
onClick: handleAcceptInvitation,
|
||||
disabled: isAccepting,
|
||||
loading: isAccepting,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
28
apps/sim/app/invite/[id]/utils.ts
Normal file
28
apps/sim/app/invite/[id]/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function getErrorMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
2
apps/sim/app/invite/components/index.ts
Normal file
2
apps/sim/app/invite/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InviteLayout } from './layout'
|
||||
export { InviteStatusCard } from './status-card'
|
||||
56
apps/sim/app/invite/components/layout.tsx
Normal file
56
apps/sim/app/invite/components/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function InviteLayout({ children }: InviteLayoutProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<main className='dark relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='mb-8 text-center'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/primary/text/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={140}
|
||||
height={42}
|
||||
priority
|
||||
className='mx-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
121
apps/sim/app/invite/components/status-card.tsx
Normal file
121
apps/sim/app/invite/components/status-card.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
|
||||
title: string
|
||||
description: string | React.ReactNode
|
||||
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success'
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>
|
||||
isExpiredError?: boolean
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
userPlus: UserPlus,
|
||||
mail: Mail,
|
||||
users: Users2,
|
||||
error: ShieldX,
|
||||
success: CheckCircle2,
|
||||
}
|
||||
|
||||
const iconColorMap = {
|
||||
userPlus: 'text-[#701ffc]',
|
||||
mail: 'text-[#701ffc]',
|
||||
users: 'text-[#701ffc]',
|
||||
error: 'text-red-500 dark:text-red-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
userPlus: 'bg-[#701ffc]/10',
|
||||
mail: 'bg-[#701ffc]/10',
|
||||
users: 'bg-[#701ffc]/10',
|
||||
error: 'bg-red-50 dark:bg-red-950/20',
|
||||
success: 'bg-green-50 dark:bg-green-950/20',
|
||||
}
|
||||
|
||||
export function InviteStatusCard({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions = [],
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center'>
|
||||
<LoadingAgent size='lg' />
|
||||
<p className='mt-4 text-muted-foreground text-sm'>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComponent = icon ? iconMap[icon] : null
|
||||
const iconColor = icon ? iconColorMap[icon] : ''
|
||||
const iconBg = icon ? iconBgMap[icon] : ''
|
||||
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
{IconComponent && (
|
||||
<div className={`mb-6 rounded-full p-3 ${iconBg}`}>
|
||||
<IconComponent className={`h-8 w-8 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className='mb-2 font-semibold text-[32px] text-white tracking-tight'>{title}</h1>
|
||||
|
||||
<p className='mb-6 text-neutral-400 text-sm leading-relaxed'>{description}</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
className={
|
||||
(action.variant || 'default') === 'default'
|
||||
? 'h-11 w-full bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
: action.variant === 'outline'
|
||||
? 'h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'h-11 w-full text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
{action.label}...
|
||||
</>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Mail, RotateCcw, ShieldX } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
function getErrorMessage(reason: string, details?: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return details
|
||||
? details
|
||||
: 'This invitation was sent to a different email address than the one you are logged in with.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'email-not-verified':
|
||||
return details
|
||||
? details
|
||||
: 'You must verify your email address before accepting invitations. Please check your email for a verification link.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function InviteError() {
|
||||
const searchParams = useSearchParams()
|
||||
const reason = searchParams?.get('reason') || 'unknown'
|
||||
const details = searchParams?.get('details')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Only set the error message on the client side
|
||||
setErrorMessage(getErrorMessage(reason, details || undefined))
|
||||
}, [reason, details])
|
||||
|
||||
// Provide a fallback message for SSR
|
||||
const displayMessage = errorMessage || 'Loading error details...'
|
||||
|
||||
const isEmailVerificationError = reason === 'email-not-verified'
|
||||
|
||||
const isExpiredError = reason === 'expired'
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
{/* Logo */}
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
|
||||
<ShieldX className='h-8 w-8 text-red-500 dark:text-red-400' />
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Invitation Error</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
{displayMessage}
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isEmailVerificationError && (
|
||||
<Button
|
||||
variant='default'
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
asChild
|
||||
>
|
||||
<Link href='/verify'>
|
||||
<Mail className='mr-2 h-4 w-4' />
|
||||
Verify Email
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
asChild
|
||||
>
|
||||
<Link href='/'>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
asChild
|
||||
>
|
||||
<Link href='/'>Return to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import InviteError from '@/app/invite/invite-error/invite-error'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function InviteErrorPage() {
|
||||
return <InviteError />
|
||||
}
|
||||
@@ -1,401 +1,3 @@
|
||||
'use client'
|
||||
import Unsubscribe from './unsubscribe'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
email: string
|
||||
token: string
|
||||
emailType: string
|
||||
isTransactional: boolean
|
||||
currentPreferences: {
|
||||
unsubscribeAll?: boolean
|
||||
unsubscribeMarketing?: boolean
|
||||
unsubscribeUpdates?: boolean
|
||||
unsubscribeNotifications?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<UnsubscribeData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
|
||||
useEffect(() => {
|
||||
if (!email || !token) {
|
||||
setError('Missing email or token in URL')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setData(data)
|
||||
} else {
|
||||
setError(data.error || 'Invalid unsubscribe link')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to validate unsubscribe link')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [email, token])
|
||||
|
||||
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
|
||||
if (!email || !token) return
|
||||
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
token,
|
||||
type,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
export default Unsubscribe
|
||||
|
||||
401
apps/sim/app/unsubscribe/unsubscribe.tsx
Normal file
401
apps/sim/app/unsubscribe/unsubscribe.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
email: string
|
||||
token: string
|
||||
emailType: string
|
||||
isTransactional: boolean
|
||||
currentPreferences: {
|
||||
unsubscribeAll?: boolean
|
||||
unsubscribeMarketing?: boolean
|
||||
unsubscribeUpdates?: boolean
|
||||
unsubscribeNotifications?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<UnsubscribeData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
|
||||
useEffect(() => {
|
||||
if (!email || !token) {
|
||||
setError('Missing email or token in URL')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setData(data)
|
||||
} else {
|
||||
setError(data.error || 'Invalid unsubscribe link')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to validate unsubscribe link')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [email, token])
|
||||
|
||||
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
|
||||
if (!email || !token) return
|
||||
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
token,
|
||||
type,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Unsubscribe() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function Workflow() {
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/workflows/sync')
|
||||
const response = await fetch('/api/workflows')
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({
|
||||
|
||||
@@ -502,7 +502,7 @@ export function FrozenCanvas({
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/logs/${executionId}/frozen-canvas`)
|
||||
const response = await fetch(`/api/logs/execution/${executionId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
@@ -216,7 +216,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
@@ -274,7 +274,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
|
||||
@@ -19,6 +19,7 @@ interface WorkspacePermissionsContextType {
|
||||
permissionsLoading: boolean
|
||||
permissionsError: string | null
|
||||
updatePermissions: (newPermissions: WorkspacePermissions) => void
|
||||
refetchPermissions: () => Promise<void>
|
||||
|
||||
// Computed user permissions (connection-aware)
|
||||
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
|
||||
@@ -32,6 +33,7 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
|
||||
permissionsLoading: false,
|
||||
permissionsError: null,
|
||||
updatePermissions: () => {},
|
||||
refetchPermissions: async () => {},
|
||||
userPermissions: {
|
||||
canRead: false,
|
||||
canEdit: false,
|
||||
@@ -74,6 +76,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
loading: permissionsLoading,
|
||||
error: permissionsError,
|
||||
updatePermissions,
|
||||
refetch: refetchPermissions,
|
||||
} = useWorkspacePermissions(workspaceId)
|
||||
|
||||
// Get base user permissions from workspace permissions
|
||||
@@ -113,10 +116,18 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
||||
permissionsLoading,
|
||||
permissionsError,
|
||||
updatePermissions,
|
||||
refetchPermissions,
|
||||
userPermissions,
|
||||
setOfflineMode: setIsOfflineMode,
|
||||
}),
|
||||
[workspacePermissions, permissionsLoading, permissionsError, updatePermissions, userPermissions]
|
||||
[
|
||||
workspacePermissions,
|
||||
permissionsLoading,
|
||||
permissionsError,
|
||||
updatePermissions,
|
||||
refetchPermissions,
|
||||
userPermissions,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -53,7 +53,7 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-primary text-sm'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-foreground text-sm'
|
||||
>
|
||||
{subdomainPart}
|
||||
</a>
|
||||
@@ -67,7 +67,7 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
className='text-foreground hover:underline'
|
||||
>
|
||||
this URL
|
||||
</a>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ExampleCommand({
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: ${apiKey}" \\
|
||||
${baseUrlForRateLimit}/api/users/rate-limit`
|
||||
${baseUrlForRateLimit}/api/users/me/rate-limit`
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -119,7 +119,7 @@ export function ExampleCommand({
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: SIM_API_KEY" \\
|
||||
${baseUrlForRateLimit}/api/users/rate-limit`
|
||||
${baseUrlForRateLimit}/api/users/me/rate-limit`
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -321,7 +321,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
|
||||
try {
|
||||
// Primary: call server-side usage check to mirror backend enforcement
|
||||
const res = await fetch('/api/usage/check', { cache: 'no-store' })
|
||||
const res = await fetch('/api/usage?context=user', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const payload = await res.json()
|
||||
const usage = payload?.data
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { type FC, memo, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Blocks,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Box,
|
||||
Check,
|
||||
Clipboard,
|
||||
Info,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Shapes,
|
||||
SquareChevronRight,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
Workflow,
|
||||
@@ -389,7 +392,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const fromBlock = Array.isArray((block as any)?.contexts)
|
||||
? ((block as any).contexts as any[])
|
||||
: []
|
||||
const allContexts = direct.length > 0 ? direct : fromBlock
|
||||
const allContexts = (direct.length > 0 ? direct : fromBlock).filter(
|
||||
(c: any) => c?.kind !== 'current_workflow'
|
||||
)
|
||||
const MAX_VISIBLE = 4
|
||||
const visible = showAllContexts
|
||||
? allContexts
|
||||
@@ -404,14 +409,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
>
|
||||
{ctx?.kind === 'past_chat' ? (
|
||||
<Bot className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'workflow' ? (
|
||||
) : ctx?.kind === 'workflow' || ctx?.kind === 'current_workflow' ? (
|
||||
<Workflow className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'blocks' ? (
|
||||
<Blocks className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'workflow_block' ? (
|
||||
<Box className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'knowledge' ? (
|
||||
<LibraryBig className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'templates' ? (
|
||||
<Shapes className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'docs' ? (
|
||||
<BookOpen className='h-3 w-3 text-muted-foreground' />
|
||||
) : ctx?.kind === 'logs' ? (
|
||||
<SquareChevronRight className='h-3 w-3 text-muted-foreground' />
|
||||
) : (
|
||||
<Info className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
@@ -500,7 +511,10 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
: []
|
||||
const labels = contexts.map((c) => c?.label).filter(Boolean) as string[]
|
||||
const labels = contexts
|
||||
.filter((c) => c?.kind !== 'current_workflow')
|
||||
.map((c) => c?.label)
|
||||
.filter(Boolean) as string[]
|
||||
if (!labels.length) return <WordWrap text={text} />
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,8 +120,10 @@ export function Panel() {
|
||||
|
||||
// Handle new chat creation with data loading
|
||||
const handleNewChat = useCallback(async () => {
|
||||
await ensureCopilotDataLoaded()
|
||||
// Instantly clear to a fresh chat locally
|
||||
copilotRef.current?.createNewChat()
|
||||
// Ensure copilot data is loaded in the background (do not await)
|
||||
ensureCopilotDataLoaded().catch(() => {})
|
||||
}, [ensureCopilotDataLoaded])
|
||||
|
||||
// Handle history dropdown opening - use smart caching instead of force refresh
|
||||
|
||||
@@ -132,7 +132,7 @@ export function WandPromptBar({
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => onSubmit(promptValue)}
|
||||
className='h-8 w-8 rounded-full text-primary hover:bg-primary/10 hover:text-primary'
|
||||
className='h-8 w-8 rounded-full text-muted-foreground hover:bg-primary/10 hover:text-foreground'
|
||||
disabled={isLoading || isStreaming || !promptValue.trim()}
|
||||
>
|
||||
<SendIcon className='h-4 w-4' />
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import 'prismjs/components/prism-python'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
|
||||
@@ -25,7 +28,7 @@ interface CodeProps {
|
||||
subBlockId: string
|
||||
isConnecting: boolean
|
||||
placeholder?: string
|
||||
language?: 'javascript' | 'json'
|
||||
language?: 'javascript' | 'json' | 'python'
|
||||
generationType?: GenerationType
|
||||
value?: string
|
||||
isPreview?: boolean
|
||||
@@ -73,7 +76,9 @@ export function Code({
|
||||
onValidationChange,
|
||||
wandConfig,
|
||||
}: CodeProps) {
|
||||
// Determine the AI prompt placeholder based on language
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const aiPromptPlaceholder = useMemo(() => {
|
||||
switch (generationType) {
|
||||
case 'json-schema':
|
||||
@@ -122,35 +127,66 @@ export function Code({
|
||||
if (onValidationChange && subBlockId === 'responseFormat') {
|
||||
const timeoutId = setTimeout(() => {
|
||||
onValidationChange(isValidJson)
|
||||
}, 150) // Match debounce time from setStoreValue
|
||||
}, 150)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [isValidJson, onValidationChange, subBlockId])
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Function to toggle collapsed state
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsedValue(blockId, collapsedStateKey, !isCollapsed)
|
||||
}
|
||||
|
||||
// Create refs to hold the handlers
|
||||
const handleStreamStartRef = useRef<() => void>(() => {})
|
||||
const handleGeneratedContentRef = useRef<(generatedCode: string) => void>(() => {})
|
||||
const handleStreamChunkRef = useRef<(chunk: string) => void>(() => {})
|
||||
|
||||
// AI Code Generation Hook - use new wand system
|
||||
const wandHook = wandConfig?.enabled
|
||||
? useWand({
|
||||
wandConfig,
|
||||
currentValue: code,
|
||||
onStreamStart: () => handleStreamStartRef.current?.(),
|
||||
onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk),
|
||||
onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content),
|
||||
})
|
||||
: null
|
||||
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
|
||||
const [remoteExecution] = useSubBlockValue<boolean>(blockId, 'remoteExecution')
|
||||
|
||||
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
|
||||
|
||||
const dynamicPlaceholder = useMemo(() => {
|
||||
if (remoteExecution && languageValue === CodeLanguage.Python) {
|
||||
return 'Write Python...'
|
||||
}
|
||||
return placeholder
|
||||
}, [remoteExecution, languageValue, placeholder])
|
||||
|
||||
const dynamicWandConfig = useMemo(() => {
|
||||
if (remoteExecution && languageValue === CodeLanguage.Python) {
|
||||
return {
|
||||
...wandConfig,
|
||||
prompt: `You are an expert Python programmer.
|
||||
Generate ONLY the raw body of a Python function based on the user's request.
|
||||
The code should be executable within a Python function body context.
|
||||
- 'params' (object): Contains input parameters derived from the JSON schema. Access these directly using the parameter name wrapped in angle brackets, e.g., '<paramName>'. Do NOT use 'params.paramName'.
|
||||
- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use os.environ or env.
|
||||
|
||||
Current code context: {context}
|
||||
|
||||
IMPORTANT FORMATTING RULES:
|
||||
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes.
|
||||
2. Reference Input Parameters/Workflow Variables: Use the exact syntax <variable_name>. Do NOT wrap it in quotes.
|
||||
3. Function Body ONLY: Do NOT include the function signature (e.g., 'def my_func(...)') or surrounding braces. Return the final value with 'return'.
|
||||
4. Imports: You may add imports as needed (standard library or pip-installed packages) without comments.
|
||||
5. No Markdown: Do NOT include backticks, code fences, or any markdown.
|
||||
6. Clarity: Write clean, readable Python code.`,
|
||||
placeholder: 'Describe the Python function you want to create...',
|
||||
}
|
||||
}
|
||||
return wandConfig
|
||||
}, [wandConfig, remoteExecution, languageValue])
|
||||
|
||||
const wandHook = useWand({
|
||||
wandConfig: wandConfig || { enabled: false, prompt: '' },
|
||||
currentValue: code,
|
||||
onStreamStart: () => handleStreamStartRef.current?.(),
|
||||
onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk),
|
||||
onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content),
|
||||
})
|
||||
|
||||
// Extract values from wand hook
|
||||
const isAiLoading = wandHook?.isLoading || false
|
||||
const isAiStreaming = wandHook?.isStreaming || false
|
||||
const generateCodeStream = wandHook?.generateStream || (() => {})
|
||||
@@ -161,7 +197,6 @@ export function Code({
|
||||
const updatePromptValue = wandHook?.updatePromptValue || (() => {})
|
||||
const cancelGeneration = wandHook?.cancelGeneration || (() => {})
|
||||
|
||||
// State management - useSubBlockValue with explicit streaming control
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
|
||||
isStreaming: isAiStreaming,
|
||||
onStreamingEnd: () => {
|
||||
@@ -171,43 +206,28 @@ export function Code({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value or prop value
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
// Define the handlers in useEffect to avoid setState during render
|
||||
useEffect(() => {
|
||||
handleStreamStartRef.current = () => {
|
||||
setCode('')
|
||||
// Streaming state is now controlled by isAiStreaming
|
||||
}
|
||||
|
||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||
setCode(generatedCode)
|
||||
if (!isPreview && !disabled) {
|
||||
setStoreValue(generatedCode)
|
||||
// Final value will be persisted when isAiStreaming becomes false
|
||||
}
|
||||
}
|
||||
|
||||
handleStreamChunkRef.current = (chunk: string) => {
|
||||
setCode((currentCode) => {
|
||||
const newCode = currentCode + chunk
|
||||
if (!isPreview && !disabled) {
|
||||
// Update the value - it won't be persisted until streaming ends
|
||||
setStoreValue(newCode)
|
||||
}
|
||||
return newCode
|
||||
})
|
||||
}
|
||||
}, [isPreview, disabled, setStoreValue])
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (isAiStreaming) return
|
||||
const valueString = value?.toString() ?? ''
|
||||
if (valueString !== code) {
|
||||
setCode(valueString)
|
||||
}
|
||||
}, [value])
|
||||
}, [value, code, isAiStreaming])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return
|
||||
@@ -276,7 +296,6 @@ export function Code({
|
||||
}
|
||||
}, [code])
|
||||
|
||||
// Handlers
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (isPreview) return
|
||||
e.preventDefault()
|
||||
@@ -335,12 +354,11 @@ export function Code({
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Render helpers
|
||||
const renderLineNumbers = () => {
|
||||
const renderLineNumbers = (): ReactElement[] => {
|
||||
const numbers: ReactElement[] = []
|
||||
let lineNumber = 1
|
||||
|
||||
visualLineHeights.forEach((height, index) => {
|
||||
visualLineHeights.forEach((height) => {
|
||||
numbers.push(
|
||||
<div key={`${lineNumber}-0`} className={cn('text-muted-foreground text-xs leading-[21px]')}>
|
||||
{lineNumber}
|
||||
@@ -361,7 +379,7 @@ export function Code({
|
||||
|
||||
if (numbers.length === 0) {
|
||||
numbers.push(
|
||||
<div key='1-0' className={cn('text-muted-foreground text-xs leading-[21px]')}>
|
||||
<div key={'1-0'} className={cn('text-muted-foreground text-xs leading-[21px]')}>
|
||||
1
|
||||
</div>
|
||||
)
|
||||
@@ -380,7 +398,7 @@ export function Code({
|
||||
onSubmit={(prompt: string) => generateCodeStream({ prompt })}
|
||||
onCancel={isAiStreaming ? cancelGeneration : hidePromptInline}
|
||||
onChange={updatePromptValue}
|
||||
placeholder={wandConfig?.placeholder || aiPromptPlaceholder}
|
||||
placeholder={dynamicWandConfig?.placeholder || aiPromptPlaceholder}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -437,7 +455,7 @@ export function Code({
|
||||
>
|
||||
{code.length === 0 && !isCollapsed && (
|
||||
<div className='pointer-events-none absolute top-[12px] left-[42px] select-none text-muted-foreground/50'>
|
||||
{placeholder}
|
||||
{dynamicPlaceholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -475,7 +493,11 @@ export function Code({
|
||||
}
|
||||
}}
|
||||
highlight={(codeToHighlight) =>
|
||||
highlight(codeToHighlight, languages[language], language)
|
||||
highlight(
|
||||
codeToHighlight,
|
||||
languages[effectiveLanguage === 'python' ? 'python' : 'javascript'],
|
||||
effectiveLanguage === 'python' ? 'python' : 'javascript'
|
||||
)
|
||||
}
|
||||
padding={12}
|
||||
style={{
|
||||
@@ -503,6 +525,7 @@ export function Code({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={code}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -45,6 +46,8 @@ export function ComboBox({
|
||||
config,
|
||||
isWide = false,
|
||||
}: ComboBoxProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -508,6 +511,7 @@ export function ComboBox({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={displayValue}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Plus, Trash } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
@@ -52,6 +53,8 @@ export function ConditionInput({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: ConditionInputProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
@@ -796,6 +799,7 @@ export function ConditionInput({
|
||||
searchTerm={block.searchTerm}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
|
||||
@@ -279,7 +279,10 @@ export function CredentialSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[250px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search credentials...' />
|
||||
<CommandInput
|
||||
placeholder='Search credentials...'
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Info } from 'lucide-react'
|
||||
import { Label, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { Switch as UISwitch } from '@/components/ui/switch'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
interface E2BSwitchProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
title: string
|
||||
value?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: boolean | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function E2BSwitch({
|
||||
blockId,
|
||||
subBlockId,
|
||||
title,
|
||||
value: propValue,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: E2BSwitchProps) {
|
||||
const e2bEnabled = isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))
|
||||
if (!e2bEnabled) return null
|
||||
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<boolean>(blockId, subBlockId)
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
if (!isPreview && !disabled) setStoreValue(checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<UISwitch
|
||||
id={`${blockId}-${subBlockId}`}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={isPreview || disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${blockId}-${subBlockId}`}
|
||||
className='cursor-pointer font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
>
|
||||
{title}
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className='h-4 w-4 cursor-pointer text-muted-foreground' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[320px] select-text whitespace-pre-wrap'>
|
||||
Python/Javascript code run in a sandbox environment. Can have slower execution times.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ChevronsUpDown, Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -49,6 +50,8 @@ export function LongInput({
|
||||
onChange,
|
||||
disabled,
|
||||
}: LongInputProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
// Local state for immediate UI updates during streaming
|
||||
const [localContent, setLocalContent] = useState<string>('')
|
||||
|
||||
@@ -457,6 +460,7 @@ export function LongInput({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -85,6 +86,9 @@ export function ShortInput({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Get ReactFlow instance for zoom control
|
||||
const reactFlowInstance = useReactFlow()
|
||||
|
||||
@@ -199,7 +203,7 @@ export function ShortInput({
|
||||
}, [value])
|
||||
|
||||
// Handle paste events to ensure long values are handled correctly
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const handlePaste = (_e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
// Let the paste happen normally
|
||||
// Then ensure scroll positions are synced after the content is updated
|
||||
setTimeout(() => {
|
||||
@@ -447,6 +451,7 @@ export function ShortInput({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
@@ -30,6 +31,8 @@ export function Table({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: TableProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<TableRow[]>(blockId, subBlockId)
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
@@ -287,6 +290,7 @@ export function Table({
|
||||
searchTerm={activeCell.searchTerm}
|
||||
inputValue={rows[activeCell.rowIndex].cells[activeCell.column] || ''}
|
||||
cursorPosition={activeCell.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setActiveCell((prev) => (prev ? { ...prev, showEnvVars: false } : null))
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -19,6 +19,9 @@ interface CodeEditorProps {
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void
|
||||
disabled?: boolean
|
||||
schemaParameters?: Array<{ name: string; type: string; description: string; required: boolean }>
|
||||
showWandButton?: boolean
|
||||
onWandClick?: () => void
|
||||
wandButtonDisabled?: boolean
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
@@ -32,6 +35,9 @@ export function CodeEditor({
|
||||
onKeyDown,
|
||||
disabled = false,
|
||||
schemaParameters = [],
|
||||
showWandButton = false,
|
||||
onWandClick,
|
||||
wandButtonDisabled = false,
|
||||
}: CodeEditorProps) {
|
||||
const [code, setCode] = useState(value)
|
||||
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
||||
@@ -206,7 +212,23 @@ export function CodeEditor({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{code.split('\n').length > 5 && (
|
||||
{showWandButton && onWandClick && (
|
||||
<button
|
||||
onClick={onWandClick}
|
||||
disabled={wandButtonDisabled}
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full border border-transparent bg-muted/80 p-0 text-foreground shadow-sm transition-all duration-200',
|
||||
'hover:border-primary/20 hover:bg-muted hover:text-foreground hover:shadow',
|
||||
'opacity-0 transition-opacity group-hover:opacity-100',
|
||||
wandButtonDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
aria-label='Generate with AI'
|
||||
>
|
||||
<Wand2 className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!showWandButton && code.split('\n').length > 5 && (
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Code, FileJson, Trash2, Wand2, X } from 'lucide-react'
|
||||
import { Code, FileJson, Trash2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -66,6 +67,8 @@ export function CustomToolModal({
|
||||
blockId,
|
||||
initialValues,
|
||||
}: CustomToolModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [activeSection, setActiveSection] = useState<ToolSection>('schema')
|
||||
const [jsonSchema, setJsonSchema] = useState('')
|
||||
const [functionCode, setFunctionCode] = useState('')
|
||||
@@ -931,25 +934,6 @@ try {
|
||||
<Label htmlFor='json-schema' className='font-medium'>
|
||||
JSON Schema
|
||||
</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 rounded-full border border-transparent bg-muted/80 p-0 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
|
||||
onClick={() => {
|
||||
logger.debug('Schema AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof schemaGeneration.showPromptInline === 'function'
|
||||
)
|
||||
schemaGeneration.isPromptVisible
|
||||
? schemaGeneration.hidePromptInline()
|
||||
: schemaGeneration.showPromptInline()
|
||||
}}
|
||||
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||
aria-label='Generate schema with AI'
|
||||
>
|
||||
<Wand2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{schemaError &&
|
||||
!schemaGeneration.isStreaming && ( // Hide schema error while streaming
|
||||
@@ -960,6 +944,18 @@ try {
|
||||
value={jsonSchema}
|
||||
onChange={handleJsonSchemaChange}
|
||||
language='json'
|
||||
showWandButton={true}
|
||||
onWandClick={() => {
|
||||
logger.debug('Schema AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof schemaGeneration.showPromptInline === 'function'
|
||||
)
|
||||
schemaGeneration.isPromptVisible
|
||||
? schemaGeneration.hidePromptInline()
|
||||
: schemaGeneration.showPromptInline()
|
||||
}}
|
||||
wandButtonDisabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||
placeholder={`{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1001,25 +997,6 @@ try {
|
||||
<Label htmlFor='function-code' className='font-medium'>
|
||||
Code (optional)
|
||||
</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 rounded-full border border-transparent bg-muted/80 p-0 text-muted-foreground shadow-sm transition-all duration-200 hover:border-primary/20 hover:bg-muted hover:text-primary hover:shadow'
|
||||
onClick={() => {
|
||||
logger.debug('Code AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof codeGeneration.showPromptInline === 'function'
|
||||
)
|
||||
codeGeneration.isPromptVisible
|
||||
? codeGeneration.hidePromptInline()
|
||||
: codeGeneration.showPromptInline()
|
||||
}}
|
||||
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||
aria-label='Generate code with AI'
|
||||
>
|
||||
<Wand2 className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
{codeError &&
|
||||
!codeGeneration.isStreaming && ( // Hide code error while streaming
|
||||
@@ -1047,6 +1024,18 @@ try {
|
||||
value={functionCode}
|
||||
onChange={handleFunctionCodeChange}
|
||||
language='javascript'
|
||||
showWandButton={true}
|
||||
onWandClick={() => {
|
||||
logger.debug('Code AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof codeGeneration.showPromptInline === 'function'
|
||||
)
|
||||
codeGeneration.isPromptVisible
|
||||
? codeGeneration.hidePromptInline()
|
||||
: codeGeneration.showPromptInline()
|
||||
}}
|
||||
wandButtonDisabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||
placeholder={
|
||||
'// This code will be executed when the tool is called. You can use environment variables with {{VARIABLE_NAME}}.'
|
||||
}
|
||||
@@ -1070,6 +1059,7 @@ try {
|
||||
searchTerm={searchTerm}
|
||||
inputValue={functionCode}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1415,7 +1415,7 @@ export function ToolInput({
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-[280px] p-2' side='top'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<p className='text-xs'>
|
||||
Control how the model uses this tool in its response.
|
||||
{tool.usageControl === 'auto' && (
|
||||
<span>
|
||||
|
||||
@@ -149,9 +149,15 @@ export function TriggerConfigSection({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[400px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${fieldDef.label.toLowerCase()}...`} />
|
||||
<CommandList className='max-h-[200px] overflow-y-auto'>
|
||||
<Command className='outline-none focus:outline-none'>
|
||||
<CommandInput
|
||||
placeholder={`Search ${fieldDef.label.toLowerCase()}...`}
|
||||
className='text-foreground placeholder:text-muted-foreground'
|
||||
/>
|
||||
<CommandList
|
||||
className='max-h-[200px] overflow-y-auto outline-none focus:outline-none'
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandEmpty>
|
||||
{availableOptions.length === 0
|
||||
? 'No options available. Please select credentials first.'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user