Compare commits

...

57 Commits

Author SHA1 Message Date
Waleed Latif
e107363ea7 v0.3.35: migrations, custom email address support 2025-08-21 12:36:51 -07:00
Waleed Latif
7e364a7977 fix(emails): remove unused useCustomFromFormat param (#1082)
* fix(mailer): remove unused useCustomFormat

* bun.lock changes
2025-08-21 12:09:03 -07:00
Waleed Latif
35a37d8b45 fix(acs): added FROM_EMAIL_ADDRESS envvar for ACS (#1081)
* fix: clear Docker build cache to use correct Next.js version

* fix(mailer): add FROM_EMAIL_ADDRESS envvar for ACS

* bun.lock

* added tests
2025-08-21 11:57:44 -07:00
Vikhyath Mondreti
2b52d88cee fix(migrations): add missing migration for document table (#1080)
* fix(migrations): add missing migration for document table

* add newline at end of file
2025-08-21 11:48:54 -07:00
Waleed Latif
abad3620a3 fix(build): clear docker build cache to use correct Next.js version 2025-08-21 01:43:45 -07:00
Waleed Latif
a37c6bc812 fix(build): clear docker build cache to use correct Next.js version (#1075)
* fix: clear Docker build cache to use correct Next.js version

- Changed GitHub Actions cache scope from build-v2 to build-v3
- This should force a fresh build without cached Next.js 15.5.0 layers
- Reverted to ^15.3.2 version format that worked on main branch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* run install

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-21 01:38:47 -07:00
Waleed Latif
cd1bd95952 fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:24:06 -07:00
Waleed Latif
4c9fdbe7fb fix(nextjs): downgrade nextjs due to known issue with bun commonjs module bundling (#1073) 2025-08-21 01:23:10 -07:00
Waleed Latif
2c47cf4161 v0.3.34: azure-openai options, billing fixes, mistral OCR via Azure, start block input format changes 2025-08-20 21:05:48 -07:00
Waleed Latif
db1cf8a6db fix(placeholder): fix starter block placeholder (#1071) 2025-08-20 21:01:37 -07:00
Vikhyath Mondreti
c6912095f7 fix placeholder text 2025-08-20 20:38:15 -07:00
Waleed Latif
154d9eef6a fix(gpt-5): fix chat-completions api (#1070) 2025-08-20 20:36:12 -07:00
Emir Karabeg
c2ded1f3e1 fix(theme-provider): preventing flash on page load (#1067)
* fix(theme-provider): preventing flash on page load

* consolidated themes to use NextJS theme logic

* improvement: optimized latency
2025-08-20 20:20:23 -07:00
Waleed Latif
ff43528d35 fix(gpt-5): fixed verbosity and reasoning params (#1069)
* fix(gpt-5): fixed verbosity and reasoning parsm

* fixed dropdown

* default values for verbosity and reasoning effort

* cleanup

* use default value in dropdown
2025-08-20 20:18:02 -07:00
Vikhyath Mondreti
692ba69864 fix type 2025-08-20 20:00:41 -07:00
Adam Gough
cb7ce8659b fix(msverify): changed consent for microsoft (#1057)
* changed consent

* changed excel error message and default sheets

* changed variable res for excel

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-20 19:54:51 -07:00
Vikhyath Mondreti
5caef3a37d fix(input-format): first time execution bug (#1068) 2025-08-20 19:52:04 -07:00
Waleed Latif
a6888da124 fix(semantics): fix incorrect imports (#1066)
* fix(semantics): fix incorrect import

* fixed all incorrecr imports
2025-08-20 19:02:52 -07:00
Vikhyath Mondreti
07b0597f4f improvement(trigger): upgrade import path for trigger (#1065) 2025-08-20 18:41:13 -07:00
Vikhyath Mondreti
71e2994f9d improvement(trigger): upgrade trigger (#1063) 2025-08-20 18:33:01 -07:00
Vikhyath Mondreti
9973b2c165 Merge branch 'staging' of github.com:simstudioai/sim into staging 2025-08-20 18:26:08 -07:00
Vikhyath Mondreti
d9e5777538 use personal access token 2025-08-20 18:24:17 -07:00
Waleed Latif
dd74267313 feat(nextjs): upgrade nextjs to 15.5 (#1062) 2025-08-20 18:22:35 -07:00
Vikhyath Mondreti
1db72dc823 pin version 2025-08-20 18:13:15 -07:00
Vikhyath Mondreti
da707fa491 improvement(gh-action): add gh action to deploy to correct environment for trigger.dev (#1060)
* improvement(gh-action): add gh action to deploy to correct environment for trigger.dev

* add dep installation

* change away from pull request target
2025-08-20 18:10:43 -07:00
Vikhyath Mondreti
9ffaf305bd feat(input-format): add value field to test input formats (#1059)
* feat(input-format): add value field to test input formats

* fix lint

* fix typing issue

* change to dropdown for boolean
2025-08-20 18:03:47 -07:00
Waleed Latif
26e6286fda fix(billing): fix team plan upgrade (#1053) 2025-08-20 17:05:35 -07:00
Waleed Latif
c795fc83aa feat(azure-openai): allow usage of azure-openai for knowledgebase uploads and wand generation (#1056)
* feat(azure-openai): allow usage of azure-openai for knowledgebase uploads

* feat(azure-openai): added azure-openai for kb and wand

* added embeddings utils, added the ability to use mistral through Azure

* fix(oauth): gdrive picker race condition, token route cleanup

* fix test

* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS (#1054)

* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS

* fix batch invitation email template

* cleanup

* improvement(emails): add help template instead of doing it inline

* remove fallback version

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2025-08-20 17:04:52 -07:00
Waleed Latif
cea42f5135 improvement(gpt-5): added reasoning level and verbosity to gpt-5 models (#1058) 2025-08-20 17:04:39 -07:00
Waleed Latif
6fd6f921dc feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS (#1054)
* feat(mailer): consolidated all emailing to mailer service, added support for Azure ACS

* fix batch invitation email template

* cleanup

* improvement(emails): add help template instead of doing it inline
2025-08-20 16:02:49 -07:00
Vikhyath Mondreti
7530fb9a4e Merge pull request #1055 from simstudioai/fix/picker-race-cond
fix(oauth): gdrive picker race condition, token route cleanup
2025-08-20 15:03:57 -07:00
Vikhyath Mondreti
9a5b035822 fix test 2025-08-20 13:55:54 -07:00
Vikhyath Mondreti
0c0b6bf967 fix(oauth): gdrive picker race condition, token route cleanup 2025-08-20 12:33:46 -07:00
Vikhyath Mondreti
5d74db53ff v0.3.33: update copilot docs 2025-08-20 09:56:09 -07:00
Siddharth Ganesan
b39bdfd55e feat(copilot-docs): update readme and docs with local hosting instructions (#1043)
* Docs

* Lint
2025-08-20 09:47:50 -07:00
Waleed Latif
6b185be9a4 v0.3.32: loop block max increase, url-encoded API calls, subflow logs, new supabase tools 2025-08-20 00:36:46 -07:00
Waleed Latif
214a0358b6 fix(billing): fix upgrade to team plan (#1045) 2025-08-20 00:28:07 -07:00
Waleed Latif
bbb5e53e43 improvement(supabase): add supabase upsert tool, insert/replace on PK conflict (#1038) 2025-08-19 21:21:09 -07:00
Waleed Latif
79e932fed9 feat(logs): added sub-workflow logs, updated trace spans UI, fix scroll behavior in workflow registry sidebar (#1037)
* added sub-workflow logs

* indent input/output in trace spans display

* better color scheme for workflow logs

* scroll behavior in sidebar updated

* cleanup

* fixed failing tests
2025-08-19 21:21:09 -07:00
Vikhyath Mondreti
9ad36c0e34 fix(oauth-block): race condition for rendering credential selectors and other subblocks + gdrive fixes (#1029)
* fix(oauth-block): race condition for rendering credential selectors and other subblocks

* fix import

* add dependsOn field to track cros-subblock deps

* remove redundant check

* remove redundant checks

* remove misleading comment

* fix

* fix jira

* fix

* fix

* confluence

* fix triggers

* fix

* fix

* make trigger creds collab supported

* fix for backwards compat

* fix trigger modal
2025-08-19 21:21:09 -07:00
Waleed Latif
2771c688ff improvement(supabase): added more verbose error logging for supabase operations (#1035)
* improvement(supabase): added more verbose error logging for supabase operations

* updated docs
2025-08-19 21:21:09 -07:00
Waleed Latif
d58ceb4bce improvement(api): add native support for form-urlencoded inputs into API block (#1033) 2025-08-19 21:21:09 -07:00
Waleed Latif
69773c3174 improvement(console): increase console max entries for larger workflows (#1032)
* improvement(console): increase console max entries for larger workflows

* increase safety limit for infinite loops
2025-08-19 21:21:09 -07:00
Waleed Latif
1619d63f2a v0.3.31: webhook fixes, advanced mode parameter filtering, credentials fixes, UI/UX improvements 2025-08-19 01:01:45 -07:00
Waleed Latif
9aa1fe8037 fix(logger): fixed logger to show prod server-side logs (#1027) 2025-08-19 00:44:24 -07:00
Emir Karabeg
1b7c111c46 Update README.md (#1026)
* Update README.md

* Update README.md
2025-08-18 23:10:18 -07:00
Siddharth Ganesan
bdfb56b262 fix(copilot): streaming (#1023)
* Fix 1

* Fix

* Bugfix

* Make thinking streaming smoother

* Better autoscroll, still not great

* Updates

* Updates

* Updates

* Restore checkpoitn logic

* Fix aborts

* Checkpoitn ui

* Lint

* Fix empty file
2025-08-18 22:48:56 -07:00
Emir Karabeg
4a7de31eee uploaded brandbook (#1024) 2025-08-18 22:04:55 -07:00
Waleed Latif
adfe56c720 improvement(logger): restore server-side logs in prod (#1022) 2025-08-18 21:01:38 -07:00
Emir Karabeg
72e3efa875 improvement(settings): ui/ux (#1021)
* completed general

* completed environment

* completed account; updated general and environment

* fixed skeleton

* finished credentials

* finished privacy; adjusted all colors and styling

* added reset password

* refactor: team and subscription

* finalized subscription settings

* fixed copilot key UI
2025-08-18 20:57:29 -07:00
Vikhyath Mondreti
b40fa3aa6e fix(picker-ui): picker UI confusing when credential not set + Microsoft OAuth Fixes (#1016)
* fix(picker-ui): picker UI confusing when credential not set

* remove comments

* remove chevron down

* fix collaboration oauth

* fix jira"

* fix

* fix ms excel selector

* fix selectors for MS blocks

* fix ms selectors

* fix

* fix ms onedrive and sharepoint

* fix to grey out dropdowns

* fix background fetches

* fix planner

* fix confluence

* fix

* fix confluence realtime sharing

* fix outlook folder selector

* check outlook folder

* make shared hook

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-18 20:21:23 -07:00
Waleed Latif
f924edde3a improvement(console): redact api keys from console store (#1020) 2025-08-18 16:36:33 -07:00
Waleed Latif
073030bfaa improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution (#1018)
* improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution

* fix serializer exclusion logic
2025-08-18 16:34:53 -07:00
Siddharth Ganesan
871f4e8e18 fix(copilot): env key validation (#1017)
* Fix v1

* Use env var

* Lint

* Fix env key validation

* Remove logger

* Fix agent url

* Fix tests
2025-08-18 16:00:56 -07:00
Siddharth Ganesan
091343a132 fix(copilot): fix origin (#1015)
* Fix v1

* Use env var

* Lint
2025-08-18 13:57:31 -07:00
Waleed Latif
63c66bfc31 fix(webhook): pin webhook URL when creating/saving generic webhook trigger (#1014)
* fix(webhook): pin webhook URL when creating a new generic webhook trigger

* change instructions copy

* remove unrelated scripts

* added optional API key for webhooks, validation tests

* remove extraneous logs
2025-08-18 13:39:49 -07:00
Waleed Latif
445ca78395 fix(export): swap upload & download icons (#1013) 2025-08-18 10:22:55 -07:00
269 changed files with 10108 additions and 5763 deletions

View File

@@ -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
View 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

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,4 @@
{
"title": "Copilot",
"pages": ["index"]
}

View File

@@ -12,6 +12,8 @@
"connections",
"---Execution---",
"execution",
"---Copilot---",
"copilot",
"---Advanced---",
"./variables/index",
"yaml",

View File

@@ -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 |

View File

@@ -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

View File

@@ -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) {

View File

@@ -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`)

View File

@@ -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',
}),
})
)

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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 }
)
}

View File

@@ -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'

View File

@@ -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])
})
})
})

View File

@@ -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]) => {

View File

@@ -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'
)
})
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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}`,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View 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 })
}
}

View File

@@ -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,16 +90,19 @@ 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,
}
)
// For streaming responses
if (stream) {
try {
const streamCompletion = await openai?.chat.completions.create({
model: 'gpt-4o',
const streamCompletion = await client.chat.completions.create({
model: useWandAzure ? wandModelName : 'gpt-4o',
messages: messages,
temperature: 0.3,
max_tokens: 10000,
@@ -141,8 +160,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 +170,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 +192,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 +204,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(

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
// 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 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({
},
})
// Mock the DB schema objects
const webhookMock = {
id: 'webhook-id-column',
path: 'path-column',
@@ -43,10 +41,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 +71,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 +109,9 @@ describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.clearAllTimers()
mockExecutionDependencies()
// Mock services/queue for rate limiting
vi.doMock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
@@ -284,10 +263,340 @@ 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' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
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' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
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' })
vi.doMock('@trigger.dev/sdk', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
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()
})
})
})

View File

@@ -1,4 +1,4 @@
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'
@@ -196,6 +196,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

View File

@@ -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'

View File

@@ -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',
},
}))

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import '@/app/globals.css'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
const logger = createLogger('RootLayout')
@@ -45,11 +46,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
@@ -70,8 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/>
{/* 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 +110,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
)}
</head>
<body suppressHydrationWarning>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
<ThemeProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
</ThemeProvider>
</body>
</html>
)

View 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>
)
}

View File

@@ -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 */}

View File

@@ -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>
</>
)
})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)}

View File

@@ -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 (

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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>
)

View File

@@ -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 doesnt 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' />

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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)
@@ -112,23 +118,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 +131,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 +171,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 +186,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 +195,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 +286,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 +348,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 +367,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 +440,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 +463,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>('')
@@ -403,18 +506,19 @@ export function MicrosoftFileSelector({
// 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)
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
fetchFileById(value)
}
}
}, [
value,
@@ -423,9 +527,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 +727,13 @@ export function MicrosoftFileSelector({
})
: availableFiles
const canShowPreview = !!(
showPreview &&
selectedFile &&
selectedFileId &&
selectedFile.id === selectedFileId
)
return (
<>
<div className='space-y-2'>
@@ -620,10 +752,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 +777,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

View File

@@ -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 */}

View File

@@ -1,32 +1,24 @@
'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 {
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 +37,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 +63,20 @@ 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')])
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || '',
(connectedCredential as string) || ''
)
// Get provider-specific values
const provider = subBlock.provider || 'google-drive'
@@ -126,135 +91,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 +133,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 +157,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 +176,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 +188,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 +196,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 +222,142 @@ 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)
// Clear related fields when a new issue is selected
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
if (!issueKey) {
collaborativeSetSubblockValue(blockId, 'manualIssueKey', '')
}
}}
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}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -541,27 +371,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}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -579,32 +412,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,10 +440,8 @@ 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 || []}
@@ -628,11 +449,11 @@ export function FileSelectorInput({
label={subBlock.placeholder || 'Select Teams message location'}
disabled={disabled || !credential}
showPreview={true}
onMessageInfoChange={setMessageInfo}
credential={credential}
selectionType={selectionType}
initialTeamId={selectedTeamId}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -646,15 +467,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,9 +483,7 @@ 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'
@@ -677,7 +492,6 @@ export function FileSelectorInput({
label={subBlock.placeholder || `Select ${itemType}`}
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setWealthboxItemInfo}
credentialId={credential}
itemType={itemType}
/>
@@ -692,37 +506,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={disabled || !credential}
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>
)
}
}

View File

@@ -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}
/>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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'
/>
)
}

View File

@@ -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 {
@@ -49,6 +49,8 @@ import {
type ToolParameterConfig,
} from '@/tools/params'
const logger = createLogger('ToolInput')
interface ToolInputProps {
blockId: string
subBlockId: string

View File

@@ -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'
)}

View File

@@ -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 } : {}),
},
}),
})

View File

@@ -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.',

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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 }
}

View File

@@ -37,6 +37,10 @@ export function useSubBlockValue<T = any>(
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Subscribe to active workflow id to avoid races where the workflow id is set after mount.
// This ensures our selector recomputes when the active workflow changes.
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const blockType = useWorkflowStore(
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
)
@@ -56,9 +60,16 @@ export function useSubBlockValue<T = any>(
const streamingValueRef = useRef<T | null>(null)
const wasStreamingRef = useRef<boolean>(false)
// Get value from subblock store - always call this hook unconditionally
// Get value from subblock store, keyed by active workflow id
// We intentionally depend on activeWorkflowId so this recomputes when it changes.
const storeValue = useSubBlockStore(
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
useCallback(
(state) => {
if (!activeWorkflowId) return null
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]
)
)
// Check if we're in diff mode and get diff value if available
@@ -123,12 +134,10 @@ export function useSubBlockValue<T = any>(
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[useWorkflowRegistry.getState().activeWorkflowId || '']: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || ''],
[activeWorkflowId || '']: {
...state.workflowValues[activeWorkflowId || ''],
[blockId]: {
...state.workflowValues[useWorkflowRegistry.getState().activeWorkflowId || '']?.[
blockId
],
...state.workflowValues[activeWorkflowId || '']?.[blockId],
[subBlockId]: newValue,
},
},
@@ -190,6 +199,7 @@ export function useSubBlockValue<T = any>(
isStreaming,
emitValue,
isShowingDiff,
activeWorkflowId,
]
)

View File

@@ -126,9 +126,12 @@ export function SubBlock({
blockId={blockId}
subBlockId={config.id}
options={config.options as { label: string; id: string }[]}
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
config={config}
/>
</div>
)
@@ -139,6 +142,7 @@ export function SubBlock({
blockId={blockId}
subBlockId={config.id}
options={config.options as { label: string; id: string }[]}
defaultValue={typeof config.value === 'function' ? config.value({}) : config.value}
placeholder={config.placeholder}
isPreview={isPreview}
previewValue={previewValue}
@@ -435,6 +439,7 @@ export function SubBlock({
disabled={isDisabled}
isConnecting={isConnecting}
config={config}
showValue={true}
/>
)
}

View File

@@ -82,11 +82,11 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
? currentBlock.is_diff
: undefined
// Get field-level diff information for this specific block
const fieldDiff =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.field_diffs?.[id]
: undefined
// Get field-level diff information for this specific block from the diff store
const diffAnalysisForFields = useWorkflowDiffStore((state) => state.diffAnalysis)
const fieldDiff = currentWorkflow.isDiffMode
? diffAnalysisForFields?.field_diffs?.[id]
: undefined
// Debug: Log diff status for this block
useEffect(() => {

View File

@@ -548,8 +548,8 @@ export function useWorkflowExecution() {
}
})
// Merge subblock states from the appropriate store
const mergedStates = mergeSubblockState(validBlocks)
// Merge subblock states from the appropriate store (scoped to active workflow)
const mergedStates = mergeSubblockState(validBlocks, activeWorkflowId ?? undefined)
// Debug: Check for blocks with undefined types after merging
Object.entries(mergedStates).forEach(([blockId, block]) => {
@@ -807,7 +807,7 @@ export function useWorkflowExecution() {
// Continue execution until there are no more pending blocks
let iterationCount = 0
const maxIterations = 100 // Safety to prevent infinite loops
const maxIterations = 500 // Safety to prevent infinite loops
while (currentPendingBlocks.length > 0 && iterationCount < maxIterations) {
logger.info(

View File

@@ -586,7 +586,7 @@ export function SearchModal({
className='bg-white/50 dark:bg-black/50'
style={{ backdropFilter: 'blur(1.5px)' }}
/>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[8px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[10px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<VisuallyHidden.Root>
<DialogTitle>Search</DialogTitle>
</VisuallyHidden.Root>

View File

@@ -1,12 +1,11 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { logger } from '@sentry/nextjs'
import { Folder, Plus, Upload } from 'lucide-react'
import { Download, Folder, Plus } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -15,7 +14,8 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
// Constants
const logger = createLogger('CreateMenu')
const TIMERS = {
LONG_PRESS_DELAY: 500,
CLOSE_DELAY: 150,
@@ -353,13 +353,13 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
</button>
{/* Import Workflow */}
{userPermissions.canEdit && !isDev && (
{userPermissions.canEdit && (
<button
className={cn(menuItemClassName, isImporting && 'cursor-not-allowed opacity-50')}
onClick={handleImportWorkflow}
disabled={isImporting}
>
<Upload className={iconClassName} />
<Download className={iconClassName} />
<span className={textClassName}>
{isImporting ? 'Importing...' : 'Import workflow'}
</span>

View File

@@ -1,21 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { ChevronDown, Lock, LogOut, User, UserPlus } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { signOut, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { RequestResetForm } from '@/app/(auth)/reset-password/reset-password-form'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')
@@ -24,329 +17,334 @@ interface AccountProps {
onOpenChange: (open: boolean) => void
}
// Mock user data - in a real app, this would come from an auth provider
interface UserData {
isLoggedIn: boolean
name?: string
email?: string
}
interface AccountData {
id: string
name: string
email: string
isActive?: boolean
}
export function Account({ onOpenChange }: AccountProps) {
const router = useRouter()
// In a real app, this would be fetched from an auth provider
const [userData, setUserData] = useState<UserData>({
isLoggedIn: false,
name: '',
email: '',
})
// Get session data using the client hook
const { data: session, isPending, error } = useSession()
const [isLoadingUserData, _setIsLoadingUserData] = useState(false)
// Reset password states
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [resetPasswordEmail, setResetPasswordEmail] = useState('')
const [isSubmittingResetPassword, setIsSubmittingResetPassword] = useState(false)
const [resetPasswordStatus, setResetPasswordStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
// Form states
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [userImage, setUserImage] = useState<string | null>(null)
// Mock accounts for the multi-account UI
const [accounts, setAccounts] = useState<AccountData[]>([])
const [open, setOpen] = useState(false)
// Loading states
const [isLoadingProfile, setIsLoadingProfile] = useState(false)
const [isUpdatingName, setIsUpdatingName] = useState(false)
// Update user data when session changes
// Edit states
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Reset password state
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [resetPasswordMessage, setResetPasswordMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
// Fetch user profile on component mount
useEffect(() => {
const updateUserData = async () => {
if (!isPending && session?.user) {
// User is logged in
setUserData({
isLoggedIn: true,
name: session.user.name || 'User',
email: session.user.email,
})
const fetchProfile = async () => {
if (!session?.user) return
setAccounts([
{
id: '1',
name: session.user.name || 'User',
email: session.user.email,
isActive: true,
},
])
setIsLoadingProfile(true)
// Pre-fill the reset password email with the current user's email
setResetPasswordEmail(session.user.email)
} else if (!isPending) {
// User is not logged in
setUserData({
isLoggedIn: false,
name: '',
email: '',
})
setAccounts([])
try {
const response = await fetch('/api/users/me/profile')
if (!response.ok) {
throw new Error('Failed to fetch profile')
}
const data = await response.json()
setName(data.user.name)
setEmail(data.user.email)
setUserImage(data.user.image)
} catch (error) {
logger.error('Error fetching profile:', error)
// Fallback to session data
if (session?.user) {
setName(session.user.name || '')
setEmail(session.user.email || '')
setUserImage(session.user.image || null)
}
} finally {
setIsLoadingProfile(false)
}
}
updateUserData()
}, [session, isPending])
fetchProfile()
}, [session])
const handleSignIn = () => {
// Use Next.js router to navigate to login page
router.push('/login')
setOpen(false)
// Focus input when entering edit mode
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditingName])
const handleUpdateName = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
return
}
if (trimmedName === (session?.user?.name || '')) {
setIsEditingName(false)
return
}
setIsUpdatingName(true)
try {
const response = await fetch('/api/users/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmedName }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update name')
}
setIsEditingName(false)
} catch (error) {
logger.error('Error updating name:', error)
setName(session?.user?.name || '')
} finally {
setIsUpdatingName(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUpdateName()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleCancelEdit = () => {
setIsEditingName(false)
setName(session?.user?.name || '')
}
const handleInputBlur = () => {
handleUpdateName()
}
const handleSignOut = async () => {
try {
// Start the sign-out process
const signOutPromise = signOut()
// Clear all user data to prevent persistence between accounts
await clearUserData()
// Set a short timeout to improve perceived performance
// while still ensuring auth state starts to clear
setTimeout(() => {
router.push('/login?fromLogout=true')
}, 100)
// Still wait for the promise to resolve/reject to catch errors
await signOutPromise
await Promise.all([signOut(), clearUserData()])
router.push('/login?fromLogout=true')
} catch (error) {
logger.error('Error signing out:', { error })
// Still navigate even if there's an error
router.push('/login?fromLogout=true')
} finally {
setOpen(false)
}
}
const handleResetPassword = async () => {
if (!resetPasswordEmail) {
setResetPasswordStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
setIsResettingPassword(true)
setResetPasswordMessage(null)
try {
setIsSubmittingResetPassword(true)
setResetPasswordStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: resetPasswordEmail,
email,
redirectTo: `${window.location.origin}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to request password reset')
const error = await response.json()
throw new Error(error.message || 'Failed to send reset password email')
}
setResetPasswordStatus({
setResetPasswordMessage({
type: 'success',
message: 'Password reset link sent to your email',
text: 'email sent',
})
// Close dialog after successful submission with a small delay for user to see success message
// Clear success message after 5 seconds
setTimeout(() => {
setResetPasswordDialogOpen(false)
setResetPasswordStatus({ type: null, message: '' })
}, 2000)
setResetPasswordMessage(null)
}, 5000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetPasswordStatus({
logger.error('Error resetting password:', error)
setResetPasswordMessage({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to request password reset',
text: 'error',
})
// Clear error message after 5 seconds
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
} finally {
setIsSubmittingResetPassword(false)
setIsResettingPassword(false)
}
}
const activeAccount = accounts.find((acc) => acc.isActive) || accounts[0]
// Loading animation component
const LoadingAccountBlock = () => (
<div className='group flex items-center justify-between gap-3 rounded-lg border bg-card p-4 shadow-sm'>
<div className='flex items-center gap-3'>
<div className='relative flex h-10 w-10 shrink-0 animate-pulse items-center justify-center overflow-hidden rounded-lg bg-muted'>
<div
className='absolute inset-0 animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent'
style={{
transform: 'translateX(-100%)',
animation: 'shimmer 1.5s infinite',
}}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='h-4 w-24 animate-pulse rounded bg-muted' />
<div className='h-3 w-32 animate-pulse rounded bg-muted' />
</div>
</div>
<div className='h-4 w-4 rounded bg-muted' />
</div>
)
return (
<div className='space-y-6 p-6'>
<div>
<h3 className='mb-4 font-medium text-lg'>Account</h3>
</div>
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-4'>
{isLoadingProfile || isPending ? (
<>
{/* User Info Section Skeleton */}
<div className='flex items-center gap-4'>
{/* User Avatar Skeleton */}
<Skeleton className='h-10 w-10 rounded-full' />
{/* Account Dropdown Component */}
<div className='max-w-xs'>
<div className='relative'>
{isPending || isLoadingUserData ? (
<LoadingAccountBlock />
) : (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<div
className={cn(
'group flex cursor-pointer items-center justify-between gap-3 rounded-lg border bg-card p-4 shadow-sm transition-all',
'hover:bg-accent/50 hover:shadow-md',
open && 'bg-accent/50 shadow-md'
)}
data-state={open ? 'open' : 'closed'}
>
<div className='flex items-center gap-3'>
<div className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-blue-500'>
{userData.isLoggedIn ? (
<div className='flex h-full w-full items-center justify-center bg-[var(--brand-primary-hover-hex)]'>
<AgentIcon className='-translate-y-[0.5px] text-white transition-transform duration-200 group-hover:scale-110' />
</div>
) : (
<div className='flex h-full w-full items-center justify-center bg-gray-500'>
<AgentIcon className='text-white transition-transform duration-200 group-hover:scale-110' />
</div>
)}
{userData.isLoggedIn && accounts.length > 1 && (
<div className='-bottom-1 -right-1 absolute flex h-5 w-5 items-center justify-center rounded-full bg-primary font-medium text-[10px] text-primary-foreground'>
{accounts.length}
</div>
)}
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='max-w-[200px] truncate font-medium leading-none'>
{userData.isLoggedIn ? activeAccount?.name : 'Sign in'}
</h3>
<p className='max-w-[200px] truncate text-muted-foreground text-sm'>
{userData.isLoggedIn ? activeAccount?.email : 'Click to sign in'}
</p>
</div>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
open && 'rotate-180'
)}
{/* User Details Skeleton */}
<div className='flex flex-col'>
<Skeleton className='mb-1 h-5 w-32' />
<Skeleton className='h-5 w-48' />
</div>
</div>
{/* Name Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<div className='flex items-center gap-4'>
<Skeleton className='h-5 w-40' />
<Skeleton className='h-5 w-[42px]' />
</div>
</div>
{/* Email Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<Skeleton className='h-5 w-48' />
</div>
{/* Password Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<div className='flex items-center gap-4'>
<Skeleton className='h-5 w-20' />
<Skeleton className='h-5 w-[42px]' />
</div>
</div>
{/* Sign Out Button Skeleton */}
<div>
<Skeleton className='h-8 w-[71px] rounded-[8px]' />
</div>
</>
) : (
<>
{/* User Info Section */}
<div className='flex items-center gap-4'>
{/* User Avatar */}
<div className='relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#802FFF]'>
{userImage ? (
<Image
src={userImage}
alt={name || 'User'}
width={40}
height={40}
className='h-full w-full object-cover'
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='max-h-[350px] w-[280px] overflow-y-auto'
sideOffset={8}
>
{userData.isLoggedIn ? (
<>
{accounts.length > 1 && (
<>
<div className='mb-2 px-2 py-1.5 font-medium text-muted-foreground text-sm'>
Switch Account
</div>
{accounts.map((account) => (
<DropdownMenuItem
key={account.id}
className={cn(
'flex cursor-pointer items-center gap-2 p-3',
account.isActive && 'bg-accent'
)}
>
<div className='relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--brand-primary-hover-hex)]'>
<User className='h-4 w-4 text-white' />
</div>
<div className='flex flex-col'>
<span className='font-medium leading-none'>{account.name}</span>
<span className='text-muted-foreground text-xs'>{account.email}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3'
onClick={() => {
setResetPasswordDialogOpen(true)
setOpen(false)
}}
>
<Lock className='h-4 w-4' />
<span>Reset Password</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3 text-destructive focus:text-destructive'
onClick={handleSignOut}
>
<LogOut className='h-4 w-4' />
<span>Sign Out</span>
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3'
onClick={handleSignIn}
>
<UserPlus className='h-4 w-4' />
<span>Sign in</span>
</DropdownMenuItem>
</>
<AgentIcon className='h-5 w-5 text-white' />
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
{/* Reset Password Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<RequestResetForm
email={resetPasswordEmail}
onEmailChange={setResetPasswordEmail}
onSubmit={handleResetPassword}
isSubmitting={isSubmittingResetPassword}
statusType={resetPasswordStatus.type}
statusMessage={resetPasswordStatus.message}
className='py-4'
/>
</DialogContent>
</Dialog>
{/* User Details */}
<div className='flex flex-col'>
<h3 className='font-medium text-sm'>{name}</h3>
<p className='font-normal text-muted-foreground text-sm'>{email}</p>
</div>
</div>
{/* Name Field */}
<div className='flex flex-col gap-2'>
<Label htmlFor='name' className='font-normal text-muted-foreground text-xs'>
Name
</Label>
{isEditingName ? (
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isUpdatingName}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div className='flex items-center gap-4'>
<span className='text-sm'>{name}</span>
<Button
variant='ghost'
className='h-auto p-0 font-normal text-muted-foreground text-xs transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => setIsEditingName(true)}
>
update
<span className='sr-only'>Update name</span>
</Button>
</div>
)}
</div>
{/* Email Field - Read Only */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Email</Label>
<p className='text-sm'>{email}</p>
</div>
{/* Password Field */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Password</Label>
<div className='flex items-center gap-4'>
<span className='text-sm'></span>
<Button
variant='ghost'
className={`h-auto p-0 font-normal text-xs transition-colors hover:bg-transparent ${
resetPasswordMessage
? resetPasswordMessage.type === 'success'
? 'text-green-500 hover:text-green-600'
: 'text-destructive hover:text-destructive/80'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={handleResetPassword}
disabled={isResettingPassword}
>
{isResettingPassword
? 'sending...'
: resetPasswordMessage
? resetPasswordMessage.text
: 'reset'}
<span className='sr-only'>Reset password</span>
</Button>
</div>
</div>
{/* Sign Out Button */}
<div>
<Button
onClick={handleSignOut}
variant='destructive'
className='h-8 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
>
Sign Out
</Button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Copy, KeySquare, Plus, Trash2 } from 'lucide-react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -13,15 +13,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
@@ -56,6 +47,13 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
// Filter API keys based on search term
const filteredApiKeys = apiKeys.filter((key) =>
key.name.toLowerCase().includes(searchTerm.toLowerCase())
)
// Fetch API keys
const fetchApiKeys = async () => {
@@ -96,10 +94,10 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
// Show the new key dialog with the API key (only shown once)
setNewKey(data.key)
setShowNewKeyDialog(true)
// Reset form
setNewKeyName('')
// Refresh the keys list
fetchApiKeys()
// Close the create dialog
setIsCreating(false)
}
} catch (error) {
logger.error('Error creating API key:', { error })
@@ -154,196 +152,236 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
}
return (
<div className='space-y-6 p-6'>
<div className='flex items-center justify-between'>
<h2 className='font-semibold text-xl'>API Keys</h2>
<Button
onClick={() => setIsCreating(true)}
disabled={isLoading}
size='sm'
className='gap-1.5'
>
<Plus className='h-4 w-4' />
Create Key
</Button>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
)}
</div>
<p className='text-muted-foreground text-sm leading-relaxed'>
API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They
have access to your account and workflows.
</p>
{isLoading ? (
<div className='mt-6 space-y-3'>
<KeySkeleton />
<KeySkeleton />
</div>
) : apiKeys.length === 0 ? (
<div className='mt-6 rounded-md border border-dashed p-8'>
<div className='flex flex-col items-center justify-center text-center'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
<KeySquare className='h-6 w-6 text-primary' />
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<div className='space-y-2'>
<ApiKeySkeleton />
<ApiKeySkeleton />
<ApiKeySkeleton />
</div>
<h3 className='mt-4 font-medium text-lg'>No API keys yet</h3>
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
You don&apos;t have any API keys yet. Create one to get started with the Sim SDK.
</p>
<Button
variant='default'
className='mt-4'
onClick={() => setIsCreating(true)}
size='sm'
>
<Plus className='mr-1.5 h-4 w-4' /> Create API Key
</Button>
</div>
</div>
) : (
<div className='mt-6 space-y-4'>
{apiKeys.map((key) => (
<Card key={key.id} className='p-4 transition-shadow hover:shadow-sm'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<h3 className='font-medium text-base'>{key.name}</h3>
<div className='flex items-center space-x-1'>
<p className='text-muted-foreground text-xs'>
Created: {formatDate(key.createdAt)} Last used: {formatDate(key.lastUsed)}
</p>
<div className='rounded bg-muted/50 px-1.5 py-0.5 font-mono text-xs'>
{key.key.slice(-6)}
) : apiKeys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Create Key" below to get started
</div>
) : (
<div className='space-y-2'>
{filteredApiKeys.map((key) => (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{key.name}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>
{key.key.slice(-6)}
</code>
</div>
<p className='text-muted-foreground text-xs'>
Last used: {formatDate(key.lastUsed)}
</p>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
<Button
variant='ghost'
size='icon'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 w-8 text-destructive hover:bg-destructive/10'
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete key</span>
</Button>
</div>
</Card>
))}
))}
{/* Show message when search has no results but there are keys */}
{searchTerm.trim() && filteredApiKeys.length === 0 && apiKeys.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No API keys found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
) : (
<>
<Button
onClick={() => setIsCreating(true)}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
</>
)}
</div>
</div>
{/* Create API Key Dialog */}
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>
<DialogDescription>
Name your API key to help you identify it later. This key will have access to your
account and workflows.
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label htmlFor='keyName'>API Key Name</Label>
<Input
id='keyName'
placeholder='e.g., Development, Production, etc.'
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className='focus-visible:ring-primary'
/>
</div>
<AlertDialog open={isCreating} onOpenChange={setIsCreating}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
This key will have access to your account and workflows. Make sure to copy it after
creation as you won't be able to see it again.
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter a name for your API key to help you identify it later.
</p>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder='e.g., Development, Production'
className='h-9 rounded-[8px]'
autoFocus
/>
</div>
<DialogFooter className='gap-2 sm:justify-end'>
<Button variant='outline' onClick={() => setIsCreating(false)}>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setNewKeyName('')}
>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
handleCreateKey()
setNewKeyName('')
}}
className='h-9 w-full rounded-[8px] bg-primary text-primary-foreground transition-all duration-200 hover:bg-primary/90'
disabled={!newKeyName.trim()}
>
Create Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New API Key Dialog */}
<Dialog
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Your API key has been created</DialogTitle>
<DialogDescription>
This is the only time you will see your API key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label>API Key</Label>
<div className='relative'>
<Input
readOnly
value={newKey.key}
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
/>
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
For security, we don&apos;t store the complete key. You won&apos;t be able to view
it again.
</p>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
<DialogFooter className='sm:justify-end'>
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='sm:max-w-md'>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Key</AlertDialogTitle>
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
<AlertDialogDescription>
{deleteKey && (
<>
Are you sure you want to delete the API key{' '}
<span className='font-semibold'>{deleteKey.name}</span>? This action cannot be
undone and any integrations using this key will no longer work.
</>
)}
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='gap-2 sm:justify-end'>
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
{deleteKey && (
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the API key name <span className='font-semibold'>{deleteKey.name}</span> to
confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Type key name to confirm'
className='h-9 rounded-[8px]'
autoFocus
/>
</div>
)}
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => {
setDeleteKey(null)
setDeleteConfirmationName('')
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteKey}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => {
handleDeleteKey()
setDeleteConfirmationName('')
}}
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'
disabled={!deleteKey || deleteConfirmationName !== deleteKey.name}
>
Delete
</AlertDialogAction>
@@ -354,16 +392,18 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
)
}
function KeySkeleton() {
// Loading skeleton for API keys
function ApiKeySkeleton() {
return (
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-32' />
<Skeleton className='h-4 w-48' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* API key name */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-20 rounded-[8px]' /> {/* Key preview */}
<Skeleton className='h-4 w-24' /> {/* Last used */}
</div>
<Skeleton className='h-8 w-8 rounded-md' />
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
</Card>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, Eye, EyeOff, KeySquare, Plus, Trash2 } from 'lucide-react'
import { Check, Copy, Eye, EyeOff, Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -10,13 +10,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Skeleton,
@@ -36,8 +29,9 @@ interface CopilotKey {
export function Copilot() {
const [keys, setKeys] = useState<CopilotKey[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [visible, setVisible] = useState<Record<string, boolean>>({})
const [searchTerm, setSearchTerm] = useState('')
// Create flow state
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
@@ -49,13 +43,16 @@ export function Copilot() {
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const hasKeys = keys.length > 0
// Filter keys based on search term
const filteredKeys = keys.filter((key) =>
key.apiKey.toLowerCase().includes(searchTerm.toLowerCase())
)
const maskedValue = useCallback((value: string, show: boolean) => {
if (show) return value
if (!value) return ''
const last6 = value.slice(-6)
return `••••••••••${last6}`
return `•••••${last6}`
}, [])
const fetchKeys = useCallback(async () => {
@@ -134,216 +131,210 @@ export function Copilot() {
}
}
// UI helpers
const isFetching = isLoading && keys.length === 0
return (
<div className='space-y-6 p-6'>
<h2 className='font-semibold text-xl'>Copilot API Keys</h2>
<p className='text-muted-foreground text-sm leading-relaxed'>
Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret
and rotate them regularly.
</p>
<p className='text-muted-foreground text-xs italic'>
For external deployments, set the <span className='font-mono'>COPILOT_API_KEY</span>{' '}
environment variable on that instance to one of the keys generated here.
</p>
{isFetching ? (
<div className='mt-6 space-y-3'>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-28' />
<Skeleton className='h-4 w-40' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
</div>
) : !hasKeys ? (
<div className='mt-6 rounded-md border border-dashed p-8'>
<div className='flex flex-col items-center justify-center text-center'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
<KeySquare className='h-6 w-6 text-primary' />
</div>
<h3 className='mt-4 font-medium text-lg'>No Copilot keys yet</h3>
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
Generate a Copilot API key to authenticate requests to the Copilot SDK and methods.
</p>
<Button
variant='default'
className='mt-4'
onClick={onGenerate}
size='sm'
disabled={isLoading}
>
<Plus className='mr-1.5 h-4 w-4' /> Generate Key
</Button>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
) : (
<div className='mt-6 space-y-4'>
{keys.map((k) => {
const isVisible = !!visible[k.id]
const value = maskedValue(k.apiKey, isVisible)
return (
<Card key={k.id} className='p-4 transition-shadow hover:shadow-sm'>
<div className='flex items-center justify-between gap-4'>
<div className='min-w-0 flex-1'>
<div className='rounded bg-muted/50 px-2 py-1 font-mono text-sm'>{value}</div>
</div>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
className='h-8 w-8'
>
{isVisible ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => onCopy(k.apiKey, k.id)}
className='h-8 w-8'
>
{copiedKeyIds[k.id] ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<div className='space-y-2'>
<CopilotKeySkeleton />
<CopilotKeySkeleton />
<CopilotKeySkeleton />
</div>
) : keys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Generate Key" below to get started
</div>
) : (
<div className='space-y-2'>
{filteredKeys.map((k) => {
const isVisible = !!visible[k.id]
const value = maskedValue(k.apiKey, isVisible)
return (
<div key={k.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
Copilot API Key
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>{value}</code>
</div>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{isVisible ? (
<EyeOff className='!h-3.5 !w-3.5' />
) : (
<Eye className='!h-3.5 !w-3.5' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 w-8 text-destructive hover:bg-destructive/10'
>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => onCopy(k.apiKey, k.id)}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{copiedKeyIds[k.id] ? (
<Check className='!h-3.5 !w-3.5' />
) : (
<Copy className='!h-3.5 !w-3.5' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
)
})}
{/* Show message when search has no results but there are keys */}
{searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No API keys found matching "{searchTerm}"
</div>
</Card>
)
})}
)}
</div>
)}
</div>
)}
</div>
{/* New Key Dialog */}
<Dialog
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
) : (
<>
<Button
onClick={onGenerate}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Generate Key
</Button>
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
</>
)}
</div>
</div>
{/* New API Key Dialog */}
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
if (!open) {
setNewKey(null)
setNewKeyCopySuccess(false)
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Your Copilot API key has been created</DialogTitle>
<DialogDescription>
This is the only time you will see the full key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
<AlertDialogContent className='rounded-[10px] sm:max-w-lg'>
<AlertDialogHeader>
<AlertDialogTitle>New Copilot API Key</AlertDialogTitle>
<AlertDialogDescription>
<span className='font-semibold'>Copy it now</span> and store it securely.
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label>API Key</Label>
<div className='relative'>
<Input
readOnly
value={newKey.apiKey}
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
/>
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
onClick={() => onCopy(newKey.apiKey)}
>
{newKeyCopySuccess ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
For security, we don't store the complete key. You won't be able to view it again.
</p>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-8'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.apiKey}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-4 w-4 rounded-[4px] p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => onCopy(newKey.apiKey)}
>
{newKeyCopySuccess ? (
<Check className='!h-3.5 !w-3.5' />
) : (
<Copy className='!h-3.5 !w-3.5' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
<DialogFooter className='sm:justify-end'>
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='sm:max-w-md'>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete Copilot API Key</AlertDialogTitle>
<AlertDialogTitle>Delete Copilot API key?</AlertDialogTitle>
<AlertDialogDescription>
{deleteKey && (
<>
Are you sure you want to delete this Copilot API key? This action cannot be
undone.
</>
)}
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='gap-2 sm:justify-end'>
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setDeleteKey(null)}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteKey) {
@@ -352,7 +343,7 @@ export function Copilot() {
setShowDeleteDialog(false)
setDeleteKey(null)
}}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
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>
@@ -362,3 +353,22 @@ export function Copilot() {
</div>
)
}
// Loading skeleton for Copilot API keys
function CopilotKeySkeleton() {
return (
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* API key label */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-40 rounded-[8px]' /> {/* Key preview */}
<div className='flex items-center gap-2'>
<Skeleton className='h-4 w-4' /> {/* Show/Hide button */}
<Skeleton className='h-4 w-4' /> {/* Copy button */}
</div>
</div>
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
</div>
)
}

View File

@@ -1,11 +1,11 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, Search } from 'lucide-react'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { client, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
@@ -294,192 +294,166 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
return (
<div className='space-y-6 p-6'>
<div>
<div className='mb-1 flex items-center justify-between'>
<h3 className='font-medium text-lg'>Credentials</h3>
{/* Search Input */}
<div className='relative w-48'>
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
<Input
placeholder='Search...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-9 pl-9 text-sm'
/>
</div>
<div className='relative flex h-full flex-col'>
{/* Search Input */}
<div className='px-6 pt-4 pb-2'>
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search services...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<p className='mb-6 text-muted-foreground text-sm'>
Connect your accounts to use tools that require authentication.
</p>
</div>
{/* Success message */}
{authSuccess && (
<div className='mb-4 rounded-md border border-green-200 bg-green-50 p-4'>
<div className='flex'>
<div className='flex-shrink-0'>
<Check className='h-5 w-5 text-green-400' />
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex flex-col gap-6 pt-2 pb-6'>
{/* Success message */}
{authSuccess && (
<div className='rounded-[8px] border border-green-200 bg-green-50 p-4'>
<div className='flex'>
<div className='flex-shrink-0'>
<Check className='h-5 w-5 text-green-400' />
</div>
<div className='ml-3'>
<p className='font-medium text-green-800 text-sm'>
Account connected successfully!
</p>
</div>
</div>
</div>
<div className='ml-3'>
<p className='font-medium text-green-800 text-sm'>Account connected successfully!</p>
)}
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className='flex items-start gap-3 rounded-[8px] border border-primary/20 bg-primary/5 p-5 text-sm shadow-sm'>
<div className='mt-0.5 min-w-5'>
<ExternalLink className='h-4 w-4 text-primary' />
</div>
<div className='flex flex-1 flex-col'>
<p className='text-muted-foreground'>
<span className='font-medium text-primary'>Action Required:</span> Please connect
your account to enable the requested features. The required service is highlighted
below.
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-primary text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary'
>
<span>Go to service</span>
<ChevronDown className='h-3.5 w-3.5' />
</Button>
</div>
</div>
</div>
</div>
)}
)}
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className='mb-6 flex items-start gap-3 rounded-md border border-primary/20 bg-primary/5 p-5 text-sm shadow-sm'>
<div className='mt-0.5 min-w-5'>
<ExternalLink className='h-4 w-4 text-primary' />
</div>
<div className='flex flex-1 flex-col'>
<p className='text-muted-foreground'>
<span className='font-medium text-primary'>Action Required:</span> Please connect your
account to enable the requested features. The required service is highlighted below.
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-primary text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary'
>
<span>Go to service</span>
<ChevronDown className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)}
{/* Loading state */}
{isLoading ? (
<div className='space-y-4'>
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
) : (
<div className='space-y-6'>
{/* Group services by provider */}
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='space-y-4'>
<h4 className='font-medium text-muted-foreground text-sm'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</h4>
<div className='space-y-4'>
{providerServices.map((service) => (
<Card
key={service.id}
className={cn(
'p-6 transition-all hover:shadow-md',
pendingService === service.id && 'border-primary shadow-md'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex w-full items-start gap-4'>
<div className='flex w-full items-start gap-4'>
<div className='flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-muted'>
{/* Loading state */}
{isLoading ? (
<div className='flex flex-col gap-6'>
{/* Google section - 5 blocks */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' /> {/* "GOOGLE" label */}
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
{/* Microsoft section - 6 blocks */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-20' /> {/* "MICROSOFT" label */}
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
</div>
) : (
<div className='flex flex-col gap-6'>
{/* Services list */}
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</Label>
{providerServices.map((service) => (
<div
key={service.id}
className={cn(
'flex items-center justify-between gap-4',
pendingService === service.id && '-m-2 rounded-[8px] bg-primary/5 p-2'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 shrink-0 items-center justify-center rounded-[8px] bg-muted'>
{typeof service.icon === 'function'
? service.icon({ className: 'h-5 w-5' })
: service.icon}
</div>
<div className='w-full space-y-1'>
<div>
<h4 className='font-medium leading-none'>{service.name}</h4>
<p className='mt-1 text-muted-foreground text-sm'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-normal text-sm'>{service.name}</span>
</div>
{service.accounts && service.accounts.length > 0 ? (
<p className='truncate text-muted-foreground text-xs'>
{service.accounts.map((a) => a.name).join(', ')}
</p>
) : (
<p className='truncate text-muted-foreground text-xs'>
{service.description}
</p>
</div>
{service.accounts && service.accounts.length > 0 && (
<div className='w-full space-y-2 pt-3'>
{service.accounts.map((account) => (
<div
key={account.id}
className='flex w-full items-center justify-between gap-2 rounded-md border bg-card/50 p-2'
>
<div className='flex items-center gap-2'>
<div className='flex h-6 w-6 items-center justify-center rounded-full bg-green-500/10'>
<Check className='h-3 w-3 text-green-600' />
</div>
<span className='font-medium text-sm'>{account.name}</span>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, account.id)}
disabled={isConnecting === `${service.id}-${account.id}`}
className='h-7 px-2'
>
{isConnecting === `${service.id}-${account.id}` ? (
<RefreshCw className='h-3 w-3 animate-spin' />
) : (
'Disconnect'
)}
</Button>
</div>
))}
{/* <Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
>
{isConnecting === service.id ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Connecting...
</>
) : (
<>
<Plus className="h-3 w-3 mr-2" />
Connect Another Account
</>
)}
</Button> */}
</div>
)}
</div>
</div>
{!service.accounts?.length && (
<div className='ml-auto flex justify-end'>
<Button
variant='default'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className='shrink-0'
>
{isConnecting === service.id ? (
<>
<RefreshCw className='mr-2 h-4 w-4 animate-spin' />
Connecting...
</>
) : (
'Connect'
)}
</Button>
</div>
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={isConnecting === `${service.id}-${service.accounts![0].id}`}
className={cn(
'h-8 text-muted-foreground hover:text-foreground',
isConnecting === `${service.id}-${service.accounts![0].id}` &&
'cursor-not-allowed'
)}
>
Disconnect
</Button>
) : (
<Button
variant='outline'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className={cn('h-8', isConnecting === service.id && 'cursor-not-allowed')}
>
Connect
</Button>
)}
</div>
</Card>
))}
</div>
</div>
))}
))}
</div>
))}
{/* Show message when search has no results */}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No services found matching "{searchTerm}"
{/* Show message when search has no results */}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No services found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -487,17 +461,15 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
// Loading skeleton for connections
function ConnectionSkeleton() {
return (
<Card className='p-6'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-4'>
<Skeleton className='h-12 w-12 rounded-lg' />
<div className='space-y-2'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-10 w-10 rounded-[8px]' />
<div className='space-y-1'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-4 w-32' />
</div>
<Skeleton className='h-9 w-24 shrink-0' />
</div>
</Card>
<Skeleton className='h-8 w-20' />
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
@@ -28,15 +28,20 @@ interface UIEnvironmentVariable extends StoreEnvironmentVariable {
interface EnvironmentVariablesProps {
onOpenChange: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) {
const { variables } = useEnvironmentStore()
export function EnvironmentVariables({
onOpenChange,
registerCloseHandler,
}: EnvironmentVariablesProps) {
const { variables, isLoading } = useEnvironmentStore()
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
@@ -75,6 +80,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
return false
}, [envVars])
// Intercept close attempts to check for unsaved changes
const handleModalClose = (open: boolean) => {
if (!open && hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(open)
}
}
// Initialization effect
useEffect(() => {
const existingVars = Object.values(variables)
@@ -84,15 +99,23 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
pendingClose.current = false
}, [variables])
// Scroll effect
// Register close handler with parent
useEffect(() => {
if (scrollContainerRef.current) {
if (registerCloseHandler) {
registerCloseHandler(handleModalClose)
}
}, [registerCloseHandler, hasChanges])
// Scroll effect - only when explicitly adding a new variable
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
setShouldScrollToBottom(false)
}
}, [envVars.length])
}, [shouldScrollToBottom])
// Variable management functions
const addEnvVar = () => {
@@ -100,6 +123,8 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
setEnvVars([...envVars, newVar])
// Clear search to ensure the new variable is visible
setSearchTerm('')
// Trigger scroll to bottom
setShouldScrollToBottom(true)
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
@@ -168,18 +193,12 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
if (parsedVars.length > 0) {
const existingVars = envVars.filter((v) => v.key || v.value)
setEnvVars([...existingVars, ...parsedVars])
// Scroll to bottom when pasting multiple variables
setShouldScrollToBottom(true)
}
}
// Dialog management
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
@@ -227,6 +246,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
autoCapitalize='off'
spellCheck='false'
name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`}
className='h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<Input
data-input-type='value'
@@ -238,7 +258,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder='Enter value'
className='allow-scroll'
className='allow-scroll h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -249,7 +269,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
variant='ghost'
size='icon'
onClick={() => removeEnvVar(originalIndex)}
className='h-10 w-10'
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
>
×
</Button>
@@ -257,64 +277,82 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
)
return (
<div className='flex h-full flex-col'>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-6'>
<div className='mb-6 flex items-center justify-between'>
<h2 className='font-medium text-lg'>Environment Variables</h2>
{/* Search Input */}
<div className='relative w-48'>
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search...'
placeholder='Search variables...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-9 pl-9 text-sm'
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
<div className={`${GRID_COLS} mb-2 px-0.5`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
</div>
)}
</div>
{/* Scrollable Content */}
<div
ref={scrollContainerRef}
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
>
<div className='space-y-2 py-2'>
{filteredEnvVars.map(({ envVar, originalIndex }) =>
renderEnvVarRow(envVar, originalIndex)
)}
{/* Show message when search has no results but there are variables */}
{searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No environment variables found matching "{searchTerm}"
</div>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<>
{/* Show 3 skeleton rows */}
{[1, 2, 3].map((index) => (
<div key={index} className={`${GRID_COLS} items-center`}>
<Skeleton className='h-9 rounded-[8px]' />
<Skeleton className='h-9 rounded-[8px]' />
<Skeleton className='h-9 w-9 rounded-[8px]' />
</div>
))}
</>
) : (
<>
{filteredEnvVars.map(({ envVar, originalIndex }) =>
renderEnvVarRow(envVar, originalIndex)
)}
{/* Show message when search has no results but there are variables */}
{searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
No environment variables found matching "{searchTerm}"
</div>
)}
</>
)}
</div>
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex flex-col gap-4'>
<Button variant='outline' size='sm' onClick={addEnvVar}>
Add Variable
</Button>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<Skeleton className='h-9 w-[108px] rounded-[8px]' />
</>
) : (
<>
<Button
onClick={addEnvVar}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Add Variable
</Button>
<div className='flex justify-end space-x-2'>
<Button variant='outline' onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanges}>
Save Changes
</Button>
</div>
<Button onClick={handleSave} disabled={!hasChanges} className='h-9 rounded-[8px]'>
Save Changes
</Button>
</>
)}
</div>
</div>
@@ -326,9 +364,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
You have unsaved changes. Do you want to save them before closing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>Discard Changes</AlertDialogCancel>
<AlertDialogAction onClick={handleSave}>Save Changes</AlertDialogAction>
<AlertDialogFooter className='flex'>
<AlertDialogCancel onClick={handleCancel} className='h-9 w-full rounded-[8px]'>
Discard Changes
</AlertDialogCancel>
<AlertDialogAction
onClick={handleSave}
className='h-9 w-full rounded-[8px] transition-all duration-200'
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { useEffect } from 'react'
import { Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
@@ -23,10 +22,7 @@ const TOOLTIPS = {
}
export function General() {
const [retryCount, setRetryCount] = useState(0)
const isLoading = useGeneralStore((state) => state.isLoading)
const error = useGeneralStore((state) => state.error)
const theme = useGeneralStore((state) => state.theme)
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
@@ -49,14 +45,15 @@ export function General() {
const toggleConsoleExpandedByDefault = useGeneralStore(
(state) => state.toggleConsoleExpandedByDefault
)
const loadSettings = useGeneralStore((state) => state.loadSettings)
// Sync theme from store to next-themes when theme changes
useEffect(() => {
const loadData = async () => {
await loadSettings(retryCount > 0)
if (!isLoading && theme) {
// Ensure next-themes is in sync with our store
const { syncThemeToNextThemes } = require('@/lib/theme-sync')
syncThemeToNextThemes(theme)
}
loadData()
}, [loadSettings, retryCount])
}, [theme, isLoading])
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
await setTheme(value)
@@ -80,129 +77,193 @@ export function General() {
}
}
const handleRetry = () => {
setRetryCount((prev) => prev + 1)
}
return (
<div className='space-y-6 p-6'>
{error && (
<Alert variant='destructive' className='mb-4'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription className='flex items-center justify-between'>
<span>Failed to load settings: {error}</span>
<Button variant='outline' size='sm' onClick={handleRetry} disabled={isLoading}>
Retry
</Button>
</AlertDescription>
</Alert>
)}
<div className='px-6 pt-4 pb-2'>
<div className='flex flex-col gap-4'>
{isLoading ? (
<>
{/* Theme setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-normal'>
Theme
</Label>
</div>
<Skeleton className='h-9 w-[180px]' />
</div>
<div>
<h2 className='mb-[22px] font-medium text-lg'>General Settings</h2>
<div className='space-y-4'>
{isLoading ? (
<>
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
</>
) : (
<>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-medium'>
Theme
</Label>
</div>
<Select
value={theme}
onValueChange={handleThemeChange}
disabled={isLoading || isThemeLoading}
>
<SelectTrigger id='theme-select' className='w-[180px]'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent>
<SelectItem value='system'>System</SelectItem>
<SelectItem value='light'>Light</SelectItem>
<SelectItem value='dark'>Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-medium'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={isLoading || isAutoConnectLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='auto-connect'
checked={isAutoConnectEnabled}
onCheckedChange={handleAutoConnectChange}
disabled={isLoading || isAutoConnectLoading}
/>
{/* Auto-connect setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-normal'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={true}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Skeleton className='h-6 w-11 rounded-full' />
</div>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-medium'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={isLoading || isConsoleExpandedByDefaultLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='console-expanded-by-default'
checked={isConsoleExpandedByDefault}
onCheckedChange={handleConsoleExpandedByDefaultChange}
disabled={isLoading || isConsoleExpandedByDefaultLoading}
/>
{/* Console expanded setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-normal'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={true}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
<Skeleton className='h-6 w-11 rounded-full' />
</div>
</>
) : (
<>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-normal'>
Theme
</Label>
</div>
<Select
value={theme}
onValueChange={handleThemeChange}
disabled={isLoading || isThemeLoading}
>
<SelectTrigger id='theme-select' className='h-9 w-[180px]'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent className='min-w-32 rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
<SelectItem
value='system'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
System
</SelectItem>
<SelectItem
value='light'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
Light
</SelectItem>
<SelectItem
value='dark'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
Dark
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-normal'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={isLoading || isAutoConnectLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='auto-connect'
checked={isAutoConnectEnabled}
onCheckedChange={handleAutoConnectChange}
disabled={isLoading || isAutoConnectLoading}
/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-normal'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={isLoading || isConsoleExpandedByDefaultLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='console-expanded-by-default'
checked={isConsoleExpandedByDefault}
onCheckedChange={handleConsoleExpandedByDefaultChange}
disabled={isLoading || isConsoleExpandedByDefaultLoading}
/>
</div>
</>
)}
</div>
</div>
)
}
const SettingRowSkeleton = () => (
<div className='flex items-center justify-between py-1'>
const SettingRowSkeleton = ({
hasInfoButton = false,
isSwitch = false,
}: {
hasInfoButton?: boolean
isSwitch?: boolean
}) => (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-32' />
{hasInfoButton && <Skeleton className='h-5 w-5 rounded' />}
</div>
<Skeleton className='h-6 w-12' />
{isSwitch ? (
<Skeleton className='h-6 w-11 rounded-full' />
) : (
<Skeleton className='h-9 w-[180px]' />
)}
</div>
)

View File

@@ -45,61 +45,69 @@ export function Privacy() {
}
return (
<div className='space-y-6 p-6'>
<div>
<h2 className='mb-[22px] font-medium text-lg'>Privacy Settings</h2>
<div className='space-y-4'>
{isLoading ? (
<SettingRowSkeleton />
) : (
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='telemetry' className='font-medium'>
Allow anonymous telemetry
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about telemetry data collection'
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='telemetry'
checked={telemetryEnabled}
onCheckedChange={handleTelemetryToggle}
disabled={isLoading}
/>
<div className='px-6 pt-4 pb-2'>
<div className='flex flex-col gap-2'>
{isLoading ? (
<SettingRowSkeleton hasInfoButton isSwitch />
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='telemetry' className='font-normal'>
Allow anonymous telemetry
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about telemetry data collection'
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
<Switch
id='telemetry'
checked={telemetryEnabled}
onCheckedChange={handleTelemetryToggle}
disabled={isLoading}
/>
</div>
)}
<div className='border-t pt-4'>
<p className='text-muted-foreground text-xs'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is collected
in accordance with our privacy policy, and you can opt-out at any time. This setting
applies to your account on all devices.
</p>
<div className='border-t pt-4'>
<p className='text-muted-foreground text-xs'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is
collected in accordance with our privacy policy, and you can opt-out at any time. This
setting applies to your account on all devices.
</p>
</div>
</div>
</div>
)
}
const SettingRowSkeleton = () => (
<div className='flex items-center justify-between py-1'>
const SettingRowSkeleton = ({
hasInfoButton = false,
isSwitch = false,
}: {
hasInfoButton?: boolean
isSwitch?: boolean
}) => (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-48' />
<Skeleton className='h-5 w-32' />
{hasInfoButton && <Skeleton className='h-7 w-7 rounded' />}
</div>
<Skeleton className='h-6 w-12' />
{isSwitch ? (
<Skeleton className='h-6 w-11 rounded-full' />
) : (
<Skeleton className='h-9 w-[180px]' />
)}
</div>
)

View File

@@ -1,13 +1,13 @@
import {
Bot,
CreditCard,
KeyRound,
KeySquare,
Lock,
FileCode,
Key,
Settings,
Shield,
UserCircle,
User,
Users,
Waypoints,
} from 'lucide-react'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
@@ -56,29 +56,29 @@ const allNavigationItems: NavigationItem[] = [
label: 'General',
icon: Settings,
},
{
id: 'credentials',
label: 'Integrations',
icon: Waypoints,
},
{
id: 'environment',
label: 'Environment',
icon: KeyRound,
icon: FileCode,
},
{
id: 'account',
label: 'Account',
icon: UserCircle,
},
{
id: 'credentials',
label: 'Credentials',
icon: Lock,
icon: User,
},
{
id: 'apikeys',
label: 'API Keys',
icon: KeySquare,
icon: Key,
},
{
id: 'copilot',
label: 'Copilot',
label: 'Copilot Keys',
icon: Bot,
},
{
@@ -126,22 +126,36 @@ export function SettingsNavigation({
})
return (
<div className='py-4'>
<div className='px-2 py-4'>
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors',
'hover:bg-muted/50',
activeSection === item.id
? 'bg-muted/50 font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<item.icon className='h-4 w-4' />
<span>{item.label}</span>
</button>
<div key={item.id} className='mb-1'>
<button
onClick={() => onSectionChange(item.id)}
className={cn(
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'
)}
>
<item.icon
className={cn(
'mr-2 h-[14px] w-[14px] flex-shrink-0 transition-colors',
activeSection === item.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
/>
<span
className={cn(
'min-w-0 flex-1 select-none truncate pr-1 text-left transition-colors',
activeSection === item.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
>
{item.label}
</span>
</button>
</div>
))}
</div>
)

View File

@@ -1,144 +0,0 @@
import { useEffect, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useActiveOrganization, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('BillingSummary')
interface BillingSummaryData {
type: 'individual' | 'organization'
plan: string
currentUsage: number
planMinimum: number
projectedCharge: number
usageLimit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
daysRemaining: number
organizationData?: {
seatCount: number
averageUsagePerSeat: number
totalMinimum: number
}
}
interface BillingSummaryProps {
showDetails?: boolean
className?: string
onDataLoaded?: (data: BillingSummaryData) => void
onError?: (error: string) => void
}
export function BillingSummary({
showDetails = true,
className = '',
onDataLoaded,
onError,
}: BillingSummaryProps) {
const { data: session } = useSession()
const { data: activeOrg } = useActiveOrganization()
const [billingSummary, setBillingSummary] = useState<BillingSummaryData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadBillingSummary() {
if (!session?.user?.id) return
try {
setIsLoading(true)
const url = new URL('/api/billing', window.location.origin)
if (activeOrg?.id) {
url.searchParams.set('context', 'organization')
url.searchParams.set('id', activeOrg.id)
} else {
url.searchParams.set('context', 'user')
}
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch billing summary: ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to load billing data')
}
setBillingSummary(result.data)
setError(null)
onDataLoaded?.(result.data)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load billing data'
setError(errorMessage)
onError?.(errorMessage)
logger.error('Failed to load billing summary', { error: err })
} finally {
setIsLoading(false)
}
}
loadBillingSummary()
}, [session?.user?.id, activeOrg?.id, onDataLoaded, onError])
const getStatusBadge = () => {
if (!billingSummary) return null
if (billingSummary.isExceeded) {
return (
<Badge variant='destructive' className='gap-1'>
<AlertCircle className='h-3 w-3' />
Limit Exceeded
</Badge>
)
}
if (billingSummary.isWarning) {
return (
<Badge variant='outline' className='gap-1 border-yellow-500 text-yellow-700'>
<AlertCircle className='h-3 w-3' />
Approaching Limit
</Badge>
)
}
return null
}
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
if (isLoading || error || !billingSummary) {
return null
}
return (
<div className={className}>
{/* Status Badge */}
{getStatusBadge()}
{/* Billing Details */}
{showDetails && (
<div className='mt-3 space-y-1 text-muted-foreground text-xs'>
<div className='flex justify-between'>
<span>Plan minimum:</span>
<span>{formatCurrency(billingSummary.planMinimum)}</span>
</div>
<div className='flex justify-between'>
<span>Projected charge:</span>
<span className='font-medium'>{formatCurrency(billingSummary.projectedCharge)}</span>
</div>
{billingSummary.organizationData && (
<div className='flex justify-between'>
<span>Team seats:</span>
<span>{billingSummary.organizationData.seatCount}</span>
</div>
)}
</div>
)}
</div>
)
}
export type { BillingSummaryData }

View File

@@ -1,16 +1,20 @@
import { useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
'use client'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -37,6 +41,16 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const { activeOrganization } = useOrganizationStore()
const { getSubscriptionStatus } = useSubscriptionStore()
// Clear error after 3 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
setError(null)
}, 3000)
return () => clearTimeout(timer)
}
}, [error])
// Don't show for free plans
if (!subscription.isPaid) {
return null
@@ -115,44 +129,41 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
return (
<>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Cancel Subscription</span>
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</div>
<Button
variant='destructive'
size='sm'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
>
Cancel
</Button>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Manage Subscription</span>
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</div>
{error && (
<Alert variant='destructive'>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
variant='outline'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
)}
>
{error ? 'Error' : 'Manage'}
</Button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel {subscription.plan} subscription?</DialogTitle>
<DialogDescription>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel {subscription.plan} subscription?</AlertDialogTitle>
<AlertDialogDescription>
You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '}
{formatDate(periodEndDate)}, then downgrade to free plan.
</DialogDescription>
</DialogHeader>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-3'>
<div className='rounded-lg bg-muted p-3 text-sm'>
<ul className='space-y-1 text-muted-foreground'>
<div className='py-2'>
<div className='rounded-[8px] bg-muted/50 p-3 text-sm'>
<ul className='space-y-1 text-muted-foreground text-xs'>
<li> Keep all features until {formatDate(periodEndDate)}</li>
<li> No more charges</li>
<li> Data preserved</li>
@@ -161,16 +172,24 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setIsDialogOpen(false)} disabled={isLoading}>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setIsDialogOpen(false)}
disabled={isLoading}
>
Keep Subscription
</Button>
<Button variant='destructive' onClick={handleCancel} disabled={isLoading}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancel}
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'
disabled={isLoading}
>
{isLoading ? 'Redirecting...' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1 @@
export { CancelSubscription } from './cancel-subscription'

View File

@@ -1,6 +1,4 @@
export { BillingSummary } from './billing-summary'
export { CancelSubscription } from './cancel-subscription'
export { EditMemberLimitDialog } from './edit-member-limit-dialog'
export { TeamSeatsDialog } from './team-seats-dialog'
export { TeamUsageOverview } from './team-usage-overview'
export { UsageLimitEditor } from './usage-limit-editor'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'

View File

@@ -0,0 +1 @@
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'

View File

@@ -0,0 +1,123 @@
'use client'
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui'
import { cn } from '@/lib/utils'
export interface PlanFeature {
icon: LucideIcon
text: string
}
export interface PlanCardProps {
name: string
price: string | ReactNode
priceSubtext?: string
features: PlanFeature[]
buttonText: string
onButtonClick: () => void
isError?: boolean
variant?: 'default' | 'compact'
layout?: 'vertical' | 'horizontal'
className?: string
}
/**
* PlanCard component for displaying subscription plan information
* Supports both vertical and horizontal layouts with flexible pricing display
*/
export function PlanCard({
name,
price,
priceSubtext,
features,
buttonText,
onButtonClick,
isError = false,
variant = 'default',
layout = 'vertical',
className,
}: PlanCardProps) {
const isHorizontal = layout === 'horizontal'
const renderPrice = () => {
if (typeof price === 'string') {
return (
<>
<span className='font-semibold text-xl'>{price}</span>
{priceSubtext && (
<span className='ml-1 text-muted-foreground text-xs'>{priceSubtext}</span>
)}
</>
)
}
return price
}
const renderFeatures = () => {
if (isHorizontal) {
return (
<div className='mt-3 flex flex-wrap items-center gap-4'>
{features.map((feature, index) => (
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
<feature.icon className='h-3 w-3 flex-shrink-0 text-muted-foreground' />
<span className='text-muted-foreground'>{feature.text}</span>
{index < features.length - 1 && (
<div className='ml-4 h-4 w-px bg-border' aria-hidden='true' />
)}
</div>
))}
</div>
)
}
return (
<ul className='mb-4 flex-1 space-y-2'>
{features.map((feature, index) => (
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
<feature.icon
className='mt-0.5 h-3 w-3 flex-shrink-0 text-muted-foreground'
aria-hidden='true'
/>
<span className='text-muted-foreground'>{feature.text}</span>
</li>
))}
</ul>
)
}
return (
<article
className={cn(
'relative flex rounded-[8px] border p-4 transition-colors hover:border-muted-foreground/20',
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
className
)}
>
<header className={isHorizontal ? undefined : 'mb-4'}>
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
<div className='flex items-baseline'>{renderPrice()}</div>
{isHorizontal && renderFeatures()}
</header>
{!isHorizontal && renderFeatures()}
<div className={isHorizontal ? 'ml-auto' : undefined}>
<Button
onClick={onButtonClick}
className={cn(
'h-9 rounded-[8px] text-xs transition-colors',
isHorizontal ? 'px-4' : 'w-full',
isError &&
'border-red-500 bg-transparent text-red-500 hover:bg-red-500 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500'
)}
variant={isError ? 'outline' : 'default'}
aria-label={`${buttonText} ${name} plan`}
>
{isError ? 'Error' : buttonText}
</Button>
</div>
</article>
)
}

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('UsageLimitEditor')
interface UsageLimitEditorProps {
currentLimit: number
canEdit: boolean
minimumLimit: number
onLimitUpdated?: (newLimit: number) => void
}
export function UsageLimitEditor({
currentLimit,
canEdit,
minimumLimit,
onLimitUpdated,
}: UsageLimitEditorProps) {
const [inputValue, setInputValue] = useState(currentLimit.toString())
const [isSaving, setIsSaving] = useState(false)
const { updateUsageLimit } = useSubscriptionStore()
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
return
}
if (newLimit === currentLimit) {
return
}
setIsSaving(true)
try {
const result = await updateUsageLimit(newLimit)
if (!result.success) {
throw new Error(result.error || 'Failed to update limit')
}
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
} catch (error) {
logger.error('Failed to update usage limit', { error })
setInputValue(currentLimit.toString())
} finally {
setIsSaving(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}
return (
<div className='flex items-center'>
<span className='mr-1 text-sm'>$</span>
{canEdit ? (
<Input
type='number'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
className='h-8 w-20 font-medium text-sm'
min={minimumLimit}
step='1'
disabled={isSaving}
autoComplete='off'
data-form-type='other'
name='usage-limit'
/>
) : (
<span className='font-medium text-sm'>{currentLimit}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'

View File

@@ -0,0 +1,209 @@
'use client'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('UsageLimit')
interface UsageLimitProps {
currentLimit: number
currentUsage: number
canEdit: boolean
minimumLimit: number
onLimitUpdated?: (newLimit: number) => void
}
export interface UsageLimitRef {
startEdit: () => void
}
export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
({ currentLimit, currentUsage, canEdit, minimumLimit, onLimitUpdated }, ref) => {
const [inputValue, setInputValue] = useState(currentLimit.toString())
const [isSaving, setIsSaving] = useState(false)
const [hasError, setHasError] = useState(false)
const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null)
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const { updateUsageLimit } = useSubscriptionStore()
const handleStartEdit = () => {
if (!canEdit) return
setIsEditing(true)
setInputValue(currentLimit.toString())
}
// Expose startEdit method through ref
useImperativeHandle(
ref,
() => ({
startEdit: handleStartEdit,
}),
[canEdit, currentLimit]
)
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
// Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
// Clear error after 2 seconds
useEffect(() => {
if (hasError) {
const timer = setTimeout(() => {
setHasError(false)
setErrorType(null)
}, 2000)
return () => clearTimeout(timer)
}
}, [hasError])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
setIsEditing(false)
return
}
// Check if new limit is below current usage
if (newLimit < currentUsage) {
setHasError(true)
setErrorType('belowUsage')
// Don't reset input value - let user see what they typed
return
}
if (newLimit === currentLimit) {
setIsEditing(false)
return
}
setIsSaving(true)
try {
const result = await updateUsageLimit(newLimit)
if (!result.success) {
throw new Error(result.error || 'Failed to update limit')
}
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
} catch (error) {
logger.error('Failed to update usage limit', { error })
// Check if the error is about being below current usage
if (error instanceof Error && error.message.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
}
setHasError(true)
} finally {
setIsSaving(false)
}
}
const handleCancelEdit = () => {
setIsEditing(false)
setInputValue(currentLimit.toString())
setHasError(false)
setErrorType(null)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
return (
<div className='flex items-center'>
{isEditing ? (
<>
<span className='text-muted-foreground text-xs tabular-nums'>$</span>
<input
ref={inputRef}
type='number'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={(e) => {
// Don't submit if clicking on the button (it will handle submission)
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget?.closest('button')) {
return
}
handleSubmit()
}}
className={cn(
'w-[3ch] border-0 bg-transparent p-0 text-xs tabular-nums',
'outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
hasError && 'text-red-500'
)}
min={minimumLimit}
max='999'
step='1'
disabled={isSaving}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</>
) : (
<span className='text-muted-foreground text-xs tabular-nums'>${currentLimit}</span>
)}
{canEdit && (
<Button
variant='ghost'
size='icon'
className={cn(
'ml-1 h-4 w-4 p-0 transition-colors hover:bg-transparent',
hasError
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={isEditing ? handleSubmit : handleStartEdit}
disabled={isSaving}
>
{isEditing ? (
hasError ? (
<X className='!h-3 !w-3' />
) : (
<Check className='!h-3 !w-3' />
)
) : (
<Pencil className='!h-3 !w-3' />
)}
<span className='sr-only'>{isEditing ? 'Save limit' : 'Edit limit'}</span>
</Button>
)}
</div>
)
}
)
UsageLimit.displayName = 'UsageLimit'

View File

@@ -0,0 +1,35 @@
import {
Building2,
Clock,
Database,
HeadphonesIcon,
Infinity as InfinityIcon,
MessageSquare,
Server,
Users,
Workflow,
Zap,
} from 'lucide-react'
import type { PlanFeature } from './components/plan-card'
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '25 runs per minute (sync)' },
{ icon: Clock, text: '200 runs per minute (async)' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Workflow, text: 'Unlimited workflows' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
]
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '75 runs per minute (sync)' },
{ icon: Clock, text: '500 runs per minute (async)' },
{ icon: InfinityIcon, text: 'Everything in Pro' },
{ icon: MessageSquare, text: 'Dedicated Slack channel' },
]
export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: 'Custom rate limits' },
{ icon: Server, text: 'Enterprise hosting' },
{ icon: HeadphonesIcon, text: 'Dedicated support' },
]

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