v0.3.31: webhook fixes, advanced mode parameter filtering, credentials fixes, UI/UX improvements

This commit is contained in:
Waleed Latif
2025-08-19 01:01:45 -07:00
committed by GitHub
136 changed files with 5188 additions and 3283 deletions

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,13 @@ 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
## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
@@ -180,4 +183,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,94 @@
---
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) and set `COPILOT_API_KEY` in your environment.
</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

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

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

@@ -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/v3', () => ({
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/v3', () => ({
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/v3', () => ({
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/v3', () => ({
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/v3', () => ({
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

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

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

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

@@ -33,6 +33,10 @@ export function ChannelSelectorInput({
// 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)
@@ -40,17 +44,14 @@ export function ChannelSelectorInput({
const provider = subBlock.provider || 'slack'
const isSlack = provider === 'slack'
// 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) => {

View File

@@ -124,12 +124,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 +178,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,6 +204,13 @@ 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) || ''
@@ -263,15 +281,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

@@ -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(() => {
@@ -363,7 +410,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,118 +428,122 @@ 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 */}

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,8 +228,9 @@ 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
}
@@ -246,7 +238,7 @@ export function GoogleDrivePicker({
setIsLoading(true)
try {
const url = new URL('/api/auth/oauth/token', window.location.origin)
url.searchParams.set('credentialId', selectedCredentialId)
url.searchParams.set('credentialId', effectiveCredentialId)
// include workflowId if available via global registry (server adds session owner otherwise)
const response = await fetch(url.toString())
@@ -265,10 +257,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 +327,6 @@ export function GoogleDrivePicker({
const handleAddCredential = () => {
// Show the OAuth modal
setShowOAuthModal(true)
setOpen(false)
}
// Clear selection
@@ -426,136 +417,47 @@ export function GoogleDrivePicker({
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'>
{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>
</>
)}
<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 && (

View File

@@ -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)
@@ -620,7 +745,9 @@ 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 ? (
@@ -643,154 +770,158 @@ 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 */}

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

@@ -22,6 +22,7 @@ import {
WealthboxFileSelector,
type WealthboxItemInfo,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components'
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'
@@ -70,6 +71,7 @@ export function FileSelectorInput({
// Use the proper hook to get the current value and setter
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
@@ -84,34 +86,10 @@ export function FileSelectorInput({
const [wealthboxItemInfo, setWealthboxItemInfo] = useState<WealthboxItemInfo | null>(null)
// 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'
@@ -254,7 +232,7 @@ export function FileSelectorInput({
// Render Google Calendar selector
if (isGoogleCalendar) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -321,7 +299,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>
@@ -347,6 +325,8 @@ export function FileSelectorInput({
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
credentialId={credential}
workflowId={workflowIdFromUrl}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -361,7 +341,7 @@ export function FileSelectorInput({
}
if (isJira) {
const credential = jiraCredential
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
<Tooltip>
@@ -391,6 +371,7 @@ export function FileSelectorInput({
credentialId={credential}
projectId={(getValue(blockId, 'projectId') as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>
@@ -413,8 +394,8 @@ export function FileSelectorInput({
}
if (isMicrosoftExcel) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -431,6 +412,9 @@ export function FileSelectorInput({
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -446,8 +430,8 @@ export function FileSelectorInput({
// Handle Microsoft Word selector
if (isMicrosoftWord) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
// Get credential reactively
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -479,7 +463,7 @@ export function FileSelectorInput({
// Handle Microsoft OneDrive selector
if (isMicrosoftOneDrive) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -496,6 +480,9 @@ export function FileSelectorInput({
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -511,7 +498,7 @@ export function FileSelectorInput({
// Handle Microsoft SharePoint selector
if (isMicrosoftSharePoint) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
return (
<TooltipProvider>
@@ -528,6 +515,9 @@ export function FileSelectorInput({
disabled={disabled || !credential}
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -543,7 +533,7 @@ export function FileSelectorInput({
// Handle Microsoft Planner task selector
if (isMicrosoftPlanner) {
const credential = (getValue(blockId, 'credential') as string) || ''
const credential = (connectedCredential as string) || ''
const planId = (getValue(blockId, 'planId') as string) || ''
return (
@@ -562,6 +552,9 @@ export function FileSelectorInput({
showPreview={true}
onFileInfoChange={setFileInfo as (info: MicrosoftFileInfo | null) => void}
planId={planId}
workflowId={activeWorkflowId || ''}
credentialId={credential}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -582,7 +575,7 @@ export function FileSelectorInput({
// Handle 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
let selectionType: 'team' | 'channel' | 'chat' = 'team'
@@ -633,6 +626,7 @@ export function FileSelectorInput({
selectionType={selectionType}
initialTeamId={selectedTeamId}
workflowId={activeWorkflowId || ''}
isForeignCredential={isForeignCredential}
/>
</div>
</TooltipTrigger>
@@ -648,8 +642,8 @@ export function FileSelectorInput({
// Render Wealthbox selector
if (isWealthbox) {
// Get credential using the same pattern as other tools
const credential = (getValue(blockId, 'credential') as string) || ''
// Get credential reactively
const credential = (connectedCredential as string) || ''
// Only handle contacts now - both notes and tasks use short-input
if (subBlock.id === 'contactId') {
@@ -697,32 +691,47 @@ export function FileSelectorInput({
}
// 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, info) => {
setSelectedFileId(val)
setFileInfo(info || null)
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}
onFileInfoChange={setFileInfo}
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,11 @@ import {
type FolderInfo,
FolderSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector'
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,9 +27,15 @@ 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) || ''
)
// Get the current value from the store or prop value if in preview mode
useEffect(() => {
@@ -67,6 +75,9 @@ export function FolderSelectorInput({
disabled={disabled}
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,7 +267,14 @@ export function FolderSelector({
setIsLoading(false)
}
},
[selectedCredentialId, selectedFolderId, onFolderInfoChange, fetchFolderById, provider]
[
selectedCredentialId,
selectedFolderId,
onFolderInfoChange,
fetchFolderById,
provider,
isForeignCredential,
]
)
// Fetch credentials on initial mount
@@ -244,21 +300,17 @@ export function FolderSelector({
}
}, [value, isPreview, previewValue])
// 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') {
const currentValue = isPreview ? (previewValue as string) : (value as string)
if (
currentValue &&
selectedCredentialId &&
(!selectedFolder || selectedFolder.id !== currentValue)
) {
fetchFolderById(currentValue)
}
}, [
value,
selectedCredentialId,
selectedFolder,
fetchFolderById,
provider,
isPreview,
previewValue,
])
}, [value, selectedCredentialId, selectedFolder, fetchFolderById, isPreview, previewValue])
// Handle folder selection
const handleSelectFolder = (folder: FolderInfo) => {
@@ -317,7 +369,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 +385,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(() => {
@@ -396,7 +397,7 @@ export function JiraProjectSelector({
role='combobox'
aria-expanded={open}
className='w-full justify-between'
disabled={disabled || !domain || !selectedCredentialId}
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
>
{selectedProject ? (
<div className='flex items-center gap-2 overflow-hidden'>
@@ -417,126 +418,131 @@ 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 */}

View File

@@ -18,6 +18,7 @@ import {
type LinearTeamInfo,
LinearTeamSelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/project-selector/components/linear-team-selector'
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,10 +44,13 @@ 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)
const [connectedCredential] = useSubBlockValue(blockId, 'credential')
const { isForeignCredential } = useForeignCredential(
subBlock.provider || subBlock.serviceId || 'jira',
(connectedCredential as string) || ''
)
// Local setters for related Jira fields to ensure immediate UI clearing
const [_issueKeyValue, setIssueKeyValue] = useSubBlockValue<string>(blockId, 'issueKey')
const [_manualIssueKeyValue, setManualIssueKeyValue] = useSubBlockValue<string>(
@@ -70,32 +74,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(() => {
@@ -240,6 +218,7 @@ export function ProjectSelectorInput({
onProjectInfoChange={setProjectInfo}
credentialId={(jiraCredential as string) || ''}
isForeignCredential={isForeignCredential}
workflowId={activeWorkflowId || ''}
/>
</div>
</TooltipTrigger>

View File

@@ -176,18 +176,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 +367,12 @@ export function TriggerModal({
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !isConfigValid() || !hasConfigChanged}
disabled={isSaving || !isConfigValid() || (!hasConfigChanged && !!triggerId)}
className={cn(
'h-10',
isConfigValid() && hasConfigChanged ? 'bg-primary hover:bg-primary/90' : '',
isConfigValid() && (hasConfigChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: '',
isSaving &&
'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20'
)}

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

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

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

@@ -2,11 +2,10 @@
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 { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -353,13 +352,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)
@@ -53,10 +49,10 @@ export function General() {
useEffect(() => {
const loadData = async () => {
await loadSettings(retryCount > 0)
await loadSettings()
}
loadData()
}, [loadSettings, retryCount])
}, [loadSettings])
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
await setTheme(value)
@@ -80,129 +76,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' },
]

View File

@@ -0,0 +1,69 @@
export interface SubscriptionPermissions {
canUpgradeToPro: boolean
canUpgradeToTeam: boolean
canViewEnterprise: boolean
canManageTeam: boolean
canEditUsageLimit: boolean
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
}
export interface SubscriptionState {
isFree: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
plan: string
status: string
}
export interface UserRole {
isTeamAdmin: boolean
userRole: string
}
export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isTeamAdmin } = userRole
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
canManageTeam: isTeam && isTeamAdmin,
canEditUsageLimit: (isFree || (isPro && !isTeam)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users see pencil
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
}
}
export function getVisiblePlans(
subscription: SubscriptionState,
userRole: UserRole
): ('pro' | 'team' | 'enterprise')[] {
const plans: ('pro' | 'team' | 'enterprise')[] = []
const { isFree, isPro, isTeam } = subscription
const { isTeamAdmin } = userRole
// Free users see all plans
if (isFree) {
plans.push('pro', 'team', 'enterprise')
}
// Pro users see team and enterprise
else if (isPro && !isTeam) {
plans.push('team', 'enterprise')
}
// Team owners see only enterprise (no team plan since they already have it)
else if (isTeam && isTeamAdmin) {
plans.push('enterprise')
}
// Team members, Enterprise users see no plans
return plans
}

View File

@@ -1,41 +1,188 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertCircle, Users } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from '@/components/ui'
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge, Progress, Skeleton } from '@/components/ui'
import { useSession, useSubscription } from '@/lib/auth-client'
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
BillingSummary,
CancelSubscription,
TeamSeatsDialog,
UsageLimitEditor,
PlanCard,
UsageLimit,
type UsageLimitRef,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
import {
getSubscriptionPermissions,
getVisiblePlans,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions'
import { useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
// Logger
const logger = createLogger('Subscription')
// Constants
const CONSTANTS = {
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
TYPEFORM_ENTERPRISE_URL: 'https://form.typeform.com/to/jqCO12pF',
PRO_PRICE: '$20',
TEAM_PRICE: '$40',
INITIAL_TEAM_SEATS: 1,
} as const
// Styles
const STYLES = {
GRADIENT_BADGE:
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer',
} as const
// Types
type TargetPlan = 'pro' | 'team'
interface SubscriptionProps {
onOpenChange: (open: boolean) => void
}
/**
* Skeleton component for subscription loading state
*/
function SubscriptionSkeleton() {
return (
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-2'>
{/* Current Plan skeleton - matches usage indicator style */}
<div className='mb-2'>
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-16' />
<Skeleton className='h-[1.125rem] w-14 rounded-[6px]' />
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<Skeleton className='h-4 w-8' />
<span className='text-muted-foreground'>/</span>
<Skeleton className='h-4 w-8' />
</div>
</div>
<Skeleton className='h-2 w-full rounded' />
</div>
</div>
</div>
{/* Plan cards skeleton */}
<div className='flex flex-col gap-2'>
{/* Pro and Team skeleton grid */}
<div className='grid grid-cols-2 gap-2'>
{/* Pro Plan Card Skeleton */}
<div className='flex flex-col rounded-[8px] border p-4'>
<div className='mb-4'>
<Skeleton className='mb-2 h-5 w-8' />
<div className='flex items-baseline'>
<Skeleton className='h-6 w-10' />
<Skeleton className='ml-1 h-3 w-12' />
</div>
</div>
<div className='mb-4 flex-1 space-y-2'>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-24' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
</div>
<Skeleton className='h-9 w-full rounded-[8px]' />
</div>
{/* Team Plan Card Skeleton */}
<div className='flex flex-col rounded-[8px] border p-4'>
<div className='mb-4'>
<Skeleton className='mb-2 h-5 w-10' />
<div className='flex items-baseline'>
<Skeleton className='h-6 w-10' />
<Skeleton className='ml-1 h-3 w-12' />
</div>
</div>
<div className='mb-4 flex-1 space-y-2'>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-24' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-28' />
</div>
</div>
<Skeleton className='h-9 w-full rounded-[8px]' />
</div>
</div>
{/* Enterprise skeleton - horizontal layout */}
<div className='flex items-center justify-between rounded-[8px] border p-4'>
<div>
<Skeleton className='mb-2 h-5 w-20' />
<Skeleton className='mb-3 h-3 w-64' />
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='h-4 w-px bg-border' />
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='h-4 w-px bg-border' />
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
</div>
</div>
<Skeleton className='h-9 w-16 rounded-[8px]' />
</div>
</div>
</div>
</div>
)
}
// Utility functions
const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() + plan.slice(1)
/**
* Subscription management component
* Handles plan display, upgrades, and billing management
*/
export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const {
isLoading,
error,
getSubscriptionStatus,
getUsage,
getBillingStatus,
@@ -43,18 +190,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
subscriptionData,
} = useSubscriptionStore()
const {
activeOrganization,
organizationBillingData,
isLoadingOrgBilling,
loadOrganizationBillingData,
getUserRole,
addSeats,
} = useOrganizationStore()
const { activeOrganization, organizationBillingData, loadOrganizationBillingData, getUserRole } =
useOrganizationStore()
const [isSeatsDialogOpen, setIsSeatsDialogOpen] = useState(false)
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef<UsageLimitRef | null>(null)
// Get real subscription data from store
const subscription = getSubscriptionStatus()
const usage = getUsage()
const billingStatus = getBillingStatus()
@@ -64,19 +206,73 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
if (subscription.isTeam && activeOrgId) {
loadOrganizationBillingData(activeOrgId)
}
}, [activeOrgId, subscription.isTeam])
}, [activeOrgId, subscription.isTeam, loadOrganizationBillingData])
// Determine if user is team admin/owner
// Auto-clear upgrade error
useEffect(() => {
if (upgradeError) {
const timer = setTimeout(() => {
setUpgradeError(null)
}, CONSTANTS.UPGRADE_ERROR_TIMEOUT)
return () => clearTimeout(timer)
}
}, [upgradeError])
// User role and permissions
const userRole = getUserRole(session?.user?.email)
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
const shouldShowOrgBilling = subscription.isTeam && isTeamAdmin && organizationBillingData
// Get permissions based on subscription state and user role
const permissions = getSubscriptionPermissions(
{
isFree: subscription.isFree,
isPro: subscription.isPro,
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
{
isTeamAdmin,
userRole: userRole || 'member',
}
)
// Get visible plans based on current subscription
const visiblePlans = getVisiblePlans(
{
isFree: subscription.isFree,
isPro: subscription.isPro,
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
{
isTeamAdmin,
userRole: userRole || 'member',
}
)
// UI state computed values
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
const badgeText = subscription.isFree ? 'Upgrade' : 'Add'
const handleBadgeClick = () => {
if (subscription.isFree) {
handleUpgrade('pro')
} else if (permissions.canEditUsageLimit && usageLimitRef.current) {
usageLimitRef.current.startEdit()
}
}
const handleUpgrade = useCallback(
async (targetPlan: 'pro' | 'team') => {
async (targetPlan: TargetPlan) => {
if (!session?.user?.id) return
// Get current subscription data including stripeSubscriptionId
const subscriptionData = useSubscriptionStore.getState().subscriptionData
const { subscriptionData } = useSubscriptionStore.getState()
const currentSubscriptionId = subscriptionData?.stripeSubscriptionId
let referenceId = session.user.id
@@ -84,33 +280,32 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
referenceId = activeOrgId
}
const currentUrl = window.location.origin + window.location.pathname
const currentUrl = `${window.location.origin}${window.location.pathname}`
try {
const upgradeParams: any = {
const upgradeParams = {
plan: targetPlan,
referenceId,
successUrl: currentUrl,
cancelUrl: currentUrl,
seats: targetPlan === 'team' ? 1 : undefined,
}
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
} as const
// Add subscriptionId if we have an existing subscription to ensure proper plan switching
if (currentSubscriptionId) {
upgradeParams.subscriptionId = currentSubscriptionId
logger.info('Upgrading existing subscription', {
// Add subscriptionId for existing subscriptions to ensure proper plan switching
const finalParams = currentSubscriptionId
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
: upgradeParams
logger.info(
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
{
targetPlan,
currentSubscriptionId,
referenceId,
})
} else {
logger.info('Creating new subscription (no existing subscription found)', {
targetPlan,
referenceId,
})
}
}
)
await betterAuthSubscription.upgrade(upgradeParams)
await betterAuthSubscription.upgrade(finalParams)
} catch (error) {
logger.error('Failed to initiate subscription upgrade:', error)
alert('Failed to initiate upgrade. Please try again or contact support.')
@@ -119,310 +314,213 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
[session?.user?.id, subscription.isTeam, activeOrgId, betterAuthSubscription]
)
const handleSeatsUpdate = useCallback(
async (seats: number) => {
if (!activeOrgId) {
logger.error('No active organization found for seat update')
return
}
const renderPlanCard = useCallback(
(planType: 'pro' | 'team' | 'enterprise', layout: 'vertical' | 'horizontal' = 'vertical') => {
const handleContactEnterprise = () => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')
try {
setIsUpdatingSeats(true)
await addSeats(seats)
setIsSeatsDialogOpen(false)
} catch (error) {
logger.error('Failed to update seats:', error)
} finally {
setIsUpdatingSeats(false)
switch (planType) {
case 'pro':
return (
<PlanCard
key='pro'
name='Pro'
price={CONSTANTS.PRO_PRICE}
priceSubtext='/month'
features={PRO_PLAN_FEATURES}
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Pro'}
onButtonClick={() => handleUpgrade('pro')}
isError={upgradeError === 'pro'}
layout={layout}
/>
)
case 'team':
return (
<PlanCard
key='team'
name='Team'
price={CONSTANTS.TEAM_PRICE}
priceSubtext='/month'
features={TEAM_PLAN_FEATURES}
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Team'}
onButtonClick={() => handleUpgrade('team')}
isError={upgradeError === 'team'}
layout={layout}
/>
)
case 'enterprise':
return (
<PlanCard
key='enterprise'
name='Enterprise'
price={<span className='font-semibold text-xl'>Custom</span>}
priceSubtext={
layout === 'horizontal'
? 'Custom solutions tailored to your enterprise needs'
: undefined
}
features={ENTERPRISE_PLAN_FEATURES}
buttonText='Contact'
onButtonClick={handleContactEnterprise}
layout={layout}
/>
)
default:
return null
}
},
[activeOrgId]
[subscription.isFree, upgradeError, handleUpgrade]
)
if (isLoading) {
return (
<div className='space-y-4 p-6'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-4 w-3/4' />
</div>
)
}
if (error) {
return (
<div className='p-6'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)
return <SubscriptionSkeleton />
}
return (
<div className='p-6'>
<div className='space-y-6'>
{/* Current Plan & Usage Overview */}
<div>
<div className='mb-2 flex items-center justify-between'>
<h3 className='font-medium text-sm'>Current Plan</h3>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm capitalize'>
{subscription.plan} Plan
</span>
{!subscription.isFree && <BillingSummary showDetails={false} />}
</div>
</div>
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-2'>
{/* Current Plan & Usage Overview - Styled like usage-indicator */}
<div className='mb-2'>
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
{/* Plan and usage info */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span
className={cn(
'font-medium text-sm',
subscription.isFree
? 'text-foreground'
: 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
)}
>
{formatPlanName(subscription.plan)}
</span>
{showBadge && (
<Badge
className={STYLES.GRADIENT_BADGE}
onClick={(e) => {
e.stopPropagation()
handleBadgeClick()
}}
>
{badgeText}
</Badge>
)}
{/* Team seats info for admins */}
{permissions.canManageTeam && (
<span className='text-muted-foreground text-xs'>
({organizationBillingData?.totalSeats || subscription.seats || 1} seats)
</span>
)}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<span className='text-muted-foreground'>${usage.current.toFixed(2)}</span>
<span className='text-muted-foreground'>/</span>
{!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={usageLimitData?.currentLimit || usage.limit}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
minimumLimit={usageLimitData?.minimumLimit || (subscription.isPro ? 20 : 40)}
/>
) : (
<span className='text-muted-foreground'>${usage.limit}</span>
)}
</div>
</div>
<div className='mb-3 flex items-center justify-between'>
<span className='font-semibold text-2xl'>
${usage.current.toFixed(2)} / ${usage.limit}
</span>
<div className='text-right'>
<span className='block text-muted-foreground text-sm'>
{usage.percentUsed}% used this period
</span>
{/* Progress Bar */}
<Progress value={Math.min(usage.percentUsed, 100)} className='h-2' />
</div>
</div>
</div>
{/* Usage Alerts */}
{billingStatus === 'exceeded' && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Usage Limit Exceeded</AlertTitle>
<AlertDescription>
You've exceeded your usage limit of ${usage.limit}. Please upgrade your plan or
increase your limit.
</AlertDescription>
</Alert>
)}
{billingStatus === 'warning' && (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Approaching Usage Limit</AlertTitle>
<AlertDescription>
You've used {usage.percentUsed}% of your ${usage.limit} limit. Consider upgrading or
increasing your limit.
</AlertDescription>
</Alert>
)}
{/* Usage Limit Editor */}
<div>
<div className='flex items-center justify-between'>
<span className='font-medium text-sm'>
{subscription.isTeam ? 'Individual Limit' : 'Monthly Limit'}
</span>
{isLoadingOrgBilling ? (
<Skeleton className='h-8 w-16' />
) : (
<UsageLimitEditor
currentLimit={usageLimitData?.currentLimit ?? usage.limit}
canEdit={
subscription.isPro ||
subscription.isTeam ||
subscription.isEnterprise ||
(subscription.isTeam && isTeamAdmin)
}
minimumLimit={usageLimitData?.minimumLimit ?? DEFAULT_FREE_CREDITS}
/>
)}
</div>
{subscription.isFree && (
<p className='mt-1 text-muted-foreground text-xs'>
Upgrade to Pro ($20 minimum) or Team ($40 minimum) to customize your usage limit.
{/* Team Member Notice */}
{permissions.showTeamMemberView && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact your team admin to increase limits
</p>
)}
{subscription.isPro && (
<p className='mt-1 text-muted-foreground text-xs'>
Pro plan minimum: $20. You can set your individual limit higher.
</p>
)}
{subscription.isTeam && !isTeamAdmin && (
<p className='mt-1 text-muted-foreground text-xs'>
Contact your team owner to adjust your limit. Team plan minimum: $40.
</p>
)}
{subscription.isTeam && isTeamAdmin && (
<p className='mt-1 text-muted-foreground text-xs'>
Team plan minimum: $40 per member. Manage team member limits in the Team tab.
</p>
)}
</div>
{/* Team Management */}
{subscription.isTeam && (
<div className='space-y-4'>
{isLoadingOrgBilling ? (
<Card>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-5' />
<Skeleton className='h-6 w-24' />
</div>
<Skeleton className='h-8 w-24' />
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-6 w-32' />
</div>
<div className='space-y-1 text-right'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-6 w-16' />
</div>
</div>
<Skeleton className='h-2 w-full' />
</CardContent>
</Card>
) : shouldShowOrgBilling ? (
<Card>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='h-5 w-5' />
Team Plan
</CardTitle>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{/* Team Summary */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Licensed Seats</span>
<span className='font-semibold'>
{organizationBillingData.totalSeats} seats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Monthly Bill</span>
<span className='font-semibold'>
${organizationBillingData.totalSeats * 40}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Current Usage</span>
<span className='font-semibold'>
${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0}
</span>
</div>
</div>
{/* Simple Explanation */}
<div className='rounded-lg bg-muted/50 p-3 text-muted-foreground text-sm'>
<p>
You pay ${organizationBillingData.totalSeats * 40}/month for{' '}
{organizationBillingData.totalSeats} licensed seats, regardless of usage. If
your team uses more than ${organizationBillingData.totalSeats * 40}, you'll be
charged for the overage.
</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='h-5 w-5' />
Team Plan
</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Your monthly allowance</span>
<span className='font-semibold'>${usage.limit}</span>
</div>
<p className='text-muted-foreground text-xs'>
Contact your team owner to adjust your limit
</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Upgrade Actions */}
{subscription.isFree && (
<div className='space-y-3'>
<Button onClick={() => handleUpgrade('pro')} className='w-full'>
Upgrade to Pro - $20/month
</Button>
<Button onClick={() => handleUpgrade('team')} variant='outline' className='w-full'>
Upgrade to Team - $40/seat/month
</Button>
<div className='py-2 text-center'>
<p className='text-muted-foreground text-xs'>
Need a custom plan?{' '}
<a
href='https://5fyxh22cfgi.typeform.com/to/EcJFBt9W'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:underline'
>
Contact us
</a>{' '}
for Enterprise pricing
</p>
</div>
</div>
)}
{/* Upgrade Plans */}
{permissions.showUpgradePlans && (
<div className='flex flex-col gap-2'>
{/* Render plans based on what should be visible */}
{(() => {
const totalPlans = visiblePlans.length
const hasEnterprise = visiblePlans.includes('enterprise')
{subscription.isPro && !subscription.isTeam && (
<Button onClick={() => handleUpgrade('team')} className='w-full'>
Upgrade to Team - $40/seat/month
</Button>
// Special handling for Pro users - show team and enterprise side by side
if (subscription.isPro && totalPlans === 2) {
return (
<div className='grid grid-cols-2 gap-2'>
{visiblePlans.map((plan) => renderPlanCard(plan, 'vertical'))}
</div>
)
}
// Default behavior for other users
const otherPlans = visiblePlans.filter((p) => p !== 'enterprise')
// Layout logic:
// Free users (3 plans): Pro and Team vertical in grid, Enterprise horizontal below
// Team admins (1 plan): Enterprise horizontal
const enterpriseLayout =
totalPlans === 1 || totalPlans === 3 ? 'horizontal' : 'vertical'
return (
<>
{otherPlans.length > 0 && (
<div
className={cn(
'grid gap-2',
otherPlans.length === 1 ? 'grid-cols-1' : 'grid-cols-2'
)}
>
{otherPlans.map((plan) => renderPlanCard(plan, 'vertical'))}
</div>
)}
{/* Enterprise plan */}
{hasEnterprise && renderPlanCard('enterprise', enterpriseLayout)}
</>
)
})()}
</div>
)}
{subscription.isEnterprise && (
<div className='py-2 text-center'>
<p className='text-muted-foreground text-sm'>
Enterprise plan - Contact support for changes
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact enterprise for support usage limit changes
</p>
</div>
)}
{/* Cancel Subscription */}
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
}}
/>
{/* Team Seats Dialog */}
<TeamSeatsDialog
open={isSeatsDialogOpen}
onOpenChange={setIsSeatsDialogOpen}
title='Update Team Seats'
description='Each seat costs $40/month and provides $40 in monthly inference credits. Adjust the number of licensed seats for your team.'
currentSeats={
shouldShowOrgBilling
? organizationBillingData?.totalSeats || 1
: subscription.seats || 1
}
initialSeats={
shouldShowOrgBilling
? organizationBillingData?.totalSeats || 1
: subscription.seats || 1
}
isLoading={isUpdatingSeats}
onConfirm={handleSeatsUpdate}
confirmButtonText='Update Seats'
showCostBreakdown={true}
/>
{permissions.canCancelSubscription && (
<div className='mt-2'>
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
}}
/>
</div>
)}
</div>
</div>
)

View File

@@ -1,8 +1,11 @@
export { MemberInvitationCard } from './member-invitation-card'
export { MemberLimit } from './member-limit'
export { NoOrganizationView } from './no-organization-view'
export { OrganizationCreationDialog } from './organization-creation-dialog'
export { OrganizationSettingsTab } from './organization-settings-tab'
export { PendingInvitationsList } from './pending-invitations-list'
export { RemoveMemberDialog } from './remove-member-dialog'
export { TeamMembersList } from './team-members-list'
export { TeamSeats } from './team-seats'
export { TeamSeatsOverview } from './team-seats-overview'
export { TeamUsage } from './team-usage'

View File

@@ -0,0 +1 @@
export { MemberInvitationCard } from './member-invitation-card'

View File

@@ -98,14 +98,14 @@ export function MemberInvitationCard({
const selectedCount = selectedWorkspaces.length
return (
<Card>
<CardHeader className='pb-4'>
<CardTitle className='text-base'>Invite Team Members</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Invite Team Members</CardTitle>
<CardDescription>
Add new members to your team and optionally give them access to specific workspaces
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='space-y-4 p-4 pt-0'>
<div className='flex items-center gap-3'>
<div className='flex-1'>
<Input
@@ -126,11 +126,14 @@ export function MemberInvitationCard({
}
}}
disabled={isInviting}
className='shrink-0 gap-1'
className='h-9 shrink-0 gap-1 rounded-[8px]'
>
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
{selectedCount > 0 && (
<Badge variant='secondary' className='ml-1 h-5 px-1.5 text-xs'>
<Badge
variant='secondary'
className='ml-1 h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
>
{selectedCount}
</Badge>
)}
@@ -142,7 +145,7 @@ export function MemberInvitationCard({
size='sm'
onClick={onInviteMember}
disabled={!inviteEmail || isInviting}
className='shrink-0 gap-2'
className='h-9 shrink-0 gap-2 rounded-[8px]'
>
{isInviting ? <ButtonSkeleton /> : <PlusCircle className='h-4 w-4' />}
Invite
@@ -153,8 +156,8 @@ export function MemberInvitationCard({
<div className='space-y-3 pt-1'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<h5 className='font-medium text-sm'>Workspace Access</h5>
<Badge variant='outline' className='text-xs'>
<h5 className='font-medium text-xs'>Workspace Access</h5>
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
Optional
</Badge>
</div>
@@ -174,7 +177,7 @@ export function MemberInvitationCard({
</p>
</div>
) : (
<div className='max-h-48 space-y-2 overflow-y-auto rounded-md border bg-muted/20 p-3'>
<div className='max-h-48 space-y-2 overflow-y-auto rounded-[8px] border bg-muted/20 p-3'>
{userWorkspaces.map((workspace) => {
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
const selectedWorkspace = selectedWorkspaces.find(
@@ -185,13 +188,13 @@ export function MemberInvitationCard({
<div
key={workspace.id}
className={cn(
'flex items-center justify-between rounded-md border bg-background p-3 transition-all',
'flex items-center justify-between rounded-[8px] border bg-background p-3 transition-all',
isSelected
? 'border-primary/20 bg-primary/5'
: 'hover:border-border hover:bg-muted/50'
)}
>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<Checkbox
id={`workspace-${workspace.id}`}
checked={isSelected}
@@ -206,12 +209,15 @@ export function MemberInvitationCard({
/>
<Label
htmlFor={`workspace-${workspace.id}`}
className='cursor-pointer font-medium text-sm leading-none'
className='cursor-pointer font-medium text-xs leading-none'
>
{workspace.name}
</Label>
{workspace.isOwner && (
<Badge variant='outline' className='text-xs'>
<Badge
variant='outline'
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
>
Owner
</Badge>
)}
@@ -242,7 +248,7 @@ export function MemberInvitationCard({
)}
{inviteSuccess && (
<Alert className='border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
<AlertDescription>
Invitation sent successfully

View File

@@ -14,7 +14,7 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface EditMemberLimitDialogProps {
interface MemberLimitProps {
open: boolean
onOpenChange: (open: boolean) => void
member: {
@@ -30,14 +30,14 @@ interface EditMemberLimitDialogProps {
planType?: string
}
export function EditMemberLimitDialog({
export function MemberLimit({
open,
onOpenChange,
member,
onSave,
isLoading,
planType = 'team',
}: EditMemberLimitDialogProps) {
}: MemberLimitProps) {
const [limitValue, setLimitValue] = useState('')
const [error, setError] = useState<string | null>(null)

View File

@@ -0,0 +1 @@
export { NoOrganizationView } from './no-organization-view'

View File

@@ -2,7 +2,7 @@ import { RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { OrganizationCreationDialog } from './'
import { OrganizationCreationDialog } from '../organization-creation-dialog'
interface NoOrganizationViewProps {
hasTeamPlan: boolean
@@ -35,11 +35,11 @@ export function NoOrganizationView({
}: NoOrganizationViewProps) {
if (hasTeamPlan || hasEnterprisePlan) {
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>Create Your Team Workspace</h3>
<h3 className='font-medium text-sm'>Create Your Team Workspace</h3>
<div className='space-y-6 rounded-lg border p-6'>
<div className='space-y-4 rounded-[8px] border p-4 shadow-xs'>
<p className='text-muted-foreground text-sm'>
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
workspace to start collaborating with your team.
@@ -47,7 +47,7 @@ export function NoOrganizationView({
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='orgName' className='font-medium text-sm'>
<label htmlFor='orgName' className='font-medium text-xs'>
Team Name
</label>
<Input
@@ -59,11 +59,11 @@ export function NoOrganizationView({
</div>
<div className='space-y-2'>
<label htmlFor='orgSlug' className='font-medium text-sm'>
<label htmlFor='orgSlug' className='font-medium text-xs'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
sim.ai/team/
</div>
<Input
@@ -77,7 +77,7 @@ export function NoOrganizationView({
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
@@ -87,6 +87,7 @@ export function NoOrganizationView({
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
className='h-9 rounded-[8px]'
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
@@ -111,9 +112,9 @@ export function NoOrganizationView({
}
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>No Team Workspace</h3>
<h3 className='font-medium text-sm'>No Team Workspace</h3>
<p className='text-muted-foreground text-sm'>
You don't have a team workspace yet. To collaborate with others, first upgrade to a team
or enterprise plan.
@@ -127,6 +128,7 @@ export function NoOrganizationView({
})
window.dispatchEvent(event)
}}
className='h-9 rounded-[8px]'
>
Upgrade to Team Plan
</Button>

View File

@@ -0,0 +1 @@
export { OrganizationCreationDialog } from './organization-creation-dialog'

View File

@@ -46,18 +46,18 @@ export function OrganizationCreationDialog({
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<label htmlFor='orgName' className='font-medium text-sm'>
<label htmlFor='orgName' className='font-medium text-xs'>
Team Name
</label>
<Input id='orgName' value={orgName} onChange={onOrgNameChange} placeholder='My Team' />
</div>
<div className='space-y-2'>
<label htmlFor='orgSlug' className='font-medium text-sm'>
<label htmlFor='orgSlug' className='font-medium text-xs'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
sim.ai/team/
</div>
<Input
@@ -70,17 +70,26 @@ export function OrganizationCreationDialog({
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isCreating}>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCreating}
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button onClick={onCreateOrganization} disabled={!orgName || !orgSlug || isCreating}>
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreating}
className='h-9 rounded-[8px]'
>
{isCreating && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
</Button>

View File

@@ -0,0 +1 @@
export { OrganizationSettingsTab } from './organization-settings-tab'

View File

@@ -28,23 +28,23 @@ export function OrganizationSettingsTab({
orgSettingsSuccess,
}: OrganizationSettingsTabProps) {
return (
<div className='mt-4 space-y-6'>
<div className='mt-4 space-y-4'>
{orgSettingsError && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{orgSettingsError}</AlertDescription>
</Alert>
)}
{orgSettingsSuccess && (
<Alert>
<Alert className='rounded-[8px]'>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{orgSettingsSuccess}</AlertDescription>
</Alert>
)}
{!isAdminOrOwner && (
<Alert>
<Alert className='rounded-[8px]'>
<AlertTitle>Read Only</AlertTitle>
<AlertDescription>
You need owner or admin permissions to modify team settings.
@@ -52,12 +52,12 @@ export function OrganizationSettingsTab({
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className='text-base'>Basic Information</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Basic Information</CardTitle>
<CardDescription>Update your team's basic information and branding</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='space-y-4 p-4 pt-0'>
<div className='space-y-2'>
<Label htmlFor='team-name'>Team Name</Label>
<Input
@@ -112,11 +112,11 @@ export function OrganizationSettingsTab({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-base'>Team Information</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Information</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<CardContent className='space-y-2 p-4 pt-0 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Team ID:</span>
<span className='font-mono'>{organization.id}</span>

View File

@@ -0,0 +1 @@
export { PendingInvitationsList } from './pending-invitations-list'

View File

@@ -20,8 +20,8 @@ export function PendingInvitationsList({
}
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Pending Invitations</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Pending Invitations</h4>
<div className='divide-y'>
{pendingInvitations.map((invitation: Invitation) => (
<div key={invitation.id} className='flex items-center justify-between p-4'>
@@ -31,13 +31,18 @@ export function PendingInvitationsList({
{invitation.email.charAt(0).toUpperCase()}
</div>
<div className='flex-1'>
<div className='font-medium'>{invitation.email}</div>
<div className='text-muted-foreground text-sm'>Invitation pending</div>
<div className='font-medium text-sm'>{invitation.email}</div>
<div className='text-muted-foreground text-xs'>Invitation pending</div>
</div>
</div>
</div>
<Button variant='outline' size='sm' onClick={() => onCancelInvitation(invitation.id)}>
<Button
variant='outline'
size='sm'
onClick={() => onCancelInvitation(invitation.id)}
className='h-8 w-8 rounded-[8px] p-0'
>
<X className='h-4 w-4' />
</Button>
</div>

View File

@@ -0,0 +1 @@
export { RemoveMemberDialog } from './remove-member-dialog'

View File

@@ -42,11 +42,11 @@ export function RemoveMemberDialog({
<input
type='checkbox'
id='reduce-seats'
className='rounded'
className='rounded-[4px]'
checked={shouldReduceSeats}
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
/>
<label htmlFor='reduce-seats' className='text-sm'>
<label htmlFor='reduce-seats' className='text-xs'>
Also reduce seat count in my subscription
</label>
</div>
@@ -56,10 +56,14 @@ export function RemoveMemberDialog({
</div>
<DialogFooter>
<Button variant='outline' onClick={onCancel}>
<Button variant='outline' onClick={onCancel} className='h-9 rounded-[8px]'>
Cancel
</Button>
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
<Button
variant='destructive'
onClick={() => onConfirmRemove(shouldReduceSeats)}
className='h-9 rounded-[8px]'
>
Remove
</Button>
</DialogFooter>

View File

@@ -17,8 +17,8 @@ export function TeamMembersList({
}: TeamMembersListProps) {
if (!organization.members || organization.members.length === 0) {
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
<div className='p-4 text-muted-foreground text-sm'>
No members in this organization yet.
</div>
@@ -27,8 +27,8 @@ export function TeamMembersList({
}
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
<div className='divide-y'>
{organization.members.map((member: Member) => (
<div key={member.id} className='flex items-center justify-between p-4'>
@@ -38,10 +38,10 @@ export function TeamMembersList({
{(member.user?.name || member.user?.email || 'U').charAt(0).toUpperCase()}
</div>
<div className='flex-1'>
<div className='font-medium'>{member.user?.name || 'Unknown'}</div>
<div className='text-muted-foreground text-sm'>{member.user?.email}</div>
<div className='font-medium text-sm'>{member.user?.name || 'Unknown'}</div>
<div className='text-muted-foreground text-xs'>{member.user?.email}</div>
</div>
<div className='rounded-full bg-primary/10 px-3 py-1 font-medium text-primary text-xs'>
<div className='h-[1.125rem] rounded-[6px] bg-primary/10 px-2 py-0 font-medium text-primary text-xs'>
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</div>
</div>
@@ -51,7 +51,12 @@ export function TeamMembersList({
{isAdminOrOwner &&
member.role !== 'owner' &&
member.user?.email !== currentUserEmail && (
<Button variant='outline' size='sm' onClick={() => onRemoveMember(member)}>
<Button
variant='outline'
size='sm'
onClick={() => onRemoveMember(member)}
className='h-8 w-8 rounded-[8px] p-0'
>
<UserX className='h-4 w-4' />
</Button>
)}

View File

@@ -47,12 +47,12 @@ export function TeamSeatsOverview({
}: TeamSeatsOverviewProps) {
if (isLoadingSubscription) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<TeamSeatsSkeleton />
</CardContent>
</Card>
@@ -61,18 +61,18 @@ export function TeamSeatsOverview({
if (!subscriptionData) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<div className='space-y-4 p-6 text-center'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-100'>
<Building2 className='h-6 w-6 text-amber-600' />
</div>
<div className='space-y-2'>
<p className='font-medium'>No Team Subscription Found</p>
<p className='font-medium text-sm'>No Team Subscription Found</p>
<p className='text-muted-foreground text-sm'>
Your subscription may need to be transferred to this organization.
</p>
@@ -82,6 +82,7 @@ export function TeamSeatsOverview({
onConfirmTeamUpgrade(2) // Start with 2 seats as default
}}
disabled={isLoading}
className='h-9 rounded-[8px]'
>
Set Up Team Subscription
</Button>
@@ -92,24 +93,24 @@ export function TeamSeatsOverview({
}
return (
<Card>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<div className='space-y-4'>
<div className='grid grid-cols-3 gap-4 text-center'>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{subscriptionData.seats || 0}</p>
<p className='font-bold text-xl'>{subscriptionData.seats || 0}</p>
<p className='text-muted-foreground text-xs'>Licensed Seats</p>
</div>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{usedSeats}</p>
<p className='font-bold text-xl'>{usedSeats}</p>
<p className='text-muted-foreground text-xs'>Used Seats</p>
</div>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
<p className='font-bold text-xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
<p className='text-muted-foreground text-xs'>Available</p>
</div>
</div>
@@ -121,7 +122,7 @@ export function TeamSeatsOverview({
{usedSeats} of {subscriptionData.seats || 0} seats
</span>
</div>
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-3' />
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
</div>
<div className='flex items-center justify-between border-t pt-2 text-sm'>
@@ -135,7 +136,7 @@ export function TeamSeatsOverview({
</div>
{checkEnterprisePlan(subscriptionData) ? (
<div className='rounded-lg bg-purple-50 p-4 text-center'>
<div className='rounded-[8px] bg-purple-50 p-4 text-center'>
<p className='font-medium text-purple-700 text-sm'>Enterprise Plan</p>
<p className='mt-1 text-purple-600 text-xs'>Contact support to modify seats</p>
</div>
@@ -146,11 +147,16 @@ export function TeamSeatsOverview({
size='sm'
onClick={onReduceSeats}
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
className='flex-1'
className='h-9 flex-1 rounded-[8px]'
>
Remove Seat
</Button>
<Button size='sm' onClick={onAddSeatDialog} disabled={isLoading} className='flex-1'>
<Button
size='sm'
onClick={onAddSeatDialog}
disabled={isLoading}
className='h-9 flex-1 rounded-[8px]'
>
Add Seat
</Button>
</div>

View File

@@ -18,7 +18,7 @@ import {
} from '@/components/ui/select'
import { env } from '@/lib/env'
interface TeamSeatsDialogProps {
interface TeamSeatsProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
@@ -31,7 +31,7 @@ interface TeamSeatsDialogProps {
showCostBreakdown?: boolean
}
export function TeamSeatsDialog({
export function TeamSeats({
open,
onOpenChange,
title,
@@ -42,7 +42,7 @@ export function TeamSeatsDialog({
onConfirm,
confirmButtonText,
showCostBreakdown = false,
}: TeamSeatsDialogProps) {
}: TeamSeatsProps) {
const [selectedSeats, setSelectedSeats] = useState(initialSeats)
useEffect(() => {
@@ -73,7 +73,7 @@ export function TeamSeatsDialog({
value={selectedSeats.toString()}
onValueChange={(value) => setSelectedSeats(Number.parseInt(value))}
>
<SelectTrigger id='seats'>
<SelectTrigger id='seats' className='rounded-[8px]'>
<SelectValue placeholder='Select number of seats' />
</SelectTrigger>
<SelectContent>

View File

@@ -9,15 +9,15 @@ import { useActiveOrganization } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useOrganizationStore } from '@/stores/organization'
import type { MemberUsageData } from '@/stores/organization/types'
import { EditMemberLimitDialog } from './'
import { MemberLimit } from '../member-limit'
const logger = createLogger('TeamUsageOverview')
const logger = createLogger('TeamUsage')
interface TeamUsageOverviewProps {
interface TeamUsageProps {
hasAdminAccess: boolean
}
export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
const { data: activeOrg } = useActiveOrganization()
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [selectedMember, setSelectedMember] = useState<MemberUsageData | null>(null)
@@ -335,7 +335,7 @@ export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
</Card>
{/* Edit Member Limit Dialog */}
<EditMemberLimitDialog
<MemberLimit
open={editDialogOpen}
onOpenChange={handleCloseEditDialog}
member={selectedMember}

View File

@@ -13,10 +13,8 @@ import { useSession } from '@/lib/auth-client'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
TeamSeatsDialog,
TeamUsageOverview,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import { generateSlug, useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
import {
MemberInvitationCard,
NoOrganizationView,
@@ -24,10 +22,10 @@ import {
PendingInvitationsList,
RemoveMemberDialog,
TeamMembersList,
TeamSeats,
TeamSeatsOverview,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components'
import { generateSlug, useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
TeamUsage,
} from './components'
const logger = createLogger('TeamManagement')
@@ -243,7 +241,7 @@ export function TeamManagement() {
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
return (
<div className='space-y-4 p-6'>
<div className='space-y-2 p-6'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-4 w-3/4' />
@@ -271,14 +269,14 @@ export function TeamManagement() {
}
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='flex items-center justify-between'>
<h3 className='font-medium text-lg'>Team Management</h3>
<h3 className='font-medium text-sm'>Team Management</h3>
{organizations.length > 1 && (
<div className='flex items-center space-x-2'>
<select
className='rounded-md border border-input bg-background px-3 py-2 text-sm'
className='h-9 rounded-[8px] border border-input bg-background px-3 py-2 text-xs'
value={activeOrganization.id}
onChange={(e) => setActiveOrganization(e.target.value)}
>
@@ -293,7 +291,7 @@ export function TeamManagement() {
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
@@ -351,7 +349,7 @@ export function TeamManagement() {
</TabsContent>
<TabsContent value='usage' className='mt-4 space-y-4'>
<TeamUsageOverview hasAdminAccess={adminOrOwner} />
<TeamUsage hasAdminAccess={adminOrOwner} />
</TabsContent>
<TabsContent value='settings'>
@@ -373,10 +371,10 @@ export function TeamManagement() {
open={removeMemberDialog.open}
memberName={removeMemberDialog.memberName}
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
}}
onShouldReduceSeatsChange={(shouldReduce) =>
onShouldReduceSeatsChange={(shouldReduce: boolean) =>
setRemoveMemberDialog({
...removeMemberDialog,
shouldReduceSeats: shouldReduce,
@@ -393,11 +391,11 @@ export function TeamManagement() {
}
/>
<TeamSeatsDialog
<TeamSeats
open={isAddSeatDialogOpen}
onOpenChange={setIsAddSeatDialogOpen}
title='Add Team Seats'
description={`Update your team size. Each seat costs $${env.TEAM_TIER_COST_LIMIT}/month and gets $${env.TEAM_TIER_COST_LIMIT} of inference credits.`}
description={`Each seat costs $${env.TEAM_TIER_COST_LIMIT}/month and provides $${env.TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
currentSeats={subscriptionData?.seats || 1}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}

View File

@@ -1,8 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -48,6 +47,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const loadSettings = useGeneralStore((state) => state.loadSettings)
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
const environmentCloseHandler = useRef<((open: boolean) => void) | null>(null)
useEffect(() => {
async function loadAllSettings() {
@@ -96,27 +96,25 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const isSubscriptionEnabled = isBillingEnabled
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => {
if (!newOpen && activeSection === 'environment' && environmentCloseHandler.current) {
environmentCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[800px]' hideCloseButton>
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[840px]'>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Settings</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
<DialogTitle className='font-medium text-lg'>Settings</DialogTitle>
</DialogHeader>
<div className='flex min-h-0 flex-1'>
{/* Navigation Sidebar */}
<div className='w-[200px] border-r'>
<div className='w-[180px]'>
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
@@ -130,7 +128,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<General />
</div>
<div className={cn('h-full', activeSection === 'environment' ? 'block' : 'hidden')}>
<EnvironmentVariables onOpenChange={onOpenChange} />
<EnvironmentVariables
onOpenChange={onOpenChange}
registerCloseHandler={(handler) => {
environmentCloseHandler.current = handler
}}
/>
</div>
<div className={cn('h-full', activeSection === 'account' ? 'block' : 'hidden')}>
<Account onOpenChange={onOpenChange} />

View File

@@ -67,7 +67,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
: 'free'
// Determine badge to show
const showAddBadge = planType !== 'free' && usage.percentUsed >= 85
const showAddBadge = planType !== 'free' && usage.percentUsed >= 50
const badgeText = planType === 'free' ? 'Upgrade' : 'Add'
const badgeType = planType === 'free' ? 'upgrade' : 'add'

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-10 w-full items-center justify-between rounded-[8px] border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}

View File

@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@@ -18,7 +18,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>

View File

@@ -273,6 +273,27 @@ export async function updateUserUsageLimit(
}
}
// Get current usage to validate against
const userStatsRecord = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsRecord.length > 0) {
const currentUsage = Number.parseFloat(
userStatsRecord[0].currentPeriodCost?.toString() || userStatsRecord[0].totalCost.toString()
)
// Validate new limit is not below current usage
if (newLimit < currentUsage) {
return {
success: false,
error: `Usage limit cannot be below current usage of $${currentUsage.toFixed(2)}`,
}
}
}
// Update the usage limit
await db
.update(userStats)

View File

@@ -57,7 +57,8 @@ export interface SendMessageRequest {
chatId?: string
workflowId?: string
mode?: 'ask' | 'agent'
depth?: 0 | 1 | 2 | 3
depth?: -2 | -1 | 0 | 1 | 2 | 3
prefetch?: boolean
createNewChat?: boolean
stream?: boolean
implicitFeedback?: string

View File

@@ -12,6 +12,7 @@ import type {
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface GetUserWorkflowParams {
@@ -170,6 +171,25 @@ export class GetUserWorkflowTool extends BaseTool {
}
}
// Merge latest subblock values from the subblock store so subblock edits are reflected
try {
if (workflowState?.blocks) {
workflowState = {
...workflowState,
blocks: mergeSubblockState(workflowState.blocks, workflowId),
}
logger.info('Merged subblock values into workflow state', {
workflowId,
blockCount: Object.keys(workflowState.blocks || {}).length,
})
}
} catch (mergeError) {
logger.warn('Failed to merge subblock values; proceeding with raw workflow state', {
workflowId,
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
}
logger.info('Validating workflow state', {
workflowId,
hasWorkflowState: !!workflowState,

View File

@@ -19,3 +19,22 @@ export function checkInternalApiKey(req: NextRequest) {
return { success: true }
}
export function checkCopilotApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
const expectedApiKey = env.COPILOT_API_KEY
if (!expectedApiKey) {
return { success: false, error: 'Copilot API key not configured' }
}
if (!apiKey) {
return { success: false, error: 'API key required' }
}
if (apiKey !== expectedApiKey) {
return { success: false, error: 'Invalid API key' }
}
return { success: true }
}

View File

@@ -44,7 +44,7 @@ const LOG_CONFIG = {
colorize: true,
},
production: {
enabled: false, // Disable all console logs in production
enabled: true, // Will be checked at runtime
minLevel: LogLevel.ERROR,
colorize: false,
},
@@ -106,6 +106,11 @@ export class Logger {
private shouldLog(level: LogLevel): boolean {
if (!config.enabled) return false
// In production, only log on server-side (where window is undefined)
if (ENV === 'production' && typeof window !== 'undefined') {
return false
}
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]
const minLevelIndex = levels.indexOf(config.minLevel)
const currentLevelIndex = levels.indexOf(level)

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@/lib/logs/console/logger'
import { mergeSubblockState } from '@/stores/workflows/utils'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import type { BlockWithDiff } from './types'
@@ -66,10 +67,26 @@ export class WorkflowDiffEngine {
hasParallels: Object.keys(currentWorkflowState.parallels || {}).length > 0,
})
// Merge subblock values from subblock store to ensure manual edits are included in baseline
let mergedBaseline: WorkflowState = currentWorkflowState
try {
mergedBaseline = {
...currentWorkflowState,
blocks: mergeSubblockState(currentWorkflowState.blocks),
}
logger.info('Merged subblock values into baseline for diff creation', {
blockCount: Object.keys(mergedBaseline.blocks || {}).length,
})
} catch (mergeError) {
logger.warn('Failed to merge subblock values into baseline; proceeding with raw state', {
error: mergeError instanceof Error ? mergeError.message : String(mergeError),
})
}
// Call the API route to create the diff
const body: any = {
yamlContent,
currentWorkflowState: currentWorkflowState,
currentWorkflowState: mergedBaseline,
}
if (diffAnalysis !== undefined && diffAnalysis !== null) {

View File

@@ -0,0 +1,13 @@
<svg width="2130" height="1200" viewBox="0 0 2130 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1184_347)">
<path d="M2130 0H0V1200H2130V0Z" fill="white"/>
<path d="M628 712.826H686.359C686.359 728.87 692.195 741.663 703.868 751.205C715.539 760.311 731.318 764.864 751.203 764.864C772.818 764.864 789.461 760.745 801.132 752.504C812.804 743.832 818.641 732.34 818.641 718.029C818.641 707.622 815.398 698.948 808.914 692.01C802.862 685.072 791.622 679.433 775.195 675.097L719.43 662.088C691.331 655.15 670.365 644.524 656.531 630.213C643.13 615.903 636.43 597.039 636.43 573.622C636.43 554.107 641.401 537.195 651.343 522.884C661.719 508.574 675.768 497.515 693.492 489.71C711.648 481.903 732.399 478 755.742 478C779.086 478 799.187 482.12 816.047 490.36C833.338 498.599 846.739 510.092 856.249 524.835C866.193 539.58 871.379 557.142 871.812 577.524H813.453C813.02 561.046 807.617 548.253 797.241 539.145C786.867 530.039 772.385 525.486 753.797 525.486C734.776 525.486 720.078 529.605 709.704 537.844C699.327 546.085 694.141 557.359 694.141 571.67C694.141 592.919 709.704 607.446 740.829 615.253L796.593 628.912C823.396 634.983 843.497 644.958 856.899 658.836C870.298 672.278 877 690.709 877 714.126C877 734.075 871.595 751.637 860.788 766.815C849.981 781.56 835.068 793.052 816.047 801.292C797.458 809.097 775.412 813 749.906 813C712.729 813 683.117 803.894 661.07 785.68C639.024 767.466 628 743.181 628 712.826Z" fill="black"/>
<path d="M918.324 805V488.257C942.504 497.069 953.168 497.069 978.968 488.257V805H918.324ZM948.001 467.32C937.248 467.32 927.786 463.433 919.614 455.661C911.871 447.455 908 437.955 908 427.159C908 415.933 911.871 406.432 919.614 398.659C927.786 390.887 937.248 387 948.001 387C959.183 387 968.645 390.887 976.388 398.659C984.129 406.432 988 415.933 988 427.159C988 437.955 984.129 447.455 976.388 455.661C968.645 463.433 959.183 467.32 948.001 467.32Z" fill="black"/>
<path d="M1087 805H1026V487.102H1080.51V540.74C1087 522.971 1099.54 507.904 1116.85 496.203C1134.58 484.068 1156 478 1181.09 478C1209.21 478 1232.57 485.585 1251.17 500.753C1269.77 515.923 1281.89 536.076 1287.51 561.213H1276.48C1280.8 536.076 1292.7 515.923 1312.17 500.753C1331.64 485.585 1355.65 478 1384.2 478C1420.54 478 1449.09 488.618 1469.85 509.856C1490.62 531.092 1501 560.13 1501 596.968V805H1441.3V611.921C1441.3 586.784 1434.81 567.498 1421.83 554.062C1409.29 540.192 1392.2 533.258 1370.57 533.258C1355.43 533.258 1342.02 536.725 1330.34 543.661C1319.09 550.161 1310.22 559.696 1303.73 572.265C1297.24 584.833 1294 599.569 1294 616.471V805H1233.65V611.27C1233.65 586.133 1227.38 567.064 1214.83 554.062C1202.29 540.626 1185.2 533.909 1163.57 533.909C1148.43 533.909 1135.02 537.376 1123.34 544.31C1112.09 550.812 1103.22 560.347 1096.73 572.914C1090.24 585.05 1087 599.569 1087 616.471V805Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1184_347">
<rect width="2130" height="1200" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -0,0 +1,13 @@
<svg width="2130" height="1200" viewBox="0 0 2130 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1184_318)">
<path d="M2130 0H0V1200H2130V0Z" fill="#6F3DFA"/>
<path d="M628 712.826H686.359C686.359 728.87 692.195 741.663 703.868 751.205C715.539 760.311 731.318 764.864 751.203 764.864C772.818 764.864 789.461 760.745 801.132 752.504C812.804 743.832 818.641 732.34 818.641 718.029C818.641 707.622 815.398 698.948 808.914 692.01C802.862 685.072 791.622 679.433 775.195 675.097L719.43 662.088C691.331 655.15 670.365 644.524 656.531 630.213C643.13 615.903 636.43 597.039 636.43 573.622C636.43 554.107 641.401 537.195 651.343 522.884C661.719 508.574 675.768 497.515 693.492 489.71C711.648 481.903 732.399 478 755.742 478C779.086 478 799.187 482.12 816.047 490.36C833.338 498.599 846.739 510.092 856.249 524.835C866.193 539.58 871.379 557.142 871.812 577.524H813.453C813.02 561.046 807.617 548.253 797.241 539.145C786.867 530.039 772.385 525.486 753.797 525.486C734.776 525.486 720.078 529.605 709.704 537.844C699.327 546.085 694.141 557.359 694.141 571.67C694.141 592.919 709.704 607.446 740.829 615.253L796.593 628.912C823.396 634.983 843.497 644.958 856.899 658.836C870.298 672.278 877 690.709 877 714.126C877 734.075 871.595 751.637 860.788 766.815C849.981 781.56 835.068 793.052 816.047 801.292C797.458 809.097 775.412 813 749.906 813C712.729 813 683.117 803.894 661.07 785.68C639.024 767.466 628 743.181 628 712.826Z" fill="white"/>
<path d="M918.324 805V488.257C942.504 497.069 953.168 497.069 978.968 488.257V805H918.324ZM948.001 467.32C937.248 467.32 927.786 463.433 919.614 455.661C911.871 447.455 908 437.955 908 427.159C908 415.933 911.871 406.432 919.614 398.659C927.786 390.887 937.248 387 948.001 387C959.183 387 968.645 390.887 976.388 398.659C984.129 406.432 988 415.933 988 427.159C988 437.955 984.129 447.455 976.388 455.661C968.645 463.433 959.183 467.32 948.001 467.32Z" fill="white"/>
<path d="M1087 805H1026V487.102H1080.51V540.74C1087 522.971 1099.54 507.904 1116.85 496.203C1134.58 484.068 1156 478 1181.09 478C1209.21 478 1232.57 485.585 1251.17 500.753C1269.77 515.923 1281.89 536.076 1287.51 561.213H1276.48C1280.8 536.076 1292.7 515.923 1312.17 500.753C1331.64 485.585 1355.65 478 1384.2 478C1420.54 478 1449.09 488.618 1469.85 509.856C1490.62 531.092 1501 560.13 1501 596.968V805H1441.3V611.921C1441.3 586.784 1434.81 567.498 1421.83 554.062C1409.29 540.192 1392.2 533.258 1370.57 533.258C1355.43 533.258 1342.02 536.725 1330.34 543.661C1319.09 550.161 1310.22 559.696 1303.73 572.265C1297.24 584.833 1294 599.569 1294 616.471V805H1233.65V611.27C1233.65 586.133 1227.38 567.064 1214.83 554.062C1202.29 540.626 1185.2 533.909 1163.57 533.909C1148.43 533.909 1135.02 537.376 1123.34 544.31C1112.09 550.812 1103.22 560.347 1096.73 572.914C1090.24 585.05 1087 599.569 1087 616.471V805Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1184_318">
<rect width="2130" height="1200" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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