v0.3.31: webhook fixes, advanced mode parameter filtering, credentials fixes, UI/UX improvements
57
README.md
@@ -1,50 +1,46 @@
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/sim.png" alt="Sim Logo" width="500"/>
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
|
||||
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
|
||||
<a href="https://docs.sim.ai"><img src="https://img.shields.io/badge/Docs-visit%20documentation-blue.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Sim</strong> is a lightweight, user-friendly platform for building AI agent workflows.
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
|
||||
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
|
||||
</p>
|
||||
|
||||
## Getting Started
|
||||
## Quickstart
|
||||
|
||||
1. Use our [cloud-hosted version](https://sim.ai)
|
||||
2. Self-host using one of the methods below
|
||||
### Cloud-hosted: [sim.ai](https://sim.ai)
|
||||
|
||||
## Self-Hosting Options
|
||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
|
||||
|
||||
### Option 1: NPM Package (Simplest)
|
||||
|
||||
The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
|
||||
### Self-hosted: NPM Package
|
||||
|
||||
```bash
|
||||
npx simstudio
|
||||
```
|
||||
→ http://localhost:3000
|
||||
|
||||
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
|
||||
#### Note
|
||||
Docker must be installed and running on your machine.
|
||||
|
||||
#### Options
|
||||
|
||||
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
|
||||
- `--no-pull`: Skip pulling the latest Docker images
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-p, --port <port>` | Port to run Sim on (default `3000`) |
|
||||
| `--no-pull` | Skip pulling latest Docker images |
|
||||
|
||||
#### Requirements
|
||||
|
||||
- Docker must be installed and running on your machine
|
||||
|
||||
### Option 2: Docker Compose
|
||||
### Self-hosted: Docker Compose
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
@@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh
|
||||
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
|
||||
```
|
||||
|
||||
### Option 3: Dev Containers
|
||||
### Self-hosted: Dev Containers
|
||||
|
||||
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
2. Open the project and click "Reopen in Container" when prompted
|
||||
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
|
||||
- This starts both the main application and the realtime socket server
|
||||
|
||||
### Option 4: Manual Setup
|
||||
### Self-hosted: Manual Setup
|
||||
|
||||
**Requirements:**
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
@@ -158,6 +154,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>
|
||||
|
||||
94
apps/docs/content/docs/copilot/index.mdx
Normal 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>
|
||||
4
apps/docs/content/docs/copilot/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Copilot",
|
||||
"pages": ["index"]
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
"connections",
|
||||
"---Execution---",
|
||||
"execution",
|
||||
"---Copilot---",
|
||||
"copilot",
|
||||
"---Advanced---",
|
||||
"./variables/index",
|
||||
"yaml",
|
||||
|
||||
@@ -105,6 +105,7 @@ describe('Copilot Chat API Route', () => {
|
||||
env: {
|
||||
SIM_AGENT_API_URL: 'http://localhost:8000',
|
||||
COPILOT_API_KEY: 'test-sim-agent-key',
|
||||
BETTER_AUTH_URL: 'http://localhost:3000',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -225,6 +226,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -288,6 +290,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -343,6 +346,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'agent',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -438,6 +442,7 @@ describe('Copilot Chat API Route', () => {
|
||||
mode: 'ask',
|
||||
provider: 'openai',
|
||||
depth: 0,
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -28,6 +28,15 @@ const logger = createLogger('CopilotChatAPI')
|
||||
// Sim Agent API configuration
|
||||
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
|
||||
|
||||
function getRequestOrigin(_req: NextRequest): string {
|
||||
try {
|
||||
// Strictly use configured Better Auth URL
|
||||
return env.BETTER_AUTH_URL || ''
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function deriveKey(keyString: string): Buffer {
|
||||
return createHash('sha256').update(keyString, 'utf8').digest()
|
||||
}
|
||||
@@ -72,7 +81,8 @@ const ChatMessageSchema = z.object({
|
||||
chatId: z.string().optional(),
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
mode: z.enum(['ask', 'agent']).optional().default('agent'),
|
||||
depth: z.number().int().min(0).max(3).optional().default(0),
|
||||
depth: z.number().int().min(-2).max(3).optional().default(0),
|
||||
prefetch: z.boolean().optional(),
|
||||
createNewChat: z.boolean().optional().default(false),
|
||||
stream: z.boolean().optional().default(true),
|
||||
implicitFeedback: z.string().optional(),
|
||||
@@ -189,6 +199,7 @@ export async function POST(req: NextRequest) {
|
||||
workflowId,
|
||||
mode,
|
||||
depth,
|
||||
prefetch,
|
||||
createNewChat,
|
||||
stream,
|
||||
implicitFeedback,
|
||||
@@ -197,6 +208,27 @@ export async function POST(req: NextRequest) {
|
||||
conversationId,
|
||||
} = ChatMessageSchema.parse(body)
|
||||
|
||||
// Derive request origin for downstream service
|
||||
const requestOrigin = getRequestOrigin(req)
|
||||
|
||||
if (!requestOrigin) {
|
||||
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
|
||||
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
|
||||
}
|
||||
|
||||
// Consolidation mapping: map negative depths to base depth with prefetch=true
|
||||
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
|
||||
let effectivePrefetch: boolean | undefined = prefetch
|
||||
if (typeof effectiveDepth === 'number') {
|
||||
if (effectiveDepth === -2) {
|
||||
effectiveDepth = 1
|
||||
effectivePrefetch = true
|
||||
} else if (effectiveDepth === -1) {
|
||||
effectiveDepth = 0
|
||||
effectivePrefetch = true
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
|
||||
userId: authenticatedUserId,
|
||||
workflowId,
|
||||
@@ -209,6 +241,8 @@ export async function POST(req: NextRequest) {
|
||||
provider: provider || 'openai',
|
||||
hasConversationId: !!conversationId,
|
||||
depth,
|
||||
prefetch,
|
||||
origin: requestOrigin,
|
||||
})
|
||||
|
||||
// Handle chat context
|
||||
@@ -384,8 +418,10 @@ export async function POST(req: NextRequest) {
|
||||
mode: mode,
|
||||
provider: providerToUse,
|
||||
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
|
||||
...(typeof depth === 'number' ? { depth } : {}),
|
||||
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
|
||||
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
|
||||
...(session?.user?.name && { userName: session.user.name }),
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
}
|
||||
|
||||
// Log the payload being sent to the streaming endpoint
|
||||
@@ -397,8 +433,10 @@ export async function POST(req: NextRequest) {
|
||||
stream,
|
||||
workflowId,
|
||||
hasConversationId: !!effectiveConversationId,
|
||||
depth: typeof depth === 'number' ? depth : undefined,
|
||||
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
|
||||
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
|
||||
messagesCount: requestPayload.messages.length,
|
||||
...(requestOrigin ? { origin: requestOrigin } : {}),
|
||||
})
|
||||
// Full payload as JSON string
|
||||
logger.info(
|
||||
@@ -458,6 +496,12 @@ export async function POST(req: NextRequest) {
|
||||
let isFirstDone = true
|
||||
let responseIdFromStart: string | undefined
|
||||
let responseIdFromDone: string | undefined
|
||||
// Track tool call progress to identify a safe done event
|
||||
const announcedToolCallIds = new Set<string>()
|
||||
const startedToolExecutionIds = new Set<string>()
|
||||
const completedToolExecutionIds = new Set<string>()
|
||||
let lastDoneResponseId: string | undefined
|
||||
let lastSafeDoneResponseId: string | undefined
|
||||
|
||||
// Send chatId as first event
|
||||
if (actualChatId) {
|
||||
@@ -575,6 +619,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
if (!event.data?.partial) {
|
||||
toolCalls.push(event.data)
|
||||
if (event.data?.id) {
|
||||
announcedToolCallIds.add(event.data.id)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -584,6 +631,14 @@ export async function POST(req: NextRequest) {
|
||||
toolName: event.toolName,
|
||||
status: event.status,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
if (event.status === 'completed') {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
} else {
|
||||
startedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_result':
|
||||
@@ -594,6 +649,9 @@ export async function POST(req: NextRequest) {
|
||||
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
|
||||
resultSize: JSON.stringify(event.result).length,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_error':
|
||||
@@ -603,6 +661,9 @@ export async function POST(req: NextRequest) {
|
||||
error: event.error,
|
||||
success: event.success,
|
||||
})
|
||||
if (event.toolCallId) {
|
||||
completedToolExecutionIds.add(event.toolCallId)
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
@@ -617,9 +678,25 @@ export async function POST(req: NextRequest) {
|
||||
case 'done':
|
||||
if (event.data?.responseId) {
|
||||
responseIdFromDone = event.data.responseId
|
||||
lastDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
|
||||
)
|
||||
// Mark this done as safe only if no tool call is currently in progress or pending
|
||||
const announced = announcedToolCallIds.size
|
||||
const completed = completedToolExecutionIds.size
|
||||
const started = startedToolExecutionIds.size
|
||||
const hasToolInProgress = announced > completed || started > completed
|
||||
if (!hasToolInProgress) {
|
||||
lastSafeDoneResponseId = responseIdFromDone
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFirstDone) {
|
||||
logger.info(
|
||||
@@ -714,7 +791,9 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const responseId = responseIdFromDone
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: 'test-secret-key',
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 401 when internal API key is not configured', async () => {
|
||||
@@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => {
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
INTERNAL_API_SECRET: undefined,
|
||||
COPILOT_API_KEY: 'test-copilot-key',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => {
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
success: false,
|
||||
error: 'Internal API key not configured',
|
||||
})
|
||||
expect(responseData.status).toBeUndefined()
|
||||
expect(responseData.success).toBe(false)
|
||||
expect(typeof responseData.error).toBe('string')
|
||||
})
|
||||
|
||||
it('should return 400 for invalid request body - missing methodId', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
|
||||
import type { NotificationStatus } from '@/lib/copilot/types'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getRedisClient } from '@/lib/redis'
|
||||
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
|
||||
@@ -232,10 +232,13 @@ export async function POST(req: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Check authentication (internal API key)
|
||||
const authResult = checkInternalApiKey(req)
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), {
|
||||
// Evaluate both auth schemes; pass if either is valid
|
||||
const internalAuth = checkInternalApiKey(req)
|
||||
const copilotAuth = checkCopilotApiKey(req)
|
||||
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
|
||||
if (!isAuthenticated) {
|
||||
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
|
||||
return NextResponse.json(createErrorResponse(errorMessage), {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
@@ -243,7 +246,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json()
|
||||
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
|
||||
|
||||
logger.info(`[${requestId}] Method execution request: ${methodId}`, {
|
||||
logger.info(`[${requestId}] Method execution request`, {
|
||||
methodId,
|
||||
toolCallId,
|
||||
hasParams: !!params && Object.keys(params).length > 0,
|
||||
|
||||
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { user } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('UpdateUserProfileAPI')
|
||||
|
||||
// Schema for updating user profile
|
||||
const UpdateProfileSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, 'Name is required').optional(),
|
||||
})
|
||||
.refine((data) => data.name !== undefined, {
|
||||
message: 'Name field must be provided',
|
||||
})
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
const validatedData = UpdateProfileSchema.parse(body)
|
||||
|
||||
// Build update object
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
if (validatedData.name !== undefined) updateData.name = validatedData.name
|
||||
|
||||
// Update user profile
|
||||
const [updatedUser] = await db
|
||||
.update(user)
|
||||
.set(updateData)
|
||||
.where(eq(user.id, userId))
|
||||
.returning()
|
||||
|
||||
if (!updatedUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] User profile updated`, {
|
||||
userId,
|
||||
updatedFields: Object.keys(validatedData),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
name: updatedUser.name,
|
||||
email: updatedUser.email,
|
||||
image: updatedUser.image,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid profile data`, {
|
||||
errors: error.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid profile data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Profile update error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch current user profile
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [userRecord] = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
emailVerified: user.emailVerified,
|
||||
})
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userRecord) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: userRecord,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Profile fetch error`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -191,6 +191,11 @@ export function DiffControls() {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
try {
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Clear preview YAML immediately
|
||||
await clearPreviewYaml().catch((error) => {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
@@ -219,10 +224,10 @@ export function DiffControls() {
|
||||
logger.warn('Failed to clear preview YAML:', error)
|
||||
})
|
||||
|
||||
// Reject is immediate (no server save needed)
|
||||
rejectChanges()
|
||||
|
||||
logger.info('Successfully rejected proposed changes')
|
||||
// Reject changes optimistically
|
||||
rejectChanges().catch((error) => {
|
||||
logger.error('Failed to reject changes (background):', error)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import {
|
||||
CodeDisplay,
|
||||
JSONView,
|
||||
@@ -349,9 +350,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
// For code display, copy just the code string
|
||||
textToCopy = entry.input.code
|
||||
} else {
|
||||
// For regular JSON display, copy the full JSON
|
||||
// For regular JSON display, copy the full JSON with redaction applied
|
||||
const dataToCopy = showInput ? entry.input : entry.output
|
||||
textToCopy = JSON.stringify(dataToCopy, null, 2)
|
||||
const redactedData = redactApiKeys(dataToCopy)
|
||||
textToCopy = JSON.stringify(redactedData, null, 2)
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
|
||||
interface JSONViewProps {
|
||||
data: any
|
||||
@@ -154,6 +155,9 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
y: number
|
||||
} | null>(null)
|
||||
|
||||
// Apply redaction to the data before displaying
|
||||
const redactedData = redactApiKeys(data)
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
@@ -167,18 +171,18 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
}
|
||||
}, [contextMenuPosition])
|
||||
|
||||
if (data === null)
|
||||
if (redactedData === null)
|
||||
return <span className='font-[380] text-muted-foreground leading-normal'>null</span>
|
||||
|
||||
// For non-object data, show simple JSON
|
||||
if (typeof data !== 'object') {
|
||||
const stringValue = JSON.stringify(data)
|
||||
if (typeof redactedData !== 'object') {
|
||||
const stringValue = JSON.stringify(redactedData)
|
||||
return (
|
||||
<span
|
||||
onContextMenu={handleContextMenu}
|
||||
className='relative max-w-full overflow-hidden break-all font-[380] font-mono text-muted-foreground leading-normal'
|
||||
>
|
||||
{typeof data === 'string' ? (
|
||||
{typeof redactedData === 'string' ? (
|
||||
<TruncatedValue value={stringValue} />
|
||||
) : (
|
||||
<span className='break-all font-[380] text-muted-foreground leading-normal'>
|
||||
@@ -192,7 +196,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy value
|
||||
</button>
|
||||
@@ -206,7 +210,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
<pre className='max-w-full overflow-hidden whitespace-pre-wrap break-all font-mono'>
|
||||
<CollapsibleJSON data={data} />
|
||||
<CollapsibleJSON data={redactedData} />
|
||||
</pre>
|
||||
{contextMenuPosition && (
|
||||
<div
|
||||
@@ -215,7 +219,7 @@ export const JSONView = ({ data }: JSONViewProps) => {
|
||||
>
|
||||
<button
|
||||
className='w-full px-3 py-1.5 text-left font-[380] text-sm hover:bg-accent'
|
||||
onClick={() => copyToClipboard(data)}
|
||||
onClick={() => copyToClipboard(redactedData)}
|
||||
>
|
||||
Copy object
|
||||
</button>
|
||||
|
||||
@@ -27,6 +27,18 @@ export function ThinkingBlock({
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-collapse when streaming ends
|
||||
if (!isStreaming) {
|
||||
setIsExpanded(false)
|
||||
return
|
||||
}
|
||||
// Expand once there is visible content while streaming
|
||||
if (content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content])
|
||||
|
||||
useEffect(() => {
|
||||
// If we already have a persisted duration, just use it
|
||||
if (typeof persistedDuration === 'number') {
|
||||
@@ -52,29 +64,10 @@ export function ThinkingBlock({
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
)}
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)}</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-1'>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className={cn(
|
||||
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
|
||||
'font-normal italic'
|
||||
@@ -82,14 +75,25 @@ export function ThinkingBlock({
|
||||
type='button'
|
||||
>
|
||||
<Brain className='h-3 w-3' />
|
||||
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
|
||||
<span>
|
||||
Thought for {formatDuration(duration)}
|
||||
{isExpanded ? ' (click to collapse)' : ''}
|
||||
</span>
|
||||
{isStreaming && (
|
||||
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
|
||||
)}
|
||||
</button>
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
|
||||
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Checkpoints below message */}
|
||||
{hasCheckpoints && (
|
||||
<div className='mt-1 flex justify-end'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>Restore?</span>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
|
||||
<span className='select-none'>
|
||||
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
|
||||
</span>
|
||||
<div className='inline-flex w-8 items-center justify-center'>
|
||||
{showRestoreConfirmation ? (
|
||||
<div className='inline-flex items-center gap-1'>
|
||||
<button
|
||||
onClick={handleConfirmRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Confirm restore'
|
||||
aria-label='Confirm restore'
|
||||
>
|
||||
{isRevertingCheckpoint ? (
|
||||
<Loader2 className='h-3 w-3 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3 w-3' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRevert}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Cancel restore'
|
||||
aria-label='Cancel restore'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
aria-label='Restore'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRevertToCheckpoint}
|
||||
disabled={isRevertingCheckpoint}
|
||||
className='flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
|
||||
title='Restore workflow to this checkpoint state'
|
||||
>
|
||||
<RotateCcw className='h-3 w-3' />
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CopilotSlider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
|
||||
<SliderPrimitive.Range className='absolute h-full bg-primary' />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
CopilotSlider.displayName = 'CopilotSlider'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FileText,
|
||||
Image,
|
||||
Infinity as InfinityIcon,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Package,
|
||||
@@ -30,11 +31,13 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCopilotStore } from '@/stores/copilot/store'
|
||||
import { CopilotSlider as Slider } from './copilot-slider'
|
||||
|
||||
export interface MessageFileAttachment {
|
||||
id: string
|
||||
@@ -426,32 +429,31 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
// Depth toggle state comes from global store; access via useCopilotStore
|
||||
const { agentDepth, setAgentDepth } = useCopilotStore()
|
||||
const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore()
|
||||
|
||||
const cycleDepth = () => {
|
||||
// Allowed UI values: 0 (Lite), 1 (Default), 2 (Pro), 3 (Max)
|
||||
const next = agentDepth === 0 ? 1 : agentDepth === 1 ? 2 : agentDepth === 2 ? 3 : 0
|
||||
setAgentDepth(next)
|
||||
// 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping.
|
||||
const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3)
|
||||
if (nextDepth === 0 && agentDepth === 3) {
|
||||
setAgentPrefetch(!agentPrefetch)
|
||||
}
|
||||
setAgentDepth(nextDepth)
|
||||
}
|
||||
|
||||
const getDepthLabel = () => {
|
||||
if (agentDepth === 0) return 'Fast'
|
||||
if (agentDepth === 1) return 'Auto'
|
||||
if (agentDepth === 2) return 'Pro'
|
||||
return 'Max'
|
||||
const getCollapsedModeLabel = () => {
|
||||
const base = getDepthLabelFor(agentDepth)
|
||||
return !agentPrefetch ? `${base} MAX` : base
|
||||
}
|
||||
|
||||
const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0) return 'Fast'
|
||||
if (value === 1) return 'Auto'
|
||||
if (value === 2) return 'Pro'
|
||||
return 'Max'
|
||||
return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert'
|
||||
}
|
||||
|
||||
// Removed descriptive suffixes; concise labels only
|
||||
const getDepthDescription = (value: 0 | 1 | 2 | 3) => {
|
||||
if (value === 0)
|
||||
return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.'
|
||||
if (value === 1) return 'Automatically balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.'
|
||||
if (value === 2)
|
||||
return 'More reasoning for larger workflows and complex edits, still balanced for speed.'
|
||||
return 'Maximum reasoning power. Best for complex workflow building and debugging.'
|
||||
@@ -548,7 +550,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
placeholder={isDragging ? 'Drop files here...' : placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
|
||||
@@ -635,126 +637,72 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='flex h-6 items-center gap-1.5 rounded-full border px-2 py-1 font-medium text-xs'
|
||||
title='Choose depth'
|
||||
title='Choose mode'
|
||||
>
|
||||
{getDepthIcon()}
|
||||
<span>{getDepthLabel()}</span>
|
||||
<span>{getCollapsedModeLabel()}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='p-0'>
|
||||
<TooltipProvider>
|
||||
<div className='w-[180px] p-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(1)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 1 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<InfinityIcon className='h-3 w-3 text-muted-foreground' />
|
||||
Auto
|
||||
</span>
|
||||
{agentDepth === 1 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Automatically balances speed and reasoning. Good fit for most tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(0)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 0 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Zap className='h-3 w-3 text-muted-foreground' />
|
||||
Fast
|
||||
</span>
|
||||
{agentDepth === 0 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Fastest and cheapest. Good for small edits, simple workflows, and small
|
||||
tasks.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(2)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 2 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<Brain className='h-3 w-3 text-muted-foreground' />
|
||||
Pro
|
||||
</span>
|
||||
{agentDepth === 2 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
More reasoning for larger workflows and complex edits, still balanced
|
||||
for speed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setAgentDepth(3)}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1.5 text-xs leading-4',
|
||||
agentDepth === 3 && 'bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<BrainCircuit className='h-3 w-3 text-muted-foreground' />
|
||||
Max
|
||||
</span>
|
||||
{agentDepth === 3 && (
|
||||
<Check className='h-3 w-3 text-muted-foreground' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Maximum reasoning power. Best for complex workflow building and
|
||||
debugging.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
|
||||
<div className='w-[260px] p-3'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-medium text-xs'>MAX mode</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='h-3.5 w-3.5 rounded text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='MAX mode info'
|
||||
>
|
||||
<Info className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side='right'
|
||||
sideOffset={6}
|
||||
align='center'
|
||||
className='max-w-[220px] border bg-popover p-2 text-[11px] text-popover-foreground leading-snug shadow-md'
|
||||
>
|
||||
Significantly increases depth of reasoning
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!agentPrefetch}
|
||||
onCheckedChange={(checked) => setAgentPrefetch(!checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className='my-2 flex justify-center'>
|
||||
<div className='h-px w-[100%] bg-border' />
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<span className='font-medium text-xs'>Mode</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{getDepthLabelFor(agentDepth)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={[agentDepth]}
|
||||
onValueChange={(val) =>
|
||||
setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3)
|
||||
}
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0'>
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[33.333%] h-2 w-[3px] bg-background' />
|
||||
<div className='-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-[66.667%] h-2 w-[3px] bg-background' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 text-[11px] text-muted-foreground'>
|
||||
{getDepthDescription(agentDepth)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -44,6 +44,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
// Scroll state
|
||||
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
// New state to track if user has intentionally scrolled during streaming
|
||||
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
|
||||
const isUserScrollingRef = useRef(false) // Track if scroll event is user-initiated
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -119,6 +122,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
@@ -143,7 +148,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
setIsNearBottom(nearBottom)
|
||||
setShowScrollButton(!nearBottom)
|
||||
}, [])
|
||||
|
||||
// If user scrolled up during streaming, mark it
|
||||
if (isSendingMessage && !nearBottom && isUserScrollingRef.current) {
|
||||
setUserHasScrolledDuringStream(true)
|
||||
}
|
||||
|
||||
// Reset the user scrolling flag after processing
|
||||
isUserScrollingRef.current = true
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -154,7 +167,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]')
|
||||
if (!viewport) return
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Mark user-initiated scrolls
|
||||
const handleUserScroll = () => {
|
||||
isUserScrollingRef.current = true
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
viewport.addEventListener('scroll', handleUserScroll, { passive: true })
|
||||
|
||||
// Also listen for scrollend event if available (for smooth scrolling)
|
||||
if ('onscrollend' in viewport) {
|
||||
@@ -165,34 +184,63 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
setTimeout(handleScroll, 100)
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener('scroll', handleScroll)
|
||||
viewport.removeEventListener('scroll', handleUserScroll)
|
||||
if ('onscrollend' in viewport) {
|
||||
viewport.removeEventListener('scrollend', handleScroll)
|
||||
}
|
||||
}
|
||||
}, [handleScroll])
|
||||
|
||||
// Smart auto-scroll: only scroll if user is near bottom or for user messages
|
||||
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const isNewUserMessage = lastMessage?.role === 'user'
|
||||
|
||||
// Always scroll for new user messages, or only if near bottom for assistant messages
|
||||
if ((isNewUserMessage || isNearBottom) && scrollAreaRef.current) {
|
||||
// Conditions for auto-scrolling:
|
||||
// 1. Always scroll for new user messages (resets the user scroll state)
|
||||
// 2. For assistant messages during streaming: only if user hasn't scrolled up
|
||||
// 3. For assistant messages when not streaming: only if near bottom
|
||||
const shouldAutoScroll =
|
||||
isNewUserMessage ||
|
||||
(isSendingMessage && !userHasScrolledDuringStream) ||
|
||||
(!isSendingMessage && isNearBottom)
|
||||
|
||||
if (shouldAutoScroll && scrollAreaRef.current) {
|
||||
const scrollContainer = scrollAreaRef.current.querySelector(
|
||||
'[data-radix-scroll-area-viewport]'
|
||||
)
|
||||
if (scrollContainer) {
|
||||
// Mark that we're programmatically scrolling
|
||||
isUserScrollingRef.current = false
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
// Let the scroll event handler update the state naturally after animation completes
|
||||
}
|
||||
}
|
||||
}, [messages, isNearBottom])
|
||||
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream])
|
||||
|
||||
// Reset user scroll state when streaming starts or when user sends a message
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (lastMessage?.role === 'user') {
|
||||
// User sent a new message - reset scroll state
|
||||
setUserHasScrolledDuringStream(false)
|
||||
isUserScrollingRef.current = false
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Reset user scroll state when streaming completes
|
||||
const prevIsSendingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// When streaming transitions from true to false, reset the user scroll state
|
||||
if (prevIsSendingRef.current && !isSendingMessage) {
|
||||
setUserHasScrolledDuringStream(false)
|
||||
}
|
||||
prevIsSendingRef.current = isSendingMessage
|
||||
}, [isSendingMessage])
|
||||
|
||||
// Auto-scroll to bottom when chat loads in
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 doesn’t contain the effective value but meta says it exists, synthesize a non-leaky placeholder to render stable UI
|
||||
const displayName = selectedCredential
|
||||
? selectedCredential.name
|
||||
: isForeign
|
||||
? 'Saved by collaborator'
|
||||
: undefined
|
||||
|
||||
// Handle selection
|
||||
const handleSelect = (credentialId: string) => {
|
||||
const previousId = selectedId || (effectiveValue as string) || ''
|
||||
@@ -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' />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -48,6 +48,7 @@ interface JiraIssueSelectorProps {
|
||||
projectId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export function JiraIssueSelector({
|
||||
@@ -63,6 +64,8 @@ export function JiraIssueSelector({
|
||||
onIssueInfoChange,
|
||||
projectId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
workflowId,
|
||||
}: JiraIssueSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
@@ -168,6 +171,7 @@ export function JiraIssueSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -264,6 +268,7 @@ export function JiraIssueSelector({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialId: selectedCredentialId,
|
||||
workflowId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -377,6 +382,10 @@ export function JiraIssueSelector({
|
||||
|
||||
// Handle open change
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (disabled || isForeignCredential) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(isOpen)
|
||||
|
||||
// Only fetch recent/default issues when opening the dropdown
|
||||
@@ -451,7 +460,7 @@ export function JiraIssueSelector({
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
className='h-10 w-full min-w-0 justify-between'
|
||||
disabled={disabled || !domain || !selectedCredentialId}
|
||||
disabled={disabled || !domain || !selectedCredentialId || isForeignCredential}
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{selectedIssue ? (
|
||||
@@ -469,118 +478,122 @@ export function JiraIssueSelector({
|
||||
<ChevronDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
{!isForeignCredential && (
|
||||
<PopoverContent className='w-[300px] p-0' align='start'>
|
||||
{/* Current account indicator */}
|
||||
{selectedCredentialId && credentials.length > 0 && (
|
||||
<div className='flex items-center justify-between border-b px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{credentials.find((cred) => cred.id === selectedCredentialId)?.name ||
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
{credentials.length > 1 && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 px-2 text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Switch
|
||||
</Button>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search issues...' onValueChange={handleSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center p-4'>
|
||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||
<span className='ml-2'>Loading issues...</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='text-destructive text-sm'>{error}</p>
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No accounts connected.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Connect a Jira account to continue.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-sm'>No issues found.</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Try a different search or account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Account selection - only show if we have multiple accounts */}
|
||||
{credentials.length > 1 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Switch Account
|
||||
</div>
|
||||
{credentials.map((cred) => (
|
||||
<CommandItem
|
||||
key={cred.id}
|
||||
value={`account-${cred.id}`}
|
||||
onSelect={() => setSelectedCredentialId(cred.id)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='font-normal'>{cred.name}</span>
|
||||
</div>
|
||||
{cred.id === selectedCredentialId && (
|
||||
<Check className='ml-auto h-4 w-4' />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{issues.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
|
||||
Issues
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
value={`issue-${issue.id}-${issue.name}`}
|
||||
onSelect={() => handleSelectIssue(issue)}
|
||||
>
|
||||
<div className='flex items-center gap-2 overflow-hidden'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span className='truncate font-normal'>{issue.name}</span>
|
||||
</div>
|
||||
{issue.id === selectedIssueId && <Check className='ml-auto h-4 w-4' />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* Connect account option - only show if no credentials */}
|
||||
{credentials.length === 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<JiraIcon className='h-4 w-4' />
|
||||
<span>Connect Jira account</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Issue preview */}
|
||||
|
||||
@@ -55,6 +55,9 @@ interface MicrosoftFileSelectorProps {
|
||||
showPreview?: boolean
|
||||
onFileInfoChange?: (fileInfo: MicrosoftFileInfo | null) => void
|
||||
planId?: string
|
||||
workflowId?: string
|
||||
credentialId?: string
|
||||
isForeignCredential?: boolean
|
||||
}
|
||||
|
||||
export function MicrosoftFileSelector({
|
||||
@@ -68,10 +71,13 @@ export function MicrosoftFileSelector({
|
||||
showPreview = true,
|
||||
onFileInfoChange,
|
||||
planId,
|
||||
workflowId,
|
||||
credentialId,
|
||||
isForeignCredential = false,
|
||||
}: MicrosoftFileSelectorProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [credentials, setCredentials] = useState<Credential[]>([])
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>(credentialId || '')
|
||||
const [selectedFileId, setSelectedFileId] = useState(value)
|
||||
const [selectedFile, setSelectedFile] = useState<MicrosoftFileInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CancelSubscription } from './cancel-subscription'
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { UsageLimitRef } from './usage-limit'
|
||||
export { UsageLimit } from './usage-limit'
|
||||
@@ -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'
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberInvitationCard } from './member-invitation-card'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberLimit } from './member-limit'
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NoOrganizationView } from './no-organization-view'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { OrganizationCreationDialog } from './organization-creation-dialog'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { OrganizationSettingsTab } from './organization-settings-tab'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { PendingInvitationsList } from './pending-invitations-list'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { RemoveMemberDialog } from './remove-member-dialog'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamMembersList } from './team-members-list'
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamSeatsOverview } from './team-seats-overview'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamSeats } from './team-seats'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamUsage } from './team-usage'
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
13
apps/sim/public/logo/426-240/b&w/b&w.svg
Normal 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 |
BIN
apps/sim/public/logo/426-240/b&w/large.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/sim/public/logo/426-240/b&w/medium.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
apps/sim/public/logo/426-240/b&w/small.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/sim/public/logo/426-240/primary/large.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/sim/public/logo/426-240/primary/medium.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
13
apps/sim/public/logo/426-240/primary/primary.svg
Normal 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 |
BIN
apps/sim/public/logo/426-240/primary/small.png
Normal file
|
After Width: | Height: | Size: 24 KiB |