mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 15:38:00 -05:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfa935a09 | ||
|
|
917552f041 | ||
|
|
4846f6c60d | ||
|
|
be810013c7 | ||
|
|
1ee4263e60 | ||
|
|
60c4668682 | ||
|
|
a268fb7c04 | ||
|
|
6c606750f5 | ||
|
|
e13adab14f | ||
|
|
44bc12b474 | ||
|
|
991f0442e9 | ||
|
|
2ebfb576ae | ||
|
|
11a7be54f2 | ||
|
|
f5219d03c3 | ||
|
|
f0643e01b4 | ||
|
|
77b0c5b9ed | ||
|
|
9dbd44e555 | ||
|
|
9ea9f2d52e | ||
|
|
4cd707fadb | ||
|
|
f0b07428bc | ||
|
|
8c9e182e10 | ||
|
|
33dd59f7a7 | ||
|
|
53ee9f99db | ||
|
|
0f2a125eae | ||
|
|
e107363ea7 | ||
|
|
7e364a7977 | ||
|
|
35a37d8b45 | ||
|
|
2b52d88cee | ||
|
|
abad3620a3 | ||
|
|
a37c6bc812 | ||
|
|
cd1bd95952 | ||
|
|
4c9fdbe7fb | ||
|
|
2c47cf4161 | ||
|
|
db1cf8a6db | ||
|
|
c6912095f7 | ||
|
|
154d9eef6a | ||
|
|
c2ded1f3e1 | ||
|
|
ff43528d35 | ||
|
|
692ba69864 | ||
|
|
cb7ce8659b | ||
|
|
5caef3a37d | ||
|
|
a6888da124 | ||
|
|
07b0597f4f | ||
|
|
71e2994f9d | ||
|
|
9973b2c165 | ||
|
|
d9e5777538 | ||
|
|
dd74267313 | ||
|
|
1db72dc823 | ||
|
|
da707fa491 | ||
|
|
9ffaf305bd | ||
|
|
26e6286fda | ||
|
|
c795fc83aa | ||
|
|
cea42f5135 | ||
|
|
6fd6f921dc | ||
|
|
7530fb9a4e | ||
|
|
9a5b035822 | ||
|
|
0c0b6bf967 | ||
|
|
5d74db53ff | ||
|
|
b39bdfd55e | ||
|
|
6b185be9a4 | ||
|
|
214a0358b6 | ||
|
|
bbb5e53e43 | ||
|
|
79e932fed9 | ||
|
|
9ad36c0e34 | ||
|
|
2771c688ff | ||
|
|
d58ceb4bce | ||
|
|
69773c3174 | ||
|
|
1619d63f2a | ||
|
|
9aa1fe8037 | ||
|
|
1b7c111c46 | ||
|
|
bdfb56b262 | ||
|
|
4a7de31eee | ||
|
|
adfe56c720 | ||
|
|
72e3efa875 | ||
|
|
b40fa3aa6e | ||
|
|
f924edde3a | ||
|
|
073030bfaa | ||
|
|
871f4e8e18 | ||
|
|
091343a132 | ||
|
|
63c66bfc31 | ||
|
|
445ca78395 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -85,8 +85,8 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=build-v2
|
||||
cache-to: type=gha,mode=max,scope=build-v2
|
||||
cache-from: type=gha,scope=build-v3
|
||||
cache-to: type=gha,mode=max,scope=build-v3
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
|
||||
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
44
.github/workflows/trigger-deploy.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Trigger.dev Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Trigger.dev Deploy
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: trigger-deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Deploy to Staging
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy -e staging
|
||||
|
||||
- name: Deploy to Production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
working-directory: ./apps/sim
|
||||
run: npx --yes trigger.dev@4.0.0 deploy
|
||||
|
||||
58
README.md
58
README.md
@@ -1,50 +1,46 @@
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/sim.png" alt="Sim Logo" width="500"/>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
||||
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
|
||||
<a href="https://docs.sim.ai"><img src="https://img.shields.io/badge/Docs-visit%20documentation-blue.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Sim</strong> is a lightweight, user-friendly platform for building AI agent workflows.
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
## Getting Started
|
||||
## Quickstart
|
||||
|
||||
1. Use our [cloud-hosted version](https://sim.ai)
|
||||
2. Self-host using one of the methods below
|
||||
### Cloud-hosted: [sim.ai](https://sim.ai)
|
||||
|
||||
## Self-Hosting Options
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
|
||||
|
||||
### Option 1: NPM Package (Simplest)
|
||||
|
||||
The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
|
||||
### Self-hosted: NPM Package
|
||||
|
||||
```bash
|
||||
npx simstudio
|
||||
```
|
||||
→ http://localhost:3000
|
||||
|
||||
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
|
||||
#### Note
|
||||
Docker must be installed and running on your machine.
|
||||
|
||||
#### Options
|
||||
|
||||
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
|
||||
- `--no-pull`: Skip pulling the latest Docker images
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-p, --port <port>` | Port to run Sim on (default `3000`) |
|
||||
| `--no-pull` | Skip pulling latest Docker images |
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Docker must be installed and running on your machine
|
||||
|
||||
### Option 2: Docker Compose
|
||||
### Self-hosted: Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh
|
||||
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
|
||||
```
|
||||
|
||||
### Option 3: Dev Containers
|
||||
### Self-hosted: Dev Containers
|
||||
|
||||
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open the project and click "Reopen in Container" when prompted
|
||||
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
|
||||
- This starts both the main application and the realtime socket server
|
||||
|
||||
### Option 4: Manual Setup
|
||||
### Self-hosted: Manual Setup
|
||||
|
||||
**Requirements:**
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
@@ -158,6 +154,14 @@ cd apps/sim
|
||||
bun run dev:sockets
|
||||
```
|
||||
|
||||
## Copilot API Keys
|
||||
|
||||
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
|
||||
|
||||
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
|
||||
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
- Host Sim on a publicly available DNS and set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to that value ([ngrok](https://ngrok.com/))
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
|
||||
@@ -180,4 +184,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
|
||||
|
||||
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
<p align="center">Made with ❤️ by the Sim Team</p>
|
||||
<p align="center">Made with ❤️ by the Sim Team</p>
|
||||
|
||||
97
apps/docs/content/docs/copilot/index.mdx
Normal file
97
apps/docs/content/docs/copilot/index.mdx
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: Copilot
|
||||
description: Build and edit workflows with Sim Copilot
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Card, Cards } from 'fumadocs-ui/components/card'
|
||||
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
|
||||
|
||||
## What is Copilot
|
||||
|
||||
Copilot is your in-editor assistant that helps you build, understand, and improve workflows. It can:
|
||||
|
||||
- **Explain**: Answer questions about Sim and your current workflow
|
||||
- **Guide**: Suggest edits and best practices
|
||||
- **Edit**: Make changes to blocks, connections, and settings when you approve
|
||||
|
||||
<Callout type="info">
|
||||
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
|
||||
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
|
||||
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
|
||||
3. Host Sim on a publicly available DNS and set `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` to that value (e.g., using ngrok)
|
||||
</Callout>
|
||||
|
||||
## Modes
|
||||
|
||||
<Cards>
|
||||
<Card title="Ask">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">
|
||||
Q&A mode for explanations, guidance, and suggestions without making changes to your workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Agent">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">
|
||||
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Depth Levels
|
||||
|
||||
<Cards>
|
||||
<Card title="Fast">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Auto">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Pro">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<Brain className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Max">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
|
||||
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Cards>
|
||||
4
apps/docs/content/docs/copilot/meta.json
Normal file
4
apps/docs/content/docs/copilot/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Copilot",
|
||||
"pages": ["index"]
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
"connections",
|
||||
"---Execution---",
|
||||
"execution",
|
||||
"---Copilot---",
|
||||
"copilot",
|
||||
"---Advanced---",
|
||||
"./variables/index",
|
||||
"yaml",
|
||||
|
||||
@@ -115,8 +115,7 @@ Read data from a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Excel spreadsheet data and metadata |
|
||||
| `data` | object | Range data from the spreadsheet |
|
||||
|
||||
### `microsoft_excel_write`
|
||||
|
||||
@@ -136,8 +135,11 @@ Write data to a Microsoft Excel spreadsheet
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Write operation results and metadata |
|
||||
| `updatedRange` | string | The range that was updated |
|
||||
| `updatedRows` | number | Number of rows that were updated |
|
||||
| `updatedColumns` | number | Number of columns that were updated |
|
||||
| `updatedCells` | number | Number of cells that were updated |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
### `microsoft_excel_table_add`
|
||||
|
||||
@@ -155,8 +157,9 @@ Add new rows to a Microsoft Excel table
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `output` | object | Table add operation results and metadata |
|
||||
| `index` | number | Index of the first row that was added |
|
||||
| `values` | array | Array of rows that were added to the table |
|
||||
| `metadata` | object | Spreadsheet metadata |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ Get a single row from a Supabase table based on filter criteria
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | object | The row data if found, null if not found |
|
||||
| `results` | array | Array containing the row data if found, empty array if not found |
|
||||
|
||||
### `supabase_update`
|
||||
|
||||
@@ -185,6 +185,26 @@ Delete rows from a Supabase table based on filter criteria
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of deleted records |
|
||||
|
||||
### `supabase_upsert`
|
||||
|
||||
Insert or update data in a Supabase table (upsert operation)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
|
||||
| `table` | string | Yes | The name of the Supabase table to upsert data into |
|
||||
| `data` | any | Yes | The data to upsert \(insert or update\) |
|
||||
| `apiKey` | string | Yes | Your Supabase service role secret key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `message` | string | Operation status message |
|
||||
| `results` | array | Array of upserted records |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { client } from '@/lib/auth-client'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
@@ -114,58 +113,16 @@ export function SocialLoginButtons({
|
||||
</Button>
|
||||
)
|
||||
|
||||
const renderGithubButton = () => {
|
||||
if (githubAvailable) return githubButton
|
||||
const hasAnyOAuthProvider = githubAvailable || googleAvailable
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{githubButton}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='border-neutral-700 bg-neutral-800 text-white'>
|
||||
<p>
|
||||
GitHub login requires OAuth credentials to be configured. Add the following
|
||||
environment variables:
|
||||
</p>
|
||||
<ul className='mt-2 space-y-1 text-neutral-300 text-xs'>
|
||||
<li>• GITHUB_CLIENT_ID</li>
|
||||
<li>• GITHUB_CLIENT_SECRET</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGoogleButton = () => {
|
||||
if (googleAvailable) return googleButton
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{googleButton}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='border-neutral-700 bg-neutral-800 text-white'>
|
||||
<p>
|
||||
Google login requires OAuth credentials to be configured. Add the following
|
||||
environment variables:
|
||||
</p>
|
||||
<ul className='mt-2 space-y-1 text-neutral-300 text-xs'>
|
||||
<li>• GOOGLE_CLIENT_ID</li>
|
||||
<li>• GOOGLE_CLIENT_SECRET</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
if (!hasAnyOAuthProvider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-3'>
|
||||
{renderGithubButton()}
|
||||
{renderGoogleButton()}
|
||||
{githubAvailable && githubButton}
|
||||
{googleAvailable && googleButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={42}
|
||||
height={42}
|
||||
className='h-[42px] w-[42px] object-contain'
|
||||
width={56}
|
||||
height={56}
|
||||
className='h-[56px] w-[56px] object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={42} height={42} />
|
||||
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={56} height={56} />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -366,11 +366,13 @@ export default function LoginPage({
|
||||
callbackURL={callbackUrl}
|
||||
/>
|
||||
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
|
||||
@@ -381,11 +381,13 @@ function SignupFormContent({
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
|
||||
@@ -354,6 +354,18 @@ export function mockExecutionDependencies() {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Trigger.dev SDK (tasks.trigger and task factory) for tests that import background modules
|
||||
*/
|
||||
export function mockTriggerDevSdk() {
|
||||
vi.mock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
task: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function mockWorkflowAccessValidation(shouldSucceed = true) {
|
||||
if (shouldSucceed) {
|
||||
vi.mock('@/app/api/workflows/middleware', () => ({
|
||||
|
||||
@@ -84,14 +84,12 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if the access token is valid
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`[${requestId}] No access token available for credential`)
|
||||
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Refresh the token if needed
|
||||
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
|
||||
return NextResponse.json({ accessToken }, { status: 200 })
|
||||
} catch (_error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshOAuthToken } from '@/lib/oauth/oauth'
|
||||
@@ -70,7 +70,8 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
})
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
|
||||
.orderBy(account.createdAt)
|
||||
// Always use the most recently updated credential for this provider
|
||||
.orderBy(desc(account.updatedAt))
|
||||
.limit(1)
|
||||
|
||||
if (connections.length === 0) {
|
||||
@@ -80,19 +81,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
|
||||
const credential = connections[0]
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(`Access token is null for user ${userId}, provider ${providerId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the token is expired and needs refreshing
|
||||
// Determine whether we should refresh: missing token OR expired token
|
||||
const now = new Date()
|
||||
const tokenExpiry = credential.accessTokenExpiresAt
|
||||
// Only refresh if we have an expiration time AND it's expired AND we have a refresh token
|
||||
const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken
|
||||
const shouldAttemptRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now))
|
||||
|
||||
if (needsRefresh) {
|
||||
if (shouldAttemptRefresh) {
|
||||
logger.info(
|
||||
`Access token expired for user ${userId}, provider ${providerId}. Attempting to refresh.`
|
||||
)
|
||||
@@ -141,6 +136,13 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
if (!credential.accessToken) {
|
||||
logger.warn(
|
||||
`Access token is null and no refresh attempted or available for user ${userId}, provider ${providerId}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
|
||||
return credential.accessToken
|
||||
}
|
||||
@@ -164,19 +166,21 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
const accessToken = credential.accessToken
|
||||
|
||||
if (needsRefresh && credential.refreshToken) {
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
const refreshedToken = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!
|
||||
)
|
||||
|
||||
if (!refreshedToken) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential: ${credentialId}`, {
|
||||
@@ -217,6 +221,7 @@ export async function refreshAccessTokenIfNeeded(
|
||||
return null
|
||||
}
|
||||
} else if (!accessToken) {
|
||||
// We have no access token and either no refresh token or not eligible to refresh
|
||||
logger.error(`[${requestId}] Missing access token for credential`)
|
||||
return null
|
||||
}
|
||||
@@ -233,21 +238,20 @@ export async function refreshTokenIfNeeded(
|
||||
credential: any,
|
||||
credentialId: string
|
||||
): Promise<{ accessToken: string; refreshed: boolean }> {
|
||||
// Check if we need to refresh the token
|
||||
// Decide if we should refresh: token missing OR expired
|
||||
const expiresAt = credential.accessTokenExpiresAt
|
||||
const now = new Date()
|
||||
// Only refresh if we have an expiration time AND it's expired
|
||||
// If no expiration time is set (newly created credentials), assume token is valid
|
||||
const needsRefresh = expiresAt && expiresAt <= now
|
||||
const shouldRefresh =
|
||||
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
|
||||
|
||||
// If token is still valid, return it directly
|
||||
if (!needsRefresh || !credential.refreshToken) {
|
||||
// If token appears valid and present, return it directly
|
||||
if (!shouldRefresh) {
|
||||
logger.info(`[${requestId}] Access token is valid`)
|
||||
return { accessToken: credential.accessToken, refreshed: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken)
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
|
||||
@@ -4,8 +4,9 @@ import { auth } from '@/lib/auth'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const hdrs = await headers()
|
||||
const response = await auth.api.generateOneTimeToken({
|
||||
headers: await headers(),
|
||||
headers: hdrs,
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
@@ -14,7 +15,6 @@ export async function POST() {
|
||||
|
||||
return NextResponse.json({ token: response.token })
|
||||
} catch (error) {
|
||||
console.error('Error generating one-time token:', error)
|
||||
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Fatal error in monthly billing cron job', { error })
|
||||
logger.error('Fatal error in daily billing cron job', { error })
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -90,18 +90,59 @@ export async function GET(request: NextRequest) {
|
||||
return authError
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ready',
|
||||
message:
|
||||
'Daily billing check cron job is ready to process users and organizations with periods ending today',
|
||||
currentDate: new Date().toISOString().split('T')[0],
|
||||
const startTime = Date.now()
|
||||
const result = await processDailyBillingCheck()
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Daily billing check (GET) completed successfully', {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
duration: `${duration}ms`,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
logger.error('Daily billing check (GET) completed with errors', {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
errorCount: result.errors.length,
|
||||
errors: result.errors,
|
||||
duration: `${duration}ms`,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error in billing health check', { error })
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false,
|
||||
summary: {
|
||||
processedUsers: result.processedUsers,
|
||||
processedOrganizations: result.processedOrganizations,
|
||||
totalChargedAmount: result.totalChargedAmount,
|
||||
errorCount: result.errors.length,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
errors: result.errors,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Fatal error in daily billing (GET) cron job', { error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error during daily billing check',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('Copilot Chat API Route', () => {
|
||||
env: {
|
||||
SIM_AGENT_API_URL: 'http://localhost:8000',
|
||||
COPILOT_API_KEY: 'test-sim-agent-key',
|
||||
BETTER_AUTH_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -225,6 +226,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -288,6 +290,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -343,6 +346,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -438,6 +442,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'ask',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -28,6 +28,15 @@ const logger = createLogger('CopilotChatAPI')
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
function getRequestOrigin(_req: NextRequest): string {
|
||||
try {
|
||||
// Strictly use configured Better Auth URL
|
||||
return env.BETTER_AUTH_URL || ''
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
@@ -72,7 +81,8 @@ const ChatMessageSchema = z.object({
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
mode: z.enum(['ask', 'agent']).optional().default('agent'),
|
||||
depth: z.number().int().min(0).max(3).optional().default(0),
|
||||
depth: z.number().int().min(-2).max(3).optional().default(0),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
@@ -189,6 +199,7 @@ export async function POST(req: NextRequest) {
|
||||
workflowId,
|
||||
mode,
|
||||
depth,
|
||||
prefetch,
|
||||
createNewChat,
|
||||
stream,
|
||||
implicitFeedback,
|
||||
@@ -197,6 +208,27 @@ export async function POST(req: NextRequest) {
|
||||
conversationId,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
|
||||
// Derive request origin for downstream service
|
||||
const requestOrigin = getRequestOrigin(req)
|
||||
|
||||
if (!requestOrigin) {
|
||||
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
|
||||
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
|
||||
}
|
||||
|
||||
// Consolidation mapping: map negative depths to base depth with prefetch=true
|
||||
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
|
||||
let effectivePrefetch: boolean | undefined = prefetch
|
||||
if (typeof effectiveDepth === 'number') {
|
||||
if (effectiveDepth === -2) {
|
||||
effectiveDepth = 1
|
||||
effectivePrefetch = true
|
||||
} else if (effectiveDepth === -1) {
|
||||
effectiveDepth = 0
|
||||
effectivePrefetch = true
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
@@ -209,6 +241,8 @@ export async function POST(req: NextRequest) {
|
||||
provider: provider || 'openai',
|
||||
hasConversationId: !!conversationId,
|
||||
depth,
|
||||
prefetch,
|
||||
origin: requestOrigin,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
@@ -384,8 +418,10 @@ export async function POST(req: NextRequest) {
|
||||
mode: mode,
|
||||
provider: providerToUse,
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof depth === 'number' ? { depth } : {}),
|
||||
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
|
||||
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
}
|
||||
|
||||
// Log the payload being sent to the streaming endpoint
|
||||
@@ -397,8 +433,10 @@ export async function POST(req: NextRequest) {
|
||||
stream,
|
||||
workflowId,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
depth: typeof depth === 'number' ? depth : undefined,
|
||||
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
|
||||
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
|
||||
messagesCount: requestPayload.messages.length,
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
})
|
||||
// Full payload as JSON string
|
||||
logger.info(
|
||||
@@ -458,6 +496,12 @@ export async function POST(req: NextRequest) {
|
||||
let isFirstDone = true
|
||||
let responseIdFromStart: string | undefined
|
||||
let responseIdFromDone: string | undefined
|
||||
// Track tool call progress to identify a safe done event
|
||||
const announcedToolCallIds = new Set<string>()
|
||||
const startedToolExecutionIds = new Set<string>()
|
||||
const completedToolExecutionIds = new Set<string>()
|
||||
let lastDoneResponseId: string | undefined
|
||||
let lastSafeDoneResponseId: string | undefined
|
||||
|
||||
// Send chatId as first event
|
||||
if (actualChatId) {
|
||||
@@ -575,6 +619,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
if (!event.data?.partial) {
|
||||
toolCalls.push(event.data)
|
||||
if (event.data?.id) {
|
||||
announcedToolCallIds.add(event.data.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -584,6 +631,14 @@ export async function POST(req: NextRequest) {
|
||||
toolName: event.toolName,
|
||||
status: event.status,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
if (event.status === 'completed') {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
} else {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
@@ -594,6 +649,9 @@ export async function POST(req: NextRequest) {
|
||||
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
|
||||
resultSize: JSON.stringify(event.result).length,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_error':
|
||||
@@ -603,6 +661,9 @@ export async function POST(req: NextRequest) {
|
||||
error: event.error,
|
||||
success: event.success,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
@@ -617,9 +678,25 @@ export async function POST(req: NextRequest) {
|
||||
case 'done':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromDone = event.data.responseId
|
||||
lastDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
|
||||
)
|
||||
// Mark this done as safe only if no tool call is currently in progress or pending
|
||||
const announced = announcedToolCallIds.size
|
||||
const completed = completedToolExecutionIds.size
|
||||
const started = startedToolExecutionIds.size
|
||||
const hasToolInProgress = announced > completed || started > completed
|
||||
if (!hasToolInProgress) {
|
||||
lastSafeDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFirstDone) {
|
||||
logger.info(
|
||||
@@ -714,7 +791,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseId = responseIdFromDone
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: 'test-secret-key',
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 401 when internal API key is not configured', async () => {
|
||||
@@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: undefined,
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Internal API key not configured',
|
||||
})
|
||||
expect(responseData.status).toBeUndefined()
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing methodId', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
|
||||
import type { NotificationStatus } from '@/lib/copilot/types'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
|
||||
@@ -232,10 +232,13 @@ export async function POST(req: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), {
|
||||
// Evaluate both auth schemes; pass if either is valid
|
||||
const internalAuth = checkInternalApiKey(req)
|
||||
const copilotAuth = checkCopilotApiKey(req)
|
||||
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
|
||||
if (!isAuthenticated) {
|
||||
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
|
||||
return NextResponse.json(createErrorResponse(errorMessage), {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
@@ -243,7 +246,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Method execution request: ${methodId}`, {
|
||||
logger.info(`[${requestId}] Method execution request`, {
|
||||
methodId,
|
||||
toolCallId,
|
||||
hasParams: !!params && Object.keys(params).length > 0,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { renderHelpConfirmationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
const helpFormSchema = z.object({
|
||||
@@ -28,18 +29,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const email = session.user.email
|
||||
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
@@ -54,18 +43,18 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
|
||||
// Validate the form data
|
||||
const result = helpFormSchema.safeParse({
|
||||
const validationResult = helpFormSchema.safeParse({
|
||||
subject,
|
||||
message,
|
||||
type,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid help request data`, {
|
||||
errors: result.error.format(),
|
||||
errors: validationResult.error.format(),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: result.error.format() },
|
||||
{ error: 'Invalid request data', details: validationResult.error.format() },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -103,63 +92,60 @@ ${message}
|
||||
emailText += `\n\n${images.length} image(s) attached.`
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const { error } = await resend.emails.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
const emailResult = await sendEmail({
|
||||
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
|
||||
subject: `[${type.toUpperCase()}] ${subject}`,
|
||||
replyTo: email,
|
||||
text: emailText,
|
||||
from: getFromEmailAddress(),
|
||||
replyTo: email,
|
||||
emailType: 'transactional',
|
||||
attachments: images.map((image) => ({
|
||||
filename: image.filename,
|
||||
content: image.content.toString('base64'),
|
||||
contentType: image.contentType,
|
||||
disposition: 'attachment', // Explicitly set as attachment
|
||||
disposition: 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, error)
|
||||
if (!emailResult.success) {
|
||||
logger.error(`[${requestId}] Error sending help request email`, emailResult.message)
|
||||
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Help request email sent successfully`)
|
||||
|
||||
// Send confirmation email to the user
|
||||
await resend.emails
|
||||
.send({
|
||||
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
|
||||
try {
|
||||
const confirmationHtml = await renderHelpConfirmationEmail(
|
||||
email,
|
||||
type as 'bug' | 'feedback' | 'feature_request' | 'other',
|
||||
images.length
|
||||
)
|
||||
|
||||
await sendEmail({
|
||||
to: [email],
|
||||
subject: `Your ${type} request has been received: ${subject}`,
|
||||
text: `
|
||||
Hello,
|
||||
|
||||
Thank you for your ${type} submission. We've received your request and will get back to you as soon as possible.
|
||||
|
||||
Your message:
|
||||
${message}
|
||||
|
||||
${images.length > 0 ? `You attached ${images.length} image(s).` : ''}
|
||||
|
||||
Best regards,
|
||||
The Sim Team
|
||||
`,
|
||||
html: confirmationHtml,
|
||||
from: getFromEmailAddress(),
|
||||
replyTo: `help@${env.EMAIL_DOMAIN || getEmailDomain()}`,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email`, err)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: 'Help request submitted successfully' },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
// Check if error is related to missing API key
|
||||
if (error instanceof Error && error.message.includes('API key')) {
|
||||
logger.error(`[${requestId}] API key configuration error`, error)
|
||||
if (error instanceof Error && error.message.includes('not configured')) {
|
||||
logger.error(`[${requestId}] Email service configuration error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service configuration error. Please check your RESEND_API_KEY.' },
|
||||
{
|
||||
error:
|
||||
'Email service configuration error. Please check your email service configuration.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runs } from '@trigger.dev/sdk/v3'
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
@@ -4,15 +4,50 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
vi.mock('@/lib/logs/console/logger')
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.mock('@/db')
|
||||
vi.mock('@/lib/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
import { handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch } from './utils'
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {},
|
||||
isTruthy: (value: string | boolean | number | undefined) =>
|
||||
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateSearchEmbedding,
|
||||
handleTagAndVectorSearch,
|
||||
handleTagOnlySearch,
|
||||
handleVectorOnlySearch,
|
||||
} from './utils'
|
||||
|
||||
describe('Knowledge Search Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handleTagOnlySearch', () => {
|
||||
it('should throw error when no filters provided', async () => {
|
||||
const params = {
|
||||
@@ -140,4 +175,251 @@ describe('Knowledge Search Utils', () => {
|
||||
expect(params.distanceThreshold).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSearchEmbedding', () => {
|
||||
it('should use Azure OpenAI when KB-specific config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no KB Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(result).toEqual([0.1, 0.2, 0.3])
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use default API version when not provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should use custom model name when provided in Azure config', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'custom-embedding-model',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle Azure OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should handle OpenAI API errors properly', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for Azure OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should include correct request body for OpenAI', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
input: ['test query'],
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
// Clean up
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { embedding } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeSearchUtils')
|
||||
|
||||
export class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
content: string
|
||||
@@ -41,61 +29,8 @@ export interface SearchParams {
|
||||
distanceThreshold?: number
|
||||
}
|
||||
|
||||
export async function generateSearchEmbedding(query: string): Promise<number[]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const embedding = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: query,
|
||||
model: 'text-embedding-3-small',
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.data || !Array.isArray(data.data) || data.data.length === 0) {
|
||||
throw new Error('Invalid response format from OpenAI embeddings API')
|
||||
}
|
||||
|
||||
return data.data[0].embedding
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 30000,
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
return embedding
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate search embedding:', error)
|
||||
throw new Error(
|
||||
`Embedding generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Use shared embedding utility
|
||||
export { generateSearchEmbedding } from '@/lib/embeddings/utils'
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
|
||||
@@ -252,5 +252,76 @@ describe('Knowledge Utils', () => {
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
AZURE_OPENAI_API_KEY: 'test-azure-key',
|
||||
AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com',
|
||||
AZURE_OPENAI_API_VERSION: '2024-12-01-preview',
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'api-key': 'test-azure-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should fallback to OpenAI when no Azure config provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
Object.assign(env, {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
}),
|
||||
} as any)
|
||||
|
||||
await generateEmbeddings(['test text'])
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-openai-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
})
|
||||
|
||||
it('should throw error when no API configuration provided', async () => {
|
||||
const { env } = await import('@/lib/env')
|
||||
Object.keys(env).forEach((key) => delete (env as any)[key])
|
||||
|
||||
await expect(generateEmbeddings(['test text'])).rejects.toThrow(
|
||||
'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { processDocument } from '@/lib/documents/document-processor'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { generateEmbeddings } from '@/lib/embeddings/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -10,22 +9,11 @@ import { document, embedding, knowledgeBase } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('KnowledgeUtils')
|
||||
|
||||
// Timeout constants (in milliseconds)
|
||||
const TIMEOUTS = {
|
||||
OVERALL_PROCESSING: 150000, // 150 seconds (2.5 minutes)
|
||||
EMBEDDINGS_API: 60000, // 60 seconds per batch
|
||||
} as const
|
||||
|
||||
class APIError extends Error {
|
||||
public status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'APIError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout wrapper for async operations
|
||||
*/
|
||||
@@ -110,18 +98,6 @@ export interface EmbeddingData {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
interface OpenAIEmbeddingResponse {
|
||||
data: Array<{
|
||||
embedding: number[]
|
||||
index: number
|
||||
}>
|
||||
model: string
|
||||
usage: {
|
||||
prompt_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseAccessResult {
|
||||
hasAccess: true
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId'>
|
||||
@@ -405,87 +381,8 @@ export async function checkChunkAccess(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings using OpenAI API with retry logic for rate limiting
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
embeddingModel = 'text-embedding-3-small'
|
||||
): Promise<number[][]> {
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const batchSize = 100
|
||||
const allEmbeddings: number[][] = []
|
||||
|
||||
for (let i = 0; i < texts.length; i += batchSize) {
|
||||
const batch = texts.slice(i, i + batchSize)
|
||||
|
||||
logger.info(
|
||||
`Generating embeddings for batch ${Math.floor(i / batchSize) + 1} (${batch.length} texts)`
|
||||
)
|
||||
|
||||
const batchEmbeddings = await retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.EMBEDDINGS_API)
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: batch,
|
||||
model: embeddingModel,
|
||||
encoding_format: 'float',
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
const error = new APIError(
|
||||
`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
response.status
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
const data: OpenAIEmbeddingResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('OpenAI API request timed out')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 60000, // Max 1 minute delay for embeddings
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
)
|
||||
|
||||
allEmbeddings.push(...batchEmbeddings)
|
||||
}
|
||||
|
||||
return allEmbeddings
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate embeddings:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Export for external use
|
||||
export { generateEmbeddings }
|
||||
|
||||
/**
|
||||
* Process a document asynchronously with full error handling
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
} = body
|
||||
|
||||
logger.info(`[${requestId}] Provider request details`, {
|
||||
@@ -58,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
messageCount: messages?.length || 0,
|
||||
hasEnvironmentVariables:
|
||||
!!environmentVariables && Object.keys(environmentVariables).length > 0,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
let finalApiKey: string
|
||||
@@ -99,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
stream,
|
||||
messages,
|
||||
environmentVariables,
|
||||
reasoningEffort,
|
||||
verbosity,
|
||||
})
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { eq, sql } 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 { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { templates } from '@/db/schema'
|
||||
import { templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -62,3 +64,153 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(100),
|
||||
category: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i),
|
||||
state: z.any().optional(), // Workflow state
|
||||
})
|
||||
|
||||
// PUT /api/templates/[id] - Update a template
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validationResult = updateTemplateSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid template data', details: validationResult.error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { name, description, author, category, icon, color, state } = validationResult.data
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
|
||||
if (existingTemplate.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for update: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Permission: template owner OR admin of the workflow's workspace (if any)
|
||||
let canUpdate = existingTemplate[0].userId === session.user.id
|
||||
|
||||
if (!canUpdate && existingTemplate[0].workflowId) {
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update the template
|
||||
const updatedTemplate = await db
|
||||
.update(templates)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
author,
|
||||
category,
|
||||
icon,
|
||||
color,
|
||||
...(state && { state }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated template: ${id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
data: updatedTemplate[0],
|
||||
message: 'Template updated successfully',
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error updating template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/templates/[id] - Delete a template
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch template
|
||||
const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
if (existing.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found for delete: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const template = existing[0]
|
||||
|
||||
// Permission: owner or admin of the workflow's workspace (if any)
|
||||
let canDelete = template.userId === session.user.id
|
||||
|
||||
if (!canDelete && template.workflowId) {
|
||||
// Look up workflow to get workspaceId
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canDelete = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Deleted template: ${id}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deleting template: ${id}`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ const QueryParamsSchema = z.object({
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
search: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
})
|
||||
|
||||
// GET /api/templates - Retrieve templates
|
||||
@@ -111,6 +112,11 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Apply workflow filter if provided (for getting template by workflow)
|
||||
if (params.workflowId) {
|
||||
conditions.push(eq(templates.workflowId, params.workflowId))
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function GET(request: NextRequest) {
|
||||
// Fetch the file from Google Drive API
|
||||
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
|
||||
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -77,6 +77,34 @@ export async function GET(request: NextRequest) {
|
||||
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
|
||||
}
|
||||
|
||||
// Resolve shortcuts transparently for UI stability
|
||||
if (
|
||||
file.mimeType === 'application/vnd.google-apps.shortcut' &&
|
||||
file.shortcutDetails?.targetId
|
||||
) {
|
||||
const targetId = file.shortcutDetails.targetId
|
||||
const shortcutResp = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (shortcutResp.ok) {
|
||||
const targetFile = await shortcutResp.json()
|
||||
file.id = targetFile.id
|
||||
file.name = targetFile.name
|
||||
file.mimeType = targetFile.mimeType
|
||||
file.iconLink = targetFile.iconLink
|
||||
file.webViewLink = targetFile.webViewLink
|
||||
file.thumbnailLink = targetFile.thumbnailLink
|
||||
file.createdTime = targetFile.createdTime
|
||||
file.modifiedTime = targetFile.modifiedTime
|
||||
file.size = targetFile.size
|
||||
file.owners = targetFile.owners
|
||||
file.exportLinks = targetFile.exportLinks
|
||||
}
|
||||
}
|
||||
|
||||
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
|
||||
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
|
||||
const format = exportFormats[file.mimeType] || 'application/pdf'
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const mimeType = searchParams.get('mimeType')
|
||||
const query = searchParams.get('query') || ''
|
||||
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credential ID`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Check if the credential belongs to the user
|
||||
if (credential.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: credential.userId,
|
||||
requestUserId: session.user.id,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
// Authorize use of the credential (supports collaborator credentials via workflow)
|
||||
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId!,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Build the query parameters for Google Drive API
|
||||
let queryParams = 'trashed=false'
|
||||
|
||||
// Add mimeType filter if provided
|
||||
// Build Drive 'q' expression safely
|
||||
const qParts: string[] = ['trashed = false']
|
||||
if (folderId) {
|
||||
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
|
||||
}
|
||||
if (mimeType) {
|
||||
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
|
||||
// Instead of using the mimeType parameter directly, we'll add it to the query
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and mimeType='${mimeType}'`
|
||||
} else {
|
||||
queryParams += `&q=mimeType='${mimeType}'`
|
||||
}
|
||||
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
|
||||
// Add search query if provided
|
||||
if (query) {
|
||||
if (queryParams.includes('q=')) {
|
||||
queryParams += ` and name contains '${query}'`
|
||||
} else {
|
||||
queryParams += `&q=name contains '${query}'`
|
||||
}
|
||||
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
|
||||
}
|
||||
const q = encodeURIComponent(qParts.join(' and '))
|
||||
|
||||
// Fetch files from Google Drive API
|
||||
// Fetch files from Google Drive API with shared drives support
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
|
||||
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssueAPI')
|
||||
const logger = createLogger('JiraIssueAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraIssuesAPI')
|
||||
const logger = createLogger('JiraIssuesAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraProjectsAPI')
|
||||
const logger = createLogger('JiraProjectsAPI')
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraUpdateAPI')
|
||||
const logger = createLogger('JiraUpdateAPI')
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getJiraCloudId } from '@/tools/jira/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = new Logger('JiraWriteAPI')
|
||||
const logger = createLogger('JiraWriteAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UpdateUserProfileAPI')
|
||||
|
||||
// Schema for updating user profile
|
||||
const UpdateProfileSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required').optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined, {
|
||||
message: 'Name field must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
const validatedData = UpdateProfileSchema.parse(body)
|
||||
|
||||
// Build update object
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
if (validatedData.name !== undefined) updateData.name = validatedData.name
|
||||
|
||||
// Update user profile
|
||||
const [updatedUser] = await db
|
||||
.update(user)
|
||||
.set(updateData)
|
||||
.where(eq(user.id, userId))
|
||||
.returning()
|
||||
|
||||
if (!updatedUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] User profile updated`, {
|
||||
userId,
|
||||
updatedFields: Object.keys(validatedData),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
image: updatedUser.image,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid profile data`, {
|
||||
errors: error.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid profile data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Profile update error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch current user profile
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [userRecord] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userRecord) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: userRecord,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Profile fetch error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { unstable_noStore as noStore } from 'next/cache'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -10,14 +10,32 @@ export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('WandGenerateAPI')
|
||||
|
||||
const openai = env.OPENAI_API_KEY
|
||||
? new OpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
})
|
||||
: null
|
||||
const azureApiKey = env.AZURE_OPENAI_API_KEY
|
||||
const azureEndpoint = env.AZURE_OPENAI_ENDPOINT
|
||||
const azureApiVersion = env.AZURE_OPENAI_API_VERSION
|
||||
const wandModelName = env.WAND_OPENAI_MODEL_NAME || 'gpt-4o'
|
||||
const openaiApiKey = env.OPENAI_API_KEY
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
logger.warn('OPENAI_API_KEY not found. Wand generation API will not function.')
|
||||
const useWandAzure = azureApiKey && azureEndpoint && azureApiVersion
|
||||
|
||||
const client = useWandAzure
|
||||
? new AzureOpenAI({
|
||||
apiKey: azureApiKey,
|
||||
apiVersion: azureApiVersion,
|
||||
endpoint: azureEndpoint,
|
||||
})
|
||||
: openaiApiKey
|
||||
? new OpenAI({
|
||||
apiKey: openaiApiKey,
|
||||
})
|
||||
: null
|
||||
|
||||
if (!useWandAzure && !openaiApiKey) {
|
||||
logger.warn(
|
||||
'Neither Azure OpenAI nor OpenAI API key found. Wand generation API will not function.'
|
||||
)
|
||||
} else {
|
||||
logger.info(`Using ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} for wand generation`)
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
@@ -32,14 +50,12 @@ interface RequestBody {
|
||||
history?: ChatMessage[]
|
||||
}
|
||||
|
||||
// The endpoint is now generic - system prompts come from wand configs
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
logger.info(`[${requestId}] Received wand generation request`)
|
||||
|
||||
if (!openai) {
|
||||
logger.error(`[${requestId}] OpenAI client not initialized. Missing API key.`)
|
||||
if (!client) {
|
||||
logger.error(`[${requestId}] AI client not initialized. Missing API key.`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Wand generation service is not configured.' },
|
||||
{ status: 503 }
|
||||
@@ -74,22 +90,34 @@ export async function POST(req: NextRequest) {
|
||||
// Add the current user prompt
|
||||
messages.push({ role: 'user', content: prompt })
|
||||
|
||||
logger.debug(`[${requestId}] Calling OpenAI API for wand generation`, {
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
})
|
||||
logger.debug(
|
||||
`[${requestId}] Calling ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API for wand generation`,
|
||||
{
|
||||
stream,
|
||||
historyLength: history.length,
|
||||
endpoint: useWandAzure ? azureEndpoint : 'api.openai.com',
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
apiVersion: useWandAzure ? azureApiVersion : 'N/A',
|
||||
}
|
||||
)
|
||||
|
||||
// For streaming responses
|
||||
if (stream) {
|
||||
try {
|
||||
const streamCompletion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
logger.debug(
|
||||
`[${requestId}] Starting streaming request to ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'}`
|
||||
)
|
||||
|
||||
const streamCompletion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
logger.debug(`[${requestId}] Stream connection established successfully`)
|
||||
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
@@ -99,21 +127,23 @@ export async function POST(req: NextRequest) {
|
||||
for await (const chunk of streamCompletion) {
|
||||
const content = chunk.choices[0]?.delta?.content || ''
|
||||
if (content) {
|
||||
// Use the same format as codegen API for consistency
|
||||
// Use SSE format identical to chat streaming
|
||||
controller.enqueue(
|
||||
encoder.encode(`${JSON.stringify({ chunk: content, done: false })}\n`)
|
||||
encoder.encode(`data: ${JSON.stringify({ chunk: content })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion signal
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: '', done: true })}\n`))
|
||||
// Send completion signal in SSE format
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`))
|
||||
controller.close()
|
||||
logger.info(`[${requestId}] Wand generation streaming completed`)
|
||||
} catch (streamError: any) {
|
||||
logger.error(`[${requestId}] Streaming error`, { error: streamError.message })
|
||||
controller.enqueue(
|
||||
encoder.encode(`${JSON.stringify({ error: 'Streaming failed', done: true })}\n`)
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ error: 'Streaming failed', done: true })}\n\n`
|
||||
)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
@@ -121,9 +151,10 @@ export async function POST(req: NextRequest) {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -141,8 +172,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// For non-streaming responses
|
||||
const completion = await openai?.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
const completion = await client.chat.completions.create({
|
||||
model: useWandAzure ? wandModelName : 'gpt-4o',
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
max_tokens: 10000,
|
||||
@@ -151,9 +182,11 @@ export async function POST(req: NextRequest) {
|
||||
const generatedContent = completion.choices[0]?.message?.content?.trim()
|
||||
|
||||
if (!generatedContent) {
|
||||
logger.error(`[${requestId}] OpenAI response was empty or invalid.`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} response was empty or invalid.`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to generate content. OpenAI response was empty.' },
|
||||
{ success: false, error: 'Failed to generate content. AI response was empty.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
@@ -171,7 +204,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
status = error.status || 500
|
||||
logger.error(`[${requestId}] OpenAI API Error: ${status} - ${error.message}`)
|
||||
logger.error(
|
||||
`[${requestId}] ${useWandAzure ? 'Azure OpenAI' : 'OpenAI'} API Error: ${status} - ${error.message}`
|
||||
)
|
||||
|
||||
if (status === 401) {
|
||||
clientErrorMessage = 'Authentication failed. Please check your API key configuration.'
|
||||
@@ -181,6 +216,10 @@ export async function POST(req: NextRequest) {
|
||||
clientErrorMessage =
|
||||
'The wand generation service is currently unavailable. Please try again later.'
|
||||
}
|
||||
} else if (useWandAzure && error.message?.includes('DeploymentNotFound')) {
|
||||
clientErrorMessage =
|
||||
'Azure OpenAI deployment not found. Please check your model deployment configuration.'
|
||||
status = 404
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { 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 { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { webhook, workflow } from '@/db/schema'
|
||||
|
||||
@@ -242,6 +244,167 @@ export async function DELETE(
|
||||
|
||||
const foundWebhook = webhookData.webhook
|
||||
|
||||
// If it's an Airtable webhook, delete it from Airtable first
|
||||
if (foundWebhook.provider === 'airtable') {
|
||||
try {
|
||||
const { baseId, externalId } = (foundWebhook.providerConfig || {}) as {
|
||||
baseId?: string
|
||||
externalId?: string
|
||||
}
|
||||
|
||||
if (!baseId) {
|
||||
logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion.`, {
|
||||
webhookId: id,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing baseId for Airtable webhook deletion' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get access token for the workflow owner
|
||||
const userIdForToken = webhookData.workflow.userId
|
||||
const accessToken = await getOAuthToken(userIdForToken, 'airtable')
|
||||
if (!accessToken) {
|
||||
logger.warn(
|
||||
`[${requestId}] Could not retrieve Airtable access token for user ${userIdForToken}. Cannot delete webhook in Airtable.`,
|
||||
{ webhookId: id }
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Airtable access token not found for webhook deletion' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve externalId if missing by listing webhooks and matching our notificationUrl
|
||||
let resolvedExternalId: string | undefined = externalId
|
||||
|
||||
if (!resolvedExternalId) {
|
||||
try {
|
||||
const requestOrigin = new URL(request.url).origin
|
||||
const effectiveOrigin = requestOrigin.includes('localhost')
|
||||
? env.NEXT_PUBLIC_APP_URL || requestOrigin
|
||||
: requestOrigin
|
||||
const expectedNotificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${foundWebhook.path}`
|
||||
|
||||
const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
|
||||
const listResp = await fetch(listUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const listBody = await listResp.json().catch(() => null)
|
||||
|
||||
if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) {
|
||||
const match = listBody.webhooks.find((w: any) => {
|
||||
const url: string | undefined = w?.notificationUrl
|
||||
if (!url) return false
|
||||
// Prefer exact match; fallback to suffix match to handle origin/host remaps
|
||||
return (
|
||||
url === expectedNotificationUrl ||
|
||||
url.endsWith(`/api/webhooks/trigger/${foundWebhook.path}`)
|
||||
)
|
||||
})
|
||||
if (match?.id) {
|
||||
resolvedExternalId = match.id as string
|
||||
// Persist resolved externalId for future operations
|
||||
try {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...(foundWebhook.providerConfig || {}),
|
||||
externalId: resolvedExternalId,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, id))
|
||||
} catch {
|
||||
// non-fatal persistence error
|
||||
}
|
||||
logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, {
|
||||
baseId,
|
||||
externalId: resolvedExternalId,
|
||||
})
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, {
|
||||
baseId,
|
||||
expectedNotificationUrl,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, {
|
||||
baseId,
|
||||
status: listResp.status,
|
||||
body: listBody,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, {
|
||||
error: e?.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If still not resolvable, skip remote deletion but proceed with local delete
|
||||
if (!resolvedExternalId) {
|
||||
logger.info(
|
||||
`[${requestId}] Airtable externalId not found; skipping remote deletion and proceeding to remove local record`,
|
||||
{ baseId }
|
||||
)
|
||||
}
|
||||
|
||||
if (resolvedExternalId) {
|
||||
const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}`
|
||||
const airtableResponse = await fetch(airtableDeleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
// Attempt to parse error body for better diagnostics
|
||||
if (!airtableResponse.ok) {
|
||||
let responseBody: any = null
|
||||
try {
|
||||
responseBody = await airtableResponse.json()
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`,
|
||||
{ baseId, externalId: resolvedExternalId, response: responseBody }
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to delete webhook from Airtable',
|
||||
details:
|
||||
(responseBody && (responseBody.error?.message || responseBody.error)) ||
|
||||
`Status ${airtableResponse.status}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, {
|
||||
baseId,
|
||||
externalId: resolvedExternalId,
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deleting Airtable webhook`, {
|
||||
webhookId: id,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete webhook from Airtable', details: error.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a Telegram webhook, delete it from Telegram first
|
||||
if (foundWebhook.provider === 'telegram') {
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollGmailWebhooks } from '@/lib/webhooks/gmail-polling-service'
|
||||
|
||||
const logger = new Logger('GmailPollingAPI')
|
||||
const logger = createLogger('GmailPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { acquireLock, releaseLock } from '@/lib/redis'
|
||||
import { pollOutlookWebhooks } from '@/lib/webhooks/outlook-polling-service'
|
||||
|
||||
const logger = new Logger('OutlookPollingAPI')
|
||||
const logger = createLogger('OutlookPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
@@ -329,7 +329,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
|
||||
try {
|
||||
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
@@ -364,7 +364,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
try {
|
||||
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
|
||||
// Use workflow owner for OAuth lookups to support collaborator-saved credentials
|
||||
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
|
||||
const success = await configureOutlookPolling(
|
||||
workflowRecord.userId,
|
||||
savedWebhook,
|
||||
|
||||
@@ -5,9 +5,23 @@ import { NextRequest } from 'next/server'
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
|
||||
import {
|
||||
createMockRequest,
|
||||
mockExecutionDependencies,
|
||||
mockTriggerDevSdk,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
// Prefer mocking the background module to avoid loading Trigger.dev at all during tests
|
||||
vi.mock('@/background/webhook-execution', () => ({
|
||||
executeWebhookJob: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
workflowId: 'test-workflow-id',
|
||||
executionId: 'test-exec-id',
|
||||
output: {},
|
||||
executedAt: new Date().toISOString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Define mock functions at the top level to be used in mocks
|
||||
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
|
||||
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
|
||||
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -33,7 +47,6 @@ const executeMock = vi.fn().mockResolvedValue({
|
||||
},
|
||||
})
|
||||
|
||||
// Mock the DB schema objects
|
||||
const webhookMock = {
|
||||
id: 'webhook-id-column',
|
||||
path: 'path-column',
|
||||
@@ -43,10 +56,6 @@ const webhookMock = {
|
||||
}
|
||||
const workflowMock = { id: 'workflow-id-column' }
|
||||
|
||||
// Mock global timers
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Mock modules at file scope before any tests
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
hasProcessedMessage: hasProcessedMessageMock,
|
||||
markMessageAsProcessed: markMessageAsProcessedMock,
|
||||
@@ -77,19 +86,6 @@ vi.mock('@/executor', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock setTimeout and other timer functions
|
||||
vi.mock('timers', () => {
|
||||
return {
|
||||
setTimeout: (callback: any) => {
|
||||
// Immediately invoke the callback
|
||||
callback()
|
||||
// Return a fake timer id
|
||||
return 123
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the database and schema
|
||||
vi.mock('@/db', () => {
|
||||
const dbMock = {
|
||||
select: vi.fn().mockImplementation((columns) => ({
|
||||
@@ -128,11 +124,10 @@ describe('Webhook Trigger API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
vi.clearAllTimers()
|
||||
|
||||
mockExecutionDependencies()
|
||||
mockTriggerDevSdk()
|
||||
|
||||
// Mock services/queue for rate limiting
|
||||
vi.doMock('@/services/queue', () => ({
|
||||
RateLimiter: vi.fn().mockImplementation(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
@@ -284,10 +279,328 @@ describe('Webhook Trigger API Route', () => {
|
||||
expect(text).toMatch(/not found/i) // Response should contain "not found" message
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Slack-specific webhook handling
|
||||
* Verifies that Slack signature verification is performed
|
||||
*/
|
||||
// TODO: Fix failing test - returns 500 instead of 200
|
||||
// it('should handle Slack webhooks with signature verification', async () => { ... })
|
||||
describe('Generic Webhook Authentication', () => {
|
||||
const setupGenericWebhook = async (config: Record<string, any>) => {
|
||||
const { db } = await import('@/db')
|
||||
const limitMock = vi.fn().mockReturnValue([
|
||||
{
|
||||
webhook: {
|
||||
id: 'generic-webhook-id',
|
||||
provider: 'generic',
|
||||
path: 'test-path',
|
||||
isActive: true,
|
||||
providerConfig: config,
|
||||
workflowId: 'test-workflow-id',
|
||||
},
|
||||
workflow: {
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
name: 'Test Workflow',
|
||||
},
|
||||
},
|
||||
])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock })
|
||||
|
||||
const subscriptionLimitMock = vi.fn().mockReturnValue([{ plan: 'pro' }])
|
||||
const subscriptionWhereMock = vi.fn().mockReturnValue({ limit: subscriptionLimitMock })
|
||||
const subscriptionFromMock = vi.fn().mockReturnValue({ where: subscriptionWhereMock })
|
||||
|
||||
// @ts-ignore - mocking the query chain
|
||||
db.select.mockImplementation((columns: any) => {
|
||||
if (columns.plan) {
|
||||
return { from: subscriptionFromMock }
|
||||
}
|
||||
return { from: fromMock }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test generic webhook without authentication (default behavior)
|
||||
*/
|
||||
it('should process generic webhook without authentication', async () => {
|
||||
await setupGenericWebhook({ requireAuth: false })
|
||||
|
||||
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
mockTriggerDevSdk()
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test generic webhook with Bearer token authentication (no custom header)
|
||||
*/
|
||||
it('should authenticate with Bearer token when no custom header is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'test-token-123',
|
||||
// No secretHeaderName - should default to Bearer
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-token-123',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
mockTriggerDevSdk()
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test generic webhook with custom header authentication
|
||||
*/
|
||||
it('should authenticate with custom header when configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'secret-token-456',
|
||||
secretHeaderName: 'X-Custom-Auth',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Auth': 'secret-token-456',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
mockTriggerDevSdk()
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case insensitive Bearer token authentication
|
||||
*/
|
||||
it('should handle case insensitive Bearer token authentication', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'case-test-token',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const testCases = [
|
||||
'Bearer case-test-token',
|
||||
'bearer case-test-token',
|
||||
'BEARER case-test-token',
|
||||
'BeArEr case-test-token',
|
||||
]
|
||||
|
||||
for (const authHeader of testCases) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authHeader,
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test case insensitive custom header authentication
|
||||
*/
|
||||
it('should handle case insensitive custom header authentication', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'custom-token-789',
|
||||
secretHeaderName: 'X-Secret-Key',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
},
|
||||
}))
|
||||
|
||||
const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key']
|
||||
|
||||
for (const headerName of testCases) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
[headerName]: 'custom-token-789',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
// Authentication passed if we don't get 401
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of wrong Bearer token
|
||||
*/
|
||||
it('should reject wrong Bearer token', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-token',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer wrong-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of wrong custom header token
|
||||
*/
|
||||
it('should reject wrong custom header token', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-custom-token',
|
||||
secretHeaderName: 'X-Auth-Key',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Auth-Key': 'wrong-custom-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test rejection of missing authentication
|
||||
*/
|
||||
it('should reject missing authentication when required', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'required-token',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', { event: 'no.auth.test' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test exclusivity - Bearer token should be rejected when custom header is configured
|
||||
*/
|
||||
it('should reject Bearer token when custom header is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'exclusive-token',
|
||||
secretHeaderName: 'X-Only-Header',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer exclusive-token', // Correct token but wrong header type
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test wrong custom header name is rejected
|
||||
*/
|
||||
it('should reject wrong custom header name', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
token: 'correct-token',
|
||||
secretHeaderName: 'X-Expected-Header',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Wrong-Header': 'correct-token', // Correct token but wrong header name
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test authentication required but no token configured
|
||||
*/
|
||||
it('should reject when auth is required but no token is configured', async () => {
|
||||
await setupGenericWebhook({
|
||||
requireAuth: true,
|
||||
// No token configured
|
||||
})
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer any-token',
|
||||
}
|
||||
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.text()).toContain(
|
||||
'Unauthorized - Authentication required but not configured'
|
||||
)
|
||||
expect(processWebhookMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
handleSlackChallenge,
|
||||
handleWhatsAppVerification,
|
||||
validateMicrosoftTeamsSignature,
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { db } from '@/db'
|
||||
import { subscription, webhook, workflow } from '@/db/schema'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
@@ -17,6 +19,7 @@ const logger = createLogger('WebhookTriggerAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Webhook Verification Handler (GET)
|
||||
@@ -196,6 +199,53 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle generic webhook authentication if enabled
|
||||
if (foundWebhook.provider === 'generic') {
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
|
||||
if (providerConfig.requireAuth) {
|
||||
const configToken = providerConfig.token
|
||||
const secretHeaderName = providerConfig.secretHeaderName
|
||||
|
||||
// --- Token Validation ---
|
||||
if (configToken) {
|
||||
let isTokenValid = false
|
||||
|
||||
if (secretHeaderName) {
|
||||
// Check custom header (headers are case-insensitive)
|
||||
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
|
||||
if (headerValue === configToken) {
|
||||
isTokenValid = true
|
||||
}
|
||||
} else {
|
||||
// Check standard Authorization header (case-insensitive Bearer keyword)
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
// Case-insensitive comparison for "Bearer" keyword
|
||||
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7) // Remove "Bearer " (7 characters)
|
||||
if (token === configToken) {
|
||||
isTokenValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTokenValid) {
|
||||
const expectedHeader = secretHeaderName || 'Authorization: Bearer TOKEN'
|
||||
logger.warn(
|
||||
`[${requestId}] Generic webhook authentication failed. Expected header: ${expectedHeader}`
|
||||
)
|
||||
return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 })
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Generic webhook requires auth but no token configured`)
|
||||
return new NextResponse('Unauthorized - Authentication required but not configured', {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PHASE 3: Rate limiting for webhook execution ---
|
||||
try {
|
||||
// Get user subscription for rate limiting
|
||||
@@ -283,10 +333,9 @@ export async function POST(
|
||||
// Continue processing - better to risk usage limit bypass than fail webhook
|
||||
}
|
||||
|
||||
// --- PHASE 5: Queue webhook execution via trigger.dev ---
|
||||
// --- PHASE 5: Queue webhook execution (trigger.dev or direct based on env) ---
|
||||
try {
|
||||
// Queue the webhook execution task
|
||||
const handle = await tasks.trigger('webhook-execution', {
|
||||
const payload = {
|
||||
webhookId: foundWebhook.id,
|
||||
workflowId: foundWorkflow.id,
|
||||
userId: foundWorkflow.userId,
|
||||
@@ -295,11 +344,24 @@ export async function POST(
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
path,
|
||||
blockId: foundWebhook.blockId,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
|
||||
)
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
const handle = await tasks.trigger('webhook-execution', payload)
|
||||
logger.info(
|
||||
`[${requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook`
|
||||
)
|
||||
} else {
|
||||
// Fire-and-forget direct execution to avoid blocking webhook response
|
||||
void executeWebhookJob(payload).catch((error) => {
|
||||
logger.error(`[${requestId}] Direct webhook execution failed`, error)
|
||||
})
|
||||
logger.info(
|
||||
`[${requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)`
|
||||
)
|
||||
}
|
||||
|
||||
// Return immediate acknowledgment with provider-specific format
|
||||
if (foundWebhook.provider === 'microsoftteams') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tasks } from '@trigger.dev/sdk/v3'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -540,7 +540,7 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Rate limit passed - trigger the task
|
||||
// Rate limit passed - always use Trigger.dev for async executions
|
||||
const handle = await tasks.trigger('workflow-execution', {
|
||||
workflowId,
|
||||
userId: authenticatedUserId,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
|
||||
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
@@ -218,6 +218,48 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
const deleteTemplatesParam = searchParams.get('deleteTemplates')
|
||||
|
||||
if (checkTemplates) {
|
||||
// Return template information for frontend to handle
|
||||
const publishedTemplates = await db
|
||||
.select()
|
||||
.from(templates)
|
||||
.where(eq(templates.workflowId, workflowId))
|
||||
|
||||
return NextResponse.json({
|
||||
hasPublishedTemplates: publishedTemplates.length > 0,
|
||||
count: publishedTemplates.length,
|
||||
publishedTemplates: publishedTemplates.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
views: t.views,
|
||||
stars: t.stars,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Handle template deletion based on user choice
|
||||
if (deleteTemplatesParam !== null) {
|
||||
const deleteTemplates = deleteTemplatesParam === 'delete'
|
||||
|
||||
if (deleteTemplates) {
|
||||
// Delete all templates associated with this workflow
|
||||
await db.delete(templates).where(eq(templates.workflowId, workflowId))
|
||||
logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`)
|
||||
} else {
|
||||
// Orphan the templates (set workflowId to null)
|
||||
await db
|
||||
.update(templates)
|
||||
.set({ workflowId: null })
|
||||
.where(eq(templates.workflowId, workflowId))
|
||||
logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`)
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -8,7 +8,7 @@ const logger = createLogger('WorkspaceByIdAPI')
|
||||
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { knowledgeBase, permissions, workspace } from '@/db/schema'
|
||||
import { knowledgeBase, permissions, templates, workspace } from '@/db/schema'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
@@ -19,6 +19,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
const url = new URL(request.url)
|
||||
const checkTemplates = url.searchParams.get('check-templates') === 'true'
|
||||
|
||||
// Check if user has any access to this workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
@@ -26,6 +28,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If checking for published templates before deletion
|
||||
if (checkTemplates) {
|
||||
try {
|
||||
// Get all workflows in this workspace
|
||||
const workspaceWorkflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
if (workspaceWorkflows.length === 0) {
|
||||
return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] })
|
||||
}
|
||||
|
||||
const workflowIds = workspaceWorkflows.map((w) => w.id)
|
||||
|
||||
// Check for published templates that reference these workflows
|
||||
const publishedTemplates = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
workflowId: templates.workflowId,
|
||||
})
|
||||
.from(templates)
|
||||
.where(inArray(templates.workflowId, workflowIds))
|
||||
|
||||
return NextResponse.json({
|
||||
hasPublishedTemplates: publishedTemplates.length > 0,
|
||||
publishedTemplates,
|
||||
count: publishedTemplates.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
|
||||
return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
@@ -108,6 +146,8 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const workspaceId = id
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates
|
||||
|
||||
// Check if user has admin permissions to delete workspace
|
||||
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
@@ -116,10 +156,39 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
|
||||
logger.info(
|
||||
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
||||
)
|
||||
|
||||
// Delete workspace and all related data in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Get all workflows in this workspace before deletion
|
||||
const workspaceWorkflows = await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
if (workspaceWorkflows.length > 0) {
|
||||
const workflowIds = workspaceWorkflows.map((w) => w.id)
|
||||
|
||||
// Handle templates based on user choice
|
||||
if (deleteTemplates) {
|
||||
// Delete published templates that reference these workflows
|
||||
await tx.delete(templates).where(inArray(templates.workflowId, workflowIds))
|
||||
logger.info(`Deleted templates for workflows in workspace ${workspaceId}`)
|
||||
} else {
|
||||
// Set workflowId to null for templates to create "orphaned" templates
|
||||
// This allows templates to remain in marketplace but without source workflows
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({ workflowId: null })
|
||||
.where(inArray(templates.workflowId, workflowIds))
|
||||
logger.info(
|
||||
`Updated templates to orphaned status for workflows in workspace ${workspaceId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all workflows in the workspace - database cascade will handle all workflow-related data
|
||||
// The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows,
|
||||
// workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans,
|
||||
|
||||
@@ -91,6 +91,7 @@ describe('Workspace Invitations API Route', () => {
|
||||
env: {
|
||||
RESEND_API_KEY: 'test-resend-key',
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
FROM_EMAIL_ADDRESS: 'Sim <noreply@test.sim.ai>',
|
||||
EMAIL_DOMAIN: 'test.sim.ai',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -2,12 +2,12 @@ import { randomUUID } from 'crypto'
|
||||
import { render } from '@react-email/render'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { Resend } from 'resend'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails/workspace-invitation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/email/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
permissions,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('WorkspaceInvitationsAPI')
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
|
||||
type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
|
||||
|
||||
@@ -241,30 +240,23 @@ async function sendInvitationEmail({
|
||||
})
|
||||
)
|
||||
|
||||
if (!resend) {
|
||||
logger.error('RESEND_API_KEY not configured')
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const emailDomain = env.EMAIL_DOMAIN || getEmailDomain()
|
||||
const fromAddress = `noreply@${emailDomain}`
|
||||
const fromAddress = getFromEmailAddress()
|
||||
|
||||
logger.info(`Attempting to send email from ${fromAddress} to ${to}`)
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromAddress,
|
||||
const result = await sendEmail({
|
||||
to,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim`,
|
||||
html: emailHtml,
|
||||
from: fromAddress,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
if (result.success) {
|
||||
logger.info(`Invitation email sent successfully to ${to}`, { result })
|
||||
} else {
|
||||
logger.error(`Failed to send invitation email to ${to}`, { error: result.message })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending invitation email:', error)
|
||||
// Continue even if email fails - the invitation is still created
|
||||
|
||||
167
apps/sim/app/chat/[subdomain]/chat-client.css
Normal file
167
apps/sim/app/chat/[subdomain]/chat-client.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* Force light mode for chat subdomain by overriding dark mode utilities */
|
||||
/* This file uses CSS variables from globals.css light mode theme */
|
||||
|
||||
/* When inside the chat layout, force all light mode CSS variables */
|
||||
.chat-light-wrapper {
|
||||
/* Core Colors - from globals.css light mode */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
/* Card Colors */
|
||||
--card: 0 0% 99.2%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
/* Popover Colors */
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
/* Primary Colors */
|
||||
--primary: 0 0% 11.2%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
/* Secondary Colors */
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 11.2%;
|
||||
|
||||
/* Muted Colors */
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 46.9%;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent: 0 0% 92.5%;
|
||||
--accent-foreground: 0 0% 11.2%;
|
||||
|
||||
/* Destructive Colors */
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
/* Border & Input Colors */
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
/* Border Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Scrollbar Properties */
|
||||
--scrollbar-track: 0 0% 85%;
|
||||
--scrollbar-thumb: 0 0% 65%;
|
||||
--scrollbar-thumb-hover: 0 0% 55%;
|
||||
--scrollbar-size: 8px;
|
||||
|
||||
/* Workflow Properties */
|
||||
--workflow-background: 0 0% 100%;
|
||||
--workflow-dots: 0 0% 94.5%;
|
||||
--card-background: 0 0% 99.2%;
|
||||
--card-border: 0 0% 89.8%;
|
||||
--card-text: 0 0% 3.9%;
|
||||
--card-hover: 0 0% 96.1%;
|
||||
|
||||
/* Base Component Properties */
|
||||
--base-muted-foreground: #737373;
|
||||
|
||||
/* Gradient Colors */
|
||||
--gradient-primary: 263 85% 70%;
|
||||
--gradient-secondary: 336 95% 65%;
|
||||
|
||||
/* Brand Colors */
|
||||
--brand-primary-hex: #701ffc;
|
||||
--brand-primary-hover-hex: #802fff;
|
||||
--brand-secondary-hex: #6518e6;
|
||||
--brand-accent-hex: #9d54ff;
|
||||
--brand-accent-hover-hex: #a66fff;
|
||||
--brand-background-hex: #0c0c0c;
|
||||
|
||||
/* UI Surface Colors */
|
||||
--surface-elevated: #202020;
|
||||
}
|
||||
|
||||
/* Override dark mode utility classes using CSS variables */
|
||||
.chat-light-wrapper :is(.dark\:bg-black) {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-900) {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-800) {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-700) {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-600) {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-300) {
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Text color overrides using CSS variables */
|
||||
.chat-light-wrapper :is(.dark\:text-gray-100) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-200) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-300) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-400) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-neutral-600) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-blue-400) {
|
||||
color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
/* Border color overrides using CSS variables */
|
||||
.chat-light-wrapper :is(.dark\:border-gray-700) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:border-gray-800) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:border-gray-600) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:divide-gray-700) > * + * {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Hover state overrides */
|
||||
.chat-light-wrapper :is(.dark\:hover\:bg-gray-800\/60:hover) {
|
||||
background-color: hsl(var(--card-hover));
|
||||
}
|
||||
|
||||
/* Code blocks specific overrides using CSS variables */
|
||||
.chat-light-wrapper pre:is(.dark\:bg-black) {
|
||||
background-color: hsl(var(--workflow-dots));
|
||||
}
|
||||
|
||||
.chat-light-wrapper code:is(.dark\:bg-gray-700) {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.chat-light-wrapper code:is(.dark\:text-gray-200) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Force color scheme */
|
||||
.chat-light-wrapper {
|
||||
color-scheme: light !important;
|
||||
}
|
||||
@@ -481,7 +481,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
|
||||
// Standard text-based chat interface
|
||||
return (
|
||||
<div className='fixed inset-0 z-[100] flex flex-col bg-background'>
|
||||
<div className='fixed inset-0 z-[100] flex flex-col bg-background text-foreground'>
|
||||
{/* Header component */}
|
||||
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
|
||||
|
||||
|
||||
@@ -22,53 +22,14 @@ export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-between bg-background/95 px-6 py-4 pt-6 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-8 md:pt-4'>
|
||||
<div className='flex items-center gap-4'>
|
||||
{customImage ? (
|
||||
{customImage && (
|
||||
<img
|
||||
src={customImage}
|
||||
alt={`${chatConfig?.title || 'Chat'} logo`}
|
||||
className='h-12 w-12 rounded-md object-cover'
|
||||
className='h-8 w-8 rounded-md object-cover'
|
||||
/>
|
||||
) : (
|
||||
// Default Sim Studio logo when no custom image is provided
|
||||
<div
|
||||
className='flex h-12 w-12 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<svg
|
||||
width='20'
|
||||
height='20'
|
||||
viewBox='0 0 50 50'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M34.1455 20.0728H16.0364C12.7026 20.0728 10 22.7753 10 26.1091V35.1637C10 38.4975 12.7026 41.2 16.0364 41.2H34.1455C37.4792 41.2 40.1818 38.4975 40.1818 35.1637V26.1091C40.1818 22.7753 37.4792 20.0728 34.1455 20.0728Z'
|
||||
fill={primaryColor}
|
||||
stroke='white'
|
||||
strokeWidth='3.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M25.0919 14.0364C26.7588 14.0364 28.1101 12.6851 28.1101 11.0182C28.1101 9.35129 26.7588 8 25.0919 8C23.425 8 22.0737 9.35129 22.0737 11.0182C22.0737 12.6851 23.425 14.0364 25.0919 14.0364Z'
|
||||
fill={primaryColor}
|
||||
stroke='white'
|
||||
strokeWidth='4'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M25.0915 14.856V19.0277M20.5645 32.1398V29.1216M29.619 29.1216V32.1398'
|
||||
stroke='white'
|
||||
strokeWidth='4'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='25' cy='11' r='2' fill={primaryColor} />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h2 className='font-medium text-lg'>
|
||||
<h2 className='font-medium text-foreground text-lg'>
|
||||
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-gray-50'>
|
||||
<div className='flex min-h-screen items-center justify-center bg-background text-foreground'>
|
||||
<div className='animate-pulse text-center'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-muted' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-muted' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
19
apps/sim/app/chat/[subdomain]/layout.tsx
Normal file
19
apps/sim/app/chat/[subdomain]/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './chat-client.css'
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
forcedTheme='light'
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className='light chat-light-wrapper' style={{ colorScheme: 'light' }}>
|
||||
{children}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -206,23 +206,22 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: hsl(var(--scrollbar-track));
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--scrollbar-thumb));
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--scrollbar-thumb-hover));
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { PublicEnvScript } from 'next-runtime-env'
|
||||
import { BrandedLayout } from '@/components/branded-layout'
|
||||
import { generateThemeCSS } from '@/lib/branding/inject-theme'
|
||||
import { generateBrandedMetadata, generateStructuredData } from '@/lib/branding/metadata'
|
||||
import { env } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
@@ -10,6 +11,8 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getAssetUrl } from '@/lib/utils'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { SessionProvider } from '@/lib/session-context'
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
@@ -45,11 +48,14 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#ffffff',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0c0c0c' },
|
||||
],
|
||||
}
|
||||
|
||||
// Generate dynamic metadata based on brand configuration
|
||||
@@ -57,6 +63,7 @@ export const metadata: Metadata = generateBrandedMetadata()
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const structuredData = generateStructuredData()
|
||||
const themeCSS = generateThemeCSS()
|
||||
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
@@ -69,9 +76,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme CSS Override */}
|
||||
{themeCSS && (
|
||||
<style
|
||||
id='theme-override'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: themeCSS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Meta tags for better SEO */}
|
||||
<meta name='theme-color' content='#ffffff' />
|
||||
<meta name='color-scheme' content='light' />
|
||||
<meta name='color-scheme' content='light dark' />
|
||||
<meta name='format-detection' content='telephone=no' />
|
||||
<meta httpEquiv='x-ua-compatible' content='ie=edge' />
|
||||
|
||||
@@ -107,16 +123,20 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
{isHosted && (
|
||||
<>
|
||||
<SpeedInsights />
|
||||
<Analytics />
|
||||
</>
|
||||
)}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -11,8 +11,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
'Build and deploy AI agents using our Figma-like canvas. Build, write evals, and deploy AI agent workflows that automate workflows and streamline your business processes.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#701FFC', // Default Sim brand primary color
|
||||
theme_color: '#701FFC', // Default Sim brand primary color
|
||||
background_color: brand.theme?.backgroundColor || '#701FFC',
|
||||
theme_color: brand.theme?.primaryColor || '#701FFC',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon/android-chrome-192x192.png',
|
||||
|
||||
19
apps/sim/app/theme-provider.tsx
Normal file
19
apps/sim/app/theme-provider.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -82,14 +82,21 @@ function transformBlockData(data: any, blockType: string, isInput: boolean) {
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
function CollapsibleInputOutput({ span, spanId }: CollapsibleInputOutputProps) {
|
||||
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
// Calculate the left margin based on depth to match the parent span's indentation
|
||||
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
|
||||
|
||||
return (
|
||||
<div className='mt-2 mr-4 mb-4 ml-8 space-y-3 overflow-hidden'>
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{/* Input Data - Collapsible */}
|
||||
{span.input && (
|
||||
<div>
|
||||
@@ -162,26 +169,30 @@ function BlockDataDisplay({
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-green-700 dark:text-green-400'>"{value}"</span>
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className='text-purple-700 dark:text-purple-400'>{value.toString()}</span>
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-4 space-y-1'>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>{index}:</span>
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -196,10 +207,10 @@ function BlockDataDisplay({
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-2'>
|
||||
<span className='flex-shrink-0 font-medium text-orange-700 dark:text-orange-400'>
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
@@ -227,12 +238,12 @@ function BlockDataDisplay({
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-1'>
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-2'>
|
||||
<span className='font-medium text-orange-700 dark:text-orange-400'>{key}:</span>
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
@@ -592,7 +603,9 @@ function TraceSpanItem({
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Block Input/Output Data - Collapsible */}
|
||||
{(span.input || span.output) && <CollapsibleInputOutput span={span} spanId={spanId} />}
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{/* Children and tool calls */}
|
||||
{/* Render child spans */}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React from 'react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { ThemeProvider } from '@/app/workspace/[workspaceId]/providers/theme-provider'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SettingsLoader } from './settings-loader'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
@@ -11,11 +11,12 @@ interface ProvidersProps {
|
||||
|
||||
const Providers = React.memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
/**
|
||||
* Loads user settings from database once per workspace session.
|
||||
* This ensures settings are synced from DB on initial load but uses
|
||||
* localStorage cache for subsequent navigation within the app.
|
||||
*/
|
||||
export function SettingsLoader() {
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only load settings once per session for authenticated users
|
||||
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
// Force load from DB on initial workspace entry
|
||||
loadSettings(true)
|
||||
}
|
||||
}, [isSessionPending, session?.user, loadSettings])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
// If theme is system, check system preference
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.add(prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
root.classList.add(theme)
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export type CategoryValue = (typeof categories)[number]['value']
|
||||
// Template data structure
|
||||
export interface Template {
|
||||
id: string
|
||||
workflowId: string
|
||||
workflowId: string | null
|
||||
userId: string
|
||||
name: string
|
||||
description: string | null
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
|
||||
interface ExampleCommandProps {
|
||||
command: string
|
||||
@@ -32,6 +33,7 @@ export function ExampleCommand({
|
||||
}: ExampleCommandProps) {
|
||||
const [mode, setMode] = useState<ExampleMode>('sync')
|
||||
const [exampleType, setExampleType] = useState<ExampleType>('execute')
|
||||
const isAsyncEnabled = isTruthy(getEnv('NEXT_PUBLIC_TRIGGER_DEV_ENABLED'))
|
||||
|
||||
// Format the curl command to use a placeholder for the API key
|
||||
const formatCurlCommand = (command: string, apiKey: string) => {
|
||||
@@ -146,62 +148,67 @@ export function ExampleCommand({
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{showLabel && <Label className='font-medium text-sm'>Example</Label>}
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMode('sync')}
|
||||
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
|
||||
mode === 'sync'
|
||||
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMode('async')}
|
||||
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
|
||||
mode === 'async'
|
||||
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Async
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
|
||||
disabled={mode === 'sync'}
|
||||
>
|
||||
<span className='truncate'>{getExampleTitle()}</span>
|
||||
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('execute')}
|
||||
>
|
||||
Async Execution
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className='cursor-pointer' onClick={() => setExampleType('status')}>
|
||||
Check Job Status
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('rate-limits')}
|
||||
>
|
||||
Rate Limits & Usage
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isAsyncEnabled && (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMode('sync')}
|
||||
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
|
||||
mode === 'sync'
|
||||
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMode('async')}
|
||||
className={`h-6 min-w-[50px] px-2 py-1 text-xs transition-none ${
|
||||
mode === 'async'
|
||||
? 'border-primary bg-primary text-primary-foreground hover:border-primary hover:bg-primary hover:text-primary-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Async
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 min-w-[140px] justify-between px-2 py-1 text-xs'
|
||||
disabled={mode === 'sync'}
|
||||
>
|
||||
<span className='truncate'>{getExampleTitle()}</span>
|
||||
<ChevronDown className='ml-1 h-3 w-3 flex-shrink-0' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('execute')}
|
||||
>
|
||||
Async Execution
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('status')}
|
||||
>
|
||||
Check Job Status
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className='cursor-pointer'
|
||||
onClick={() => setExampleType('rate-limits')}
|
||||
>
|
||||
Rate Limits & Usage
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='group relative h-[120px] rounded-md border bg-background transition-colors hover:bg-muted/50'>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -81,7 +81,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
|
||||
<TooltipTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Download className='h-5 w-5' />
|
||||
<Upload className='h-5 w-5' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -89,7 +89,7 @@ export function ExportControls({ disabled = false }: ExportControlsProps) {
|
||||
onClick={handleExportYaml}
|
||||
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
|
||||
>
|
||||
<Download className='h-5 w-5' />
|
||||
<Upload className='h-5 w-5' />
|
||||
<span className='sr-only'>Export as YAML</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Award,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Database,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
@@ -48,6 +49,16 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ColorPicker } from '@/components/ui/color-picker'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -68,6 +79,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -100,7 +112,6 @@ interface TemplateModalProps {
|
||||
workflowId: string
|
||||
}
|
||||
|
||||
// Enhanced icon selection with category-relevant icons
|
||||
const icons = [
|
||||
// Content & Documentation
|
||||
{ value: 'FileText', label: 'File Text', component: FileText },
|
||||
@@ -165,6 +176,10 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
const { data: session } = useSession()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [iconPopoverOpen, setIconPopoverOpen] = useState(false)
|
||||
const [existingTemplate, setExistingTemplate] = useState<any>(null)
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
@@ -178,6 +193,63 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
},
|
||||
})
|
||||
|
||||
// Watch form state to determine if all required fields are valid
|
||||
const formValues = form.watch()
|
||||
const isFormValid =
|
||||
form.formState.isValid &&
|
||||
formValues.name?.trim() &&
|
||||
formValues.description?.trim() &&
|
||||
formValues.author?.trim() &&
|
||||
formValues.category
|
||||
|
||||
// Check for existing template when modal opens
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
checkExistingTemplate()
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
const checkExistingTemplate = async () => {
|
||||
setIsLoadingTemplate(true)
|
||||
try {
|
||||
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const template = result.data?.[0] || null
|
||||
setExistingTemplate(template)
|
||||
|
||||
// Pre-fill form with existing template data
|
||||
if (template) {
|
||||
form.reset({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
category: template.category,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
})
|
||||
} else {
|
||||
// No existing template found
|
||||
setExistingTemplate(null)
|
||||
// Reset form to defaults
|
||||
form.reset({
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
category: '',
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking existing template:', error)
|
||||
setExistingTemplate(null)
|
||||
} finally {
|
||||
setIsLoadingTemplate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: TemplateFormData) => {
|
||||
if (!session?.user) {
|
||||
logger.error('User not authenticated')
|
||||
@@ -201,21 +273,36 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
state: templateState,
|
||||
}
|
||||
|
||||
const response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
let response
|
||||
if (existingTemplate) {
|
||||
// Update existing template
|
||||
response = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
} else {
|
||||
// Create new template
|
||||
response = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(templateData),
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to create template')
|
||||
throw new Error(
|
||||
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info('Template created successfully:', result)
|
||||
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
|
||||
|
||||
// Reset form and close modal
|
||||
form.reset()
|
||||
@@ -241,7 +328,35 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
>
|
||||
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Publish Template</DialogTitle>
|
||||
<div className='flex items-center gap-3'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
{isLoadingTemplate
|
||||
? 'Loading...'
|
||||
: existingTemplate
|
||||
? 'Update Template'
|
||||
: 'Publish Template'}
|
||||
</DialogTitle>
|
||||
{existingTemplate && (
|
||||
<div className='flex items-center gap-2'>
|
||||
{existingTemplate.stars > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'>
|
||||
<Star className='h-3 w-3 fill-yellow-400 text-yellow-400' />
|
||||
<span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'>
|
||||
{existingTemplate.stars}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{existingTemplate.views > 0 && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'>
|
||||
<Eye className='h-3 w-3 text-blue-500' />
|
||||
<span className='font-medium text-blue-700 text-xs dark:text-blue-300'>
|
||||
{existingTemplate.views}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
@@ -259,65 +374,189 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='flex flex-1 flex-col overflow-hidden'
|
||||
>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex gap-3'>
|
||||
<div className='flex-1 overflow-y-auto px-6 py-6'>
|
||||
{isLoadingTemplate ? (
|
||||
<div className='space-y-6'>
|
||||
{/* Icon and Color row */}
|
||||
<div className='flex gap-3'>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-8' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Icon picker */}
|
||||
</div>
|
||||
<div className='w-20'>
|
||||
<Skeleton className='mb-2 h-4 w-10' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-20' /> {/* Color picker */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-12' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Author and Category row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-20 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex gap-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Icon
|
||||
</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
|
||||
field.value === icon.value &&
|
||||
'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Color
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20'
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='icon'
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Icon</FormLabel>
|
||||
<Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' role='combobox' className='h-10 w-20 p-0'>
|
||||
<SelectedIconComponent className='h-4 w-4' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='z-50 w-84 p-0' align='start'>
|
||||
<div className='p-3'>
|
||||
<div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'>
|
||||
{icons.map((icon) => {
|
||||
const IconComponent = icon.component
|
||||
return (
|
||||
<button
|
||||
key={icon.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
field.onChange(icon.value)
|
||||
setIconPopoverOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-md border transition-colors hover:bg-muted',
|
||||
field.value === icon.value &&
|
||||
'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<IconComponent className='h-4 w-4' />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter template name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Author
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Category
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='color'
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem className='w-20'>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Description
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ColorPicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
className='h-10 w-20'
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='resize-none'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -325,91 +564,28 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter template name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='author'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Author</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Enter author name' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder='Describe what this template does...'
|
||||
className='resize-none'
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className='mt-auto border-t px-6 pt-4 pb-6'>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex items-center'>
|
||||
{existingTemplate && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isSubmitting || isLoadingTemplate}
|
||||
className='h-10 rounded-md px-4 py-2'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !isFormValid || isLoadingTemplate}
|
||||
className={cn(
|
||||
'font-medium',
|
||||
'ml-auto font-medium',
|
||||
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
@@ -420,16 +596,59 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Publishing...
|
||||
{existingTemplate ? 'Updating...' : 'Publishing...'}
|
||||
</>
|
||||
) : existingTemplate ? (
|
||||
'Update Template'
|
||||
) : (
|
||||
'Publish'
|
||||
'Publish Template'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
{existingTemplate && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this template will remove it from the gallery. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!existingTemplate) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${existingTemplate.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to delete template')
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete template', err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
@@ -113,6 +112,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
|
||||
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
|
||||
|
||||
// Delete workflow state - grouped for better organization
|
||||
const [deleteState, setDeleteState] = useState({
|
||||
showDialog: false,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [] as any[],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
|
||||
// Deployed state management
|
||||
const [deployedState, setDeployedState] = useState<WorkflowState | null>(null)
|
||||
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
|
||||
@@ -337,35 +345,170 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleting the current workflow
|
||||
* Reset delete state
|
||||
*/
|
||||
const handleDeleteWorkflow = () => {
|
||||
const resetDeleteState = useCallback(() => {
|
||||
setDeleteState({
|
||||
showDialog: false,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Navigate to next workflow after deletion
|
||||
*/
|
||||
const navigateAfterDeletion = useCallback(
|
||||
(currentWorkflowId: string) => {
|
||||
const sidebarWorkflows = getSidebarOrderedWorkflows()
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
|
||||
|
||||
// Find next workflow: try next, then previous
|
||||
let nextWorkflowId: string | null = null
|
||||
if (sidebarWorkflows.length > 1) {
|
||||
if (currentIndex < sidebarWorkflows.length - 1) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
|
||||
} else if (currentIndex > 0) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to next workflow or workspace home
|
||||
if (nextWorkflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
|
||||
} else {
|
||||
router.push(`/workspace/${workspaceId}`)
|
||||
}
|
||||
},
|
||||
[workspaceId, router]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if workflow has published templates
|
||||
*/
|
||||
const checkPublishedTemplates = useCallback(async (workflowId: string) => {
|
||||
const checkResponse = await fetch(`/api/workflows/${workflowId}?check-templates=true`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!checkResponse.ok) {
|
||||
throw new Error(`Failed to check templates: ${checkResponse.statusText}`)
|
||||
}
|
||||
|
||||
return await checkResponse.json()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Delete workflow with optional template handling
|
||||
*/
|
||||
const deleteWorkflowWithTemplates = useCallback(
|
||||
async (workflowId: string, templateAction?: 'keep' | 'delete') => {
|
||||
const endpoint = templateAction
|
||||
? `/api/workflows/${workflowId}?deleteTemplates=${templateAction}`
|
||||
: null
|
||||
|
||||
if (endpoint) {
|
||||
// Use custom endpoint for template handling
|
||||
const response = await fetch(endpoint, { method: 'DELETE' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete workflow: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Manual registry cleanup since we used custom API
|
||||
useWorkflowRegistry.setState((state) => {
|
||||
const newWorkflows = { ...state.workflows }
|
||||
delete newWorkflows[workflowId]
|
||||
|
||||
return {
|
||||
...state,
|
||||
workflows: newWorkflows,
|
||||
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Use registry's built-in deletion (handles database + state)
|
||||
await useWorkflowRegistry.getState().removeWorkflow(workflowId)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle deleting the current workflow - called after user confirms
|
||||
*/
|
||||
const handleDeleteWorkflow = useCallback(async () => {
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
if (!currentWorkflowId || !userPermissions.canEdit) return
|
||||
|
||||
const sidebarWorkflows = getSidebarOrderedWorkflows()
|
||||
const currentIndex = sidebarWorkflows.findIndex((w) => w.id === currentWorkflowId)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
|
||||
|
||||
// Find next workflow: try next, then previous
|
||||
let nextWorkflowId: string | null = null
|
||||
if (sidebarWorkflows.length > 1) {
|
||||
if (currentIndex < sidebarWorkflows.length - 1) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex + 1].id
|
||||
} else if (currentIndex > 0) {
|
||||
nextWorkflowId = sidebarWorkflows[currentIndex - 1].id
|
||||
try {
|
||||
// Check if workflow has published templates
|
||||
const checkData = await checkPublishedTemplates(currentWorkflowId)
|
||||
|
||||
if (checkData.hasPublishedTemplates) {
|
||||
setDeleteState((prev) => ({
|
||||
...prev,
|
||||
hasPublishedTemplates: true,
|
||||
publishedTemplates: checkData.publishedTemplates || [],
|
||||
showTemplateChoice: true,
|
||||
isDeleting: false, // Stop showing "Deleting..." and show template choice
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to next workflow or workspace home
|
||||
if (nextWorkflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
|
||||
} else {
|
||||
router.push(`/workspace/${workspaceId}`)
|
||||
// No templates, proceed with standard deletion
|
||||
navigateAfterDeletion(currentWorkflowId)
|
||||
await deleteWorkflowWithTemplates(currentWorkflowId)
|
||||
resetDeleteState()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workflow:', error)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
|
||||
}
|
||||
}, [
|
||||
params.workflowId,
|
||||
userPermissions.canEdit,
|
||||
checkPublishedTemplates,
|
||||
navigateAfterDeletion,
|
||||
deleteWorkflowWithTemplates,
|
||||
resetDeleteState,
|
||||
])
|
||||
|
||||
// Remove the workflow from the registry using the URL parameter
|
||||
useWorkflowRegistry.getState().removeWorkflow(currentWorkflowId)
|
||||
}
|
||||
/**
|
||||
* Handle template action selection
|
||||
*/
|
||||
const handleTemplateAction = useCallback(
|
||||
async (action: 'keep' | 'delete') => {
|
||||
const currentWorkflowId = params.workflowId as string
|
||||
if (!currentWorkflowId || !userPermissions.canEdit) return
|
||||
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: true }))
|
||||
|
||||
try {
|
||||
logger.info(`Deleting workflow ${currentWorkflowId} with template action: ${action}`)
|
||||
|
||||
navigateAfterDeletion(currentWorkflowId)
|
||||
await deleteWorkflowWithTemplates(currentWorkflowId, action)
|
||||
|
||||
logger.info(
|
||||
`Successfully deleted workflow ${currentWorkflowId} with template action: ${action}`
|
||||
)
|
||||
resetDeleteState()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workflow:', error)
|
||||
setDeleteState((prev) => ({ ...prev, isDeleting: false }))
|
||||
}
|
||||
},
|
||||
[
|
||||
params.workflowId,
|
||||
userPermissions.canEdit,
|
||||
navigateAfterDeletion,
|
||||
deleteWorkflowWithTemplates,
|
||||
resetDeleteState,
|
||||
]
|
||||
)
|
||||
|
||||
// Helper function to open subscription settings
|
||||
const openSubscriptionSettings = () => {
|
||||
@@ -422,7 +565,23 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialog
|
||||
open={deleteState.showDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
// Reset all state when opening dialog to ensure clean start
|
||||
setDeleteState({
|
||||
showDialog: true,
|
||||
isDeleting: false,
|
||||
hasPublishedTemplates: false,
|
||||
publishedTemplates: [],
|
||||
showTemplateChoice: false,
|
||||
})
|
||||
} else {
|
||||
resetDeleteState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -444,21 +603,71 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workflow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions, and
|
||||
configuration.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
|
||||
</AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? (
|
||||
<div className='space-y-3'>
|
||||
<AlertDialogDescription>
|
||||
This workflow has {deleteState.publishedTemplates.length} published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
|
||||
</AlertDialogDescription>
|
||||
{deleteState.publishedTemplates.length > 0 && (
|
||||
<ul className='list-disc space-y-1 pl-6'>
|
||||
{deleteState.publishedTemplates.map((template) => (
|
||||
<li key={template.id}>{template.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<AlertDialogDescription>
|
||||
What would you like to do with the published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
Deleting this workflow will permanently remove all associated blocks, executions,
|
||||
and configuration.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteWorkflow}
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
{deleteState.showTemplateChoice ? (
|
||||
<div className='flex w-full gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => handleTemplateAction('keep')}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
>
|
||||
Keep templates
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTemplateAction('delete')}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]'>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDeleteWorkflow()
|
||||
}}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -1002,10 +1211,10 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{renderToggleButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{renderDuplicateButton()}
|
||||
{renderDeleteButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{isExpanded && renderPublishButton()}
|
||||
{renderDeleteButton()}
|
||||
{renderDuplicateButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{renderDeployButton()}
|
||||
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
|
||||
|
||||
|
||||
@@ -191,6 +191,11 @@ export function DiffControls() {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
try {
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Clear preview YAML immediately
|
||||
await clearPreviewYaml().catch((error) => {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
@@ -219,10 +224,10 @@ export function DiffControls() {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
})
|
||||
|
||||
// Reject is immediate (no server save needed)
|
||||
rejectChanges()
|
||||
|
||||
logger.info('Successfully rejected proposed changes')
|
||||
// Reject changes optimistically
|
||||
rejectChanges().catch((error) => {
|
||||
logger.error('Failed to reject changes (background):', error)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import {
|
||||
CodeDisplay,
|
||||
JSONView,
|
||||
@@ -349,9 +350,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
// For code display, copy just the code string
|
||||
textToCopy = entry.input.code
|
||||
} else {
|
||||
// For regular JSON display, copy the full JSON
|
||||
// For regular JSON display, copy the full JSON with redaction applied
|
||||
const dataToCopy = showInput ? entry.input : entry.output
|
||||
textToCopy = JSON.stringify(dataToCopy, null, 2)
|
||||
const redactedData = redactApiKeys(dataToCopy)
|
||||
textToCopy = JSON.stringify(redactedData, null, 2)
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
|
||||
interface JSONViewProps {
|
||||
data: any
|
||||
@@ -154,6 +155,9 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
// Apply redaction to the data before displaying
|
||||
const redactedData = redactApiKeys(data)
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
@@ -167,18 +171,18 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
}
|
||||
}, [contextMenuPosition])
|
||||
|
||||
if (data === null)
|
||||
if (redactedData === null)
|
||||
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
|
||||
|
||||
// For non-object data, show simple JSON
|
||||
if (typeof data !== 'object') {
|
||||
const stringValue = JSON.stringify(data)
|
||||
if (typeof redactedData !== 'object') {
|
||||
const stringValue = JSON.stringify(redactedData)
|
||||
return (
|
||||
<span
|
||||
onContextMenu={handleContextMenu}
|
||||
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
|
||||
>
|
||||
{typeof data === 'string' ? (
|
||||
{typeof redactedData === 'string' ? (
|
||||
<TruncatedValue value={stringValue} />
|
||||
) : (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
@@ -192,7 +196,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy value
|
||||
</button>
|
||||
@@ -206,7 +210,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
|
||||
<CollapsibleJSON data={data} />
|
||||
<CollapsibleJSON data={redactedData} />
|
||||
</pre>
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
@@ -215,7 +219,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
|
||||
@@ -27,6 +27,18 @@ export function ThinkingBlock({
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-collapse when streaming ends
|
||||
if (!isStreaming) {
|
||||
setIsExpanded(false)
|
||||
return
|
||||
}
|
||||
// Expand once there is visible content while streaming
|
||||
if (content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content])
|
||||
|
||||
useEffect(() => {
|
||||
// If we already have a persisted duration, just use it
|
||||
if (typeof persistedDuration === 'number') {
|
||||
@@ -52,29 +64,10 @@ export function ThinkingBlock({
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
)}
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)}</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-1'>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
@@ -82,14 +75,25 @@ export function ThinkingBlock({
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
|
||||
<span>
|
||||
Thought for {formatDuration(duration)}
|
||||
{isExpanded ? ' (click to collapse)' : ''}
|
||||
</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Checkpoints below message */}
|
||||
{hasCheckpoints && (
|
||||
<div className='mt-1 flex justify-end'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>Restore?</span>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
|
||||
<span className='select-none'>
|
||||
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
|
||||
</span>
|
||||
<div className='inline-flex w-8 items-center justify-center'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='inline-flex items-center gap-1'>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
aria-label='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
aria-label='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
aria-label='Restore'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CopilotSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
|
||||
<SliderPrimitive.Range className='absolute h-full bg-primary' />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
CopilotSlider.displayName = 'CopilotSlider'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FileText,
|
||||
Image,
|
||||
Infinity as InfinityIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Package,
|
||||
@@ -30,11 +31,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import { CopilotSlider as Slider } from './copilot-slider'
|
||||
|
||||
export interface MessageFileAttachment {
|
||||
id: string
|
||||
@@ -426,32 +429,31 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
// Depth toggle state comes from global store; access via useCopilotStore
|
||||
const { agentDepth, setAgentDepth } = useCopilotStore()
|
||||
const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore()
|
||||
|
||||
const cycleDepth = () => {
|
||||
// Allowed UI values: 0 (Lite), 1 (Default), 2 (Pro), 3 (Max)
|
||||
const next = agentDepth === 0 ? 1 : agentDepth === 1 ? 2 : agentDepth === 2 ? 3 : 0
|
||||
setAgentDepth(next)
|
||||
// 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping.
|
||||
const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3)
|
||||
if (nextDepth === 0 && agentDepth === 3) {
|
||||
setAgentPrefetch(!agentPrefetch)
|
||||
}
|
||||
setAgentDepth(nextDepth)
|
||||
}
|
||||
|
||||
const getDepthLabel = () => {
|
||||
if (agentDepth === 0) return 'Fast'
|
||||
if (agentDepth === 1) return 'Auto'
|
||||
if (agentDepth === 2) return 'Pro'
|
||||
return 'Max'
|
||||
const getCollapsedModeLabel = () => {
|
||||
const base = getDepthLabelFor(agentDepth)
|
||||
return !agentPrefetch ? `${base} MAX` : base
|
||||
}
|
||||
|
||||
const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0) return 'Fast'
|
||||
if (value === 1) return 'Auto'
|
||||
if (value === 2) return 'Pro'
|
||||
return 'Max'
|
||||
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert'
|
||||
}
|
||||
|
||||
// Removed descriptive suffixes; concise labels only
|
||||
const getDepthDescription = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0)
|
||||
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.'
|
||||
if (value === 1) return 'Automatically balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 2)
|
||||
return 'More reasoning for larger workflows and complex edits, still balanced for speed.'
|
||||
return 'Maximum reasoning power. Best for complex workflow building and debugging.'
|
||||
@@ -548,7 +550,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
placeholder={isDragging ? 'Drop files here...' : placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
|
||||
@@ -635,126 +637,72 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
|
||||
title='Choose depth'
|
||||
title='Choose mode'
|
||||
>
|
||||
{getDepthIcon()}
|
||||
<span>{getDepthLabel()}</span>
|
||||
<span>{getCollapsedModeLabel()}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='p-0'>
|
||||
<TooltipProvider>
|
||||
<div className='w-[180px] p-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(1)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 1 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<InfinityIcon className='h-3 w-3 text-muted-foreground' />
|
||||
Auto
|
||||
</span>
|
||||
{agentDepth === 1 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Automatically balances speed and reasoning. Good fit for most tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(0)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 0 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Zap className='h-3 w-3 text-muted-foreground' />
|
||||
Fast
|
||||
</span>
|
||||
{agentDepth === 0 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Fastest and cheapest. Good for small edits, simple workflows, and small
|
||||
tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(2)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 2 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Brain className='h-3 w-3 text-muted-foreground' />
|
||||
Pro
|
||||
</span>
|
||||
{agentDepth === 2 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
More reasoning for larger workflows and complex edits, still balanced
|
||||
for speed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(3)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 3 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
|
||||
Max
|
||||
</span>
|
||||
{agentDepth === 3 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Maximum reasoning power. Best for complex workflow building and
|
||||
debugging.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<div className='w-[260px] p-3'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium text-xs'>MAX mode</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='h-3.5 w-3.5 rounded text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='MAX mode info'
|
||||
>
|
||||
<Info className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Significantly increases depth of reasoning
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!agentPrefetch}
|
||||
onCheckedChange={(checked) => setAgentPrefetch(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-2 flex justify-center'>
|
||||
<div className='h-px w-[100%] bg-border' />
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<span className='font-medium text-xs'>Mode</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{getDepthLabelFor(agentDepth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={[agentDepth]}
|
||||
onValueChange={(val) =>
|
||||
setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3)
|
||||
}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0'>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[33.333%] h-2 w-[3px] bg-background' />
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[66.667%] h-2 w-[3px] bg-background' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 text-[11px] text-muted-foreground'>
|
||||
{getDepthDescription(agentDepth)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -44,6 +44,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
// Scroll state
|
||||
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
// New state to track if user has intentionally scrolled during streaming
|
||||
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
|
||||
const isUserScrollingRef = useRef(false) // Track if scroll event is user-initiated
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -119,6 +122,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
@@ -143,7 +148,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
setIsNearBottom(nearBottom)
|
||||
setShowScrollButton(!nearBottom)
|
||||
}, [])
|
||||
|
||||
// If user scrolled up during streaming, mark it
|
||||
if (isSendingMessage && !nearBottom && isUserScrollingRef.current) {
|
||||
setUserHasScrolledDuringStream(true)
|
||||
}
|
||||
|
||||
// Reset the user scrolling flag after processing
|
||||
isUserScrollingRef.current = true
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -154,7 +167,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
|
||||
if (!viewport) return
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Mark user-initiated scrolls
|
||||
const handleUserScroll = () => {
|
||||
isUserScrollingRef.current = true
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
viewport.addEventListener('scroll', handleUserScroll, { passive: true })
|
||||
|
||||
// Also listen for scrollend event if available (for smooth scrolling)
|
||||
if ('onscrollend' in viewport) {
|
||||
@@ -165,34 +184,63 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
setTimeout(handleScroll, 100)
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', handleScroll)
|
||||
viewport.removeEventListener('scroll', handleUserScroll)
|
||||
if ('onscrollend' in viewport) {
|
||||
viewport.removeEventListener('scrollend', handleScroll)
|
||||
}
|
||||
}
|
||||
}, [handleScroll])
|
||||
|
||||
// Smart auto-scroll: only scroll if user is near bottom or for user messages
|
||||
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isNewUserMessage = lastMessage?.role === 'user'
|
||||
|
||||
// Always scroll for new user messages, or only if near bottom for assistant messages
|
||||
if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) {
|
||||
// Conditions for auto-scrolling:
|
||||
// 1. Always scroll for new user messages (resets the user scroll state)
|
||||
// 2. For assistant messages during streaming: only if user hasn't scrolled up
|
||||
// 3. For assistant messages when not streaming: only if near bottom
|
||||
const shouldAutoScroll =
|
||||
isNewUserMessage ||
|
||||
(isSendingMessage && !userHasScrolledDuringStream) ||
|
||||
(!isSendingMessage && isNearBottom)
|
||||
|
||||
if (shouldAutoScroll && scrollAreaRef.current) {
|
||||
const scrollContainer = scrollAreaRef.current.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
// Let the scroll event handler update the state naturally after animation completes
|
||||
}
|
||||
}
|
||||
}, [messages, isNearBottom])
|
||||
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream])
|
||||
|
||||
// Reset user scroll state when streaming starts or when user sends a message
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'user') {
|
||||
// User sent a new message - reset scroll state
|
||||
setUserHasScrolledDuringStream(false)
|
||||
isUserScrollingRef.current = false
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Reset user scroll state when streaming completes
|
||||
const prevIsSendingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// When streaming transitions from true to false, reset the user scroll state
|
||||
if (prevIsSendingRef.current && !isSendingMessage) {
|
||||
setUserHasScrolledDuringStream(false)
|
||||
}
|
||||
prevIsSendingRef.current = isSendingMessage
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Auto-scroll to bottom when chat loads in
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
type SlackChannelInfo,
|
||||
SlackChannelSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface ChannelSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -29,28 +29,29 @@ export function ChannelSelectorInput({
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
}: ChannelSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
|
||||
// Use the proper hook to get the current value and setter (same as file-selector)
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Reactive upstream fields
|
||||
const [authMethod] = useSubBlockValue(blockId, 'authMethod')
|
||||
const [botToken] = useSubBlockValue(blockId, 'botToken')
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'slack'
|
||||
const isSlack = provider === 'slack'
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to store
|
||||
const authMethod = getValue(blockId, 'authMethod') as string
|
||||
const botToken = getValue(blockId, 'botToken') as string
|
||||
|
||||
// Get the credential for the provider - use provided credential or fall back to reactive values
|
||||
let credential: string
|
||||
if (providedCredential) {
|
||||
credential = providedCredential
|
||||
} else if (authMethod === 'bot_token' && botToken) {
|
||||
credential = botToken
|
||||
} else if ((authMethod as string) === 'bot_token' && (botToken as string)) {
|
||||
credential = botToken as string
|
||||
} else {
|
||||
credential = (getValue(blockId, 'credential') as string) || ''
|
||||
credential = (connectedCredential as string) || ''
|
||||
}
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
@@ -58,18 +59,11 @@ export function ChannelSelectorInput({
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode (same pattern as file-selector)
|
||||
useEffect(() => {
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
const value = previewValue
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedChannelId(value)
|
||||
}
|
||||
} else {
|
||||
const value = getValue(blockId, subBlock.id)
|
||||
if (value && typeof value === 'string') {
|
||||
setSelectedChannelId(value)
|
||||
}
|
||||
const val = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
if (val && typeof val === 'string') {
|
||||
setSelectedChannelId(val)
|
||||
}
|
||||
}, [blockId, subBlock.id, getValue, isPreview, previewValue])
|
||||
}, [isPreview, previewValue, storeValue])
|
||||
|
||||
// Handle channel selection (same pattern as file-selector)
|
||||
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
|
||||
@@ -95,15 +89,10 @@ export function ChannelSelectorInput({
|
||||
}}
|
||||
credential={credential}
|
||||
label={subBlock.placeholder || 'Select Slack channel'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Slack account or enter a bot token first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('CredentialSelector')
|
||||
|
||||
@@ -124,12 +123,10 @@ export function CredentialSelector({
|
||||
}
|
||||
}, [effectiveProviderId, selectedId, activeWorkflowId])
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
// Fetch credentials on initial mount and whenever the subblock value changes externally
|
||||
useEffect(() => {
|
||||
fetchCredentials()
|
||||
// This effect should only run once on mount, so empty dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [fetchCredentials, effectiveValue])
|
||||
|
||||
// When the selectedId changes (e.g., collaborator saved a credential), determine if it's foreign
|
||||
useEffect(() => {
|
||||
@@ -180,6 +177,19 @@ export function CredentialSelector({
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Also handle BFCache restores (back/forward navigation) where visibility change may not fire reliably
|
||||
useEffect(() => {
|
||||
const handlePageShow = (event: any) => {
|
||||
if (event?.persisted) {
|
||||
fetchCredentials()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
|
||||
// Handle popover open to fetch fresh credentials
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -193,23 +203,19 @@ export function CredentialSelector({
|
||||
const selectedCredential = credentials.find((cred) => cred.id === selectedId)
|
||||
const isForeign = !!(selectedId && !selectedCredential && hasForeignMeta)
|
||||
|
||||
// If the list doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
|
||||
const displayName = selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: undefined
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
setSelectedId(credentialId)
|
||||
if (!isPreview) {
|
||||
setStoreValue(credentialId)
|
||||
// If credential changed, clear other sub-block fields for a clean state
|
||||
if (previousId && previousId !== credentialId) {
|
||||
const wfId = (activeWorkflowId as string) || ''
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[wfId] || {}
|
||||
const blockValues = workflowValues[blockId] || {}
|
||||
Object.keys(blockValues).forEach((key) => {
|
||||
if (key !== subBlock.id) {
|
||||
collaborativeSetSubblockValue(blockId, key, '')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -263,15 +269,9 @@ export function CredentialSelector({
|
||||
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
|
||||
{getProviderIcon(provider)}
|
||||
<span
|
||||
className={
|
||||
selectedCredential ? 'truncate font-normal' : 'truncate text-muted-foreground'
|
||||
}
|
||||
className={displayName ? 'truncate font-normal' : 'truncate text-muted-foreground'}
|
||||
>
|
||||
{selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: label}
|
||||
{displayName || label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
interface DateInputProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
placeholder?: string
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
placeholder,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: DateInputProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const date = value ? new Date(value) : undefined
|
||||
|
||||
const isPastDate = React.useMemo(() => {
|
||||
if (!date) return false
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return date < today
|
||||
}, [date])
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
if (selectedDate) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
}
|
||||
setStoreValue(selectedDate?.toISOString() || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground',
|
||||
isPastDate && 'border-red-500'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className='mr-1 h-4 w-4' />
|
||||
{date ? format(date, 'MMM d, yy') : <span>{placeholder || 'Pick a date'}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar mode='single' selected={date} onSelect={handleDateSelect} initialFocus />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
@@ -65,6 +66,9 @@ export function DocumentSelector({
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
const isDisabled = finalDisabled
|
||||
|
||||
// Fetch documents for the selected knowledge base
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
@@ -103,6 +107,7 @@ export function DocumentSelector({
|
||||
// Handle dropdown open/close - fetch documents when opening
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isPreview) return
|
||||
if (isDisabled) return
|
||||
|
||||
setOpen(isOpen)
|
||||
|
||||
@@ -124,13 +129,14 @@ export function DocumentSelector({
|
||||
|
||||
// Sync selected document with value prop
|
||||
useEffect(() => {
|
||||
if (isDisabled) return
|
||||
if (value && documents.length > 0) {
|
||||
const docInfo = documents.find((doc) => doc.id === value)
|
||||
setSelectedDocument(docInfo || null)
|
||||
} else {
|
||||
setSelectedDocument(null)
|
||||
}
|
||||
}, [value, documents])
|
||||
}, [value, documents, isDisabled])
|
||||
|
||||
// Reset documents when knowledge base changes
|
||||
useEffect(() => {
|
||||
@@ -141,10 +147,10 @@ export function DocumentSelector({
|
||||
|
||||
// Fetch documents when knowledge base is available
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId && !isPreview) {
|
||||
if (knowledgeBaseId && !isPreview && !isDisabled) {
|
||||
fetchDocuments()
|
||||
}
|
||||
}, [knowledgeBaseId, isPreview, fetchDocuments])
|
||||
}, [knowledgeBaseId, isPreview, isDisabled, fetchDocuments])
|
||||
|
||||
const formatDocumentName = (document: DocumentData) => {
|
||||
return document.filename
|
||||
@@ -166,9 +172,6 @@ export function DocumentSelector({
|
||||
|
||||
const label = subBlock.placeholder || 'Select document'
|
||||
|
||||
// Show disabled state if no knowledge base is selected
|
||||
const isDisabled = disabled || isPreview || !knowledgeBaseId
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -22,6 +22,7 @@ interface DropdownProps {
|
||||
previewValue?: string | null
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
config?: import('@/blocks/types').SubBlockConfig
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -34,6 +35,7 @@ export function Dropdown({
|
||||
previewValue,
|
||||
disabled,
|
||||
placeholder = 'Select an option...',
|
||||
config,
|
||||
}: DropdownProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
@@ -281,7 +283,7 @@ export function Dropdown({
|
||||
|
||||
{/* Dropdown */}
|
||||
{open && (
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full min-w-[286px]'>
|
||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
||||
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
|
||||
@@ -46,6 +46,8 @@ interface ConfluenceFileSelectorProps {
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: ConfluenceFileInfo | null) => void
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function ConfluenceFileSelector({
|
||||
@@ -60,6 +62,8 @@ export function ConfluenceFileSelector({
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: ConfluenceFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -71,6 +75,12 @@ export function ConfluenceFileSelector({
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Keep internal credential in sync with prop (handles late arrival and BFCache restores)
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Handle search with debounce
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -156,6 +166,7 @@ export function ConfluenceFileSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -189,6 +200,18 @@ export function ConfluenceFileSelector({
|
||||
if (data.file) {
|
||||
setSelectedFile(data.file)
|
||||
onFileInfoChange?.(data.file)
|
||||
} else {
|
||||
const fileInfo: ConfluenceFileInfo = {
|
||||
id: data.id || pageId,
|
||||
name: data.title || `Page ${pageId}`,
|
||||
mimeType: 'confluence/page',
|
||||
webViewLink: undefined,
|
||||
modifiedTime: undefined,
|
||||
spaceId: undefined,
|
||||
url: undefined,
|
||||
}
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching page info:', error)
|
||||
@@ -197,13 +220,14 @@ export function ConfluenceFileSelector({
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, onFileInfoChange]
|
||||
[selectedCredentialId, domain, onFileInfoChange, workflowId]
|
||||
)
|
||||
|
||||
// Fetch pages from Confluence
|
||||
const fetchFiles = useCallback(
|
||||
async (searchQuery?: string) => {
|
||||
if (!selectedCredentialId || !domain) return
|
||||
if (isForeignCredential) return
|
||||
|
||||
// Validate domain format
|
||||
const trimmedDomain = domain.trim().toLowerCase()
|
||||
@@ -228,6 +252,7 @@ export function ConfluenceFileSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -267,6 +292,12 @@ export function ConfluenceFileSelector({
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Confluence pages fetch unauthorized (expected for collaborator)')
|
||||
setFiles([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
logger.error('Confluence API error:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to fetch pages')
|
||||
}
|
||||
@@ -294,7 +325,15 @@ export function ConfluenceFileSelector({
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, domain, selectedFileId, onFileInfoChange, fetchPageInfo]
|
||||
[
|
||||
selectedCredentialId,
|
||||
domain,
|
||||
selectedFileId,
|
||||
onFileInfoChange,
|
||||
fetchPageInfo,
|
||||
workflowId,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
@@ -310,7 +349,7 @@ export function ConfluenceFileSelector({
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch files when opening the dropdown and if we have valid credentials and domain
|
||||
if (isOpen && selectedCredentialId && domain && domain.includes('.')) {
|
||||
if (isOpen && !isForeignCredential && selectedCredentialId && domain && domain.includes('.')) {
|
||||
fetchFiles()
|
||||
}
|
||||
}
|
||||
@@ -320,7 +359,15 @@ export function ConfluenceFileSelector({
|
||||
if (value && selectedCredentialId && !selectedFile && domain && domain.includes('.')) {
|
||||
fetchPageInfo(value)
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedFile, domain, fetchPageInfo])
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
selectedFile,
|
||||
domain,
|
||||
fetchPageInfo,
|
||||
workflowId,
|
||||
isForeignCredential,
|
||||
])
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
useEffect(() => {
|
||||
@@ -329,6 +376,14 @@ export function ConfluenceFileSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear preview when value is cleared (e.g., collaborator cleared or domain change cascade)
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedFile(null)
|
||||
onFileInfoChange?.(null)
|
||||
}
|
||||
}, [value, onFileInfoChange])
|
||||
|
||||
// Handle file selection
|
||||
const handleSelectFile = (file: ConfluenceFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
@@ -363,7 +418,7 @@ export function ConfluenceFileSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain}
|
||||
disabled={disabled || !domain || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
@@ -381,122 +436,126 @@ export function ConfluenceFileSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading pages...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Confluence account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No pages found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Files list */}
|
||||
{files.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Pages
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{file.name}</span>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span>Connect Confluence account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search pages...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading pages...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Confluence account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No pages found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Files list */}
|
||||
{files.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Pages
|
||||
</div>
|
||||
{files.map((file) => (
|
||||
<CommandItem
|
||||
key={file.id}
|
||||
value={`file-${file.id}-${file.name}`}
|
||||
onSelect={() => handleSelectFile(file)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{file.name}</span>
|
||||
</div>
|
||||
{file.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<ConfluenceIcon className='h-4 w-4' />
|
||||
<span>Connect Confluence account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{showPreview && selectedFile && selectedFileId && selectedFile.id === selectedFileId && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
|
||||
import { ExternalLink, FileIcon, FolderIcon, RefreshCw, X } from 'lucide-react'
|
||||
import useDrivePicker from 'react-google-drive-picker'
|
||||
import { GoogleDocsIcon, GoogleSheetsIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -74,7 +66,6 @@ export function GoogleDrivePicker({
|
||||
credentialId,
|
||||
workflowId,
|
||||
}: GoogleDrivePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
@@ -237,18 +228,20 @@ export function GoogleDrivePicker({
|
||||
])
|
||||
|
||||
// Fetch the access token for the selected credential
|
||||
const fetchAccessToken = async (): Promise<string | null> => {
|
||||
if (!selectedCredentialId) {
|
||||
const fetchAccessToken = async (credentialOverrideId?: string): Promise<string | null> => {
|
||||
const effectiveCredentialId = credentialOverrideId || selectedCredentialId
|
||||
if (!effectiveCredentialId) {
|
||||
logger.error('No credential ID selected for Google Drive Picker')
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const url = new URL('/api/auth/oauth/token', window.location.origin)
|
||||
url.searchParams.set('credentialId', selectedCredentialId)
|
||||
// include workflowId if available via global registry (server adds session owner otherwise)
|
||||
const response = await fetch(url.toString())
|
||||
const response = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: effectiveCredentialId, workflowId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch access token: ${response.status}`)
|
||||
@@ -265,10 +258,10 @@ export function GoogleDrivePicker({
|
||||
}
|
||||
|
||||
// Handle opening the Google Drive Picker
|
||||
const handleOpenPicker = async () => {
|
||||
const handleOpenPicker = async (credentialOverrideId?: string) => {
|
||||
try {
|
||||
// First, get the access token for the selected credential
|
||||
const accessToken = await fetchAccessToken()
|
||||
const accessToken = await fetchAccessToken(credentialOverrideId)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token for Google Drive Picker')
|
||||
@@ -335,7 +328,6 @@ export function GoogleDrivePicker({
|
||||
const handleAddCredential = () => {
|
||||
// Show the OAuth modal
|
||||
setShowOAuthModal(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
@@ -423,142 +415,60 @@ export function GoogleDrivePicker({
|
||||
return <FileIcon className={`${iconSize} text-muted-foreground`} />
|
||||
}
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
</>
|
||||
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='truncate text-muted-foreground'>Loading document...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || isLoading}
|
||||
onClick={async () => {
|
||||
// Decide which credential to use
|
||||
let idToUse = selectedCredentialId
|
||||
if (!idToUse && credentials.length === 1) {
|
||||
idToUse = credentials[0].id
|
||||
setSelectedCredentialId(idToUse)
|
||||
}
|
||||
|
||||
if (!idToUse) {
|
||||
// No credentials — prompt OAuth
|
||||
handleAddCredential()
|
||||
return
|
||||
}
|
||||
|
||||
await handleOpenPicker(idToUse)
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
</>
|
||||
) : selectedFileId && isLoadingSelectedFile && selectedCredentialId ? (
|
||||
<>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='truncate text-muted-foreground'>Loading document...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='truncate text-muted-foreground'>{label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName(provider)} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No documents available.</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Open picker button - only show if we have credentials */}
|
||||
{credentials.length > 0 && selectedCredentialId && (
|
||||
<CommandGroup>
|
||||
<div className='p-2'>
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
handleOpenPicker()
|
||||
}}
|
||||
>
|
||||
Open Google Drive Picker
|
||||
</Button>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type Credential,
|
||||
getProviderIdFromServiceId,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@/lib/oauth'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
|
||||
const logger = new Logger('JiraIssueSelector')
|
||||
const logger = createLogger('JiraIssueSelector')
|
||||
|
||||
export interface JiraIssueInfo {
|
||||
id: string
|
||||
@@ -48,6 +48,7 @@ interface JiraIssueSelectorProps {
|
||||
projectId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
@@ -63,6 +64,8 @@ export function JiraIssueSelector({
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -168,6 +171,7 @@ export function JiraIssueSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -264,6 +268,7 @@ export function JiraIssueSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -377,6 +382,10 @@ export function JiraIssueSelector({
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
@@ -451,7 +460,7 @@ export function JiraIssueSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId}
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedIssue ? (
|
||||
@@ -469,118 +478,122 @@ export function JiraIssueSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Issue preview */}
|
||||
|
||||
@@ -55,6 +55,9 @@ interface MicrosoftFileSelectorProps {
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void
|
||||
planId?: string
|
||||
workflowId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function MicrosoftFileSelector({
|
||||
@@ -68,10 +71,13 @@ export function MicrosoftFileSelector({
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
planId,
|
||||
workflowId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
}: MicrosoftFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<MicrosoftFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -82,6 +88,8 @@ export function MicrosoftFileSelector({
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
|
||||
const initialFetchRef = useRef(false)
|
||||
// Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops
|
||||
const lastMetaAttemptRef = useRef<string>('')
|
||||
|
||||
// Handle Microsoft Planner task selection
|
||||
const [plannerTasks, setPlannerTasks] = useState<PlannerTask[]>([])
|
||||
@@ -112,23 +120,11 @@ export function MicrosoftFileSelector({
|
||||
const data = await response.json()
|
||||
setCredentials(data.credentials)
|
||||
|
||||
// Auto-select logic for credentials
|
||||
if (data.credentials.length > 0) {
|
||||
// If we already have a selected credential ID, check if it's valid
|
||||
if (
|
||||
selectedCredentialId &&
|
||||
data.credentials.some((cred: Credential) => cred.id === selectedCredentialId)
|
||||
) {
|
||||
// Keep the current selection
|
||||
} else {
|
||||
// Otherwise, select the default or first credential
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) {
|
||||
setSelectedCredentialId(defaultCred.id)
|
||||
} else if (data.credentials.length === 1) {
|
||||
setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
// If a credentialId prop is provided (collaborator case), do not auto-select
|
||||
if (!credentialId && data.credentials.length > 0 && !selectedCredentialId) {
|
||||
const defaultCred = data.credentials.find((cred: Credential) => cred.isDefault)
|
||||
if (defaultCred) setSelectedCredentialId(defaultCred.id)
|
||||
else if (data.credentials.length === 1) setSelectedCredentialId(data.credentials[0].id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -137,11 +133,18 @@ export function MicrosoftFileSelector({
|
||||
setIsLoading(false)
|
||||
setCredentialsLoaded(true)
|
||||
}
|
||||
}, [provider, getProviderId, selectedCredentialId])
|
||||
}, [provider, getProviderId, selectedCredentialId, credentialId])
|
||||
|
||||
// Keep internal credential in sync with prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Fetch available files for the selected credential
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!selectedCredentialId) return
|
||||
if (!selectedCredentialId || isForeignCredential) return
|
||||
|
||||
setIsLoadingFiles(true)
|
||||
try {
|
||||
@@ -170,9 +173,13 @@ export function MicrosoftFileSelector({
|
||||
const data = await response.json()
|
||||
setAvailableFiles(data.files || [])
|
||||
} else {
|
||||
logger.error('Error fetching available files:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
const txt = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Suppress noisy auth errors for collaborators; lists are intentionally gated
|
||||
logger.info('Skipping list fetch (auth)', { status: response.status })
|
||||
} else {
|
||||
logger.warn('Non-OK list fetch', { status: response.status, txt })
|
||||
}
|
||||
setAvailableFiles([])
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -181,7 +188,7 @@ export function MicrosoftFileSelector({
|
||||
} finally {
|
||||
setIsLoadingFiles(false)
|
||||
}
|
||||
}, [selectedCredentialId, searchQuery, serviceId])
|
||||
}, [selectedCredentialId, searchQuery, serviceId, isForeignCredential])
|
||||
|
||||
// Fetch a single file by ID when we have a selectedFileId but no metadata
|
||||
const fetchFileById = useCallback(
|
||||
@@ -190,49 +197,90 @@ export function MicrosoftFileSelector({
|
||||
|
||||
setIsLoadingSelectedFile(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
fileId: fileId,
|
||||
})
|
||||
|
||||
// Route to correct endpoint based on service
|
||||
let endpoint: string
|
||||
if (serviceId === 'onedrive') {
|
||||
endpoint = `/api/tools/onedrive/folder?${queryParams.toString()}`
|
||||
} else if (serviceId === 'sharepoint') {
|
||||
// Change from fileId to siteId for SharePoint
|
||||
const sharepointParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
siteId: fileId, // Use siteId instead of fileId
|
||||
// Use owner-scoped token for OneDrive items (files/folders) and Excel
|
||||
if (serviceId !== 'sharepoint') {
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
endpoint = `/api/tools/sharepoint/site?${sharepointParams.toString()}`
|
||||
} else {
|
||||
endpoint = `/api/auth/oauth/microsoft/file?${queryParams.toString()}`
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.text()
|
||||
logger.error('Failed to get access token for Microsoft file fetch', { err })
|
||||
return null
|
||||
}
|
||||
const { accessToken } = await tokenRes.json()
|
||||
if (!accessToken) return null
|
||||
|
||||
const graphUrl =
|
||||
`https://graph.microsoft.com/v1.0/me/drive/items/${encodeURIComponent(fileId)}?` +
|
||||
new URLSearchParams({
|
||||
$select:
|
||||
'id,name,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy,file,folder',
|
||||
}).toString()
|
||||
const resp = await fetch(graphUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const t = await resp.text()
|
||||
// For 404/403, keep current selection; this often means the item moved or is shared differently.
|
||||
if (resp.status !== 404 && resp.status !== 403) {
|
||||
logger.warn('Graph error fetching file by ID', { status: resp.status, t })
|
||||
}
|
||||
return null
|
||||
}
|
||||
const file = await resp.json()
|
||||
const fileInfo: MicrosoftFileInfo = {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimeType:
|
||||
file?.file?.mimeType || (file.folder ? 'application/vnd.ms-onedrive.folder' : ''),
|
||||
iconLink: file.thumbnails?.[0]?.small?.url,
|
||||
webViewLink: file.webUrl,
|
||||
thumbnailLink: file.thumbnails?.[0]?.medium?.url,
|
||||
createdTime: file.createdDateTime,
|
||||
modifiedTime: file.lastModifiedDateTime,
|
||||
size: file.size?.toString(),
|
||||
owners: file.createdBy
|
||||
? [
|
||||
{
|
||||
displayName: file.createdBy.user?.displayName || 'Unknown',
|
||||
emailAddress: file.createdBy.user?.email || '',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
setSelectedFile(fileInfo)
|
||||
onFileInfoChange?.(fileInfo)
|
||||
return fileInfo
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.file) {
|
||||
setSelectedFile(data.file)
|
||||
onFileInfoChange?.(data.file)
|
||||
return data.file
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Error fetching file by ID:', { error: errorText })
|
||||
|
||||
// If file not found or access denied, clear the selection
|
||||
if (response.status === 404 || response.status === 403) {
|
||||
logger.info('File not accessible, clearing selection')
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
onFileInfoChange?.(null)
|
||||
// SharePoint site: fetch via Graph sites endpoint for collaborator visibility
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { accessToken: spToken } = await tokenRes.json()
|
||||
if (!spToken) return null
|
||||
const spResp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(fileId)}?$select=id,displayName,webUrl`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${spToken}` },
|
||||
}
|
||||
)
|
||||
if (!spResp.ok) return null
|
||||
const site = await spResp.json()
|
||||
const siteInfo: MicrosoftFileInfo = {
|
||||
id: site.id,
|
||||
name: site.displayName,
|
||||
mimeType: 'sharepoint/site',
|
||||
webViewLink: site.webUrl,
|
||||
}
|
||||
return null
|
||||
setSelectedFile(siteInfo)
|
||||
onFileInfoChange?.(siteInfo)
|
||||
return siteInfo
|
||||
} catch (error) {
|
||||
logger.error('Error fetching file by ID:', { error })
|
||||
return null
|
||||
@@ -240,16 +288,22 @@ export function MicrosoftFileSelector({
|
||||
setIsLoadingSelectedFile(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onFileInfoChange, serviceId]
|
||||
[selectedCredentialId, onFileInfoChange, serviceId, workflowId, onChange]
|
||||
)
|
||||
|
||||
// Fetch Microsoft Planner tasks when planId and credentials are available
|
||||
const fetchPlannerTasks = useCallback(async () => {
|
||||
if (!selectedCredentialId || !planId || serviceId !== 'microsoft-planner') {
|
||||
if (
|
||||
!selectedCredentialId ||
|
||||
!planId ||
|
||||
serviceId !== 'microsoft-planner' ||
|
||||
isForeignCredential
|
||||
) {
|
||||
logger.info('Skipping task fetch - missing requirements:', {
|
||||
selectedCredentialId: !!selectedCredentialId,
|
||||
planId: !!planId,
|
||||
serviceId,
|
||||
isForeignCredential,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -296,11 +350,17 @@ export function MicrosoftFileSelector({
|
||||
setPlannerTasks(transformedTasks)
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('API response not ok:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorText,
|
||||
})
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Planner list fetch unauthorized (expected for collaborator)', {
|
||||
status: response.status,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Planner tasks fetch non-OK', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorText,
|
||||
})
|
||||
}
|
||||
setPlannerTasks([])
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -309,7 +369,50 @@ export function MicrosoftFileSelector({
|
||||
} finally {
|
||||
setIsLoadingTasks(false)
|
||||
}
|
||||
}, [selectedCredentialId, planId, serviceId])
|
||||
}, [selectedCredentialId, planId, serviceId, isForeignCredential])
|
||||
|
||||
// Fetch a single planner task by ID for collaborator preview
|
||||
const fetchPlannerTaskById = useCallback(
|
||||
async (taskId: string) => {
|
||||
if (!selectedCredentialId || !taskId || serviceId !== 'microsoft-planner') return null
|
||||
setIsLoadingTasks(true)
|
||||
try {
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { accessToken } = await tokenRes.json()
|
||||
if (!accessToken) return null
|
||||
const resp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/planner/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
)
|
||||
if (!resp.ok) return null
|
||||
const task = await resp.json()
|
||||
const taskAsFileInfo: MicrosoftFileInfo = {
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
mimeType: 'planner/task',
|
||||
webViewLink: `https://tasks.office.com/planner/task/${task.id}`,
|
||||
createdTime: task.createdDateTime,
|
||||
modifiedTime: task.createdDateTime,
|
||||
}
|
||||
setSelectedTask(task)
|
||||
setSelectedFile(taskAsFileInfo)
|
||||
onFileInfoChange?.(taskAsFileInfo)
|
||||
return taskAsFileInfo
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
setIsLoadingTasks(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, workflowId, onFileInfoChange, serviceId]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
@@ -339,10 +442,15 @@ export function MicrosoftFileSelector({
|
||||
|
||||
// Fetch planner tasks when credentials and planId change
|
||||
useEffect(() => {
|
||||
if (serviceId === 'microsoft-planner' && selectedCredentialId && planId) {
|
||||
if (
|
||||
serviceId === 'microsoft-planner' &&
|
||||
selectedCredentialId &&
|
||||
planId &&
|
||||
!isForeignCredential
|
||||
) {
|
||||
fetchPlannerTasks()
|
||||
}
|
||||
}, [selectedCredentialId, planId, serviceId, fetchPlannerTasks])
|
||||
}, [selectedCredentialId, planId, serviceId, isForeignCredential, fetchPlannerTasks])
|
||||
|
||||
// Handle task selection for planner
|
||||
const handleTaskSelect = (task: PlannerTask) => {
|
||||
@@ -357,26 +465,23 @@ export function MicrosoftFileSelector({
|
||||
modifiedTime: task.createdDateTime,
|
||||
}
|
||||
|
||||
// Update internal state first to avoid race with list refetch
|
||||
setSelectedFileId(taskId)
|
||||
setSelectedFile(taskAsFileInfo)
|
||||
setSelectedTask(task)
|
||||
// Then propagate up
|
||||
onChange(taskId, taskAsFileInfo)
|
||||
onFileInfoChange?.(taskAsFileInfo)
|
||||
setOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
// Keep internal selectedFileId in sync with the value prop
|
||||
// Keep internal selectedFileId in sync with the value prop (do not clear selectedFile; we'll resolve new metadata below)
|
||||
useEffect(() => {
|
||||
if (value !== selectedFileId) {
|
||||
const previousFileId = selectedFileId
|
||||
setSelectedFileId(value)
|
||||
// Only clear selected file info if we had a different file before (not initial load)
|
||||
if (previousFileId && previousFileId !== value && selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
}
|
||||
}, [value, selectedFileId, selectedFile])
|
||||
}, [value, selectedFileId])
|
||||
|
||||
// Track previous credential ID to detect changes
|
||||
const prevCredentialIdRef = useRef<string>('')
|
||||
@@ -393,28 +498,40 @@ export function MicrosoftFileSelector({
|
||||
setSelectedFileId('')
|
||||
onChange('')
|
||||
}
|
||||
// Reset memo when credential is cleared
|
||||
lastMetaAttemptRef.current = ''
|
||||
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
|
||||
// Credentials changed (not initial load) - clear file info to force refetch
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
// Reset memo when switching credentials
|
||||
lastMetaAttemptRef.current = ''
|
||||
}
|
||||
}, [selectedCredentialId, selectedFile, onChange])
|
||||
|
||||
// Fetch the selected file metadata once credentials are loaded or changed
|
||||
useEffect(() => {
|
||||
// Only fetch if we have both a file ID and credentials, credentials are loaded, but no file info yet
|
||||
// Fetch metadata when the external value doesn't match our current selectedFile
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
credentialsLoaded &&
|
||||
!selectedFile &&
|
||||
!isLoadingSelectedFile &&
|
||||
serviceId !== 'microsoft-planner' &&
|
||||
serviceId !== 'sharepoint' &&
|
||||
serviceId !== 'onedrive'
|
||||
(!selectedFile || selectedFile.id !== value) &&
|
||||
!isLoadingSelectedFile
|
||||
) {
|
||||
fetchFileById(value)
|
||||
// Avoid tight retry loops by memoizing the last attempt tuple
|
||||
const attemptKey = `${selectedCredentialId}::${value}`
|
||||
if (lastMetaAttemptRef.current === attemptKey) {
|
||||
return
|
||||
}
|
||||
lastMetaAttemptRef.current = attemptKey
|
||||
|
||||
if (serviceId === 'microsoft-planner') {
|
||||
void fetchPlannerTaskById(value)
|
||||
} else {
|
||||
void fetchFileById(value)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
@@ -423,9 +540,30 @@ export function MicrosoftFileSelector({
|
||||
selectedFile,
|
||||
isLoadingSelectedFile,
|
||||
fetchFileById,
|
||||
fetchPlannerTaskById,
|
||||
serviceId,
|
||||
])
|
||||
|
||||
// Resolve planner task selection for collaborators
|
||||
useEffect(() => {
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
credentialsLoaded &&
|
||||
!selectedTask &&
|
||||
serviceId === 'microsoft-planner'
|
||||
) {
|
||||
void fetchPlannerTaskById(value)
|
||||
}
|
||||
}, [
|
||||
value,
|
||||
selectedCredentialId,
|
||||
credentialsLoaded,
|
||||
selectedTask,
|
||||
serviceId,
|
||||
fetchPlannerTaskById,
|
||||
])
|
||||
|
||||
// Handle selecting a file from the available files
|
||||
const handleFileSelect = (file: MicrosoftFileInfo) => {
|
||||
setSelectedFileId(file.id)
|
||||
@@ -602,6 +740,13 @@ export function MicrosoftFileSelector({
|
||||
})
|
||||
: availableFiles
|
||||
|
||||
const canShowPreview = !!(
|
||||
showPreview &&
|
||||
selectedFile &&
|
||||
selectedFileId &&
|
||||
selectedFile.id === selectedFileId
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -620,10 +765,12 @@ export function MicrosoftFileSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || (serviceId === 'microsoft-planner' && !planId)}
|
||||
disabled={
|
||||
disabled || isForeignCredential || (serviceId === 'microsoft-planner' && !planId)
|
||||
}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedFile ? (
|
||||
{canShowPreview ? (
|
||||
<>
|
||||
{getFileIcon(selectedFile, 'sm')}
|
||||
<span className='truncate font-normal'>{selectedFile.name}</span>
|
||||
@@ -643,158 +790,162 @@ export function MicrosoftFileSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(provider)}
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder={getSearchPlaceholder()} onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading || isLoadingFiles || isLoadingTasks ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName(provider)} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : serviceId === 'microsoft-planner' && !planId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Plan ID required.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please enter a Plan ID first to see tasks.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>{getEmptyStateText().title}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{getEmptyStateText().description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Available files/tasks - only show if we have credentials and items */}
|
||||
{credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFileTypeTitleCase()}
|
||||
</div>
|
||||
{filteredTasks.map((item) => {
|
||||
const isPlanner = serviceId === 'microsoft-planner'
|
||||
const isPlannerTask = isPlanner && 'title' in item
|
||||
const plannerTask = item as PlannerTask
|
||||
const fileInfo = item as MicrosoftFileInfo
|
||||
|
||||
const displayName = isPlannerTask ? plannerTask.title : fileInfo.name
|
||||
const dateField = isPlannerTask
|
||||
? plannerTask.createdDateTime
|
||||
: fileInfo.createdTime
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`file-${item.id}-${displayName}`}
|
||||
onSelect={() =>
|
||||
isPlannerTask
|
||||
? handleTaskSelect(plannerTask)
|
||||
: handleFileSelect(fileInfo)
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFileIcon(
|
||||
isPlannerTask
|
||||
? {
|
||||
...fileInfo,
|
||||
id: plannerTask.id || '',
|
||||
name: plannerTask.title,
|
||||
mimeType: 'planner/task',
|
||||
}
|
||||
: fileInfo,
|
||||
'sm'
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<span className='truncate font-normal'>{displayName}</span>
|
||||
{dateField && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Modified {new Date(dateField).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder={getSearchPlaceholder()} onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading || isLoadingFiles || isLoadingTasks ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName(provider)} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : serviceId === 'microsoft-planner' && !planId ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>Plan ID required.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Please enter a Plan ID first to see tasks.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>{getEmptyStateText().title}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{getEmptyStateText().description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getProviderIcon(cred.provider)}
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Available files/tasks - only show if we have credentials and items */}
|
||||
{credentials.length > 0 && selectedCredentialId && filteredTasks.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFileTypeTitleCase()}
|
||||
</div>
|
||||
{filteredTasks.map((item) => {
|
||||
const isPlanner = serviceId === 'microsoft-planner'
|
||||
const isPlannerTask = isPlanner && 'title' in item
|
||||
const plannerTask = item as PlannerTask
|
||||
const fileInfo = item as MicrosoftFileInfo
|
||||
|
||||
const displayName = isPlannerTask ? plannerTask.title : fileInfo.name
|
||||
const dateField = isPlannerTask
|
||||
? plannerTask.createdDateTime
|
||||
: fileInfo.createdTime
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`file-${item.id}-${displayName}`}
|
||||
onSelect={() =>
|
||||
isPlannerTask
|
||||
? handleTaskSelect(plannerTask)
|
||||
: handleFileSelect(fileInfo)
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{getFileIcon(
|
||||
isPlannerTask
|
||||
? {
|
||||
...fileInfo,
|
||||
id: plannerTask.id || '',
|
||||
name: plannerTask.title,
|
||||
mimeType: 'planner/task',
|
||||
}
|
||||
: fileInfo,
|
||||
'sm'
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<span className='truncate font-normal'>{displayName}</span>
|
||||
{dateField && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Modified {new Date(dateField).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{item.id === selectedFileId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
{getProviderIcon(provider)}
|
||||
<span>Connect {getProviderName(provider)} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* File preview */}
|
||||
{showPreview && selectedFile && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -48,6 +48,7 @@ interface TeamsMessageSelectorProps {
|
||||
selectionType?: 'team' | 'channel' | 'chat'
|
||||
initialTeamId?: string
|
||||
workflowId: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function TeamsMessageSelector({
|
||||
@@ -64,6 +65,7 @@ export function TeamsMessageSelector({
|
||||
selectionType = 'team',
|
||||
initialTeamId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: TeamsMessageSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -324,6 +326,10 @@ export function TeamsMessageSelector({
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
// Only fetch data when opening the dropdown
|
||||
if (isOpen && selectedCredentialId) {
|
||||
@@ -693,7 +699,7 @@ export function TeamsMessageSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled}
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedMessage ? (
|
||||
@@ -715,120 +721,124 @@ export function TeamsMessageSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${selectionStage}s...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {selectionStage}s...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
{selectionStage === 'chat' && error.includes('teams') && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
There was an issue fetching chats. Please try again or connect a different
|
||||
account.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Microsoft Teams account to{' '}
|
||||
{selectionStage === 'chat'
|
||||
? 'access your chats'
|
||||
: selectionStage === 'channel'
|
||||
? 'see your channels'
|
||||
: 'continue'}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{selectionStage === 'team'
|
||||
? 'Try a different account.'
|
||||
: selectionStage === 'channel'
|
||||
? selectedTeamId
|
||||
? 'This team has no channels or you may not have access.'
|
||||
: 'Please select a team first to see its channels.'
|
||||
: 'Try a different account or check if you have any active chats.'}
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedCredentialId(cred.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Display appropriate options based on selection stage */}
|
||||
{renderSelectionOptions()}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span>Connect Microsoft Teams account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder={`Search ${selectionStage}s...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {selectionStage}s...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
{selectionStage === 'chat' && error.includes('teams') && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
There was an issue fetching chats. Please try again or connect a
|
||||
different account.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Microsoft Teams account to{' '}
|
||||
{selectionStage === 'chat'
|
||||
? 'access your chats'
|
||||
: selectionStage === 'channel'
|
||||
? 'see your channels'
|
||||
: 'continue'}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {selectionStage}s found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{selectionStage === 'team'
|
||||
? 'Try a different account.'
|
||||
: selectionStage === 'channel'
|
||||
? selectedTeamId
|
||||
? 'This team has no channels or you may not have access.'
|
||||
: 'Please select a team first to see its channels.'
|
||||
: 'Try a different account or check if you have any active chats.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedCredentialId(cred.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Display appropriate options based on selection stage */}
|
||||
{renderSelectionOptions()}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<MicrosoftTeamsIcon className='h-4 w-4' />
|
||||
<span>Connect Microsoft Teams account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Selection preview */}
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import {
|
||||
type ConfluenceFileInfo,
|
||||
ConfluenceFileSelector,
|
||||
type DiscordChannelInfo,
|
||||
DiscordChannelSelector,
|
||||
type FileInfo,
|
||||
type GoogleCalendarInfo,
|
||||
GoogleCalendarSelector,
|
||||
GoogleDrivePicker,
|
||||
type JiraIssueInfo,
|
||||
JiraIssueSelector,
|
||||
type MicrosoftFileInfo,
|
||||
MicrosoftFileSelector,
|
||||
type TeamsMessageInfo,
|
||||
TeamsMessageSelector,
|
||||
WealthboxFileSelector,
|
||||
type WealthboxItemInfo,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
interface FileSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -45,11 +38,12 @@ export function FileSelectorInput({
|
||||
previewValue,
|
||||
previewContextValues,
|
||||
}: FileSelectorInputProps) {
|
||||
const { getValue } = useSubBlockStore()
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
// Central dependsOn gating for this selector instance
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Helper to coerce various preview value shapes into a string ID
|
||||
const coerceToIdString = (val: unknown): string => {
|
||||
@@ -70,48 +64,24 @@ export function FileSelectorInput({
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>('')
|
||||
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
|
||||
const [_issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
|
||||
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(null)
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<string>('')
|
||||
const [messageInfo, setMessageInfo] = useState<TeamsMessageInfo | null>(null)
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string>('')
|
||||
const [calendarInfo, setCalendarInfo] = useState<GoogleCalendarInfo | null>(null)
|
||||
const [selectedWealthboxItemId, setSelectedWealthboxItemId] = useState<string>('')
|
||||
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [domainValue] = useSubBlockValue(blockId, 'domain')
|
||||
const [projectIdValue] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValue] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValue] = useSubBlockValue(blockId, 'teamId')
|
||||
const [operationValue] = useSubBlockValue(blockId, 'operation')
|
||||
const [serverIdValue] = useSubBlockValue(blockId, 'serverId')
|
||||
const [botTokenValue] = useSubBlockValue(blockId, 'botToken')
|
||||
|
||||
// Determine if the persisted credential belongs to the current viewer
|
||||
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
const cred = (getValue(blockId, 'credential') as string) || ''
|
||||
if (!cred) {
|
||||
setIsForeignCredential(false)
|
||||
return
|
||||
}
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
|
||||
if (aborted) return
|
||||
if (!resp.ok) {
|
||||
setIsForeignCredential(true)
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
// If credential not returned for this session user, it's foreign
|
||||
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
|
||||
} catch {
|
||||
setIsForeignCredential(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, getValue(blockId, 'credential')])
|
||||
// Use service providerId where available (e.g., onedrive/sharepoint) instead of base provider ("microsoft")
|
||||
const foreignCheckProvider = subBlock.serviceId
|
||||
? getProviderIdFromServiceId(subBlock.serviceId)
|
||||
: (subBlock.provider as string) || ''
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
foreignCheckProvider,
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'google-drive'
|
||||
@@ -126,135 +96,36 @@ export function FileSelectorInput({
|
||||
const isWealthbox = provider === 'wealthbox'
|
||||
const isMicrosoftSharePoint = provider === 'microsoft' && subBlock.serviceId === 'sharepoint'
|
||||
const isMicrosoftPlanner = provider === 'microsoft-planner'
|
||||
|
||||
// For Confluence and Jira, we need the domain and credentials
|
||||
const domain =
|
||||
isConfluence || isJira
|
||||
? (isPreview && previewContextValues?.domain?.value) ||
|
||||
(getValue(blockId, 'domain') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.domain?.value) || (domainValue as string) || ''
|
||||
: ''
|
||||
const jiraCredential = isJira
|
||||
? (isPreview && previewContextValues?.credential?.value) ||
|
||||
(getValue(blockId, 'credential') as string) ||
|
||||
(connectedCredential as string) ||
|
||||
''
|
||||
: ''
|
||||
|
||||
// For Discord, we need the bot token and server ID
|
||||
const botToken = isDiscord
|
||||
? (isPreview && previewContextValues?.botToken?.value) ||
|
||||
(getValue(blockId, 'botToken') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.botToken?.value) || (botTokenValue as string) || ''
|
||||
: ''
|
||||
const serverId = isDiscord
|
||||
? (isPreview && previewContextValues?.serverId?.value) ||
|
||||
(getValue(blockId, 'serverId') as string) ||
|
||||
''
|
||||
? (isPreview && previewContextValues?.serverId?.value) || (serverIdValue as string) || ''
|
||||
: ''
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
// Keep local selection in sync with store value (and preview)
|
||||
useEffect(() => {
|
||||
const raw = isPreview && previewValue !== undefined ? previewValue : storeValue
|
||||
const effective = coerceToIdString(raw)
|
||||
if (effective) {
|
||||
if (isJira) {
|
||||
setSelectedIssueId(effective)
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId(effective)
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId(effective)
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId(effective)
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId(effective)
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId(effective)
|
||||
} else {
|
||||
setSelectedFileId(effective)
|
||||
}
|
||||
} else {
|
||||
// Clear when value becomes empty
|
||||
if (isJira) {
|
||||
setSelectedIssueId('')
|
||||
} else if (isDiscord) {
|
||||
setSelectedChannelId('')
|
||||
} else if (isMicrosoftTeams) {
|
||||
setSelectedMessageId('')
|
||||
} else if (isGoogleCalendar) {
|
||||
setSelectedCalendarId('')
|
||||
} else if (isWealthbox) {
|
||||
setSelectedWealthboxItemId('')
|
||||
} else if (isMicrosoftSharePoint) {
|
||||
setSelectedFileId('')
|
||||
} else {
|
||||
setSelectedFileId('')
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPreview,
|
||||
previewValue,
|
||||
storeValue,
|
||||
isJira,
|
||||
isDiscord,
|
||||
isMicrosoftTeams,
|
||||
isGoogleCalendar,
|
||||
isWealthbox,
|
||||
isMicrosoftSharePoint,
|
||||
])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (fileId: string, info?: any) => {
|
||||
setSelectedFileId(fileId)
|
||||
setFileInfo(info || null)
|
||||
setStoreValue(fileId)
|
||||
}
|
||||
|
||||
// Handle issue selection
|
||||
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
|
||||
setSelectedIssueId(issueKey)
|
||||
setIssueInfo(info || null)
|
||||
setStoreValue(issueKey)
|
||||
|
||||
// Clear the fields when a new issue is selected
|
||||
if (isJira) {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
if (!issueKey) {
|
||||
// Also clear the manual issue key when cleared
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle channel selection
|
||||
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
|
||||
setSelectedChannelId(channelId)
|
||||
setChannelInfo(info || null)
|
||||
setStoreValue(channelId)
|
||||
}
|
||||
|
||||
// Handle calendar selection
|
||||
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
|
||||
setSelectedCalendarId(calendarId)
|
||||
setCalendarInfo(info || null)
|
||||
setStoreValue(calendarId)
|
||||
}
|
||||
|
||||
// Handle Wealthbox item selection
|
||||
const handleWealthboxItemChange = (itemId: string, info?: WealthboxItemInfo) => {
|
||||
setSelectedWealthboxItemId(itemId)
|
||||
setWealthboxItemInfo(info || null)
|
||||
setStoreValue(itemId)
|
||||
}
|
||||
|
||||
// For Google Drive
|
||||
const clientId = getEnv('NEXT_PUBLIC_GOOGLE_CLIENT_ID') || ''
|
||||
const apiKey = getEnv('NEXT_PUBLIC_GOOGLE_API_KEY') || ''
|
||||
|
||||
// Render Google Calendar selector
|
||||
if (isGoogleCalendar) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -267,25 +138,17 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedCalendarId(val)
|
||||
setCalendarInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
label={subBlock.placeholder || 'Select Google Calendar'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onCalendarInfoChange={setCalendarInfo}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Google Calendar credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -299,21 +162,18 @@ export function FileSelectorInput({
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<DiscordChannelSelector
|
||||
value={selectedChannelId}
|
||||
onChange={handleChannelChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(channelId) => setStoreValue(channelId)}
|
||||
botToken={botToken}
|
||||
serverId={serverId}
|
||||
label={subBlock.placeholder || 'Select Discord channel'}
|
||||
disabled={disabled || !botToken || !serverId}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(!botToken || !serverId) && (
|
||||
<TooltipContent side='top'>
|
||||
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -321,7 +181,7 @@ export function FileSelectorInput({
|
||||
|
||||
// Render the appropriate picker based on provider
|
||||
if (isConfluence) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -333,9 +193,7 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
domain={domain}
|
||||
@@ -343,25 +201,21 @@ export function FileSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Confluence page'}
|
||||
disabled={disabled || !domain}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Confluence domain first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isJira) {
|
||||
const credential = jiraCredential
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -373,161 +227,136 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedIssueId(val)
|
||||
setIssueInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
onChange={(issueKey) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
|
||||
}}
|
||||
domain={domain}
|
||||
provider='jira'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira issue'}
|
||||
disabled={
|
||||
disabled || !domain || !credential || !(getValue(blockId, 'projectId') as string)
|
||||
}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
|
||||
credentialId={credential}
|
||||
projectId={(getValue(blockId, 'projectId') as string) || ''}
|
||||
projectId={(projectIdValue as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !credential ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Jira credentials first</p>
|
||||
</TooltipContent>
|
||||
) : !(getValue(blockId, 'projectId') as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira project first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMicrosoftExcel) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-excel'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Excel file'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Excel credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Word selector
|
||||
// Microsoft Word selector
|
||||
if (isMicrosoftWord) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-word'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Microsoft Word document'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft Word credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft OneDrive selector
|
||||
// Microsoft OneDrive selector
|
||||
if (isMicrosoftOneDrive) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select OneDrive folder'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Microsoft credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft SharePoint selector
|
||||
// Microsoft SharePoint selector
|
||||
if (isMicrosoftSharePoint) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
|
||||
const credential = (connectedCredential as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={coerceToIdString(selectedFileId)}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select SharePoint site'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -541,27 +370,30 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Planner task selector
|
||||
// Microsoft Planner task selector
|
||||
if (isMicrosoftPlanner) {
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
const planId = (getValue(blockId, 'planId') as string) || ''
|
||||
|
||||
const credential = (connectedCredential as string) || ''
|
||||
const planId = (planIdValue as string) || ''
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<MicrosoftFileSelector
|
||||
value={selectedFileId}
|
||||
onChange={handleFileChange}
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(fileId) => setStoreValue(fileId)}
|
||||
provider='microsoft-planner'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId='microsoft-planner'
|
||||
label={subBlock.placeholder || 'Select task'}
|
||||
disabled={disabled || !credential || !planId}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
|
||||
planId={planId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
credentialId={credential}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -579,32 +411,22 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Handle Microsoft Teams selector
|
||||
// Microsoft Teams selector
|
||||
if (isMicrosoftTeams) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
const credential = (connectedCredential as string) || ''
|
||||
|
||||
// Determine the selector type based on the subBlock ID
|
||||
// Determine the selector type based on the subBlock ID / operation
|
||||
let selectionType: 'team' | 'channel' | 'chat' = 'team'
|
||||
|
||||
if (subBlock.id === 'teamId') {
|
||||
selectionType = 'team'
|
||||
} else if (subBlock.id === 'channelId') {
|
||||
selectionType = 'channel'
|
||||
} else if (subBlock.id === 'chatId') {
|
||||
selectionType = 'chat'
|
||||
} else {
|
||||
// Fallback: look at the operation to determine the selection type
|
||||
const operation = (getValue(blockId, 'operation') as string) || ''
|
||||
if (operation.includes('chat')) {
|
||||
selectionType = 'chat'
|
||||
} else if (operation.includes('channel')) {
|
||||
selectionType = 'channel'
|
||||
}
|
||||
if (subBlock.id === 'teamId') selectionType = 'team'
|
||||
else if (subBlock.id === 'channelId') selectionType = 'channel'
|
||||
else if (subBlock.id === 'chatId') selectionType = 'chat'
|
||||
else {
|
||||
const operation = (operationValue as string) || ''
|
||||
if (operation.includes('chat')) selectionType = 'chat'
|
||||
else if (operation.includes('channel')) selectionType = 'channel'
|
||||
}
|
||||
|
||||
// Get the teamId from workflow parameters for channel selector
|
||||
const selectedTeamId = (getValue(blockId, 'teamId') as string) || ''
|
||||
const selectedTeamId = (teamIdValue as string) || ''
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -617,22 +439,20 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(value, info) => {
|
||||
setSelectedMessageId(value)
|
||||
setMessageInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, value)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='microsoft-teams'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Teams message location'}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onMessageInfoChange={setMessageInfo}
|
||||
credential={credential}
|
||||
selectionType={selectionType}
|
||||
initialTeamId={selectedTeamId}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@@ -646,15 +466,11 @@ export function FileSelectorInput({
|
||||
)
|
||||
}
|
||||
|
||||
// Render Wealthbox selector
|
||||
// Wealthbox selector
|
||||
if (isWealthbox) {
|
||||
// Get credential using the same pattern as other tools
|
||||
const credential = (getValue(blockId, 'credential') as string) || ''
|
||||
|
||||
// Only handle contacts now - both notes and tasks use short-input
|
||||
const credential = (connectedCredential as string) || ''
|
||||
if (subBlock.id === 'contactId') {
|
||||
const itemType = 'contact'
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -666,18 +482,15 @@ export function FileSelectorInput({
|
||||
? (previewValue as string)
|
||||
: (storeValue as string)) || ''
|
||||
}
|
||||
onChange={(val, info) => {
|
||||
setSelectedWealthboxItemId(val)
|
||||
setWealthboxItemInfo(info || null)
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider='wealthbox'
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || `Select ${itemType}`}
|
||||
disabled={disabled || !credential}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setWealthboxItemInfo}
|
||||
credentialId={credential}
|
||||
itemType={itemType}
|
||||
/>
|
||||
@@ -692,37 +505,49 @@ export function FileSelectorInput({
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
// If it's noteId or taskId, we should not render the file selector since they now use short-input
|
||||
// noteId or taskId now use short-input
|
||||
return null
|
||||
}
|
||||
|
||||
// Default to Google Drive picker
|
||||
return (
|
||||
<GoogleDrivePicker
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val, info) => {
|
||||
setSelectedFileId(val)
|
||||
setFileInfo(info || null)
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
disabled={disabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
onFileInfoChange={setFileInfo}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={
|
||||
((isPreview && previewContextValues?.credential?.value) ||
|
||||
(getValue(blockId, 'credential') as string) ||
|
||||
'') as string
|
||||
}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
)
|
||||
{
|
||||
const credential = ((isPreview && previewContextValues?.credential?.value) ||
|
||||
(connectedCredential as string) ||
|
||||
'') as string
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<GoogleDrivePicker
|
||||
value={coerceToIdString(
|
||||
(isPreview && previewValue !== undefined ? previewValue : storeValue) as any
|
||||
)}
|
||||
onChange={(val) => {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, val)
|
||||
}}
|
||||
provider={provider}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select file'}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
mimeTypeFilter={subBlock.mimeType}
|
||||
showPreview={true}
|
||||
clientId={clientId}
|
||||
apiKey={apiKey}
|
||||
credentialId={credential}
|
||||
workflowId={workflowIdFromUrl}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!credential && (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select Google Drive credentials first</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
type FolderInfo,
|
||||
FolderSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface FolderSelectorInputProps {
|
||||
blockId: string
|
||||
@@ -25,12 +28,23 @@ export function FolderSelectorInput({
|
||||
previewValue,
|
||||
}: FolderSelectorInputProps) {
|
||||
const [storeValue, _setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>('')
|
||||
const [_folderInfo, setFolderInfo] = useState<FolderInfo | null>(null)
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
subBlock.provider || subBlock.serviceId || 'outlook',
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
|
||||
// Central dependsOn gating
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
// When gated/disabled, do not set defaults or write to store
|
||||
if (finalDisabled) return
|
||||
if (isPreview && previewValue !== undefined) {
|
||||
setSelectedFolderId(previewValue)
|
||||
return
|
||||
@@ -46,7 +60,15 @@ export function FolderSelectorInput({
|
||||
if (!isPreview) {
|
||||
collaborativeSetSubblockValue(blockId, subBlock.id, defaultValue)
|
||||
}
|
||||
}, [blockId, subBlock.id, storeValue, collaborativeSetSubblockValue, isPreview, previewValue])
|
||||
}, [
|
||||
blockId,
|
||||
subBlock.id,
|
||||
storeValue,
|
||||
collaborativeSetSubblockValue,
|
||||
isPreview,
|
||||
previewValue,
|
||||
finalDisabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
const handleFolderChange = (folderId: string, info?: FolderInfo) => {
|
||||
@@ -64,9 +86,12 @@ export function FolderSelectorInput({
|
||||
provider={subBlock.provider || 'google-email'}
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
label={subBlock.placeholder || 'Select folder'}
|
||||
disabled={disabled}
|
||||
disabled={finalDisabled}
|
||||
serviceId={subBlock.serviceId}
|
||||
onFolderInfoChange={setFolderInfo}
|
||||
credentialId={(connectedCredential as string) || ''}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ interface FolderSelectorProps {
|
||||
onFolderInfoChange?: (folderInfo: FolderInfo | null) => void
|
||||
isPreview?: boolean
|
||||
previewValue?: any | null
|
||||
credentialId?: string
|
||||
workflowId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function FolderSelector({
|
||||
@@ -51,11 +54,16 @@ export function FolderSelector({
|
||||
onFolderInfoChange,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
credentialId,
|
||||
workflowId,
|
||||
isForeignCredential = false,
|
||||
}: FolderSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [folders, setFolders] = useState<FolderInfo[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<Credential['id'] | ''>(
|
||||
credentialId || ''
|
||||
)
|
||||
const [selectedFolderId, setSelectedFolderId] = useState('')
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -72,6 +80,13 @@ export function FolderSelector({
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
|
||||
// Keep internal credential in sync with prop
|
||||
useEffect(() => {
|
||||
if (credentialId && credentialId !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
}, [credentialId, selectedCredentialId])
|
||||
|
||||
// Determine the appropriate service ID based on provider and scopes
|
||||
const getServiceId = (): string => {
|
||||
if (serviceId) return serviceId
|
||||
@@ -124,18 +139,43 @@ export function FolderSelector({
|
||||
// Fetch a single folder by ID when we have a selectedFolderId but no metadata
|
||||
const fetchFolderById = useCallback(
|
||||
async (folderId: string) => {
|
||||
if (!selectedCredentialId || !folderId || provider === 'outlook') return null
|
||||
if (!selectedCredentialId || !folderId) return null
|
||||
|
||||
setIsLoadingSelectedFolder(true)
|
||||
try {
|
||||
// Construct query parameters
|
||||
if (provider === 'outlook') {
|
||||
// Resolve Outlook folder name with owner-scoped token
|
||||
const tokenRes = await fetch('/api/auth/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ credentialId: selectedCredentialId, workflowId }),
|
||||
})
|
||||
if (!tokenRes.ok) return null
|
||||
const { accessToken } = await tokenRes.json()
|
||||
if (!accessToken) return null
|
||||
const resp = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/me/mailFolders/${encodeURIComponent(folderId)}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
if (!resp.ok) return null
|
||||
const folder = await resp.json()
|
||||
const folderInfo: FolderInfo = {
|
||||
id: folder.id,
|
||||
name: folder.displayName,
|
||||
type: 'folder',
|
||||
messagesTotal: folder.totalItemCount,
|
||||
messagesUnread: folder.unreadItemCount,
|
||||
}
|
||||
setSelectedFolder(folderInfo)
|
||||
onFolderInfoChange?.(folderInfo)
|
||||
return folderInfo
|
||||
}
|
||||
// Gmail label resolution
|
||||
const queryParams = new URLSearchParams({
|
||||
credentialId: selectedCredentialId,
|
||||
labelId: folderId,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/tools/gmail/label?${queryParams.toString()}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.label) {
|
||||
@@ -156,7 +196,7 @@ export function FolderSelector({
|
||||
setIsLoadingSelectedFolder(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, onFolderInfoChange, provider]
|
||||
[selectedCredentialId, onFolderInfoChange, provider, workflowId]
|
||||
)
|
||||
|
||||
// Fetch folders from Gmail or Outlook
|
||||
@@ -178,6 +218,12 @@ export function FolderSelector({
|
||||
// Determine the API endpoint based on provider
|
||||
let apiEndpoint: string
|
||||
if (provider === 'outlook') {
|
||||
// Skip list fetch for collaborators; only show selected
|
||||
if (isForeignCredential) {
|
||||
setFolders([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
apiEndpoint = `/api/tools/outlook/folders?${queryParams.toString()}`
|
||||
} else {
|
||||
// Default to Gmail
|
||||
@@ -206,9 +252,12 @@ export function FolderSelector({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error('Error fetching folders:', {
|
||||
error: await response.text(),
|
||||
})
|
||||
const text = await response.text()
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.info('Folder list fetch unauthorized (expected for collaborator)')
|
||||
} else {
|
||||
logger.warn('Error fetching folders', { status: response.status, text })
|
||||
}
|
||||
setFolders([])
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -218,36 +267,51 @@ export function FolderSelector({
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById, provider]
|
||||
[
|
||||
selectedCredentialId,
|
||||
selectedFolderId,
|
||||
onFolderInfoChange,
|
||||
fetchFolderById,
|
||||
provider,
|
||||
isForeignCredential,
|
||||
]
|
||||
)
|
||||
|
||||
// Fetch credentials on initial mount
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (!initialFetchRef.current) {
|
||||
fetchCredentials()
|
||||
initialFetchRef.current = true
|
||||
}
|
||||
}, [fetchCredentials])
|
||||
}, [fetchCredentials, disabled])
|
||||
|
||||
// Fetch folders when credential is selected
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (selectedCredentialId) {
|
||||
fetchFolders()
|
||||
}
|
||||
}, [selectedCredentialId, fetchFolders])
|
||||
}, [selectedCredentialId, fetchFolders, disabled])
|
||||
|
||||
// Keep internal selectedFolderId in sync with the value prop
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue !== selectedFolderId) {
|
||||
setSelectedFolderId(currentValue || '')
|
||||
}
|
||||
}, [value, isPreview, previewValue])
|
||||
}, [value, isPreview, previewValue, disabled])
|
||||
|
||||
// Fetch the selected folder metadata once credentials are ready (Gmail only)
|
||||
// Fetch the selected folder metadata once credentials are ready or value changes
|
||||
useEffect(() => {
|
||||
const currentValue = isPreview ? previewValue : value
|
||||
if (currentValue && selectedCredentialId && !selectedFolder && provider !== 'outlook') {
|
||||
if (disabled) return
|
||||
const currentValue = isPreview ? (previewValue as string) : (value as string)
|
||||
if (
|
||||
currentValue &&
|
||||
selectedCredentialId &&
|
||||
(!selectedFolder || selectedFolder.id !== currentValue)
|
||||
) {
|
||||
fetchFolderById(currentValue)
|
||||
}
|
||||
}, [
|
||||
@@ -255,9 +319,9 @@ export function FolderSelector({
|
||||
selectedCredentialId,
|
||||
selectedFolder,
|
||||
fetchFolderById,
|
||||
provider,
|
||||
isPreview,
|
||||
previewValue,
|
||||
disabled,
|
||||
])
|
||||
|
||||
// Handle folder selection
|
||||
@@ -317,7 +381,7 @@ export function FolderSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled}
|
||||
disabled={disabled || isForeignCredential}
|
||||
>
|
||||
{selectedFolder ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
@@ -333,114 +397,120 @@ export function FolderSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={`Search ${getFolderLabel()}...`}
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {getFolderLabel()}...</span>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName()} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Folders list */}
|
||||
{folders.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<CommandItem
|
||||
key={folder.id}
|
||||
value={`folder-${folder.id}-${folder.name}`}
|
||||
onSelect={() => handleSelectFolder(folder)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{folder.name}</span>
|
||||
{folder.id === selectedFolderId && <Check className='ml-auto h-4 w-4' />}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<span>Connect {getProviderName()} account</span>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={`Search ${getFolderLabel()}...`}
|
||||
onValueChange={handleSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading {getFolderLabel()}...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a {getProviderName()} account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No {getFolderLabel()} found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Folders list */}
|
||||
{folders.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
{getFolderLabel().charAt(0).toUpperCase() + getFolderLabel().slice(1)}
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<CommandItem
|
||||
key={folder.id}
|
||||
value={`folder-${folder.id}-${folder.name}`}
|
||||
onSelect={() => handleSelectFolder(folder)}
|
||||
>
|
||||
<div className='flex w-full items-center gap-2 overflow-hidden'>
|
||||
{getFolderIcon('sm')}
|
||||
<span className='truncate font-normal'>{folder.name}</span>
|
||||
{folder.id === selectedFolderId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<span>Connect {getProviderName()} account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export { Code } from './code'
|
||||
export { ComboBox } from './combobox'
|
||||
export { ConditionInput } from './condition-input'
|
||||
export { CredentialSelector } from './credential-selector/credential-selector'
|
||||
export { DateInput } from './date-input'
|
||||
export { DocumentSelector } from './document-selector/document-selector'
|
||||
export { Dropdown } from './dropdown'
|
||||
export { EvalInput } from './eval-input'
|
||||
|
||||
@@ -50,6 +50,7 @@ interface JiraProjectSelectorProps {
|
||||
onProjectInfoChange?: (projectInfo: JiraProjectInfo | null) => void
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraProjectSelector({
|
||||
@@ -64,6 +65,8 @@ export function JiraProjectSelector({
|
||||
showPreview = true,
|
||||
onProjectInfoChange,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraProjectSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -153,6 +156,7 @@ export function JiraProjectSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -238,6 +242,7 @@ export function JiraProjectSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -334,16 +339,12 @@ export function JiraProjectSelector({
|
||||
|
||||
// Fetch the selected project metadata once credentials are ready or changed
|
||||
useEffect(() => {
|
||||
if (
|
||||
value &&
|
||||
selectedCredentialId &&
|
||||
domain &&
|
||||
domain.includes('.') &&
|
||||
(!selectedProject || selectedProject.id !== value)
|
||||
) {
|
||||
fetchProjectInfo(value)
|
||||
if (value && selectedCredentialId && domain && domain.includes('.')) {
|
||||
if (!selectedProject || selectedProject.id !== value) {
|
||||
fetchProjectInfo(value)
|
||||
}
|
||||
}
|
||||
}, [value, selectedCredentialId, selectedProject, domain, fetchProjectInfo])
|
||||
}, [value, selectedCredentialId, domain, fetchProjectInfo, selectedProject])
|
||||
|
||||
// Keep internal selectedProjectId in sync with the value prop
|
||||
useEffect(() => {
|
||||
@@ -352,6 +353,14 @@ export function JiraProjectSelector({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Clear local preview when value is cleared remotely or via collaborator
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedProject(null)
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
}, [value, onProjectInfoChange])
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -386,6 +395,8 @@ export function JiraProjectSelector({
|
||||
onProjectInfoChange?.(null)
|
||||
}
|
||||
|
||||
const canShowPreview = !!(showPreview && selectedProject && value && selectedProject.id === value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
@@ -396,9 +407,9 @@ export function JiraProjectSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='w-full justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId}
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
{selectedProject ? (
|
||||
{canShowPreview ? (
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{selectedProject.name}</span>
|
||||
@@ -417,130 +428,135 @@ export function JiraProjectSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{project.avatarUrl ? (
|
||||
<img
|
||||
src={project.avatarUrl}
|
||||
alt={project.name}
|
||||
className='h-4 w-4 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === selectedProjectId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search projects...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading projects...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No projects found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Projects
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => handleSelectProject(project)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
{project.avatarUrl ? (
|
||||
<img
|
||||
src={project.avatarUrl}
|
||||
alt={project.name}
|
||||
className='h-4 w-4 rounded'
|
||||
/>
|
||||
) : (
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='truncate font-normal'>{project.name}</span>
|
||||
</div>
|
||||
{project.id === selectedProjectId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Project preview */}
|
||||
{showPreview && selectedProject && (
|
||||
{canShowPreview && (
|
||||
<div className='relative mt-2 rounded-md border border-muted bg-muted/10 p-2'>
|
||||
<div className='absolute top-2 right-2'>
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type LinearTeamInfo,
|
||||
LinearTeamSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-foreign-credential'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
@@ -43,20 +45,18 @@ export function ProjectSelectorInput({
|
||||
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
|
||||
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
|
||||
const [isForeignCredential, setIsForeignCredential] = useState<boolean>(false)
|
||||
|
||||
// Use the proper hook to get the current value and setter
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
// Local setters for related Jira fields to ensure immediate UI clearing
|
||||
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
|
||||
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
|
||||
blockId,
|
||||
'manualIssueKey'
|
||||
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
subBlock.provider || subBlock.serviceId || 'jira',
|
||||
(connectedCredential as string) || ''
|
||||
)
|
||||
// Reactive dependencies from store for Linear
|
||||
const [linearCredential] = useSubBlockValue(blockId, 'credential')
|
||||
const [linearTeamId] = useSubBlockValue(blockId, 'teamId')
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null
|
||||
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, isPreview })
|
||||
|
||||
// Get provider-specific values
|
||||
const provider = subBlock.provider || 'jira'
|
||||
@@ -70,32 +70,6 @@ export function ProjectSelectorInput({
|
||||
const botToken = ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
useEffect(() => {
|
||||
const cred = (jiraCredential as string) || ''
|
||||
if (!cred) {
|
||||
setIsForeignCredential(false)
|
||||
return
|
||||
}
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/auth/oauth/credentials?credentialId=${cred}`)
|
||||
if (aborted) return
|
||||
if (!resp.ok) {
|
||||
setIsForeignCredential(true)
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
setIsForeignCredential(!(data.credentials && data.credentials.length === 1))
|
||||
} catch {
|
||||
setIsForeignCredential(true)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockId, jiraCredential])
|
||||
|
||||
// Get the current value from the store or prop value if in preview mode
|
||||
useEffect(() => {
|
||||
@@ -117,27 +91,6 @@ export function ProjectSelectorInput({
|
||||
setProjectInfo(info || null)
|
||||
setStoreValue(projectId)
|
||||
|
||||
// Clear the issue-related fields when a new project is selected
|
||||
if (provider === 'jira') {
|
||||
collaborativeSetSubblockValue(blockId, 'summary', '')
|
||||
collaborativeSetSubblockValue(blockId, 'description', '')
|
||||
// Clear both the basic and advanced issue key fields to ensure UI resets
|
||||
collaborativeSetSubblockValue(blockId, 'issueKey', '')
|
||||
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
|
||||
// Also clear locally for immediate UI feedback on this client
|
||||
setIssueKeyValue('')
|
||||
setManualIssueKeyValue('')
|
||||
} else if (provider === 'discord') {
|
||||
collaborativeSetSubblockValue(blockId, 'channelId', '')
|
||||
} else if (provider === 'linear') {
|
||||
if (subBlock.id === 'teamId') {
|
||||
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', '')
|
||||
} else if (subBlock.id === 'projectId') {
|
||||
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
|
||||
}
|
||||
}
|
||||
|
||||
onProjectSelect?.(projectId)
|
||||
}
|
||||
|
||||
@@ -185,7 +138,7 @@ export function ProjectSelectorInput({
|
||||
}}
|
||||
credential={(linearCredential as string) || ''}
|
||||
label={subBlock.placeholder || 'Select Linear team'}
|
||||
disabled={disabled || !(linearCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
@@ -193,7 +146,7 @@ export function ProjectSelectorInput({
|
||||
(() => {
|
||||
const credential = (linearCredential as string) || ''
|
||||
const teamId = (linearTeamId as string) || ''
|
||||
const isDisabled = disabled || !credential || !teamId
|
||||
const isDisabled = finalDisabled
|
||||
return (
|
||||
<LinearProjectSelector
|
||||
value={selectedProjectId}
|
||||
@@ -235,23 +188,15 @@ export function ProjectSelectorInput({
|
||||
requiredScopes={subBlock.requiredScopes || []}
|
||||
serviceId={subBlock.serviceId}
|
||||
label={subBlock.placeholder || 'Select Jira project'}
|
||||
disabled={disabled || !domain || !(jiraCredential as string)}
|
||||
disabled={finalDisabled}
|
||||
showPreview={true}
|
||||
onProjectInfoChange={setProjectInfo}
|
||||
credentialId={(jiraCredential as string) || ''}
|
||||
isForeignCredential={isForeignCredential}
|
||||
workflowId={activeWorkflowId || ''}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{!domain ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please enter a Jira domain first</p>
|
||||
</TooltipContent>
|
||||
) : !(jiraCredential as string) ? (
|
||||
<TooltipContent side='top'>
|
||||
<p>Please select a Jira account first</p>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Plus, Trash } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,10 +8,16 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
|
||||
@@ -59,27 +65,31 @@ export function FieldFormat({
|
||||
emptyMessage = 'No fields defined',
|
||||
showType = true,
|
||||
showValue = false,
|
||||
valuePlaceholder = 'Enter value or <variable.name>',
|
||||
valuePlaceholder = 'Enter test value',
|
||||
isConnecting = false,
|
||||
config,
|
||||
}: FieldFormatProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<Field[]>(blockId, subBlockId)
|
||||
const [tagDropdownStates, setTagDropdownStates] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
visible: boolean
|
||||
cursorPosition: number
|
||||
}
|
||||
>
|
||||
>({})
|
||||
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement>>({})
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({})
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const fields: Field[] = value || []
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
;(fields || []).forEach((f) => {
|
||||
if (localValues[f.id] === undefined) {
|
||||
initial[f.id] = (f.value as string) || ''
|
||||
}
|
||||
})
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setLocalValues((prev) => ({ ...prev, ...initial }))
|
||||
}
|
||||
}, [fields])
|
||||
|
||||
// Field operations
|
||||
const addField = () => {
|
||||
if (isPreview || disabled) return
|
||||
@@ -88,12 +98,12 @@ export function FieldFormat({
|
||||
...DEFAULT_FIELD,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setStoreValue([...fields, newField])
|
||||
setStoreValue([...(fields || []), newField])
|
||||
}
|
||||
|
||||
const removeField = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.filter((field: Field) => field.id !== id))
|
||||
setStoreValue((fields || []).filter((field: Field) => field.id !== id))
|
||||
}
|
||||
|
||||
// Validate field name for API safety
|
||||
@@ -103,38 +113,22 @@ export function FieldFormat({
|
||||
return name.replace(/[\x00-\x1F"\\]/g, '').trim()
|
||||
}
|
||||
|
||||
// Tag dropdown handlers
|
||||
const handleValueInputChange = (fieldId: string, newValue: string) => {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
const cursorPosition = input.selectionStart || 0
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
|
||||
updateField(fieldId, 'value', newValue)
|
||||
setLocalValues((prev) => ({ ...prev, [fieldId]: newValue }))
|
||||
}
|
||||
|
||||
const handleTagSelect = (fieldId: string, newValue: string) => {
|
||||
updateField(fieldId, 'value', newValue)
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
}
|
||||
// Value normalization: keep it simple for string types
|
||||
|
||||
const handleTagDropdownClose = (fieldId: string) => {
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: { ...prev[fieldId], visible: false },
|
||||
}))
|
||||
const handleValueInputBlur = (field: Field) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const inputEl = valueInputRefs.current[field.id]
|
||||
if (!inputEl) return
|
||||
|
||||
const current = localValues[field.id] ?? inputEl.value ?? ''
|
||||
const trimmed = current.trim()
|
||||
if (!trimmed) return
|
||||
updateField(field.id, 'value', current)
|
||||
}
|
||||
|
||||
// Drag and drop handlers for connection blocks
|
||||
@@ -152,47 +146,8 @@ export function FieldFormat({
|
||||
const handleDrop = (e: React.DragEvent, fieldId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [fieldId]: false }))
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock' && data.connectionData) {
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
if (!input) return
|
||||
|
||||
// Focus the input first
|
||||
input.focus()
|
||||
|
||||
// Get current cursor position or use end of field
|
||||
const dropPosition = input.selectionStart ?? (input.value?.length || 0)
|
||||
|
||||
// Insert '<' at drop position to trigger the dropdown
|
||||
const currentValue = input.value || ''
|
||||
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
|
||||
|
||||
// Update the field value
|
||||
updateField(fieldId, 'value', newValue)
|
||||
|
||||
// Set cursor position and show dropdown
|
||||
setTimeout(() => {
|
||||
input.selectionStart = dropPosition + 1
|
||||
input.selectionEnd = dropPosition + 1
|
||||
|
||||
// Trigger dropdown by simulating the tag check
|
||||
const cursorPosition = dropPosition + 1
|
||||
const shouldShow = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setTagDropdownStates((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: {
|
||||
visible: shouldShow.show,
|
||||
cursorPosition,
|
||||
},
|
||||
}))
|
||||
}, 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling drop:', error)
|
||||
}
|
||||
const input = valueInputRefs.current[fieldId]
|
||||
input?.focus()
|
||||
}
|
||||
|
||||
// Update handlers
|
||||
@@ -204,12 +159,14 @@ export function FieldFormat({
|
||||
value = validateFieldName(value)
|
||||
}
|
||||
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
setStoreValue((fields || []).map((f: Field) => (f.id === id ? { ...f, [field]: value } : f)))
|
||||
}
|
||||
|
||||
const toggleCollapse = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue(fields.map((f: Field) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
|
||||
setStoreValue(
|
||||
(fields || []).map((f: Field) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f))
|
||||
)
|
||||
}
|
||||
|
||||
// Field header
|
||||
@@ -371,54 +328,66 @@ export function FieldFormat({
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>Value</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={field.value || ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleTagDropdownClose(field.id)
|
||||
{field.type === 'boolean' ? (
|
||||
<Select
|
||||
value={localValues[field.id] ?? (field.value as string) ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setLocalValues((prev) => ({ ...prev, [field.id]: v }))
|
||||
if (!isPreview && !disabled) updateField(field.id, 'value', v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-9 w-full justify-between font-normal'>
|
||||
<SelectValue placeholder='Select value' className='truncate' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='true'>true</SelectItem>
|
||||
<SelectItem value='false'>false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'object' || field.type === 'array' ? (
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={localValues[field.id] ?? (field.value as string) ?? ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onBlur={() => handleValueInputBlur(field)}
|
||||
placeholder={
|
||||
field.type === 'object' ? '{\n "key": "value"\n}' : '[\n 1, 2, 3\n]'
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, field.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, field.id)}
|
||||
onDrop={(e) => handleDrop(e, field.id)}
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'h-9 text-transparent caret-foreground placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
{field.value && (
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center px-3 py-2'>
|
||||
<div className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm'>
|
||||
{formatDisplayText(field.value, true)}
|
||||
</div>
|
||||
</div>
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'min-h-[120px] font-mono text-sm placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[field.id] = el
|
||||
}}
|
||||
name='value'
|
||||
value={localValues[field.id] ?? field.value ?? ''}
|
||||
onChange={(e) => handleValueInputChange(field.id, e.target.value)}
|
||||
onBlur={() => handleValueInputBlur(field)}
|
||||
onDragOver={(e) => handleDragOver(e, field.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, field.id)}
|
||||
onDrop={(e) => handleDrop(e, field.id)}
|
||||
placeholder={valuePlaceholder}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'h-9 placeholder:text-muted-foreground/50',
|
||||
dragHighlight[field.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting &&
|
||||
config?.connectionDroppable !== false &&
|
||||
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<TagDropdown
|
||||
visible={tagDropdownStates[field.id]?.visible || false}
|
||||
onSelect={(newValue) => handleTagSelect(field.id, newValue)}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={field.value || ''}
|
||||
cursorPosition={tagDropdownStates[field.id]?.cursorPosition || 0}
|
||||
onClose={() => handleTagDropdownClose(field.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,7 +429,7 @@ export function ResponseFormat(
|
||||
emptyMessage='No response fields defined'
|
||||
showType={false}
|
||||
showValue={true}
|
||||
valuePlaceholder='Enter value or <variable.name>'
|
||||
valuePlaceholder='Enter test value'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { logger } from '@trigger.dev/sdk/v3'
|
||||
import { PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Toggle } from '@/components/ui/toggle'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider, OAuthService } from '@/lib/oauth/oauth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
CheckboxList,
|
||||
Code,
|
||||
ComboBox,
|
||||
DateInput,
|
||||
FileSelectorInput,
|
||||
FileUpload,
|
||||
LongInput,
|
||||
@@ -49,6 +48,8 @@ import {
|
||||
type ToolParameterConfig,
|
||||
} from '@/tools/params'
|
||||
|
||||
const logger = createLogger('ToolInput')
|
||||
|
||||
interface ToolInputProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
@@ -170,33 +171,6 @@ function TableSyncWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
function DateInputSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
value,
|
||||
onChange,
|
||||
uiComponent,
|
||||
disabled,
|
||||
}: {
|
||||
blockId: string
|
||||
paramId: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
uiComponent: any
|
||||
disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
|
||||
<DateInput
|
||||
blockId={blockId}
|
||||
subBlockId={paramId}
|
||||
placeholder={uiComponent.placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</GenericSyncWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeInputSyncWrapper({
|
||||
blockId,
|
||||
paramId,
|
||||
@@ -1157,18 +1131,6 @@ export function ToolInput({
|
||||
/>
|
||||
)
|
||||
|
||||
case 'date-input':
|
||||
return (
|
||||
<DateInputSyncWrapper
|
||||
blockId={blockId}
|
||||
paramId={param.id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
uiComponent={uiComponent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'time-input':
|
||||
return (
|
||||
<TimeInputSyncWrapper
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -46,18 +46,51 @@ export function TriggerModal({
|
||||
const [config, setConfig] = useState<Record<string, any>>(initialConfig)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Track if config has changed from initial values
|
||||
// Snapshot initial values at open for stable dirty-checking across collaborators
|
||||
const initialConfigRef = useRef<Record<string, any>>(initialConfig)
|
||||
const initialCredentialRef = useRef<string | null>(null)
|
||||
|
||||
// Capture initial credential on first detect
|
||||
useEffect(() => {
|
||||
if (initialCredentialRef.current !== null) return
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const cred = (subBlockStore.getValue(blockId, 'triggerCredentials') as string | null) || null
|
||||
initialCredentialRef.current = cred
|
||||
}, [blockId])
|
||||
|
||||
// Track if config has changed from initial snapshot
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
return JSON.stringify(config) !== JSON.stringify(initialConfig)
|
||||
}, [config, initialConfig])
|
||||
return JSON.stringify(config) !== JSON.stringify(initialConfigRef.current)
|
||||
}, [config])
|
||||
|
||||
// Track if credential has changed from initial snapshot (computed later once selectedCredentialId is declared)
|
||||
let hasCredentialChanged = false
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [generatedPath, setGeneratedPath] = useState('')
|
||||
const [hasCredentials, setHasCredentials] = useState(false)
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
hasCredentialChanged = selectedCredentialId !== initialCredentialRef.current
|
||||
const [dynamicOptions, setDynamicOptions] = useState<
|
||||
Record<string, Array<{ id: string; name: string }>>
|
||||
>({})
|
||||
const lastCredentialIdRef = useRef<string | null>(null)
|
||||
|
||||
// Reset provider-dependent config fields when credentials change
|
||||
const resetFieldsForCredentialChange = () => {
|
||||
setConfig((prev) => {
|
||||
const next = { ...prev }
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
if (Array.isArray(next.labelIds)) next.labelIds = []
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
if (Array.isArray(next.folderIds)) next.folderIds = []
|
||||
} else if (triggerDef.provider === 'airtable') {
|
||||
if (typeof next.baseId === 'string') next.baseId = ''
|
||||
if (typeof next.tableId === 'string') next.tableId = ''
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize config with default values from trigger definition
|
||||
useEffect(() => {
|
||||
@@ -79,35 +112,71 @@ export function TriggerModal({
|
||||
}
|
||||
}, [triggerDef.configFields, initialConfig])
|
||||
|
||||
// Monitor credential selection
|
||||
// Monitor credential selection across collaborators; clear options on change/clear
|
||||
useEffect(() => {
|
||||
if (triggerDef.requiresCredentials && triggerDef.credentialProvider) {
|
||||
// Check if credentials are selected by monitoring the sub-block store
|
||||
const checkCredentials = () => {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials')
|
||||
const hasCredential = Boolean(credentialValue)
|
||||
const credentialValue = subBlockStore.getValue(blockId, 'triggerCredentials') as
|
||||
| string
|
||||
| null
|
||||
const currentCredentialId = credentialValue || null
|
||||
const hasCredential = Boolean(currentCredentialId)
|
||||
setHasCredentials(hasCredential)
|
||||
|
||||
// If credential changed and it's a Gmail trigger, load labels
|
||||
if (hasCredential && credentialValue !== selectedCredentialId) {
|
||||
setSelectedCredentialId(credentialValue)
|
||||
// If credential was cleared by another user, reset local state and dynamic options
|
||||
if (!hasCredential) {
|
||||
if (selectedCredentialId !== null) {
|
||||
setSelectedCredentialId(null)
|
||||
}
|
||||
// Clear provider-specific dynamic options
|
||||
setDynamicOptions({})
|
||||
// Per requirements: only clear dependent selections on actual credential CHANGE,
|
||||
// not when it becomes empty. So we do NOT reset fields here.
|
||||
lastCredentialIdRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// If credential changed, clear options immediately and load for new cred
|
||||
const previousCredentialId = lastCredentialIdRef.current
|
||||
|
||||
// First detection (prev null → current non-null): do not clear selections
|
||||
if (previousCredentialId === null) {
|
||||
setSelectedCredentialId(currentCredentialId)
|
||||
lastCredentialIdRef.current = currentCredentialId
|
||||
if (typeof currentCredentialId === 'string') {
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
void loadGmailLabels(currentCredentialId)
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
void loadOutlookFolders(currentCredentialId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Real change (prev non-null → different non-null): clear dependent selections
|
||||
if (
|
||||
typeof currentCredentialId === 'string' &&
|
||||
currentCredentialId !== previousCredentialId
|
||||
) {
|
||||
setSelectedCredentialId(currentCredentialId)
|
||||
lastCredentialIdRef.current = currentCredentialId
|
||||
// Clear stale options before loading new ones
|
||||
setDynamicOptions({})
|
||||
// Clear any selected values that depend on the credential
|
||||
resetFieldsForCredentialChange()
|
||||
if (triggerDef.provider === 'gmail') {
|
||||
loadGmailLabels(credentialValue)
|
||||
void loadGmailLabels(currentCredentialId)
|
||||
} else if (triggerDef.provider === 'outlook') {
|
||||
loadOutlookFolders(credentialValue)
|
||||
void loadOutlookFolders(currentCredentialId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkCredentials()
|
||||
|
||||
// Set up a subscription to monitor changes
|
||||
const unsubscribe = useSubBlockStore.subscribe(checkCredentials)
|
||||
|
||||
return unsubscribe
|
||||
}
|
||||
// If credentials aren't required, set to true
|
||||
setHasCredentials(true)
|
||||
}, [
|
||||
blockId,
|
||||
@@ -176,18 +245,28 @@ export function TriggerModal({
|
||||
|
||||
let finalPath = triggerPath
|
||||
|
||||
// If no path exists, generate one automatically
|
||||
if (!finalPath) {
|
||||
// If no path exists and we haven't generated one yet, generate one
|
||||
if (!finalPath && !generatedPath) {
|
||||
// Use UUID format consistent with other webhooks
|
||||
finalPath = crypto.randomUUID()
|
||||
setGeneratedPath(finalPath)
|
||||
const newPath = crypto.randomUUID()
|
||||
setGeneratedPath(newPath)
|
||||
finalPath = newPath
|
||||
} else if (generatedPath && !triggerPath) {
|
||||
// Use the already generated path
|
||||
finalPath = generatedPath
|
||||
}
|
||||
|
||||
if (finalPath) {
|
||||
const baseUrl = window.location.origin
|
||||
setWebhookUrl(`${baseUrl}/api/webhooks/trigger/${finalPath}`)
|
||||
}
|
||||
}, [triggerPath, triggerDef.provider, triggerDef.requiresCredentials, triggerDef.webhook])
|
||||
}, [
|
||||
triggerPath,
|
||||
generatedPath,
|
||||
triggerDef.provider,
|
||||
triggerDef.requiresCredentials,
|
||||
triggerDef.webhook,
|
||||
])
|
||||
|
||||
const handleConfigChange = (fieldId: string, value: any) => {
|
||||
setConfig((prev) => ({
|
||||
@@ -357,10 +436,16 @@ export function TriggerModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isConfigValid() || !hasConfigChanged}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!isConfigValid() ||
|
||||
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
|
||||
}
|
||||
className={cn(
|
||||
'h-10',
|
||||
isConfigValid() && hasConfigChanged ? 'bg-primary hover:bg-primary/90' : '',
|
||||
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
|
||||
? 'bg-primary hover:bg-primary/90'
|
||||
: '',
|
||||
isSaving &&
|
||||
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20'
|
||||
)}
|
||||
|
||||
@@ -172,6 +172,11 @@ export function TriggerConfig({
|
||||
// Map trigger ID to webhook provider name
|
||||
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
|
||||
|
||||
// Include selected credential from the modal (if any)
|
||||
const selectedCredentialId =
|
||||
(useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) ||
|
||||
null
|
||||
|
||||
// For credential-based triggers (like Gmail), create webhook entry for polling service but no webhook URL
|
||||
if (triggerDef.requiresCredentials && !triggerDef.webhook) {
|
||||
// Gmail polling service requires a webhook database entry to find the configuration
|
||||
@@ -185,7 +190,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path: '', // Empty path - API will generate dummy path for Gmail
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -225,7 +233,10 @@ export function TriggerConfig({
|
||||
blockId,
|
||||
path,
|
||||
provider: webhookProvider,
|
||||
providerConfig: config,
|
||||
providerConfig: {
|
||||
...config,
|
||||
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
|
||||
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
|
||||
|
||||
const logger = new Logger('GmailConfig')
|
||||
const logger = createLogger('GmailConfig')
|
||||
|
||||
const TOOLTIPS = {
|
||||
labels: 'Select which email labels to monitor.',
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { Logger } from '@/lib/logs/console/logger'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
|
||||
import { ConfigSection } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components'
|
||||
|
||||
const logger = new Logger('OutlookConfig')
|
||||
const logger = createLogger('OutlookConfig')
|
||||
|
||||
interface OutlookFolder {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
/**
|
||||
* Centralized dependsOn gating for sub-block components.
|
||||
* - Computes dependency values from the active workflow/block
|
||||
* - Returns a stable disabled flag to pass to inputs and to guard effects
|
||||
*/
|
||||
export function useDependsOnGate(
|
||||
blockId: string,
|
||||
subBlock: SubBlockConfig,
|
||||
opts?: { disabled?: boolean; isPreview?: boolean }
|
||||
) {
|
||||
const disabledProp = opts?.disabled ?? false
|
||||
const isPreview = opts?.isPreview ?? false
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
|
||||
// Use only explicit dependsOn from block config. No inference.
|
||||
const dependsOn: string[] = (subBlock.dependsOn as string[] | undefined) || []
|
||||
|
||||
const dependencyValues = useSubBlockStore((state) => {
|
||||
if (dependsOn.length === 0) return [] as any[]
|
||||
if (!activeWorkflowId) return dependsOn.map(() => null)
|
||||
const workflowValues = state.workflowValues[activeWorkflowId] || {}
|
||||
const blockValues = (workflowValues as any)[blockId] || {}
|
||||
return dependsOn.map((depKey) => (blockValues as any)[depKey] ?? null)
|
||||
}) as any[]
|
||||
|
||||
const depsSatisfied = useMemo(() => {
|
||||
if (dependsOn.length === 0) return true
|
||||
return dependencyValues.every((v) =>
|
||||
typeof v === 'string' ? v.trim().length > 0 : v !== null && v !== undefined && v !== ''
|
||||
)
|
||||
}, [dependencyValues, dependsOn])
|
||||
|
||||
// Block everything except the credential field itself until dependencies are set
|
||||
const blocked =
|
||||
!isPreview && dependsOn.length > 0 && !depsSatisfied && subBlock.type !== 'oauth-input'
|
||||
|
||||
const finalDisabled = disabledProp || isPreview || blocked
|
||||
|
||||
return {
|
||||
dependsOn,
|
||||
dependencyValues,
|
||||
depsSatisfied,
|
||||
blocked,
|
||||
finalDisabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export function useForeignCredential(
|
||||
provider: string | undefined,
|
||||
credentialId: string | undefined
|
||||
) {
|
||||
const [isForeign, setIsForeign] = useState<boolean>(false)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
|
||||
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function check() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (!normalizedCredentialId) {
|
||||
if (!cancelled) setIsForeign(false)
|
||||
return
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
|
||||
)
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setIsForeign(true)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
|
||||
if (!cancelled) setIsForeign(!isOwn)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setIsForeign(true)
|
||||
setError((e as Error).message)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void check()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [normalizedProvider, normalizedCredentialId])
|
||||
|
||||
return { isForeignCredential: isForeign, loading, error }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user