mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
feat(settings): added reactquery for settings, removed zustand stores, added apollo, added workflow block selector dropdown search, added add environment variable option to empty env var dropdown (#1971)
* feat(settings): added reactquery for settings, removed zustand stores, added apollo, added workflow block selector dropdown search, added add environment variable option to empty env var dropdown * fix delete dialog for copilot keys * simplify combobox * fix more z indices * consolidated duplicate hooks --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
@@ -4003,3 +4003,32 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
height='26'
|
||||
viewBox='0 0 36 36'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M19.5993 0.0862365L19.605 13.2568C19.6058 15.3375 17.4222 16.6715 15.6079 15.6986L2.58376 8.7153C3.57706 7.05795 4.82616 5.57609 6.27427 4.32386L16.489 13.8945C17.0303 14.4015 17.8835 13.8518 17.6605 13.1398L13.6992 0.493553C15.0326 0.17147 16.4233 0 17.8536 0C18.4428 0 19.0248 0.0296814 19.5993 0.0862365Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M16.0635 36.1087L16.0578 23.0046C16.057 20.9239 18.2407 19.5898 20.0549 20.5627L33.0838 27.5486C32.0838 29.2016 30.8289 30.6786 29.3751 31.925L19.1738 22.3668C18.6326 21.8598 17.7793 22.4095 18.0023 23.1215L21.9486 35.72C20.6338 36.0329 19.263 36.1989 17.8539 36.1989C17.2497 36.1989 16.6523 36.1683 16.0635 36.1087Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M22.0105 16.77L31.4705 6.39392C30.2362 4.92008 28.7742 3.6486 27.1384 2.63702L20.2306 15.8767C19.2709 17.716 20.5871 19.9298 22.6396 19.9288L35.6183 19.923C35.6775 19.3234 35.7082 18.7151 35.7082 18.0996C35.7082 16.6683 35.5436 15.2761 35.2338 13.9406L22.7549 17.9576C22.0526 18.1837 21.5103 17.3187 22.0105 16.77Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M0.0842758 16.3383L13.0237 16.3325C15.0764 16.3317 16.3923 18.5454 15.4327 20.3846L8.56047 33.5561C6.93095 32.547 5.47394 31.2801 4.24344 29.8121L13.653 19.4914C14.1531 18.9427 13.6107 18.0777 12.9084 18.3037L0.485078 22.3029C0.168551 20.954 0 19.5467 0 18.0994C0 17.5051 0.0290814 16.9177 0.0842758 16.3383Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
AirtableIcon,
|
||||
ApolloIcon,
|
||||
ArxivIcon,
|
||||
AsanaIcon,
|
||||
BrainIcon,
|
||||
@@ -80,78 +81,79 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
postgresql: PostgresIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
translate: TranslateIcon,
|
||||
tavily: TavilyIcon,
|
||||
stagehand_agent: StagehandIcon,
|
||||
youtube: YouTubeIcon,
|
||||
supabase: SupabaseIcon,
|
||||
vision: EyeIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
arxiv: ArxivIcon,
|
||||
webflow: WebflowIcon,
|
||||
pinecone: PineconeIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
typeform: TypeformIcon,
|
||||
qdrant: QdrantIcon,
|
||||
asana: AsanaIcon,
|
||||
memory: BrainIcon,
|
||||
serper: SerperIcon,
|
||||
linear: LinearIcon,
|
||||
exa: ExaAIIcon,
|
||||
telegram: TelegramIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
hunter: HunterIOIcon,
|
||||
linkup: LinkupIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
airtable: AirtableIcon,
|
||||
discord: DiscordIcon,
|
||||
jina: JinaAIIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
google_search: GoogleIcon,
|
||||
x: xIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
zep: ZepIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
thinking: BrainIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
stagehand: StagehandIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
file: DocumentIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
gmail: GmailIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
resend: ResendIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
clay: ClayIcon,
|
||||
jira: JiraIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
notion: NotionIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
github: GithubIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
reddit: RedditIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
stripe: StripeIcon,
|
||||
s3: S3Icon,
|
||||
trello: TrelloIcon,
|
||||
mem0: Mem0Icon,
|
||||
knowledge: PackageSearchIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
slack: SlackIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
image_generator: ImageIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
youtube: YouTubeIcon,
|
||||
x: xIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand_agent: StagehandIcon,
|
||||
stagehand: StagehandIcon,
|
||||
slack: SlackIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
serper: SerperIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
qdrant: QdrantIcon,
|
||||
postgresql: PostgresIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
linkup: LinkupIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
discord: DiscordIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
airtable: AirtableIcon,
|
||||
}
|
||||
|
||||
579
apps/docs/content/docs/en/tools/apollo.mdx
Normal file
579
apps/docs/content/docs/en/tools/apollo.mdx
Normal file
@@ -0,0 +1,579 @@
|
||||
---
|
||||
title: Apollo
|
||||
description: Search, enrich, and manage contacts with Apollo.io
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="apollo"
|
||||
color="#EBF212"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Apollo.io](https://apollo.io/) is a leading sales intelligence and engagement platform that empowers users to find, enrich, and engage contacts and companies at scale. Apollo.io combines an extensive contact database with robust enrichment and workflow automation tools, assisting sales, marketing, and recruiting teams to accelerate growth.
|
||||
|
||||
With Apollo.io, you can:
|
||||
|
||||
- **Search millions of contacts and companies**: Find precise leads using advanced filters
|
||||
- **Enrich leads and accounts**: Fill in missing details with verified data and up-to-date information
|
||||
- **Manage and organize CRM records**: Keep your people and company data accurate and actionable
|
||||
- **Automate outreach**: Add contacts to sequences and create follow-up tasks directly from Apollo.io
|
||||
|
||||
In Sim, the Apollo.io integration allows your agents to perform core Apollo operations programmatically:
|
||||
|
||||
- **Search people and companies**: Use `apollo_people_search` to discover new leads using flexible filters.
|
||||
- **Enrich people data**: Use `apollo_people_enrich` to augment contacts with verified information.
|
||||
- **Enrich people in bulk**: Use `apollo_people_bulk_enrich` for large-scale enrichment of multiple contacts at once.
|
||||
- **Search and enrich companies**: Use `apollo_company_search` and `apollo_company_enrich` to discover and update key company information.
|
||||
|
||||
This enables your agents to build powerful workflows for prospecting, CRM enrichment, and automation without manual data entry or switching tabs. Integrate Apollo.io as a dynamic data source and CRM engine — empowering your agents to identify, qualify, and reach out to leads seamlessly as part of their daily operations.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrates Apollo.io into the workflow. Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `apollo_people_search`
|
||||
|
||||
Search Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `person_titles` | array | No | Job titles to search for \(e.g., \["CEO", "VP of Sales"\]\) |
|
||||
| `person_locations` | array | No | Locations to search in \(e.g., \["San Francisco, CA", "New York, NY"\]\) |
|
||||
| `person_seniorities` | array | No | Seniority levels \(e.g., \["senior", "executive", "manager"\]\) |
|
||||
| `organization_names` | array | No | Company names to search within |
|
||||
| `q_keywords` | string | No | Keywords to search for |
|
||||
| `page` | number | No | Page number for pagination \(default: 1\) |
|
||||
| `per_page` | number | No | Results per page \(default: 25, max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array of people matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_people_enrich`
|
||||
|
||||
Enrich data for a single person using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `first_name` | string | No | First name of the person |
|
||||
| `last_name` | string | No | Last name of the person |
|
||||
| `email` | string | No | Email address of the person |
|
||||
| `organization_name` | string | No | Company name where the person works |
|
||||
| `domain` | string | No | Company domain \(e.g., apollo.io\) |
|
||||
| `linkedin_url` | string | No | LinkedIn profile URL |
|
||||
| `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) |
|
||||
| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `person` | json | Enriched person data from Apollo |
|
||||
| `metadata` | json | Enrichment metadata including enriched status |
|
||||
|
||||
### `apollo_people_bulk_enrich`
|
||||
|
||||
Enrich data for up to 10 people at once using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `people` | array | Yes | Array of people to enrich \(max 10\) |
|
||||
| `reveal_personal_emails` | boolean | No | Reveal personal email addresses \(uses credits\) |
|
||||
| `reveal_phone_number` | boolean | No | Reveal phone numbers \(uses credits\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `people` | json | Array of enriched people data |
|
||||
| `metadata` | json | Bulk enrichment metadata including total and enriched counts |
|
||||
|
||||
### `apollo_organization_search`
|
||||
|
||||
Search Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organization_locations` | array | No | Company locations to search |
|
||||
| `organization_num_employees_ranges` | array | No | Employee count ranges \(e.g., \["1-10", "11-50"\]\) |
|
||||
| `q_organization_keyword_tags` | array | No | Industry or keyword tags |
|
||||
| `q_organization_name` | string | No | Organization name to search for |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array of organizations matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_organization_enrich`
|
||||
|
||||
Enrich data for a single organization using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organization_name` | string | No | Name of the organization \(at least one of organization_name or domain is required\) |
|
||||
| `domain` | string | No | Company domain \(e.g., apollo.io\) \(at least one of domain or organization_name is required\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organization` | json | Enriched organization data from Apollo |
|
||||
| `metadata` | json | Enrichment metadata including enriched status |
|
||||
|
||||
### `apollo_organization_bulk_enrich`
|
||||
|
||||
Enrich data for up to 10 organizations at once using Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `organizations` | array | Yes | Array of organizations to enrich \(max 10\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | json | Array of enriched organization data |
|
||||
| `metadata` | json | Bulk enrichment metadata including total and enriched counts |
|
||||
|
||||
### `apollo_contact_create`
|
||||
|
||||
Create a new contact in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `first_name` | string | Yes | First name of the contact |
|
||||
| `last_name` | string | Yes | Last name of the contact |
|
||||
| `email` | string | No | Email address of the contact |
|
||||
| `title` | string | No | Job title |
|
||||
| `account_id` | string | No | Apollo account ID to associate with |
|
||||
| `owner_id` | string | No | User ID of the contact owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Created contact data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_contact_update`
|
||||
|
||||
Update an existing contact in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `contact_id` | string | Yes | ID of the contact to update |
|
||||
| `first_name` | string | No | First name of the contact |
|
||||
| `last_name` | string | No | Last name of the contact |
|
||||
| `email` | string | No | Email address |
|
||||
| `title` | string | No | Job title |
|
||||
| `account_id` | string | No | Apollo account ID |
|
||||
| `owner_id` | string | No | User ID of the contact owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | json | Updated contact data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_contact_search`
|
||||
|
||||
Search your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `q_keywords` | string | No | Keywords to search for |
|
||||
| `contact_stage_ids` | array | No | Filter by contact stage IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts` | json | Array of contacts matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_contact_bulk_create`
|
||||
|
||||
Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contacts` | array | Yes | Array of contacts to create \(max 100\). Each contact should include first_name, last_name, and optionally email, title, account_id, owner_id |
|
||||
| `run_dedupe` | boolean | No | Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_contacts` | json | Array of newly created contacts |
|
||||
| `existing_contacts` | json | Array of existing contacts \(when deduplication is enabled\) |
|
||||
| `metadata` | json | Bulk creation metadata including counts of created and existing contacts |
|
||||
|
||||
### `apollo_contact_bulk_update`
|
||||
|
||||
Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contacts` | array | Yes | Array of contacts to update \(max 100\). Each contact must include id field, and optionally first_name, last_name, email, title, account_id, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_contacts` | json | Array of successfully updated contacts |
|
||||
| `failed_contacts` | json | Array of contacts that failed to update |
|
||||
| `metadata` | json | Bulk update metadata including counts of updated and failed contacts |
|
||||
|
||||
### `apollo_account_create`
|
||||
|
||||
Create a new account (company) in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `name` | string | Yes | Company name |
|
||||
| `website_url` | string | No | Company website URL |
|
||||
| `phone` | string | No | Company phone number |
|
||||
| `owner_id` | string | No | User ID of the account owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Created account data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_account_update`
|
||||
|
||||
Update an existing account in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `account_id` | string | Yes | ID of the account to update |
|
||||
| `name` | string | No | Company name |
|
||||
| `website_url` | string | No | Company website URL |
|
||||
| `phone` | string | No | Company phone number |
|
||||
| `owner_id` | string | No | User ID of the account owner |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `account` | json | Updated account data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_account_search`
|
||||
|
||||
Search your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `q_keywords` | string | No | Keywords to search for in account data |
|
||||
| `owner_id` | string | No | Filter by account owner user ID |
|
||||
| `account_stage_ids` | array | No | Filter by account stage IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `accounts` | json | Array of accounts matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_account_bulk_create`
|
||||
|
||||
Create up to 100 accounts at once in your Apollo database. Note: Apollo does not apply deduplication - duplicate accounts may be created if entries share similar names or domains. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `accounts` | array | Yes | Array of accounts to create \(max 100\). Each account should include name \(required\), and optionally website_url, phone, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `created_accounts` | json | Array of newly created accounts |
|
||||
| `failed_accounts` | json | Array of accounts that failed to create |
|
||||
| `metadata` | json | Bulk creation metadata including counts of created and failed accounts |
|
||||
|
||||
### `apollo_account_bulk_update`
|
||||
|
||||
Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `accounts` | array | Yes | Array of accounts to update \(max 1000\). Each account must include id field, and optionally name, website_url, phone, owner_id |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `updated_accounts` | json | Array of successfully updated accounts |
|
||||
| `failed_accounts` | json | Array of accounts that failed to update |
|
||||
| `metadata` | json | Bulk update metadata including counts of updated and failed accounts |
|
||||
|
||||
### `apollo_opportunity_create`
|
||||
|
||||
Create a new deal for an account in your Apollo database (master key required)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `name` | string | Yes | Name of the opportunity/deal |
|
||||
| `account_id` | string | Yes | ID of the account this opportunity belongs to |
|
||||
| `amount` | number | No | Monetary value of the opportunity |
|
||||
| `stage_id` | string | No | ID of the deal stage |
|
||||
| `owner_id` | string | No | User ID of the opportunity owner |
|
||||
| `close_date` | string | No | Expected close date \(ISO 8601 format\) |
|
||||
| `description` | string | No | Description or notes about the opportunity |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Created opportunity data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_opportunity_search`
|
||||
|
||||
Search and list all deals/opportunities in your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `q_keywords` | string | No | Keywords to search for in opportunity names |
|
||||
| `account_ids` | array | No | Filter by specific account IDs |
|
||||
| `stage_ids` | array | No | Filter by deal stage IDs |
|
||||
| `owner_ids` | array | No | Filter by opportunity owner IDs |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunities` | json | Array of opportunities matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_opportunity_get`
|
||||
|
||||
Retrieve complete details of a specific deal/opportunity by ID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `opportunity_id` | string | Yes | ID of the opportunity to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Complete opportunity data from Apollo |
|
||||
| `metadata` | json | Retrieval metadata including found status |
|
||||
|
||||
### `apollo_opportunity_update`
|
||||
|
||||
Update an existing deal/opportunity in your Apollo database
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key |
|
||||
| `opportunity_id` | string | Yes | ID of the opportunity to update |
|
||||
| `name` | string | No | Name of the opportunity/deal |
|
||||
| `amount` | number | No | Monetary value of the opportunity |
|
||||
| `stage_id` | string | No | ID of the deal stage |
|
||||
| `owner_id` | string | No | User ID of the opportunity owner |
|
||||
| `close_date` | string | No | Expected close date \(ISO 8601 format\) |
|
||||
| `description` | string | No | Description or notes about the opportunity |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `opportunity` | json | Updated opportunity data from Apollo |
|
||||
| `metadata` | json | Update metadata including updated status |
|
||||
|
||||
### `apollo_sequence_search`
|
||||
|
||||
Search for sequences/campaigns in your team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `q_name` | string | No | Search sequences by name |
|
||||
| `active` | boolean | No | Filter by active status \(true for active sequences, false for inactive\) |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `sequences` | json | Array of sequences/campaigns matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_sequence_add_contacts`
|
||||
|
||||
Add contacts to an Apollo sequence
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `sequence_id` | string | Yes | ID of the sequence to add contacts to |
|
||||
| `contact_ids` | array | Yes | Array of contact IDs to add to the sequence |
|
||||
| `emailer_campaign_id` | string | No | Optional emailer campaign ID |
|
||||
| `send_email_from_user_id` | string | No | User ID to send emails from |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contacts_added` | json | Array of contact IDs added to the sequence |
|
||||
| `metadata` | json | Sequence metadata including sequence_id and total_added count |
|
||||
|
||||
### `apollo_task_create`
|
||||
|
||||
Create a new task in Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `note` | string | Yes | Task note/description |
|
||||
| `contact_id` | string | No | Contact ID to associate with |
|
||||
| `account_id` | string | No | Account ID to associate with |
|
||||
| `due_at` | string | No | Due date in ISO format |
|
||||
| `priority` | string | No | Task priority |
|
||||
| `type` | string | No | Task type |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `task` | json | Created task data from Apollo |
|
||||
| `metadata` | json | Creation metadata including created status |
|
||||
|
||||
### `apollo_task_search`
|
||||
|
||||
Search for tasks in Apollo
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
| `contact_id` | string | No | Filter by contact ID |
|
||||
| `account_id` | string | No | Filter by account ID |
|
||||
| `completed` | boolean | No | Filter by completion status |
|
||||
| `page` | number | No | Page number for pagination |
|
||||
| `per_page` | number | No | Results per page \(max: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tasks` | json | Array of tasks matching the search criteria |
|
||||
| `metadata` | json | Pagination information including page, per_page, and total_entries |
|
||||
|
||||
### `apollo_email_accounts`
|
||||
|
||||
Get list of team
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Apollo API key \(master key required\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `email_accounts` | json | Array of team email accounts linked in Apollo |
|
||||
| `metadata` | json | Metadata including total count of email accounts |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `apollo`
|
||||
@@ -2,6 +2,7 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"airtable",
|
||||
"apollo",
|
||||
"arxiv",
|
||||
"asana",
|
||||
"browser_use",
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreateTeamOrganization')
|
||||
const logger = createLogger('OrganizationsAPI')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get organizations where user is owner or admin
|
||||
const userOrganizations = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(member.organizationId, organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
organizations: userOrganizations,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch organizations', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { OneDollarStats } from '@/components/analytics/onedollarstats'
|
||||
import { SessionProvider } from '@/lib/session/session-context'
|
||||
import { season } from '@/app/fonts/season/season'
|
||||
import { HydrationErrorHandler } from '@/app/hydration-error-handler'
|
||||
import { QueryProvider } from '@/app/providers/query-client-provider'
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
@@ -173,12 +174,14 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<OneDollarStats />
|
||||
<PostHogProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
{children}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
|
||||
26
apps/sim/app/providers/query-client-provider.tsx
Normal file
26
apps/sim/app/providers/query-client-provider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { TimerOff } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import {
|
||||
FilterSection,
|
||||
FolderFilter,
|
||||
@@ -11,14 +12,14 @@ import {
|
||||
Trigger,
|
||||
Workflow,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
/**
|
||||
* Filters component for logs page - includes timeline and other filter options
|
||||
*/
|
||||
export function Filters() {
|
||||
const { getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
const isPaid = subscription.isPaid
|
||||
|
||||
const handleUpgradeClick = (e: React.MouseEvent) => {
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
|
||||
/**
|
||||
* Loads user settings from database once per workspace session.
|
||||
* This ensures settings are synced from DB on initial load but uses
|
||||
* localStorage cache for subsequent navigation within the app.
|
||||
* React Query handles the fetching and automatically syncs to Zustand store.
|
||||
* This ensures settings are available throughout the app.
|
||||
*/
|
||||
export function SettingsLoader() {
|
||||
const { data: session, isPending: isSessionPending } = useSession()
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
// Use React Query hook which automatically syncs to Zustand
|
||||
// This replaces the old Zustand loadSettings() call
|
||||
const { refetch } = useGeneralSettings()
|
||||
|
||||
useEffect(() => {
|
||||
// Only load settings once per session for authenticated users
|
||||
if (!isSessionPending && session?.user && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
// Force load from DB on initial workspace entry
|
||||
loadSettings(true)
|
||||
// Force refetch from DB on initial workspace entry
|
||||
refetch()
|
||||
}
|
||||
}, [isSessionPending, session?.user, loadSettings])
|
||||
}, [isSessionPending, session?.user, refetch])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface DeploymentInfoProps {
|
||||
getInputFormatExample?: (includeStreaming?: boolean) => string
|
||||
selectedStreamingOutputs: string[]
|
||||
onSelectedStreamingOutputsChange: (outputs: string[]) => void
|
||||
onLoadDeploymentComplete: () => void
|
||||
}
|
||||
|
||||
export function DeploymentInfo({
|
||||
@@ -60,6 +61,7 @@ export function DeploymentInfo({
|
||||
getInputFormatExample,
|
||||
selectedStreamingOutputs,
|
||||
onSelectedStreamingOutputsChange,
|
||||
onLoadDeploymentComplete,
|
||||
}: DeploymentInfoProps) {
|
||||
const [isViewingDeployed, setIsViewingDeployed] = useState(false)
|
||||
|
||||
@@ -174,6 +176,7 @@ export function DeploymentInfo({
|
||||
needsRedeployment={deploymentInfo.needsRedeployment}
|
||||
activeDeployedState={deployedState}
|
||||
workflowId={workflowId}
|
||||
onLoadDeploymentComplete={onLoadDeploymentComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -784,6 +784,7 @@ export function DeployModal({
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1062,6 +1063,7 @@ export function DeployModal({
|
||||
}
|
||||
workflowId={workflowId}
|
||||
isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -35,6 +35,7 @@ interface DeployedWorkflowModalProps {
|
||||
selectedVersionLabel?: string
|
||||
workflowId: string
|
||||
isSelectedVersionActive?: boolean
|
||||
onLoadDeploymentComplete?: () => void
|
||||
}
|
||||
|
||||
export function DeployedWorkflowModal({
|
||||
@@ -49,6 +50,7 @@ export function DeployedWorkflowModal({
|
||||
selectedVersionLabel,
|
||||
workflowId,
|
||||
isSelectedVersionActive,
|
||||
onLoadDeploymentComplete,
|
||||
}: DeployedWorkflowModalProps) {
|
||||
const [showRevertDialog, setShowRevertDialog] = useState(false)
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
@@ -82,6 +84,7 @@ export function DeployedWorkflowModal({
|
||||
|
||||
setShowRevertDialog(false)
|
||||
onClose()
|
||||
onLoadDeploymentComplete?.()
|
||||
} catch (error) {
|
||||
logger.error('Failed to revert workflow:', error)
|
||||
}
|
||||
@@ -91,7 +94,7 @@ export function DeployedWorkflowModal({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className='max-h-[100vh] overflow-y-auto sm:max-w-[1100px]'
|
||||
style={{ zIndex: 1000 }}
|
||||
style={{ zIndex: 10000020 }}
|
||||
hideCloseButton={true}
|
||||
>
|
||||
<div className='sr-only'>
|
||||
@@ -136,7 +139,7 @@ export function DeployedWorkflowModal({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='outline'>Load Deployment</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent style={{ zIndex: 1001 }} className='sm:max-w-[425px]'>
|
||||
<AlertDialogContent className='sm:max-w-[425px]'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Load this Deployment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
||||
@@ -43,7 +43,6 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import { usePanelStore } from '@/stores/panel/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -81,11 +80,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
const { lastSaved, setNeedsRedeploymentFlag, blocks } = useWorkflowStore()
|
||||
const {
|
||||
workflows,
|
||||
updateWorkflow,
|
||||
activeWorkflowId,
|
||||
removeWorkflow,
|
||||
duplicateWorkflow,
|
||||
setDeploymentStatus,
|
||||
isLoading: isRegistryLoading,
|
||||
} = useWorkflowRegistry()
|
||||
const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
|
||||
@@ -100,7 +96,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
useWorkflowExecution()
|
||||
|
||||
// Local state
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [, setMounted] = useState(false)
|
||||
const [, forceUpdate] = useState({})
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false)
|
||||
@@ -332,7 +328,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
/**
|
||||
* Check user usage limits and cache results
|
||||
*/
|
||||
async function checkUserUsage(userId: string, forceRefresh = false): Promise<any | null> {
|
||||
async function checkUserUsage(_userId: string, forceRefresh = false): Promise<any | null> {
|
||||
const now = Date.now()
|
||||
const cacheAge = now - usageDataCache.timestamp
|
||||
|
||||
@@ -355,14 +351,8 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
return usage
|
||||
}
|
||||
|
||||
// Fallback: use store if API not available
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
if (forceRefresh) await refresh()
|
||||
const usage = getUsage()
|
||||
|
||||
// Update cache
|
||||
usageDataCache = { data: usage, timestamp: now, expirationMs: usageDataCache.expirationMs }
|
||||
return usage
|
||||
// No fallback needed anymore - React Query handles this
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error('Error checking usage limits:', { error })
|
||||
return null
|
||||
@@ -1113,6 +1103,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{getTooltipContent()}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/sub-block-input-controller'
|
||||
@@ -10,19 +8,14 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
const logger = createLogger('ComboBox')
|
||||
|
||||
/**
|
||||
* Constants for ComboBox component behavior
|
||||
*/
|
||||
const CURSOR_POSITION_DELAY = 0
|
||||
const SCROLL_SYNC_DELAY = 0
|
||||
const DEFAULT_MODEL = 'gpt-4o'
|
||||
const ZOOM_FACTOR_BASE = 0.96
|
||||
const MIN_ZOOM = 0.1
|
||||
const MAX_ZOOM = 1
|
||||
const ZOOM_DURATION = 0
|
||||
const DROPDOWN_CLOSE_DELAY = 150
|
||||
|
||||
/**
|
||||
* Represents a selectable option in the combobox
|
||||
@@ -57,17 +50,6 @@ interface ComboBoxProps {
|
||||
config: SubBlockConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* ComboBox component that provides a searchable dropdown with support for:
|
||||
* - Free text input or selection from predefined options
|
||||
* - Environment variable and tag insertion via special triggers
|
||||
* - Drag and drop connections from other blocks
|
||||
* - Keyboard navigation (Arrow keys, Enter, Escape)
|
||||
* - Preview mode for displaying read-only values
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Rendered ComboBox component
|
||||
*/
|
||||
export function ComboBox({
|
||||
options,
|
||||
defaultValue,
|
||||
@@ -81,20 +63,12 @@ export function ComboBox({
|
||||
config,
|
||||
}: ComboBoxProps) {
|
||||
// Hooks and context
|
||||
const params = useParams()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
const reactFlowInstance = useReactFlow()
|
||||
|
||||
// State management
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Determine the active value based on mode (preview vs. controlled vs. store)
|
||||
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
|
||||
@@ -123,15 +97,6 @@ export function ComboBox({
|
||||
return typeof option === 'string' ? option : option.id
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Extracts the display label from an option
|
||||
* @param option - The option to extract label from
|
||||
* @returns The option's display label
|
||||
*/
|
||||
const getOptionLabel = useCallback((option: ComboBoxOption): string => {
|
||||
return typeof option === 'string' ? option : option.label
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Determines the default option value to use.
|
||||
* Priority: explicit defaultValue > gpt-4o for model field > first option
|
||||
@@ -157,33 +122,27 @@ export function ComboBox({
|
||||
}, [defaultValue, evaluatedOptions, subBlockId, getOptionValue])
|
||||
|
||||
/**
|
||||
* Filters options based on current input value
|
||||
* Shows all options when dropdown is closed or when value matches an exact option
|
||||
* Otherwise filters by search term
|
||||
* Resolve the user-facing text for the current stored value.
|
||||
* - For object options, map stored ID -> label
|
||||
* - For everything else, display the raw value
|
||||
*/
|
||||
const filteredOptions = useMemo(() => {
|
||||
// Always show all options when dropdown is not open
|
||||
if (!open) return evaluatedOptions
|
||||
const displayValue = useMemo(() => {
|
||||
const raw = value?.toString() ?? ''
|
||||
if (!raw) return ''
|
||||
|
||||
// If no value or value matches an exact option, show all options
|
||||
if (!value) return evaluatedOptions
|
||||
|
||||
const currentValue = value.toString()
|
||||
const exactMatch = evaluatedOptions.find(
|
||||
(opt) => getOptionValue(opt) === currentValue || getOptionLabel(opt) === currentValue
|
||||
const match = evaluatedOptions.find((option) =>
|
||||
typeof option === 'string' ? option === raw : option.id === raw
|
||||
)
|
||||
|
||||
// If current value exactly matches an option, show all options (user just selected it)
|
||||
if (exactMatch) return evaluatedOptions
|
||||
if (!match) return raw
|
||||
return typeof match === 'string' ? match : match.label
|
||||
}, [value, evaluatedOptions])
|
||||
|
||||
// Otherwise filter based on current input
|
||||
return evaluatedOptions.filter((option) => {
|
||||
const label = getOptionLabel(option).toLowerCase()
|
||||
const optionValue = getOptionValue(option).toLowerCase()
|
||||
const search = currentValue.toLowerCase()
|
||||
return label.includes(search) || optionValue.includes(search)
|
||||
})
|
||||
}, [evaluatedOptions, value, open, getOptionValue, getOptionLabel])
|
||||
const [inputValue, setInputValue] = useState(displayValue)
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(displayValue)
|
||||
}, [displayValue])
|
||||
|
||||
// Mark store as initialized on first render
|
||||
useEffect(() => {
|
||||
@@ -201,128 +160,6 @@ export function ComboBox({
|
||||
}
|
||||
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
|
||||
|
||||
/**
|
||||
* Handles selection of an option from the dropdown
|
||||
* @param selectedValue - The value of the selected option
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(selectedValue: string) => {
|
||||
if (!isPreview && !disabled) {
|
||||
setStoreValue(selectedValue)
|
||||
}
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
inputRef.current?.blur()
|
||||
},
|
||||
[isPreview, disabled, setStoreValue]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles click on the dropdown chevron button
|
||||
* @param e - Mouse event
|
||||
*/
|
||||
const handleDropdownClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
setOpen((prev) => {
|
||||
const newOpen = !prev
|
||||
if (newOpen) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
return newOpen
|
||||
})
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles focus event on the input
|
||||
*/
|
||||
const handleFocus = useCallback(() => {
|
||||
setOpen(true)
|
||||
setHighlightedIndex(-1)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles blur event on the input
|
||||
* Delays closing to allow for dropdown interactions
|
||||
*/
|
||||
const handleBlur = useCallback(() => {
|
||||
// Delay closing to allow dropdown selection
|
||||
setTimeout(() => {
|
||||
const activeElement = document.activeElement
|
||||
if (!activeElement || !activeElement.closest('.absolute.top-full')) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}, DROPDOWN_CLOSE_DELAY)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles keyboard navigation and selection
|
||||
* @param e - Keyboard event
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!open) {
|
||||
setOpen(true)
|
||||
setHighlightedIndex(0)
|
||||
} else {
|
||||
setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (open) {
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredOptions.length - 1))
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
const selectedOption = filteredOptions[highlightedIndex]
|
||||
if (selectedOption) {
|
||||
handleSelect(getOptionValue(selectedOption))
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, filteredOptions, highlightedIndex, handleSelect, getOptionValue]
|
||||
)
|
||||
|
||||
/**
|
||||
* Synchronizes overlay scroll with input scroll
|
||||
* @param e - UI event from input element
|
||||
*/
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLInputElement>) => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollLeft = e.currentTarget.scrollLeft
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Synchronizes overlay scroll after paste operation
|
||||
* @param e - Clipboard event
|
||||
*/
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
setTimeout(() => {
|
||||
if (inputRef.current && overlayRef.current) {
|
||||
overlayRef.current.scrollLeft = inputRef.current.scrollLeft
|
||||
}
|
||||
}, SCROLL_SYNC_DELAY)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles wheel event for ReactFlow zoom control
|
||||
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas
|
||||
@@ -362,121 +199,34 @@ export function ComboBox({
|
||||
[reactFlowInstance]
|
||||
)
|
||||
|
||||
// Synchronize overlay scroll position with input when value changes
|
||||
useEffect(() => {
|
||||
if (inputRef.current && overlayRef.current) {
|
||||
overlayRef.current.scrollLeft = inputRef.current.scrollLeft
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Adjust highlighted index when filtered options change
|
||||
useEffect(() => {
|
||||
setHighlightedIndex((prev) => {
|
||||
if (prev >= 0 && prev < filteredOptions.length) {
|
||||
return prev
|
||||
}
|
||||
return -1
|
||||
})
|
||||
}, [filteredOptions])
|
||||
|
||||
// Scroll highlighted option into view for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && dropdownRef.current) {
|
||||
const highlightedElement = dropdownRef.current.querySelector(
|
||||
`[data-option-index="${highlightedIndex}"]`
|
||||
)
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex])
|
||||
|
||||
// Handle clicks outside the dropdown to close it
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(target) &&
|
||||
!target.closest('[data-radix-popper-content-wrapper]') &&
|
||||
!target.closest('.absolute.top-full')
|
||||
) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const displayValue = useMemo(() => value?.toString() ?? '', [value])
|
||||
|
||||
/**
|
||||
* Handles value change from Combobox
|
||||
*/
|
||||
const handleComboboxChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(newValue)
|
||||
}
|
||||
},
|
||||
[isPreview, setStoreValue]
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the icon for the currently selected option
|
||||
*/
|
||||
const selectedOptionIcon = useMemo(() => {
|
||||
const selectedOpt = comboboxOptions.find((opt) => opt.value === displayValue)
|
||||
return selectedOpt?.icon
|
||||
}, [comboboxOptions, displayValue])
|
||||
const selectedOption = useMemo(() => {
|
||||
if (!value) return undefined
|
||||
return comboboxOptions.find((opt) => opt.value === value)
|
||||
}, [comboboxOptions, value])
|
||||
|
||||
const selectedOptionIcon = selectedOption?.icon
|
||||
|
||||
/**
|
||||
* Overlay content for the editable combobox
|
||||
*/
|
||||
const overlayContent = useMemo(() => {
|
||||
const SelectedIcon = selectedOptionIcon
|
||||
const displayLabel = inputValue
|
||||
return (
|
||||
<div className='flex w-full items-center truncate [scrollbar-width:none]'>
|
||||
{SelectedIcon && <SelectedIcon className='mr-[8px] h-3 w-3 flex-shrink-0 opacity-60' />}
|
||||
<div className='truncate'>
|
||||
{formatDisplayText(displayValue, {
|
||||
{formatDisplayText(displayLabel, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [displayValue, accessiblePrefixes, selectedOptionIcon])
|
||||
|
||||
/**
|
||||
* Handles mouse enter on dropdown option
|
||||
* @param index - Index of the option
|
||||
*/
|
||||
const handleOptionMouseEnter = useCallback((index: number) => {
|
||||
setHighlightedIndex(index)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles mouse down on dropdown option
|
||||
* @param e - Mouse event
|
||||
* @param optionValue - Value of the selected option
|
||||
*/
|
||||
const handleOptionMouseDown = useCallback(
|
||||
(e: React.MouseEvent, optionValue: string) => {
|
||||
e.preventDefault()
|
||||
handleSelect(optionValue)
|
||||
},
|
||||
[handleSelect]
|
||||
)
|
||||
}, [inputValue, accessiblePrefixes, selectedOption, selectedOptionIcon])
|
||||
|
||||
return (
|
||||
<div className='relative w-full'>
|
||||
@@ -486,9 +236,23 @@ export function ComboBox({
|
||||
config={config}
|
||||
value={propValue}
|
||||
onChange={(newValue) => {
|
||||
if (!isPreview) {
|
||||
setStoreValue(newValue)
|
||||
if (isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedOption = evaluatedOptions.find((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option === newValue
|
||||
}
|
||||
return option.id === newValue
|
||||
})
|
||||
|
||||
if (!matchedOption) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id
|
||||
setStoreValue(nextValue)
|
||||
}}
|
||||
isPreview={isPreview}
|
||||
disabled={disabled}
|
||||
@@ -497,9 +261,19 @@ export function ComboBox({
|
||||
{({ ref, onChange: ctrlOnChange, onDrop, onDragOver }) => (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={displayValue}
|
||||
value={inputValue}
|
||||
selectedValue={value ?? ''}
|
||||
onChange={(newValue) => {
|
||||
// Use controller's handler for consistency
|
||||
const matchedComboboxOption = comboboxOptions.find(
|
||||
(option) => option.value === newValue
|
||||
)
|
||||
if (matchedComboboxOption) {
|
||||
setInputValue(matchedComboboxOption.label)
|
||||
} else {
|
||||
setInputValue(newValue)
|
||||
}
|
||||
|
||||
// Use controller's handler so env vars, tags, and DnD still work
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue, selectionStart: newValue.length },
|
||||
} as React.ChangeEvent<HTMLInputElement>
|
||||
@@ -515,8 +289,6 @@ export function ComboBox({
|
||||
inputProps={{
|
||||
onDrop: onDrop as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onDragOver: onDragOver as (e: React.DragEvent<HTMLInputElement>) => void,
|
||||
onScroll: handleScroll,
|
||||
onPaste: handlePaste,
|
||||
onWheel: handleWheel,
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
@@ -8,7 +9,11 @@ import {
|
||||
PopoverSection,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
useWorkspaceEnvironment,
|
||||
type WorkspaceEnvironmentData,
|
||||
} from '@/hooks/queries/environment'
|
||||
|
||||
/**
|
||||
* Props for the EnvVarDropdown component
|
||||
@@ -113,28 +118,27 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
maxHeight = 'none',
|
||||
inputRef,
|
||||
}) => {
|
||||
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
|
||||
const userEnvVars = useEnvironmentStore((state) => Object.keys(state.variables))
|
||||
const [workspaceEnvData, setWorkspaceEnvData] = useState<{
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
conflicts: string[]
|
||||
}>({ workspace: {}, personal: {}, conflicts: [] })
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
// React Query hooks for environment variables
|
||||
const { data: personalEnv = {} } = usePersonalEnvironment()
|
||||
const { data: workspaceEnvData } = useWorkspaceEnvironment(workspaceId || '', {
|
||||
select: useCallback(
|
||||
(data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({
|
||||
workspace: data.workspace || {},
|
||||
personal: data.personal || {},
|
||||
conflicts: data.conflicts || [],
|
||||
}),
|
||||
[]
|
||||
),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId && visible) {
|
||||
loadWorkspaceEnvironment(workspaceId).then((data) => {
|
||||
setWorkspaceEnvData(data)
|
||||
})
|
||||
}
|
||||
}, [workspaceId, visible, loadWorkspaceEnvironment])
|
||||
const userEnvVars = Object.keys(personalEnv)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const envVarGroups: EnvVarGroup[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceVars = Object.keys(workspaceEnvData.workspace)
|
||||
const personalVars = Object.keys(workspaceEnvData.personal)
|
||||
if (workspaceId && workspaceEnvData) {
|
||||
const workspaceVars = Object.keys(workspaceEnvData?.workspace || {})
|
||||
const personalVars = Object.keys(workspaceEnvData?.personal || {})
|
||||
|
||||
envVarGroups.push({ label: 'Workspace', variables: workspaceVars })
|
||||
envVarGroups.push({ label: 'Personal', variables: personalVars })
|
||||
@@ -163,6 +167,11 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
setSelectedIndex(0)
|
||||
}, [searchTerm])
|
||||
|
||||
const openEnvironmentSettings = () => {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } }))
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const handleEnvVarSelect = (envVar: string) => {
|
||||
const textBeforeCursor = inputValue.slice(0, cursorPosition)
|
||||
const textAfterCursor = inputValue.slice(cursorPosition)
|
||||
@@ -284,9 +293,17 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{filteredEnvVars.length === 0 ? (
|
||||
<div className='px-[6px] py-[8px] text-[12px] text-[var(--white)]/60'>
|
||||
No matching environment variables
|
||||
</div>
|
||||
<PopoverScrollArea>
|
||||
<PopoverItem
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
openEnvironmentSettings()
|
||||
}}
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
<span>Create environment variable</span>
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
) : (
|
||||
<PopoverScrollArea>
|
||||
{filteredGroups.map((group) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useEnabledServers, useMcpServersStore } from '@/stores/mcp-servers/store'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
|
||||
interface McpServerSelectorProps {
|
||||
blockId: string
|
||||
@@ -36,8 +36,8 @@ export function McpServerSelector({
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { fetchServers, isLoading, error } = useMcpServersStore()
|
||||
const enabledServers = useEnabledServers()
|
||||
const { data: servers = [], isLoading, error } = useMcpServers(workspaceId)
|
||||
const enabledServers = servers.filter((s) => s.enabled && !s.deletedAt)
|
||||
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
@@ -48,15 +48,9 @@ export function McpServerSelector({
|
||||
|
||||
const selectedServer = enabledServers.find((server) => server.id === selectedServerId)
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers(workspaceId)
|
||||
}, [fetchServers, workspaceId])
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
fetchServers(workspaceId)
|
||||
}
|
||||
// React Query automatically keeps server list fresh
|
||||
}
|
||||
|
||||
const handleSelect = (serverId: string) => {
|
||||
@@ -102,7 +96,9 @@ export function McpServerSelector({
|
||||
) : error ? (
|
||||
<div className='p-4 text-center'>
|
||||
<p className='font-medium text-destructive text-sm'>Error loading servers</p>
|
||||
<p className='text-muted-foreground text-xs'>{error}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='p-4 text-center'>
|
||||
|
||||
@@ -36,7 +36,12 @@ import {
|
||||
import { CodeEditor } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor'
|
||||
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
|
||||
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import {
|
||||
useCreateCustomTool,
|
||||
useCustomTools,
|
||||
useDeleteCustomTool,
|
||||
useUpdateCustomTool,
|
||||
} from '@/hooks/queries/custom-tools'
|
||||
|
||||
const logger = createLogger('CustomToolModal')
|
||||
|
||||
@@ -261,9 +266,11 @@ try {
|
||||
// Schema params keyboard navigation
|
||||
const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0)
|
||||
|
||||
const createTool = useCustomToolsStore((state) => state.createTool)
|
||||
const updateTool = useCustomToolsStore((state) => state.updateTool)
|
||||
const deleteTool = useCustomToolsStore((state) => state.deleteTool)
|
||||
// React Query mutations
|
||||
const createToolMutation = useCreateCustomTool()
|
||||
const updateToolMutation = useUpdateCustomTool()
|
||||
const deleteToolMutation = useDeleteCustomTool()
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
|
||||
// Initialize form with initial values if provided
|
||||
useEffect(() => {
|
||||
@@ -448,10 +455,8 @@ try {
|
||||
if (isEditing && !toolIdToUpdate && initialValues?.schema) {
|
||||
const originalName = initialValues.schema.function?.name
|
||||
if (originalName) {
|
||||
const customToolsStore = useCustomToolsStore.getState()
|
||||
const existingTools = customToolsStore.getAllTools()
|
||||
const originalTool = existingTools.find(
|
||||
(tool) => tool.schema.function.name === originalName
|
||||
const originalTool = customTools.find(
|
||||
(tool) => tool.schema?.function?.name === originalName
|
||||
)
|
||||
if (originalTool) {
|
||||
toolIdToUpdate = originalTool.id
|
||||
@@ -460,23 +465,27 @@ try {
|
||||
}
|
||||
|
||||
// Save to the store (server validates duplicates)
|
||||
let _finalToolId: string | undefined = toolIdToUpdate
|
||||
|
||||
if (isEditing && toolIdToUpdate) {
|
||||
// Update existing tool
|
||||
await updateTool(workspaceId, toolIdToUpdate, {
|
||||
title: name,
|
||||
schema,
|
||||
code: functionCode || '',
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
toolId: toolIdToUpdate,
|
||||
updates: {
|
||||
title: name,
|
||||
schema,
|
||||
code: functionCode || '',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Create new tool
|
||||
const createdTool = await createTool(workspaceId, {
|
||||
title: name,
|
||||
schema,
|
||||
code: functionCode || '',
|
||||
await createToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
tool: {
|
||||
title: name,
|
||||
schema,
|
||||
code: functionCode || '',
|
||||
},
|
||||
})
|
||||
_finalToolId = createdTool.id
|
||||
}
|
||||
|
||||
// Create the custom tool object for the parent component
|
||||
@@ -782,8 +791,11 @@ try {
|
||||
try {
|
||||
setShowDeleteConfirm(false)
|
||||
|
||||
// Delete from store (which calls the API)
|
||||
await deleteTool(workspaceId, toolId)
|
||||
// Delete using React Query mutation
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
toolId,
|
||||
})
|
||||
logger.info(`Deleted tool: ${toolId}`)
|
||||
|
||||
// Notify parent component if callback provided
|
||||
@@ -966,11 +978,6 @@ try {
|
||||
language='json'
|
||||
showWandButton={true}
|
||||
onWandClick={() => {
|
||||
logger.debug('Schema AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof schemaGeneration.showPromptInline === 'function'
|
||||
)
|
||||
schemaGeneration.isPromptVisible
|
||||
? schemaGeneration.hidePromptInline()
|
||||
: schemaGeneration.showPromptInline()
|
||||
@@ -1045,11 +1052,6 @@ try {
|
||||
language='javascript'
|
||||
showWandButton={true}
|
||||
onWandClick={() => {
|
||||
logger.debug('Code AI button clicked')
|
||||
logger.debug(
|
||||
'showPromptInline function exists:',
|
||||
typeof codeGeneration.showPromptInline === 'function'
|
||||
)
|
||||
codeGeneration.isPromptVisible
|
||||
? codeGeneration.hidePromptInline()
|
||||
: codeGeneration.showPromptInline()
|
||||
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import { useMcpServersStore } from '@/stores/mcp-servers/store'
|
||||
|
||||
const logger = createLogger('McpServerModal')
|
||||
|
||||
@@ -61,7 +61,7 @@ export function McpServerModal({
|
||||
url: '',
|
||||
headers: { '': '' },
|
||||
})
|
||||
const { createServer, isLoading, error: storeError, clearError } = useMcpServersStore()
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
// MCP server testing
|
||||
@@ -79,7 +79,7 @@ export function McpServerModal({
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const error = localError || storeError
|
||||
const error = localError || createServerMutation.error?.message
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
@@ -89,7 +89,7 @@ export function McpServerModal({
|
||||
headers: { '': '' },
|
||||
})
|
||||
setLocalError(null)
|
||||
clearError()
|
||||
createServerMutation.reset()
|
||||
setShowEnvVars(false)
|
||||
setActiveInputField(null)
|
||||
setActiveHeaderIndex(null)
|
||||
@@ -210,7 +210,7 @@ export function McpServerModal({
|
||||
}
|
||||
|
||||
setLocalError(null)
|
||||
clearError()
|
||||
createServerMutation.reset()
|
||||
|
||||
try {
|
||||
// If no test has been done, test first
|
||||
@@ -242,13 +242,16 @@ export function McpServerModal({
|
||||
)
|
||||
)
|
||||
|
||||
await createServer(workspaceId, {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: 30000,
|
||||
headers: cleanHeaders,
|
||||
enabled: true,
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: 30000,
|
||||
headers: cleanHeaders,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Added MCP server: ${formData.name}`)
|
||||
@@ -267,8 +270,7 @@ export function McpServerModal({
|
||||
testConnection,
|
||||
onOpenChange,
|
||||
onServerCreated,
|
||||
createServer,
|
||||
clearError,
|
||||
createServerMutation,
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
@@ -563,16 +565,18 @@ export function McpServerModal({
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={createServerMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !formData.name.trim() || !formData.url?.trim()}
|
||||
disabled={
|
||||
createServerMutation.isPending || !formData.name.trim() || !formData.url?.trim()
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Adding...' : 'Add Server'}
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,9 +50,9 @@ import { ToolCommand } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon
|
||||
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { useCustomTools } from '@/hooks/queries/custom-tools'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
formatParameterLabel,
|
||||
@@ -476,8 +476,7 @@ export function ToolInput({
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const customTools = useCustomToolsStore((state) => state.getAllTools())
|
||||
const fetchCustomTools = useCustomToolsStore((state) => state.fetchTools)
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
const subBlockStore = useSubBlockStore()
|
||||
|
||||
// MCP tools integration
|
||||
@@ -488,13 +487,6 @@ export function ToolInput({
|
||||
refreshTools,
|
||||
} = useMcpTools(workspaceId)
|
||||
|
||||
// Fetch custom tools on mount
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchCustomTools(workspaceId)
|
||||
}
|
||||
}, [workspaceId, fetchCustomTools])
|
||||
|
||||
// Get the current model from the 'model' subblock
|
||||
const modelValue = useSubBlockStore.getState().getValue(blockId, 'model')
|
||||
const model = typeof modelValue === 'string' ? modelValue : ''
|
||||
@@ -706,7 +698,7 @@ export function ToolInput({
|
||||
(customTool: CustomTool) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const customToolId = `custom-${customTool.schema.function.name}`
|
||||
const customToolId = `custom-${customTool.schema?.function?.name || 'unknown'}`
|
||||
|
||||
const newTool: StoredTool = {
|
||||
type: 'custom-tool',
|
||||
@@ -782,7 +774,7 @@ export function ToolInput({
|
||||
customTools.some(
|
||||
(customTool) =>
|
||||
customTool.id === toolId &&
|
||||
customTool.schema.function.name === tool.schema.function.name
|
||||
customTool.schema?.function?.name === tool.schema.function.name
|
||||
)
|
||||
) {
|
||||
return false
|
||||
@@ -823,7 +815,6 @@ export function ToolInput({
|
||||
const handleOperationChange = useCallback(
|
||||
(toolIndex: number, operation: string) => {
|
||||
if (isPreview || disabled) {
|
||||
logger.info('❌ Early return: preview or disabled')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -832,7 +823,6 @@ export function ToolInput({
|
||||
const newToolId = getToolIdForOperation(tool.type, operation)
|
||||
|
||||
if (!newToolId) {
|
||||
logger.info('❌ Early return: no newToolId')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -840,7 +830,6 @@ export function ToolInput({
|
||||
const toolParams = getToolParametersConfig(newToolId, tool.type)
|
||||
|
||||
if (!toolParams) {
|
||||
logger.info('❌ Early return: no toolParams')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1400,7 +1389,7 @@ export function ToolInput({
|
||||
const newTool: StoredTool = {
|
||||
type: 'custom-tool',
|
||||
title: customTool.title,
|
||||
toolId: `custom-${customTool.schema.function.name}`,
|
||||
toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`,
|
||||
params: {},
|
||||
isExpanded: true,
|
||||
schema: customTool.schema,
|
||||
@@ -1934,7 +1923,7 @@ export function ToolInput({
|
||||
const newTool: StoredTool = {
|
||||
type: 'custom-tool',
|
||||
title: customTool.title,
|
||||
toolId: `custom-${customTool.schema.function.name}`,
|
||||
toolId: `custom-${customTool.schema?.function?.name || 'unknown'}`,
|
||||
params: {},
|
||||
isExpanded: true,
|
||||
schema: customTool.schema,
|
||||
@@ -2025,8 +2014,8 @@ export function ToolInput({
|
||||
? {
|
||||
id: customTools.find(
|
||||
(tool) =>
|
||||
tool.schema.function.name ===
|
||||
selectedTools[editingToolIndex].schema.function.name
|
||||
tool.schema?.function?.name ===
|
||||
selectedTools[editingToolIndex].schema?.function?.name
|
||||
)?.id,
|
||||
schema: selectedTools[editingToolIndex].schema,
|
||||
code: selectedTools[editingToolIndex].code || '',
|
||||
|
||||
@@ -67,12 +67,61 @@ interface SubBlockProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the field is required for validation. Intentionally unused.
|
||||
* Returns whether the field is required for validation.
|
||||
* Evaluates conditional requirements based on current field values.
|
||||
* @param config - The sub-block configuration
|
||||
* @param subBlockValues - Current values of all subblocks
|
||||
* @returns True if the field is required
|
||||
*/
|
||||
const isFieldRequired = (config: SubBlockConfig): boolean => {
|
||||
return config.required === true
|
||||
const isFieldRequired = (config: SubBlockConfig, subBlockValues?: Record<string, any>): boolean => {
|
||||
if (!config.required) return false
|
||||
if (typeof config.required === 'boolean') return config.required
|
||||
|
||||
// Helper function to evaluate a condition
|
||||
const evalCond = (
|
||||
cond: {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean>
|
||||
not?: boolean
|
||||
and?: {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean> | undefined
|
||||
not?: boolean
|
||||
}
|
||||
},
|
||||
values: Record<string, any>
|
||||
): boolean => {
|
||||
const fieldValue = values[cond.field]?.value
|
||||
const condValue = cond.value
|
||||
|
||||
let match: boolean
|
||||
if (Array.isArray(condValue)) {
|
||||
match = condValue.includes(fieldValue)
|
||||
} else {
|
||||
match = fieldValue === condValue
|
||||
}
|
||||
|
||||
if (cond.not) match = !match
|
||||
|
||||
if (cond.and) {
|
||||
const andFieldValue = values[cond.and.field]?.value
|
||||
const andCondValue = cond.and.value
|
||||
let andMatch: boolean
|
||||
if (Array.isArray(andCondValue)) {
|
||||
andMatch = andCondValue.includes(andFieldValue)
|
||||
} else {
|
||||
andMatch = andFieldValue === andCondValue
|
||||
}
|
||||
if (cond.and.not) andMatch = !andMatch
|
||||
match = match && andMatch
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
|
||||
// If required is a condition object or function, evaluate it
|
||||
const condition = typeof config.required === 'function' ? config.required() : config.required
|
||||
return evalCond(condition, subBlockValues || {})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +145,7 @@ const getPreviewValue = (
|
||||
* @param config - The sub-block configuration
|
||||
* @param isValidJson - Whether the JSON is valid
|
||||
* @param wandState - Wand interaction state
|
||||
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
|
||||
* @returns The label JSX element or null if no title or for switch types
|
||||
*/
|
||||
const renderLabel = (
|
||||
@@ -113,7 +163,8 @@ const renderLabel = (
|
||||
onSearchSubmit: () => void
|
||||
onSearchCancel: () => void
|
||||
searchInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
},
|
||||
subBlockValues?: Record<string, any>
|
||||
): JSX.Element | null => {
|
||||
if (config.type === 'switch') return null
|
||||
if (!config.title) return null
|
||||
@@ -132,10 +183,13 @@ const renderLabel = (
|
||||
searchInputRef,
|
||||
} = wandState
|
||||
|
||||
const required = isFieldRequired(config, subBlockValues)
|
||||
|
||||
return (
|
||||
<Label className='flex items-center justify-between gap-[6px] pl-[2px]'>
|
||||
<div className='flex items-center gap-[6px] whitespace-nowrap'>
|
||||
{config.title}
|
||||
{required && <span className='ml-0.5'>*</span>}
|
||||
{config.id === 'responseFormat' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -769,19 +823,24 @@ function SubBlockComponent({
|
||||
|
||||
return (
|
||||
<div onMouseDown={handleMouseDown} className='flex flex-col gap-[10px]'>
|
||||
{renderLabel(config, isValidJson, {
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
})}
|
||||
{renderLabel(
|
||||
config,
|
||||
isValidJson,
|
||||
{
|
||||
isSearchActive,
|
||||
searchQuery,
|
||||
isWandEnabled,
|
||||
isPreview,
|
||||
isStreaming: wandControlRef.current?.isWandStreaming ?? false,
|
||||
onSearchClick: handleSearchClick,
|
||||
onSearchBlur: handleSearchBlur,
|
||||
onSearchChange: handleSearchChange,
|
||||
onSearchSubmit: handleSearchSubmit,
|
||||
onSearchCancel: handleSearchCancel,
|
||||
searchInputRef,
|
||||
},
|
||||
subBlockValues
|
||||
)}
|
||||
{renderInput()}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import type { UsageData as StoreUsageData } from '@/stores/subscription/types'
|
||||
import type { UsageData as StoreUsageData } from '@/lib/subscription/types'
|
||||
|
||||
const logger = createLogger('useUsageLimits')
|
||||
|
||||
@@ -120,25 +119,7 @@ export function useUsageLimits(options?: {
|
||||
return usage
|
||||
}
|
||||
|
||||
// Fallback: use store if API not available (user context only)
|
||||
if (context === 'user') {
|
||||
const { getUsage, refresh } = useSubscriptionStore.getState()
|
||||
if (forceRefresh) await refresh()
|
||||
const storeUsage = getUsage()
|
||||
const usage = normalizeUsageData(storeUsage)
|
||||
|
||||
// Update cache
|
||||
usageDataCache = {
|
||||
data: usage,
|
||||
timestamp: now,
|
||||
expirationMs: usageDataCache.expirationMs,
|
||||
}
|
||||
|
||||
setUsageData(usage)
|
||||
setUsageExceeded(usage?.isExceeded || false)
|
||||
return usage
|
||||
}
|
||||
|
||||
// No fallback available - React Query handles this globally
|
||||
throw new Error('Failed to fetch usage data')
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to check usage limits')
|
||||
@@ -186,12 +167,18 @@ export function useUsageLimits(options?: {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// User context
|
||||
const { updateUsageLimit } = useSubscriptionStore.getState()
|
||||
const result = await updateUsageLimit(newLimit)
|
||||
// User context - use API directly
|
||||
const response = await fetch('/api/usage', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ context: 'user', limit: newLimit }),
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update limit')
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update limit')
|
||||
}
|
||||
|
||||
// Clear cache and refresh
|
||||
|
||||
@@ -40,13 +40,13 @@ import {
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/consts'
|
||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -1158,19 +1158,8 @@ const WorkflowContent = React.memo(() => {
|
||||
setIsWorkflowReady(shouldBeReady)
|
||||
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
|
||||
|
||||
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
|
||||
const clearWorkspaceEnvCache = useEnvironmentStore((state) => state.clearWorkspaceEnvCache)
|
||||
const prevWorkspaceIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
if (prevWorkspaceIdRef.current && prevWorkspaceIdRef.current !== workspaceId) {
|
||||
clearWorkspaceEnvCache(prevWorkspaceIdRef.current)
|
||||
}
|
||||
void loadWorkspaceEnvironment(workspaceId)
|
||||
|
||||
prevWorkspaceIdRef.current = workspaceId
|
||||
}, [workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
|
||||
// Preload workspace environment - React Query handles caching automatically
|
||||
useWorkspaceEnvironment(workspaceId)
|
||||
|
||||
// Handle navigation and validation
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,12 +8,12 @@ import { AgentIcon } from '@/components/icons'
|
||||
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 { signOut, useSession } from '@/lib/auth-client'
|
||||
import { signOut } from '@/lib/auth-client'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
|
||||
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
|
||||
import { clearUserData } from '@/stores'
|
||||
|
||||
const logger = createLogger('Account')
|
||||
@@ -26,15 +26,12 @@ export function Account(_props: AccountProps) {
|
||||
const router = useRouter()
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
const { data: session, isPending } = useSession()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [userImage, setUserImage] = useState<string | null>(null)
|
||||
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false)
|
||||
const [isUpdatingName, setIsUpdatingName] = useState(false)
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: profile } = useUserProfile()
|
||||
const updateProfile = useUpdateUserProfile()
|
||||
|
||||
// Local UI state
|
||||
const [name, setName] = useState(profile?.name || '')
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -46,6 +43,13 @@ export function Account(_props: AccountProps) {
|
||||
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
// Update local name state when profile data changes
|
||||
useEffect(() => {
|
||||
if (profile?.name) {
|
||||
setName(profile.name)
|
||||
}
|
||||
}, [profile?.name])
|
||||
|
||||
const {
|
||||
previewUrl: profilePictureUrl,
|
||||
fileInputRef: profilePictureInputRef,
|
||||
@@ -53,22 +57,15 @@ export function Account(_props: AccountProps) {
|
||||
handleFileChange: handleProfilePictureChange,
|
||||
isUploading: isUploadingProfilePicture,
|
||||
} = useProfilePictureUpload({
|
||||
currentImage: userImage,
|
||||
currentImage: profile?.image || null,
|
||||
onUpload: async (url) => {
|
||||
if (url) {
|
||||
try {
|
||||
await updateUserImage(url)
|
||||
setUploadError(null)
|
||||
} catch (error) {
|
||||
setUploadError('Failed to update profile picture')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await updateUserImage(null)
|
||||
setUploadError(null)
|
||||
} catch (error) {
|
||||
setUploadError('Failed to remove profile picture')
|
||||
}
|
||||
try {
|
||||
await updateProfile.mutateAsync({ image: url })
|
||||
setUploadError(null)
|
||||
} catch (error) {
|
||||
setUploadError(
|
||||
url ? 'Failed to update profile picture' : 'Failed to remove profile picture'
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -77,57 +74,6 @@ export function Account(_props: AccountProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const updateUserImage = async (imageUrl: string | null) => {
|
||||
try {
|
||||
const response = await fetch('/api/users/me/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: imageUrl }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update profile picture')
|
||||
}
|
||||
|
||||
setUserImage(imageUrl)
|
||||
} catch (error) {
|
||||
logger.error('Error updating profile image:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
if (!session?.user) return
|
||||
|
||||
setIsLoadingProfile(true)
|
||||
|
||||
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)
|
||||
if (session?.user) {
|
||||
setName(session.user.name || '')
|
||||
setEmail(session.user.email || '')
|
||||
setUserImage(session.user.image || null)
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProfile()
|
||||
}, [session])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingName && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
@@ -142,31 +88,17 @@ export function Account(_props: AccountProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedName === (session?.user?.name || '')) {
|
||||
if (trimmedName === profile?.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')
|
||||
}
|
||||
|
||||
await updateProfile.mutateAsync({ name: trimmedName })
|
||||
setIsEditingName(false)
|
||||
} catch (error) {
|
||||
logger.error('Error updating name:', error)
|
||||
setName(session?.user?.name || '')
|
||||
} finally {
|
||||
setIsUpdatingName(false)
|
||||
setName(profile?.name || '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +114,7 @@ export function Account(_props: AccountProps) {
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingName(false)
|
||||
setName(session?.user?.name || '')
|
||||
setName(profile?.name || '')
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
@@ -200,6 +132,8 @@ export function Account(_props: AccountProps) {
|
||||
}
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!profile?.email) return
|
||||
|
||||
setIsResettingPassword(true)
|
||||
setResetPasswordMessage(null)
|
||||
|
||||
@@ -208,7 +142,7 @@ export function Account(_props: AccountProps) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
email: profile.email,
|
||||
redirectTo: `${getBaseUrl()}/reset-password`,
|
||||
}),
|
||||
})
|
||||
@@ -244,191 +178,142 @@ export function Account(_props: AccountProps) {
|
||||
return (
|
||||
<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' />
|
||||
|
||||
{/* 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'>
|
||||
{/* Profile Picture Upload */}
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='group relative flex h-12 w-12 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
|
||||
onClick={handleProfilePictureClick}
|
||||
>
|
||||
{(() => {
|
||||
const imageUrl = profilePictureUrl || userImage || brandConfig.logoUrl
|
||||
return imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={name || 'User'}
|
||||
width={48}
|
||||
height={48}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${
|
||||
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<AgentIcon className='h-6 w-6 text-white' />
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
|
||||
isUploadingProfilePicture
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
{/* User Info Section */}
|
||||
<div className='flex items-center gap-4'>
|
||||
{/* Profile Picture Upload */}
|
||||
<div className='relative'>
|
||||
<div
|
||||
className='group relative flex h-12 w-12 flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
|
||||
onClick={handleProfilePictureClick}
|
||||
>
|
||||
{(() => {
|
||||
const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
|
||||
return imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={profile?.name || 'User'}
|
||||
width={48}
|
||||
height={48}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${
|
||||
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{isUploadingProfilePicture ? (
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent' />
|
||||
) : (
|
||||
<Camera className='h-5 w-5 text-white' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
) : (
|
||||
<AgentIcon className='h-6 w-6 text-white' />
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Hidden file input */}
|
||||
<Input
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
className='hidden'
|
||||
ref={profilePictureInputRef}
|
||||
onChange={handleProfilePictureChange}
|
||||
disabled={isUploadingProfilePicture}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Details */}
|
||||
<div className='flex flex-1 flex-col justify-center'>
|
||||
<h3 className='font-medium text-base'>{name}</h3>
|
||||
<p className='font-normal text-muted-foreground text-sm'>{email}</p>
|
||||
{uploadError && <p className='mt-1 text-destructive text-xs'>{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Field */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='name' className='font-normal text-muted-foreground text-sm'>
|
||||
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-base 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-base'>{name}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-auto p-0 font-normal text-muted-foreground text-sm 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-sm'>Email</Label>
|
||||
<p className='text-base'>{email}</p>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-sm'>Password</Label>
|
||||
<div className='flex items-center gap-4'>
|
||||
<span className='text-base'>••••••••</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={`h-auto p-0 font-normal text-sm 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'
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
|
||||
isUploadingProfilePicture ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
Sign Out
|
||||
{isUploadingProfilePicture ? (
|
||||
<div className='h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent' />
|
||||
) : (
|
||||
<Camera className='h-5 w-5 text-white' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<Input
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg'
|
||||
className='hidden'
|
||||
ref={profilePictureInputRef}
|
||||
onChange={handleProfilePictureChange}
|
||||
disabled={isUploadingProfilePicture}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User Details */}
|
||||
<div className='flex flex-1 flex-col justify-center'>
|
||||
<h3 className='font-medium text-base'>{profile?.name || ''}</h3>
|
||||
<p className='font-normal text-muted-foreground text-sm'>{profile?.email || ''}</p>
|
||||
{uploadError && <p className='mt-1 text-destructive text-xs'>{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name Field */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='name' className='font-normal text-muted-foreground text-sm'>
|
||||
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-base outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
maxLength={100}
|
||||
disabled={updateProfile.isPending}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center gap-4'>
|
||||
<span className='text-base'>{profile?.name || ''}</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-auto p-0 font-normal text-muted-foreground text-sm 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-sm'>Email</Label>
|
||||
<p className='text-base'>{profile?.email || ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-sm'>Password</Label>
|
||||
<div className='flex items-center gap-4'>
|
||||
<span className='text-base'>••••••••</span>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className={`h-auto p-0 font-normal text-sm 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>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,14 @@ import { Input, Label, Skeleton, Switch } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
type ApiKey,
|
||||
useApiKeys,
|
||||
useCreateApiKey,
|
||||
useDeleteApiKey,
|
||||
useUpdateWorkspaceApiKeySettings,
|
||||
} from '@/hooks/queries/api-keys'
|
||||
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('ApiKeys')
|
||||
|
||||
@@ -25,17 +33,6 @@ interface ApiKeysProps {
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
displayKey?: string
|
||||
lastUsed?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
interface ApiKeyDisplayProps {
|
||||
apiKey: ApiKey
|
||||
}
|
||||
@@ -57,13 +54,29 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
|
||||
// State for both workspace and personal keys
|
||||
const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([])
|
||||
const [personalKeys, setPersonalKeys] = useState<ApiKey[]>([])
|
||||
const [conflicts, setConflicts] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// React Query hooks
|
||||
const {
|
||||
data: apiKeysData,
|
||||
isLoading: isLoadingKeys,
|
||||
refetch: refetchApiKeys,
|
||||
} = useApiKeys(workspaceId)
|
||||
const { data: workspaceSettingsData, isLoading: isLoadingSettings } =
|
||||
useWorkspaceSettings(workspaceId)
|
||||
const createApiKeyMutation = useCreateApiKey()
|
||||
const deleteApiKeyMutation = useDeleteApiKey()
|
||||
const updateSettingsMutation = useUpdateWorkspaceApiKeySettings()
|
||||
|
||||
// Extract data from queries
|
||||
const workspaceKeys = apiKeysData?.workspaceKeys || []
|
||||
const personalKeys = apiKeysData?.personalKeys || []
|
||||
const conflicts = apiKeysData?.conflicts || []
|
||||
const isLoading = isLoadingKeys || isLoadingSettings
|
||||
|
||||
const allowPersonalApiKeys =
|
||||
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
|
||||
|
||||
// Local UI state
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [newKey, setNewKey] = useState<ApiKey | null>(null)
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
|
||||
@@ -76,17 +89,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
|
||||
const [allowPersonalApiKeys, setAllowPersonalApiKeys] = useState<boolean>(true)
|
||||
const [workspaceAdmins, setWorkspaceAdmins] = useState<
|
||||
Array<{ userId: string; name: string; email: string; permissionType: string }>
|
||||
>([])
|
||||
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
|
||||
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
|
||||
|
||||
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
|
||||
const createButtonDisabled =
|
||||
workspaceSettingsLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
|
||||
const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -113,118 +117,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
return filteredWorkspaceKeys.length > 0 ? 'mt-8' : 'mt-0'
|
||||
}, [searchTerm, filteredWorkspaceKeys])
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
if (!userId || !workspaceId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [workspaceResponse, personalResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}/api-keys`),
|
||||
fetch('/api/users/me/api-keys'),
|
||||
])
|
||||
|
||||
let workspaceKeys: ApiKey[] = []
|
||||
let personalKeys: ApiKey[] = []
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const workspaceData = await workspaceResponse.json()
|
||||
workspaceKeys = workspaceData.keys || []
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace API keys:', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (personalResponse.ok) {
|
||||
const personalData = await personalResponse.json()
|
||||
personalKeys = personalData.keys || []
|
||||
} else {
|
||||
logger.error('Failed to fetch personal API keys:', { status: personalResponse.status })
|
||||
}
|
||||
|
||||
// Client-side conflict detection
|
||||
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
|
||||
const conflicts = personalKeys
|
||||
.filter((key) => workspaceKeyNames.has(key.name))
|
||||
.map((key) => key.name)
|
||||
|
||||
setWorkspaceKeys(workspaceKeys)
|
||||
setPersonalKeys(personalKeys)
|
||||
setConflicts(conflicts)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching API keys:', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkspaceSettings = async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
setWorkspaceSettingsLoading(true)
|
||||
try {
|
||||
const [workspaceResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
fetch(`/api/workspaces/${workspaceId}/permissions`),
|
||||
])
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const data = await workspaceResponse.json()
|
||||
const workspaceData = data.workspace ?? {}
|
||||
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
|
||||
setAllowPersonalApiKeys(
|
||||
workspaceData.allowPersonalApiKeys === undefined
|
||||
? true
|
||||
: Boolean(workspaceData.allowPersonalApiKeys)
|
||||
)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (permissionsResponse.ok) {
|
||||
const data = await permissionsResponse.json()
|
||||
const users = Array.isArray(data.users) ? data.users : []
|
||||
const admins = users.filter((user: any) => user.permissionType === 'admin')
|
||||
setWorkspaceAdmins(admins)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace permissions', {
|
||||
status: permissionsResponse.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace settings:', { error })
|
||||
} finally {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateWorkspaceSettings = async (updates: {
|
||||
billedAccountUserId?: string
|
||||
allowPersonalApiKeys?: boolean
|
||||
}) => {
|
||||
if (!workspaceId) return
|
||||
setWorkspaceSettingsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
await fetchWorkspaceSettings()
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace settings:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setWorkspaceSettingsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!userId || !newKeyName.trim()) return
|
||||
|
||||
@@ -242,63 +134,28 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmittingCreate(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const url =
|
||||
keyType === 'workspace'
|
||||
? `/api/workspaces/${workspaceId}/api-keys`
|
||||
: '/api/users/me/api-keys'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newKeyName.trim(),
|
||||
}),
|
||||
const data = await createApiKeyMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: trimmedName,
|
||||
keyType,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
fetchApiKeys()
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
setIsSubmittingCreate(false)
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
setIsCreateDialogOpen(false)
|
||||
} catch (error: any) {
|
||||
logger.error('API key creation failed:', { error })
|
||||
const errorMessage = error.message || 'Failed to create API key. Please try again.'
|
||||
if (errorMessage.toLowerCase().includes('already exists')) {
|
||||
setCreateError(errorMessage)
|
||||
} else {
|
||||
let errorData
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch (parseError) {
|
||||
logger.error('Error parsing API response:', parseError)
|
||||
errorData = { error: 'Server error' }
|
||||
}
|
||||
|
||||
logger.error('API key creation failed:', { status: response.status, errorData })
|
||||
|
||||
const serverMessage = typeof errorData?.error === 'string' ? errorData.error : null
|
||||
if (response.status === 409 || serverMessage?.toLowerCase().includes('already exists')) {
|
||||
const errorMessage =
|
||||
serverMessage ||
|
||||
(keyType === 'workspace'
|
||||
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`)
|
||||
logger.error('Setting error message:', errorMessage)
|
||||
setCreateError(errorMessage)
|
||||
} else {
|
||||
setCreateError(errorData.error || 'Failed to create API key. Please try again.')
|
||||
}
|
||||
setCreateError('Failed to create API key. Please check your connection and try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setCreateError('Failed to create API key. Please check your connection and try again.')
|
||||
logger.error('Error creating API key:', { error })
|
||||
} finally {
|
||||
setIsSubmittingCreate(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,33 +164,21 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
|
||||
try {
|
||||
const isWorkspaceKey = workspaceKeys.some((k) => k.id === deleteKey.id)
|
||||
const url = isWorkspaceKey
|
||||
? `/api/workspaces/${workspaceId}/api-keys/${deleteKey.id}`
|
||||
: `/api/users/me/api-keys/${deleteKey.id}`
|
||||
|
||||
if (isWorkspaceKey) {
|
||||
setWorkspaceKeys((prev) => prev.filter((k) => k.id !== deleteKey.id))
|
||||
} else {
|
||||
setPersonalKeys((prev) => prev.filter((k) => k.id !== deleteKey.id))
|
||||
setConflicts((prev) => prev.filter((name) => name !== deleteKey.name))
|
||||
}
|
||||
const keyTypeToDelete = isWorkspaceKey ? 'workspace' : 'personal'
|
||||
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteKey(null)
|
||||
setDeleteConfirmationName('')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
await deleteApiKeyMutation.mutateAsync({
|
||||
workspaceId,
|
||||
keyId: deleteKey.id,
|
||||
keyType: keyTypeToDelete,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to delete API key:', errorData)
|
||||
fetchApiKeys()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting API key:', { error })
|
||||
fetchApiKeys()
|
||||
// Refetch to restore correct state in case of error
|
||||
refetchApiKeys()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,30 +192,17 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
onOpenChange?.(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && workspaceId) {
|
||||
fetchApiKeys()
|
||||
}
|
||||
}, [userId, workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchWorkspaceSettings()
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowPersonalApiKeys && keyType === 'personal') {
|
||||
setKeyType('workspace')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowPersonalApiKeys])
|
||||
}, [allowPersonalApiKeys, keyType])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
@@ -449,19 +281,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{workspaceSettingsLoading ? (
|
||||
{isLoadingSettings ? (
|
||||
<Skeleton className='h-5 w-16 rounded-full' />
|
||||
) : (
|
||||
<Switch
|
||||
checked={allowPersonalApiKeys}
|
||||
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
|
||||
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
|
||||
onCheckedChange={async (checked) => {
|
||||
const previous = allowPersonalApiKeys
|
||||
setAllowPersonalApiKeys(checked)
|
||||
try {
|
||||
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
workspaceId,
|
||||
allowPersonalApiKeys: checked,
|
||||
})
|
||||
} catch (error) {
|
||||
setAllowPersonalApiKeys(previous)
|
||||
logger.error('Error updating workspace settings:', { error })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -706,7 +539,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
className='h-9 w-full rounded-[8px] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
disabled={
|
||||
!newKeyName.trim() ||
|
||||
isSubmittingCreate ||
|
||||
createApiKeyMutation.isPending ||
|
||||
(keyType === 'workspace' && !canManageWorkspaceKeys)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,290 +1,195 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Brain, BrainCircuit, Check, Copy, Plus, Zap } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Check, Copy, Plus } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Skeleton,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@/components/emcn'
|
||||
import { Label } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
|
||||
import {
|
||||
type CopilotKey,
|
||||
useCopilotKeys,
|
||||
useDeleteCopilotKey,
|
||||
useGenerateCopilotKey,
|
||||
} from '@/hooks/queries/copilot-keys'
|
||||
|
||||
const logger = createLogger('CopilotSettings')
|
||||
|
||||
interface CopilotKey {
|
||||
id: string
|
||||
displayKey: string
|
||||
}
|
||||
// Commented out model-related code
|
||||
// interface ModelOption {
|
||||
// value: string
|
||||
// label: string
|
||||
// icon: 'brain' | 'brainCircuit' | 'zap'
|
||||
// }
|
||||
|
||||
interface ModelOption {
|
||||
value: string
|
||||
label: string
|
||||
icon: 'brain' | 'brainCircuit' | 'zap'
|
||||
}
|
||||
// const OPENAI_MODELS: ModelOption[] = [
|
||||
// // Zap models first
|
||||
// { value: 'gpt-4o', label: 'gpt-4o', icon: 'zap' },
|
||||
// { value: 'gpt-4.1', label: 'gpt-4.1', icon: 'zap' },
|
||||
// { value: 'gpt-5-fast', label: 'gpt-5-fast', icon: 'zap' },
|
||||
// // Brain models
|
||||
// { value: 'gpt-5', label: 'gpt-5', icon: 'brain' },
|
||||
// { value: 'gpt-5-medium', label: 'gpt-5-medium', icon: 'brain' },
|
||||
// // BrainCircuit models
|
||||
// { value: 'gpt-5-high', label: 'gpt-5-high', icon: 'brainCircuit' },
|
||||
// { value: 'o3', label: 'o3', icon: 'brainCircuit' },
|
||||
// ]
|
||||
|
||||
const OPENAI_MODELS: ModelOption[] = [
|
||||
// Zap models first
|
||||
{ value: 'gpt-4o', label: 'gpt-4o', icon: 'zap' },
|
||||
{ value: 'gpt-4.1', label: 'gpt-4.1', icon: 'zap' },
|
||||
{ value: 'gpt-5-fast', label: 'gpt-5-fast', icon: 'zap' },
|
||||
// Brain models
|
||||
{ value: 'gpt-5', label: 'gpt-5', icon: 'brain' },
|
||||
{ value: 'gpt-5-medium', label: 'gpt-5-medium', icon: 'brain' },
|
||||
// BrainCircuit models
|
||||
{ value: 'gpt-5-high', label: 'gpt-5-high', icon: 'brainCircuit' },
|
||||
{ value: 'o3', label: 'o3', icon: 'brainCircuit' },
|
||||
]
|
||||
// const ANTHROPIC_MODELS: ModelOption[] = [
|
||||
// // Zap model (Haiku)
|
||||
// { value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' },
|
||||
// // Brain models
|
||||
// { value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' },
|
||||
// { value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' },
|
||||
// // BrainCircuit models
|
||||
// { value: 'claude-4.1-opus', label: 'claude-4.1-opus', icon: 'brainCircuit' },
|
||||
// ]
|
||||
|
||||
const ANTHROPIC_MODELS: ModelOption[] = [
|
||||
// Zap model (Haiku)
|
||||
{ value: 'claude-4.5-haiku', label: 'claude-4.5-haiku', icon: 'zap' },
|
||||
// Brain models
|
||||
{ value: 'claude-4-sonnet', label: 'claude-4-sonnet', icon: 'brain' },
|
||||
{ value: 'claude-4.5-sonnet', label: 'claude-4.5-sonnet', icon: 'brain' },
|
||||
// BrainCircuit models
|
||||
{ value: 'claude-4.1-opus', label: 'claude-4.1-opus', icon: 'brainCircuit' },
|
||||
]
|
||||
// const ALL_MODELS: ModelOption[] = [...OPENAI_MODELS, ...ANTHROPIC_MODELS]
|
||||
|
||||
const ALL_MODELS: ModelOption[] = [...OPENAI_MODELS, ...ANTHROPIC_MODELS]
|
||||
// // Default enabled/disabled state for all models
|
||||
// const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
// 'gpt-4o': false,
|
||||
// 'gpt-4.1': false,
|
||||
// 'gpt-5-fast': false,
|
||||
// 'gpt-5': true,
|
||||
// 'gpt-5-medium': true,
|
||||
// 'gpt-5-high': false,
|
||||
// o3: true,
|
||||
// 'claude-4-sonnet': false,
|
||||
// 'claude-4.5-haiku': true,
|
||||
// 'claude-4.5-sonnet': true,
|
||||
// 'claude-4.1-opus': true,
|
||||
// }
|
||||
|
||||
// Default enabled/disabled state for all models
|
||||
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
'gpt-4o': false,
|
||||
'gpt-4.1': false,
|
||||
'gpt-5-fast': false,
|
||||
'gpt-5': true,
|
||||
'gpt-5-medium': true,
|
||||
'gpt-5-high': false,
|
||||
o3: true,
|
||||
'claude-4-sonnet': false,
|
||||
'claude-4.5-haiku': true,
|
||||
'claude-4.5-sonnet': true,
|
||||
'claude-4.1-opus': true,
|
||||
}
|
||||
|
||||
const getModelIcon = (iconType: 'brain' | 'brainCircuit' | 'zap') => {
|
||||
switch (iconType) {
|
||||
case 'brainCircuit':
|
||||
return <BrainCircuit className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
case 'brain':
|
||||
return <Brain className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
case 'zap':
|
||||
return <Zap className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
}
|
||||
}
|
||||
// const getModelIcon = (iconType: 'brain' | 'brainCircuit' | 'zap') => {
|
||||
// switch (iconType) {
|
||||
// case 'brainCircuit':
|
||||
// return <BrainCircuit className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
// case 'brain':
|
||||
// return <Brain className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
// case 'zap':
|
||||
// return <Zap className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
// }
|
||||
// }
|
||||
|
||||
export function Copilot() {
|
||||
const [keys, setKeys] = useState<CopilotKey[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [enabledModelsMap, setEnabledModelsMap] = useState<Record<string, boolean>>({})
|
||||
const [isModelsLoading, setIsModelsLoading] = useState(true)
|
||||
const hasFetchedModels = useRef(false)
|
||||
|
||||
const { setEnabledModels: setStoreEnabledModels } = useCopilotStore()
|
||||
// React Query hooks
|
||||
const { data: keys = [] } = useCopilotKeys()
|
||||
const generateKey = useGenerateCopilotKey()
|
||||
const deleteKeyMutation = useDeleteCopilotKey()
|
||||
|
||||
// Create flow state
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [newKeyCopySuccess, setNewKeyCopySuccess] = useState(false)
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
|
||||
// Delete flow state
|
||||
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/copilot/api-keys')
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setKeys(Array.isArray(data.keys) ? (data.keys as CopilotKey[]) : [])
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch copilot keys', { error })
|
||||
setKeys([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchEnabledModels = useCallback(async () => {
|
||||
if (hasFetchedModels.current) return
|
||||
hasFetchedModels.current = true
|
||||
|
||||
try {
|
||||
setIsModelsLoading(true)
|
||||
const res = await fetch('/api/copilot/user-models')
|
||||
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
|
||||
const data = await res.json()
|
||||
const modelsMap = data.enabledModels || DEFAULT_ENABLED_MODELS
|
||||
|
||||
setEnabledModelsMap(modelsMap)
|
||||
|
||||
// Convert to array for store (API already merged with defaults)
|
||||
const enabledArray = Object.entries(modelsMap)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([modelId]) => modelId)
|
||||
setStoreEnabledModels(enabledArray)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch enabled models', { error })
|
||||
setEnabledModelsMap(DEFAULT_ENABLED_MODELS)
|
||||
setStoreEnabledModels(
|
||||
Object.keys(DEFAULT_ENABLED_MODELS).filter((key) => DEFAULT_ENABLED_MODELS[key])
|
||||
)
|
||||
} finally {
|
||||
setIsModelsLoading(false)
|
||||
}
|
||||
}, [setStoreEnabledModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (isHosted) {
|
||||
fetchKeys()
|
||||
}
|
||||
fetchEnabledModels()
|
||||
}, [])
|
||||
const [deleteConfirmationKey, setDeleteConfirmationKey] = useState('')
|
||||
|
||||
const onGenerate = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/copilot/api-keys/generate', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to generate API key')
|
||||
}
|
||||
const data = await res.json()
|
||||
const data = await generateKey.mutateAsync()
|
||||
if (data?.key?.apiKey) {
|
||||
setNewKey(data.key.apiKey)
|
||||
setShowNewKeyDialog(true)
|
||||
}
|
||||
|
||||
await fetchKeys()
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate copilot API key', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key)
|
||||
setCopySuccess(true)
|
||||
setTimeout(() => setCopySuccess(false), 2000)
|
||||
}
|
||||
|
||||
const handleDeleteKey = async () => {
|
||||
if (!deleteKey) return
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/copilot/api-keys?id=${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to delete API key')
|
||||
}
|
||||
await fetchKeys()
|
||||
// Close dialog and clear state immediately for optimistic update
|
||||
setShowDeleteDialog(false)
|
||||
const keyToDelete = deleteKey
|
||||
setDeleteKey(null)
|
||||
setDeleteConfirmationKey('')
|
||||
|
||||
await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete copilot API key', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onCopy = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setNewKeyCopySuccess(true)
|
||||
setTimeout(() => setNewKeyCopySuccess(false), 1500)
|
||||
} catch (error) {
|
||||
logger.error('Copy failed', { error })
|
||||
}
|
||||
}
|
||||
// Commented out model-related functions
|
||||
// const toggleModel = async (modelValue: string, enabled: boolean) => {
|
||||
// const newModelsMap = { ...enabledModelsMap, [modelValue]: enabled }
|
||||
// setEnabledModelsMap(newModelsMap)
|
||||
|
||||
const toggleModel = async (modelValue: string, enabled: boolean) => {
|
||||
const newModelsMap = { ...enabledModelsMap, [modelValue]: enabled }
|
||||
setEnabledModelsMap(newModelsMap)
|
||||
// // Convert to array for store
|
||||
// const enabledArray = Object.entries(newModelsMap)
|
||||
// .filter(([_, isEnabled]) => isEnabled)
|
||||
// .map(([modelId]) => modelId)
|
||||
// setStoreEnabledModels(enabledArray)
|
||||
|
||||
// Convert to array for store
|
||||
const enabledArray = Object.entries(newModelsMap)
|
||||
.filter(([_, isEnabled]) => isEnabled)
|
||||
.map(([modelId]) => modelId)
|
||||
setStoreEnabledModels(enabledArray)
|
||||
// try {
|
||||
// const res = await fetch('/api/copilot/user-models', {
|
||||
// method: 'PUT',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ enabledModels: newModelsMap }),
|
||||
// })
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/copilot/user-models', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabledModels: newModelsMap }),
|
||||
})
|
||||
// if (!res.ok) {
|
||||
// throw new Error('Failed to update models')
|
||||
// }
|
||||
// } catch (error) {
|
||||
// logger.error('Failed to update enabled models', { error })
|
||||
// // Revert on error
|
||||
// setEnabledModelsMap(enabledModelsMap)
|
||||
// const revertedArray = Object.entries(enabledModelsMap)
|
||||
// .filter(([_, isEnabled]) => isEnabled)
|
||||
// .map(([modelId]) => modelId)
|
||||
// setStoreEnabledModels(revertedArray)
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to update models')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update enabled models', { error })
|
||||
// Revert on error
|
||||
setEnabledModelsMap(enabledModelsMap)
|
||||
const revertedArray = Object.entries(enabledModelsMap)
|
||||
.filter(([_, isEnabled]) => isEnabled)
|
||||
.map(([modelId]) => modelId)
|
||||
setStoreEnabledModels(revertedArray)
|
||||
}
|
||||
}
|
||||
|
||||
const enabledCount = Object.values(enabledModelsMap).filter(Boolean).length
|
||||
const totalCount = ALL_MODELS.length
|
||||
// const enabledCount = Object.values(enabledModelsMap).filter(Boolean).length
|
||||
// const totalCount = ALL_MODELS.length
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{/* Sticky Header with API Keys (only for hosted) */}
|
||||
{isHosted && (
|
||||
<div className='sticky top-0 z-10 bg-background px-6 py-4'>
|
||||
<div className='space-y-3'>
|
||||
{/* API Keys Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h3 className='font-semibold text-foreground text-sm'>API Keys</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Generate keys for programmatic access
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 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-3.5 w-3.5 stroke-[2px]' />
|
||||
Create
|
||||
</Button>
|
||||
{/* Scrollable Content */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
|
||||
<div className='space-y-2 pt-2 pb-6'>
|
||||
{keys.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
Click "Create Key" below to get started
|
||||
</div>
|
||||
|
||||
{/* API Keys List */}
|
||||
<div className='space-y-2'>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<CopilotKeySkeleton />
|
||||
<CopilotKeySkeleton />
|
||||
</>
|
||||
) : keys.length === 0 ? (
|
||||
<div className='py-3 text-center text-muted-foreground text-xs'>
|
||||
No API keys yet
|
||||
</div>
|
||||
) : (
|
||||
keys.map((k) => (
|
||||
<div key={k.id} className='flex flex-col gap-2'>
|
||||
<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'>{k.displayKey}</code>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='mb-2 font-medium text-[13px] text-foreground'>Copilot API Keys</div>
|
||||
{keys.map((key) => (
|
||||
<div key={key.id} className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-xs uppercase'>
|
||||
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'>{key.displayKey}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setDeleteKey(k)
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
@@ -293,182 +198,97 @@ export function Copilot() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable Content - Models Section */}
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
<div className='space-y-3'>
|
||||
{/* Models Header */}
|
||||
<div>
|
||||
<h3 className='font-semibold text-foreground text-sm'>Models</h3>
|
||||
</div>
|
||||
|
||||
{/* Models List */}
|
||||
{isModelsLoading ? (
|
||||
<div className='space-y-2'>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className='flex items-center justify-between py-1.5'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-5 w-9 rounded-full' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{/* Anthropic Models */}
|
||||
<div>
|
||||
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
|
||||
Anthropic
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{ANTHROPIC_MODELS.map((model) => {
|
||||
const isEnabled = enabledModelsMap[model.value] ?? false
|
||||
return (
|
||||
<div
|
||||
key={model.value}
|
||||
className='-mx-2 flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getModelIcon(model.icon)}
|
||||
<span className='text-foreground text-sm'>{model.label}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => toggleModel(model.value, checked)}
|
||||
className='scale-90'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenAI Models */}
|
||||
<div>
|
||||
<div className='mb-2 px-2 font-medium text-[10px] text-muted-foreground uppercase'>
|
||||
OpenAI
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
{OPENAI_MODELS.map((model) => {
|
||||
const isEnabled = enabledModelsMap[model.value] ?? false
|
||||
return (
|
||||
<div
|
||||
key={model.value}
|
||||
className='-mx-2 flex items-center justify-between rounded px-2 py-1.5 hover:bg-muted/50'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{getModelIcon(model.icon)}
|
||||
<span className='text-foreground text-sm'>{model.label}</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => toggleModel(model.value, checked)}
|
||||
className='scale-90'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className='bg-background'>
|
||||
<div className='flex w-full items-center px-6 py-4'>
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
variant='ghost'
|
||||
disabled={generateKey.isPending}
|
||||
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:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
<Plus className='h-4 w-4 stroke-[2px]' />
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New API Key Dialog */}
|
||||
<AlertDialog
|
||||
<Modal
|
||||
open={showNewKeyDialog}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange={(open: boolean) => {
|
||||
setShowNewKeyDialog(open)
|
||||
if (!open) {
|
||||
setNewKey(null)
|
||||
setNewKeyCopySuccess(false)
|
||||
setCopySuccess(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className='rounded-[10px] sm:max-w-lg'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Your API key has been created</ModalTitle>
|
||||
<ModalDescription>
|
||||
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>
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
{newKey && (
|
||||
<div className='relative'>
|
||||
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-8'>
|
||||
<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}</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)}
|
||||
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)}
|
||||
>
|
||||
{newKeyCopySuccess ? (
|
||||
<Check className='!h-3.5 !w-3.5' />
|
||||
) : (
|
||||
<Copy className='!h-3.5 !w-3.5' />
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
|
||||
<ModalHeader>
|
||||
<ModalTitle>Delete API key?</ModalTitle>
|
||||
<ModalDescription>
|
||||
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>
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => setDeleteKey(null)}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
<ModalFooter className='flex'>
|
||||
<Button
|
||||
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
|
||||
onClick={() => {
|
||||
if (deleteKey) {
|
||||
onDelete(deleteKey.id)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteKey(null)
|
||||
setDeleteConfirmationKey('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteKey}
|
||||
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>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopilotKeySkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Skeleton className='h-8 w-20 rounded-[8px]' />
|
||||
</div>
|
||||
<Skeleton className='h-8 w-14' />
|
||||
</div>
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,27 +6,17 @@ import { Camera, Check, User, Users } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button, Input, Textarea } from '@/components/emcn'
|
||||
import { Button, Combobox, Input, Textarea } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Skeleton,
|
||||
} from '@/components/ui'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/hooks/use-profile-picture-upload'
|
||||
import {
|
||||
useCreatorProfile,
|
||||
useOrganizations,
|
||||
useSaveCreatorProfile,
|
||||
} from '@/hooks/queries/creator-profile'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
const logger = createLogger('CreatorProfile')
|
||||
@@ -47,18 +37,17 @@ const creatorProfileSchema = z.object({
|
||||
|
||||
type CreatorProfileFormData = z.infer<typeof creatorProfileSchema>
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export function CreatorProfile() {
|
||||
const { data: session } = useSession()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const userId = session?.user?.id || ''
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: organizations = [] } = useOrganizations()
|
||||
const { data: existingProfile } = useCreatorProfile(userId)
|
||||
const saveProfile = useSaveCreatorProfile()
|
||||
|
||||
// Local UI state
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||
const [existingProfile, setExistingProfile] = useState<any>(null)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
const form = useForm<CreatorProfileFormData>({
|
||||
@@ -98,72 +87,32 @@ export function CreatorProfile() {
|
||||
|
||||
const referenceType = form.watch('referenceType')
|
||||
|
||||
// Fetch organizations
|
||||
// Update form when profile data loads
|
||||
useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const orgs = (data.organizations || []).filter(
|
||||
(org: any) => org.role === 'owner' || org.role === 'admin'
|
||||
)
|
||||
setOrganizations(orgs)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizations:', error)
|
||||
}
|
||||
if (existingProfile) {
|
||||
const details = existingProfile.details as CreatorProfileDetails | null
|
||||
form.reset({
|
||||
referenceType: existingProfile.referenceType,
|
||||
referenceId: existingProfile.referenceId,
|
||||
name: existingProfile.name || '',
|
||||
profileImageUrl: existingProfile.profileImageUrl || '',
|
||||
about: details?.about || '',
|
||||
xUrl: details?.xUrl || '',
|
||||
linkedinUrl: details?.linkedinUrl || '',
|
||||
websiteUrl: details?.websiteUrl || '',
|
||||
contactEmail: details?.contactEmail || '',
|
||||
})
|
||||
}
|
||||
|
||||
fetchOrganizations()
|
||||
}, [session?.user?.id])
|
||||
|
||||
// Load existing profile
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
if (!session?.user?.id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/creator-profiles?userId=${session.user.id}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.profiles && data.profiles.length > 0) {
|
||||
const profile = data.profiles[0]
|
||||
const details = profile.details as CreatorProfileDetails | null
|
||||
setExistingProfile(profile)
|
||||
form.reset({
|
||||
referenceType: profile.referenceType,
|
||||
referenceId: profile.referenceId,
|
||||
name: profile.name || '',
|
||||
profileImageUrl: profile.profileImageUrl || '',
|
||||
about: details?.about || '',
|
||||
xUrl: details?.xUrl || '',
|
||||
linkedinUrl: details?.linkedinUrl || '',
|
||||
websiteUrl: details?.websiteUrl || '',
|
||||
contactEmail: details?.contactEmail || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading profile:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadProfile()
|
||||
}, [session?.user?.id, form])
|
||||
}, [existingProfile, form])
|
||||
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const onSubmit = async (data: CreatorProfileFormData) => {
|
||||
if (!session?.user?.id) return
|
||||
if (!userId) return
|
||||
|
||||
setSaveStatus('saving')
|
||||
setSaveError(null)
|
||||
|
||||
try {
|
||||
const details: CreatorProfileDetails = {}
|
||||
if (data.about) details.about = data.about
|
||||
@@ -172,64 +121,29 @@ export function CreatorProfile() {
|
||||
if (data.websiteUrl) details.websiteUrl = data.websiteUrl
|
||||
if (data.contactEmail) details.contactEmail = data.contactEmail
|
||||
|
||||
const payload = {
|
||||
await saveProfile.mutateAsync({
|
||||
referenceType: data.referenceType,
|
||||
referenceId: data.referenceId,
|
||||
name: data.name,
|
||||
profileImageUrl: data.profileImageUrl,
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
}
|
||||
|
||||
const url = existingProfile
|
||||
? `/api/creator-profiles/${existingProfile.id}`
|
||||
: '/api/creator-profiles'
|
||||
const method = existingProfile ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
existingProfileId: existingProfile?.id,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setExistingProfile(result.data)
|
||||
logger.info('Creator profile saved successfully')
|
||||
setSaveStatus('saved')
|
||||
setSaveStatus('saved')
|
||||
|
||||
// Dispatch event to notify that a creator profile was saved
|
||||
window.dispatchEvent(new CustomEvent('creator-profile-saved'))
|
||||
|
||||
// Reset to idle after 2 seconds
|
||||
setTimeout(() => {
|
||||
setSaveStatus('idle')
|
||||
}, 2000)
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData.error || 'Failed to save creator profile'
|
||||
logger.error('Failed to save creator profile')
|
||||
setSaveError(errorMessage)
|
||||
// Reset to idle after 2 seconds
|
||||
setTimeout(() => {
|
||||
setSaveStatus('idle')
|
||||
}
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
logger.error('Error saving creator profile:', error)
|
||||
setSaveError('Failed to save creator profile. Please check your connection and try again.')
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save creator profile'
|
||||
setSaveError(errorMessage)
|
||||
setSaveStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-9 w-64 rounded-[8px]' />
|
||||
<Skeleton className='h-9 w-64 rounded-[8px]' />
|
||||
<Skeleton className='h-9 w-64 rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full flex-col'>
|
||||
<Form {...form}>
|
||||
@@ -243,35 +157,29 @@ export function CreatorProfile() {
|
||||
control={form.control}
|
||||
name='referenceType'
|
||||
render={({ field }) => (
|
||||
<FormItem className='space-y-3'>
|
||||
<FormLabel>Profile Type</FormLabel>
|
||||
<FormItem className='space-y-2'>
|
||||
<FormLabel className='font-[360] text-sm'>Profile Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className='flex flex-col space-y-1'
|
||||
>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='user' id='user' />
|
||||
<label
|
||||
htmlFor='user'
|
||||
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
|
||||
>
|
||||
<User className='h-4 w-4' />
|
||||
Personal Profile
|
||||
</label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<RadioGroupItem value='organization' id='organization' />
|
||||
<label
|
||||
htmlFor='organization'
|
||||
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
|
||||
>
|
||||
<Users className='h-4 w-4' />
|
||||
Organization Profile
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant={field.value === 'user' ? 'outline' : 'default'}
|
||||
onClick={() => field.onChange('user')}
|
||||
className='h-8'
|
||||
>
|
||||
<User className='mr-1.5 h-3.5 w-3.5' />
|
||||
Personal
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant={field.value === 'organization' ? 'outline' : 'default'}
|
||||
onClick={() => field.onChange('organization')}
|
||||
className='h-8'
|
||||
>
|
||||
<Users className='mr-1.5 h-3.5 w-3.5' />
|
||||
Organization
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -286,21 +194,19 @@ export function CreatorProfile() {
|
||||
name='referenceId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Select organization' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormLabel className='font-[360] text-sm'>Organization</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox
|
||||
options={organizations.map((org) => ({
|
||||
label: org.name,
|
||||
value: org.id,
|
||||
}))}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder='Select organization'
|
||||
editable={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -4,11 +4,17 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Input, Label, Skeleton } from '@/components/ui'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { Input, Label } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
type ServiceInfo,
|
||||
useConnectOAuthService,
|
||||
useDisconnectOAuthService,
|
||||
useOAuthConnections,
|
||||
} from '@/hooks/queries/oauth-connections'
|
||||
|
||||
const logger = createLogger('Credentials')
|
||||
|
||||
@@ -17,12 +23,6 @@ interface CredentialsProps {
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
interface ServiceInfo extends OAuthServiceConfig {
|
||||
isConnected: boolean
|
||||
lastConnected?: string
|
||||
accounts?: { id: string; name: string }[]
|
||||
}
|
||||
|
||||
export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -30,10 +30,13 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
const userId = session?.user?.id
|
||||
const pendingServiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [services, setServices] = useState<ServiceInfo[]>([])
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: services = [] } = useOAuthConnections()
|
||||
const connectService = useConnectOAuthService()
|
||||
const disconnectService = useDisconnectOAuthService()
|
||||
|
||||
// Local UI state
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isConnecting, setIsConnecting] = useState<string | null>(null)
|
||||
const [pendingService, setPendingService] = useState<string | null>(null)
|
||||
const [_pendingScopes, setPendingScopes] = useState<string[]>([])
|
||||
const [authSuccess, setAuthSuccess] = useState(false)
|
||||
@@ -41,98 +44,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
|
||||
const connectionAddedRef = useRef<boolean>(false)
|
||||
|
||||
// Define available services from our standardized OAuth providers
|
||||
const defineServices = (): ServiceInfo[] => {
|
||||
const servicesList: ServiceInfo[] = []
|
||||
|
||||
// Convert our standardized providers to ServiceInfo objects
|
||||
Object.values(OAUTH_PROVIDERS).forEach((provider) => {
|
||||
Object.values(provider.services).forEach((service) => {
|
||||
servicesList.push({
|
||||
...service,
|
||||
isConnected: false,
|
||||
scopes: service.scopes || [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return servicesList
|
||||
}
|
||||
|
||||
// Fetch services and their connection status
|
||||
const fetchServices = async () => {
|
||||
if (!userId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Start with the base service definitions
|
||||
const serviceDefinitions = defineServices()
|
||||
|
||||
// Fetch all OAuth connections for the user
|
||||
const response = await fetch('/api/auth/oauth/connections')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const connections = data.connections || []
|
||||
|
||||
// Update services with connection status and account info
|
||||
const updatedServices = serviceDefinitions.map((service) => {
|
||||
// Find matching connection - now we can do an exact match on providerId
|
||||
const connection = connections.find((conn: any) => {
|
||||
// Exact match on providerId is the most reliable
|
||||
return conn.provider === service.providerId
|
||||
})
|
||||
|
||||
// If we found an exact match, use it
|
||||
if (connection) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: connection.accounts?.length > 0,
|
||||
accounts: connection.accounts || [],
|
||||
lastConnected: connection.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, check if any connection has all the required scopes
|
||||
const connectionWithScopes = connections.find((conn: any) => {
|
||||
// Only consider connections from the same base provider
|
||||
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all required scopes for this service are included in the connection
|
||||
if (conn.scopes && service.scopes) {
|
||||
return service.scopes.every((scope) => conn.scopes.includes(scope))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (connectionWithScopes) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: connectionWithScopes.accounts?.length > 0,
|
||||
accounts: connectionWithScopes.accounts || [],
|
||||
lastConnected: connectionWithScopes.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
})
|
||||
|
||||
setServices(updatedServices)
|
||||
} else {
|
||||
// If there's an error, just use the base definitions
|
||||
setServices(serviceDefinitions)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching services:', { error })
|
||||
// Use base definitions on error
|
||||
setServices(defineServices())
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for OAuth callback
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code')
|
||||
@@ -167,25 +78,13 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
// Set success flag
|
||||
setAuthSuccess(true)
|
||||
|
||||
// Refresh connections to show the new connection
|
||||
if (userId) {
|
||||
fetchServices()
|
||||
}
|
||||
|
||||
// Clear the URL parameters
|
||||
router.replace('/workspace')
|
||||
} else if (error) {
|
||||
logger.error('OAuth error:', { error })
|
||||
router.replace('/workspace')
|
||||
}
|
||||
}, [searchParams, router, userId])
|
||||
|
||||
// Fetch services on mount
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchServices()
|
||||
}
|
||||
}, [userId])
|
||||
}, [searchParams, router])
|
||||
|
||||
// Track when a new connection is added compared to previous render
|
||||
useEffect(() => {
|
||||
@@ -227,66 +126,32 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
// Handle connect button click
|
||||
const handleConnect = async (service: ServiceInfo) => {
|
||||
try {
|
||||
setIsConnecting(service.id)
|
||||
|
||||
logger.info('Connecting service:', {
|
||||
serviceId: service.id,
|
||||
providerId: service.providerId,
|
||||
scopes: service.scopes,
|
||||
})
|
||||
|
||||
if (service.providerId === 'trello') {
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
await connectService.mutateAsync({
|
||||
providerId: service.providerId,
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('OAuth connection error:', { error })
|
||||
setIsConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle disconnect button click
|
||||
const handleDisconnect = async (service: ServiceInfo, accountId: string) => {
|
||||
setIsConnecting(`${service.id}-${accountId}`)
|
||||
try {
|
||||
// Call the API to disconnect the account
|
||||
const response = await fetch('/api/auth/oauth/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: service.providerId.split('-')[0],
|
||||
providerId: service.providerId,
|
||||
}),
|
||||
await disconnectService.mutateAsync({
|
||||
provider: service.providerId.split('-')[0],
|
||||
providerId: service.providerId,
|
||||
serviceId: service.id,
|
||||
accountId,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update the local state by removing the disconnected account
|
||||
setServices((prev) =>
|
||||
prev.map((svc) => {
|
||||
if (svc.id === service.id) {
|
||||
return {
|
||||
...svc,
|
||||
accounts: svc.accounts?.filter((acc) => acc.id !== accountId) || [],
|
||||
isConnected: (svc.accounts?.length || 0) > 1,
|
||||
}
|
||||
}
|
||||
return svc
|
||||
})
|
||||
)
|
||||
} else {
|
||||
logger.error('Error disconnecting service')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting service:', { error })
|
||||
} finally {
|
||||
setIsConnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,122 +259,77 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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='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>
|
||||
{/* Services list */}
|
||||
<div className='flex flex-col gap-6'>
|
||||
{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='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 ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
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'
|
||||
onClick={() => handleConnect(service)}
|
||||
disabled={isConnecting === service.id}
|
||||
className={cn('h-8', isConnecting === service.id && 'cursor-not-allowed')}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</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}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{service.accounts && service.accounts.length > 0 ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDisconnect(service, service.accounts![0].id)}
|
||||
disabled={disconnectService.isPending}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => handleConnect(service)}
|
||||
disabled={connectService.isPending}
|
||||
className='h-8'
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</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}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton for connections
|
||||
function ConnectionSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<Skeleton className='h-8 w-20' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Label } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
const logger = createLogger('CustomToolsSettings')
|
||||
|
||||
@@ -32,26 +32,16 @@ function CustomToolSkeleton() {
|
||||
export function CustomTools() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { tools, isLoading, error, fetchTools, deleteTool, clearError } = useCustomToolsStore()
|
||||
|
||||
// React Query hooks
|
||||
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
|
||||
const deleteToolMutation = useDeleteCustomTool()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [deletingTools, setDeletingTools] = useState<Set<string>>(new Set())
|
||||
const [editingTool, setEditingTool] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchTools(workspaceId)
|
||||
}
|
||||
}, [workspaceId, fetchTools])
|
||||
|
||||
// Clear store errors when modal opens (errors should show in modal, not in settings)
|
||||
useEffect(() => {
|
||||
if (showAddForm || editingTool) {
|
||||
clearError()
|
||||
}
|
||||
}, [showAddForm, editingTool, clearError])
|
||||
|
||||
const filteredTools = tools.filter((tool) => {
|
||||
if (!searchTerm.trim()) return true
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
@@ -69,15 +59,13 @@ export function CustomTools() {
|
||||
setDeletingTools((prev) => new Set(prev).add(toolId))
|
||||
try {
|
||||
// Pass null workspaceId for user-scoped tools (legacy tools without workspaceId)
|
||||
await deleteTool(tool.workspaceId ?? null, toolId)
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId: tool.workspaceId ?? null,
|
||||
toolId,
|
||||
})
|
||||
logger.info(`Deleted custom tool: ${toolId}`)
|
||||
// Silently refresh the list - no toast notification
|
||||
if (workspaceId) {
|
||||
await fetchTools(workspaceId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting custom tool:', error)
|
||||
// Silently handle error - no toast notification
|
||||
} finally {
|
||||
setDeletingTools((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -90,9 +78,8 @@ export function CustomTools() {
|
||||
const handleToolSaved = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingTool(null)
|
||||
if (workspaceId) {
|
||||
fetchTools(workspaceId)
|
||||
}
|
||||
// React Query will automatically refetch via cache invalidation
|
||||
refetchTools()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,7 +90,9 @@ export function CustomTools() {
|
||||
{error && !showAddForm && !editingTool && (
|
||||
<Alert variant='destructive' className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : 'An error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -229,7 +218,16 @@ export function CustomTools() {
|
||||
onSave={handleToolSaved}
|
||||
onDelete={() => {}}
|
||||
blockId=''
|
||||
initialValues={editingTool ? tools.find((t) => t.id === editingTool) : undefined}
|
||||
initialValues={
|
||||
editingTool
|
||||
? (() => {
|
||||
const tool = tools.find((t) => t.id === editingTool)
|
||||
return tool?.schema
|
||||
? { id: tool.id, schema: tool.schema, code: tool.code }
|
||||
: undefined
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,8 +16,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
useRemoveWorkspaceEnvironment,
|
||||
useSavePersonalEnvironment,
|
||||
useUpsertWorkspaceEnvironment,
|
||||
useWorkspaceEnvironment,
|
||||
type WorkspaceEnvironmentData,
|
||||
} from '@/hooks/queries/environment'
|
||||
|
||||
const logger = createLogger('EnvironmentVariables')
|
||||
|
||||
@@ -37,7 +43,9 @@ const createEmptyEnvVar = (): UIEnvironmentVariable => ({
|
||||
id: generateRowId(),
|
||||
})
|
||||
|
||||
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
|
||||
interface UIEnvironmentVariable {
|
||||
key: string
|
||||
value: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
@@ -50,16 +58,31 @@ export function EnvironmentVariables({
|
||||
onOpenChange,
|
||||
registerCloseHandler,
|
||||
}: EnvironmentVariablesProps) {
|
||||
const {
|
||||
variables,
|
||||
isLoading,
|
||||
loadWorkspaceEnvironment,
|
||||
upsertWorkspaceEnvironment,
|
||||
removeWorkspaceEnvironmentKeys,
|
||||
} = useEnvironmentStore()
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
// React Query hooks
|
||||
const { data: personalEnvData, isLoading: isPersonalLoading } = usePersonalEnvironment()
|
||||
const { data: workspaceEnvData, isLoading: isWorkspaceLoading } = useWorkspaceEnvironment(
|
||||
workspaceId,
|
||||
{
|
||||
select: useCallback(
|
||||
(data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({
|
||||
workspace: data.workspace || {},
|
||||
personal: data.personal || {},
|
||||
conflicts: data.conflicts || [],
|
||||
}),
|
||||
[]
|
||||
),
|
||||
}
|
||||
)
|
||||
const savePersonalMutation = useSavePersonalEnvironment()
|
||||
const upsertWorkspaceMutation = useUpsertWorkspaceEnvironment()
|
||||
const removeWorkspaceMutation = useRemoveWorkspaceEnvironment()
|
||||
|
||||
const isLoading = isPersonalLoading || isWorkspaceLoading
|
||||
const variables = personalEnvData || {}
|
||||
|
||||
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
|
||||
@@ -69,7 +92,6 @@ export function EnvironmentVariables({
|
||||
const [conflicts, setConflicts] = useState<string[]>([])
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null)
|
||||
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
|
||||
const [isWorkspaceLoading, setIsWorkspaceLoading] = useState(true)
|
||||
const initialWorkspaceVarsRef = useRef<Record<string, string>>({})
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -132,14 +154,25 @@ export function EnvironmentVariables({
|
||||
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
const handleModalClose = (open: boolean) => {
|
||||
if (!open && hasChanges) {
|
||||
setShowUnsavedChanges(true)
|
||||
pendingClose.current = true
|
||||
} else {
|
||||
onOpenChange(open)
|
||||
}
|
||||
}
|
||||
// Use ref to track hasChanges to break dependency chain
|
||||
const hasChangesRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
hasChangesRef.current = hasChanges
|
||||
}, [hasChanges])
|
||||
|
||||
// Memoize handleModalClose to prevent infinite loops
|
||||
const handleModalClose = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open && hasChangesRef.current) {
|
||||
setShowUnsavedChanges(true)
|
||||
pendingClose.current = true
|
||||
} else {
|
||||
onOpenChange(open)
|
||||
}
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const existingVars = Object.values(variables)
|
||||
@@ -155,35 +188,19 @@ export function EnvironmentVariables({
|
||||
}, [variables])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
if (!workspaceId) {
|
||||
setIsWorkspaceLoading(false)
|
||||
return
|
||||
}
|
||||
setIsWorkspaceLoading(true)
|
||||
try {
|
||||
const data = await loadWorkspaceEnvironment(workspaceId)
|
||||
if (!mounted) return
|
||||
setWorkspaceVars(data.workspace || {})
|
||||
initialWorkspaceVarsRef.current = data.workspace || {}
|
||||
setConflicts(data.conflicts || [])
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsWorkspaceLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
if (workspaceEnvData) {
|
||||
setWorkspaceVars(workspaceEnvData?.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
|
||||
setConflicts(workspaceEnvData?.conflicts || [])
|
||||
}
|
||||
}, [workspaceId, loadWorkspaceEnvironment])
|
||||
}, [workspaceEnvData])
|
||||
|
||||
// Register the close handler - now with stable dependencies
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler, hasChanges])
|
||||
}, [registerCloseHandler, handleModalClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
@@ -351,7 +368,7 @@ export function EnvironmentVariables({
|
||||
}),
|
||||
{}
|
||||
)
|
||||
await useEnvironmentStore.getState().saveEnvironmentVariables(validVariables)
|
||||
await savePersonalMutation.mutateAsync({ variables: validVariables })
|
||||
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
@@ -369,10 +386,10 @@ export function EnvironmentVariables({
|
||||
|
||||
if (workspaceId) {
|
||||
if (Object.keys(toUpsert).length) {
|
||||
await upsertWorkspaceEnvironment(workspaceId, toUpsert)
|
||||
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await removeWorkspaceEnvironmentKeys(workspaceId, toDelete)
|
||||
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,7 +546,7 @@ export function EnvironmentVariables({
|
||||
{/* Scrollable Content */}
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
|
||||
<div className='space-y-2 pt-2 pb-6'>
|
||||
{isLoading || isWorkspaceLoading ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
{/* Show 3 skeleton rows */}
|
||||
{[1, 2, 3].map((index) => (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Tooltip, Trash } from '@/components/emcn'
|
||||
import { Input, Progress, Skeleton } from '@/components/ui'
|
||||
import { Input, Progress } from '@/components/ui'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,6 +19,12 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
useDeleteWorkspaceFile,
|
||||
useStorageInfo,
|
||||
useUploadWorkspaceFile,
|
||||
useWorkspaceFiles,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import { useUserPermissions } from '@/hooks/use-user-permissions'
|
||||
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
|
||||
|
||||
@@ -45,77 +51,26 @@ const SUPPORTED_EXTENSIONS = [
|
||||
const ACCEPT_ATTR =
|
||||
'.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.yaml,.yml'
|
||||
|
||||
interface StorageInfo {
|
||||
usedBytes: number
|
||||
limitBytes: number
|
||||
percentUsed: number
|
||||
}
|
||||
|
||||
export function Files() {
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const [files, setFiles] = useState<WorkspaceFileRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: files = [] } = useWorkspaceFiles(workspaceId)
|
||||
const { data: storageInfo } = useStorageInfo(isBillingEnabled)
|
||||
const uploadFile = useUploadWorkspaceFile()
|
||||
const deleteFile = useDeleteWorkspaceFile()
|
||||
|
||||
// Local UI state
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [deletingFileId, setDeletingFileId] = useState<string | null>(null)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [planName, setPlanName] = useState<string>('free')
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
|
||||
const { permissions: workspacePermissions, loading: permissionsLoading } =
|
||||
useWorkspacePermissions(workspaceId)
|
||||
const userPermissions = useUserPermissions(workspacePermissions, permissionsLoading)
|
||||
|
||||
const loadFiles = async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setFiles(data.files)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading workspace files:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadStorageInfo = async () => {
|
||||
if (!isBillingEnabled) {
|
||||
setStorageLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setStorageLoading(true)
|
||||
const response = await fetch('/api/users/me/usage-limits')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.storage) {
|
||||
setStorageInfo(data.storage)
|
||||
if (data.usage?.plan) {
|
||||
setPlanName(data.usage.plan)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading storage info:', error)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles()
|
||||
void loadStorageInfo()
|
||||
}, [workspaceId])
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
@@ -143,30 +98,14 @@ export function Files() {
|
||||
for (let i = 0; i < allowedFiles.length; i++) {
|
||||
const selectedFile = allowedFiles[i]
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
lastError = data.error || 'Upload failed'
|
||||
} else {
|
||||
setUploadProgress({ completed: i + 1, total: allowedFiles.length })
|
||||
}
|
||||
await uploadFile.mutateAsync({ workspaceId, file: selectedFile })
|
||||
setUploadProgress({ completed: i + 1, total: allowedFiles.length })
|
||||
} catch (err) {
|
||||
logger.error('Error uploading file:', err)
|
||||
lastError = 'Upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
await loadFiles()
|
||||
if (isBillingEnabled) {
|
||||
await loadStorageInfo()
|
||||
}
|
||||
if (unsupported.length) {
|
||||
lastError = `Unsupported file type: ${unsupported.join(', ')}`
|
||||
}
|
||||
@@ -194,42 +133,13 @@ export function Files() {
|
||||
if (!workspaceId) return
|
||||
|
||||
try {
|
||||
setDeletingFileId(file.id)
|
||||
|
||||
const previousFiles = files
|
||||
const previousStorageInfo = storageInfo
|
||||
|
||||
setFiles((prev) => prev.filter((f) => f.id !== file.id))
|
||||
|
||||
if (isBillingEnabled && storageInfo) {
|
||||
const newUsedBytes = Math.max(0, storageInfo.usedBytes - file.size)
|
||||
const newPercentUsed = (newUsedBytes / storageInfo.limitBytes) * 100
|
||||
setStorageInfo({
|
||||
...storageInfo,
|
||||
usedBytes: newUsedBytes,
|
||||
percentUsed: newPercentUsed,
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files/${file.id}`, {
|
||||
method: 'DELETE',
|
||||
await deleteFile.mutateAsync({
|
||||
workspaceId,
|
||||
fileId: file.id,
|
||||
fileSize: file.size,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
setFiles(previousFiles)
|
||||
setStorageInfo(previousStorageInfo)
|
||||
logger.error('Failed to delete file:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting file:', error)
|
||||
await loadFiles()
|
||||
if (isBillingEnabled) {
|
||||
await loadStorageInfo()
|
||||
}
|
||||
} finally {
|
||||
setDeletingFileId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +184,7 @@ export function Files() {
|
||||
free: 'Free',
|
||||
} as const
|
||||
|
||||
const planName = storageInfo?.plan || 'free'
|
||||
const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free'
|
||||
|
||||
const GRADIENT_TEXT_STYLES =
|
||||
@@ -293,34 +204,28 @@ export function Files() {
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
{isBillingEnabled && (
|
||||
<>
|
||||
{storageLoading ? (
|
||||
<Skeleton className='h-4 w-32' />
|
||||
) : storageInfo ? (
|
||||
<div className='flex flex-col items-end gap-1'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
planName === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
|
||||
)}
|
||||
>
|
||||
{displayPlanName}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatStorageSize(storageInfo.usedBytes)} /{' '}
|
||||
{formatStorageSize(storageInfo.limitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(storageInfo.percentUsed, 100)}
|
||||
className='h-1 w-full'
|
||||
indicatorClassName='bg-black dark:bg-white'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
{isBillingEnabled && storageInfo && (
|
||||
<div className='flex flex-col items-end gap-1'>
|
||||
<div className='flex items-center gap-2 text-sm'>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
planName === 'free' ? 'text-foreground' : GRADIENT_TEXT_STYLES
|
||||
)}
|
||||
>
|
||||
{displayPlanName}
|
||||
</span>
|
||||
<span className='text-muted-foreground tabular-nums'>
|
||||
{formatStorageSize(storageInfo.usedBytes)} /{' '}
|
||||
{formatStorageSize(storageInfo.limitBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(storageInfo.percentUsed, 100)}
|
||||
className='h-1 w-full'
|
||||
indicatorClassName='bg-black dark:bg-white'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{userPermissions.canEdit && (
|
||||
<div className='flex items-center'>
|
||||
@@ -361,9 +266,7 @@ export function Files() {
|
||||
|
||||
{/* Files Table */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
|
||||
{loading ? (
|
||||
<div className='py-8 text-center text-muted-foreground text-sm'>Loading files...</div>
|
||||
) : files.length === 0 ? (
|
||||
{files.length === 0 ? (
|
||||
<div className='py-8 text-center text-muted-foreground text-sm'>
|
||||
No files uploaded yet
|
||||
</div>
|
||||
@@ -422,7 +325,7 @@ export function Files() {
|
||||
variant='ghost'
|
||||
onClick={() => handleDelete(file)}
|
||||
className='h-6 w-6 p-0'
|
||||
disabled={deletingFileId === file.id}
|
||||
disabled={deleteFile.isPending}
|
||||
aria-label={`Delete ${file.name}`}
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -11,11 +11,10 @@ import { Label } from '@/components/ui/label'
|
||||
// SelectTrigger,
|
||||
// SelectValue,
|
||||
// } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
|
||||
const TOOLTIPS = {
|
||||
autoConnect: 'Automatically connect nodes.',
|
||||
@@ -35,39 +34,11 @@ export function General() {
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
|
||||
const isLoading = useGeneralStore((state) => state.isLoading)
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: settings, isLoading } = useGeneralSettings()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
|
||||
|
||||
const isAutoPanEnabled = useGeneralStore((state) => state.isAutoPanEnabled)
|
||||
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
|
||||
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
|
||||
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
|
||||
const superUserModeEnabled = useGeneralStore((state) => state.superUserModeEnabled)
|
||||
|
||||
// Loading states
|
||||
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
|
||||
|
||||
const isAutoPanLoading = useGeneralStore((state) => state.isAutoPanLoading)
|
||||
const isConsoleExpandedByDefaultLoading = useGeneralStore(
|
||||
(state) => state.isConsoleExpandedByDefaultLoading
|
||||
)
|
||||
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
|
||||
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
|
||||
const isTrainingControlsLoading = useGeneralStore((state) => state.isTrainingControlsLoading)
|
||||
const isSuperUserModeLoading = useGeneralStore((state) => state.isSuperUserModeLoading)
|
||||
|
||||
const setTheme = useGeneralStore((state) => state.setTheme)
|
||||
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
|
||||
|
||||
const toggleAutoPan = useGeneralStore((state) => state.toggleAutoPan)
|
||||
const toggleConsoleExpandedByDefault = useGeneralStore(
|
||||
(state) => state.toggleConsoleExpandedByDefault
|
||||
)
|
||||
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
|
||||
const toggleTrainingControls = useGeneralStore((state) => state.toggleTrainingControls)
|
||||
const toggleSuperUserMode = useGeneralStore((state) => state.toggleSuperUserMode)
|
||||
|
||||
// Fetch super user status from database
|
||||
useEffect(() => {
|
||||
@@ -91,8 +62,8 @@ export function General() {
|
||||
}, [session?.user?.id])
|
||||
|
||||
const handleSuperUserModeToggle = async (checked: boolean) => {
|
||||
if (checked !== superUserModeEnabled && !isSuperUserModeLoading) {
|
||||
await toggleSuperUserMode()
|
||||
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,301 +78,233 @@ export function General() {
|
||||
// }, [theme, isLoading])
|
||||
|
||||
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
|
||||
await setTheme(value)
|
||||
await updateSetting.mutateAsync({ key: 'theme', value })
|
||||
}
|
||||
|
||||
const handleAutoConnectChange = async (checked: boolean) => {
|
||||
if (checked !== isAutoConnectEnabled && !isAutoConnectLoading) {
|
||||
await toggleAutoConnect()
|
||||
if (checked !== settings?.autoConnect && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'autoConnect', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoPanChange = async (checked: boolean) => {
|
||||
if (checked !== isAutoPanEnabled && !isAutoPanLoading) {
|
||||
await toggleAutoPan()
|
||||
if (checked !== settings?.autoPan && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'autoPan', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleConsoleExpandedByDefaultChange = async (checked: boolean) => {
|
||||
if (checked !== isConsoleExpandedByDefault && !isConsoleExpandedByDefaultLoading) {
|
||||
await toggleConsoleExpandedByDefault()
|
||||
if (checked !== settings?.consoleExpandedByDefault && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'consoleExpandedByDefault', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFloatingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== showFloatingControls && !isFloatingControlsLoading) {
|
||||
await toggleFloatingControls()
|
||||
if (checked !== settings?.showFloatingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showFloatingControls', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrainingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== showTrainingControls && !isTrainingControlsLoading) {
|
||||
await toggleTrainingControls()
|
||||
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='px-6 pt-4 pb-2'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{isLoading ? (
|
||||
<>
|
||||
{/* COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace */}
|
||||
{/* 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> */}
|
||||
|
||||
{/* 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.Root>
|
||||
<Tooltip.Trigger 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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Skeleton className='h-6 w-11 rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* 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.Root>
|
||||
<Tooltip.Trigger 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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Skeleton className='h-6 w-11 rounded-full' />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace */}
|
||||
{/* <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}
|
||||
{/* COMMENTED OUT: Theme switching disabled - dark mode is forced for workspace */}
|
||||
{/* <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={settings?.theme}
|
||||
onValueChange={handleThemeChange}
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
<SelectTrigger id='theme-select' className='h-9 w-[180px]'>
|
||||
<SelectValue placeholder='Select theme' />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='min-w-32 rounded-[10px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'>
|
||||
<SelectItem
|
||||
value='system'
|
||||
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
|
||||
>
|
||||
<SelectTrigger id='theme-select' className='h-9 w-[180px]'>
|
||||
<SelectValue placeholder='Select theme' />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='min-w-32 rounded-[10px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'>
|
||||
<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.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about auto-connect feature'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={settings?.autoConnect ?? true}
|
||||
onCheckedChange={handleAutoConnectChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</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.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about console expanded by default'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='console-expanded-by-default'
|
||||
checked={settings?.consoleExpandedByDefault ?? true}
|
||||
onCheckedChange={handleConsoleExpandedByDefaultChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='floating-controls' className='font-normal'>
|
||||
Floating controls
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about floating controls'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.floatingControls}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='floating-controls'
|
||||
checked={settings?.showFloatingControls ?? true}
|
||||
onCheckedChange={handleFloatingControlsChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isTrainingEnabled && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='training-controls' className='font-normal'>
|
||||
Training controls
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 p-0'
|
||||
aria-label='Learn more about training controls'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
System
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value='light'
|
||||
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
|
||||
<Info className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.trainingControls}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='training-controls'
|
||||
checked={settings?.showTrainingControls ?? false}
|
||||
onCheckedChange={handleTrainingControlsChange}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Super User Mode Toggle - Only visible to super users */}
|
||||
{!loadingSuperUser && isSuperUser && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='super-user-mode' className='font-normal'>
|
||||
Super User Mode
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about super user mode'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
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.Root>
|
||||
<Tooltip.Trigger 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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='auto-connect'
|
||||
checked={isAutoConnectEnabled}
|
||||
onCheckedChange={handleAutoConnectChange}
|
||||
disabled={isLoading || isAutoConnectLoading}
|
||||
/>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.superUserMode}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</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.Root>
|
||||
<Tooltip.Trigger 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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='console-expanded-by-default'
|
||||
checked={isConsoleExpandedByDefault}
|
||||
onCheckedChange={handleConsoleExpandedByDefaultChange}
|
||||
disabled={isLoading || isConsoleExpandedByDefaultLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='floating-controls' className='font-normal'>
|
||||
Floating controls
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about floating controls'
|
||||
disabled={isLoading || isFloatingControlsLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.floatingControls}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='floating-controls'
|
||||
checked={showFloatingControls}
|
||||
onCheckedChange={handleFloatingControlsChange}
|
||||
disabled={isLoading || isFloatingControlsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isTrainingEnabled && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='training-controls' className='font-normal'>
|
||||
Training controls
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-5 w-5 p-0'
|
||||
aria-label='Learn more about training controls'
|
||||
disabled={isLoading || isTrainingControlsLoading}
|
||||
>
|
||||
<Info className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.trainingControls}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='training-controls'
|
||||
checked={showTrainingControls}
|
||||
onCheckedChange={handleTrainingControlsChange}
|
||||
disabled={isLoading || isTrainingControlsLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Super User Mode Toggle - Only visible to super users */}
|
||||
{!loadingSuperUser && isSuperUser && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='super-user-mode' className='font-normal'>
|
||||
Super User Mode
|
||||
</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about super user mode'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.superUserMode}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='super-user-mode'
|
||||
checked={superUserModeEnabled}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
disabled={isLoading || isSuperUserModeLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Switch
|
||||
id='super-user-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { AlertCircle, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import {
|
||||
useCreateMcpServer,
|
||||
useDeleteMcpServer,
|
||||
useMcpServers,
|
||||
useMcpToolsQuery,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { useMcpServersStore } from '@/stores/mcp-servers/store'
|
||||
import { AddServerForm } from './components/add-server-form'
|
||||
import type { McpServerFormData } from './types'
|
||||
|
||||
@@ -18,15 +23,19 @@ const logger = createLogger('McpSettings')
|
||||
export function MCP() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { mcpTools, error: toolsError, refreshTools } = useMcpTools(workspaceId)
|
||||
|
||||
// React Query hooks
|
||||
const {
|
||||
servers,
|
||||
data: servers = [],
|
||||
isLoading: serversLoading,
|
||||
error: serversError,
|
||||
fetchServers,
|
||||
createServer,
|
||||
deleteServer,
|
||||
} = useMcpServersStore()
|
||||
} = useMcpServers(workspaceId)
|
||||
const { data: mcpToolsData = [], error: toolsError } = useMcpToolsQuery(workspaceId)
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
|
||||
// Keep the old hook for backward compatibility with other features that use it
|
||||
const { refreshTools } = useMcpTools(workspaceId)
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -162,13 +171,16 @@ export function MCP() {
|
||||
return
|
||||
}
|
||||
|
||||
await createServer(workspaceId, {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: formData.timeout || 30000,
|
||||
headers: formData.headers,
|
||||
enabled: true,
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
config: {
|
||||
name: formData.name.trim(),
|
||||
transport: formData.transport,
|
||||
url: formData.url,
|
||||
timeout: formData.timeout || 30000,
|
||||
headers: formData.headers,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`Added MCP server: ${formData.name}`)
|
||||
@@ -196,7 +208,7 @@ export function MCP() {
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
createServer,
|
||||
createServerMutation,
|
||||
refreshTools,
|
||||
clearTestResult,
|
||||
workspaceId,
|
||||
@@ -207,7 +219,7 @@ export function MCP() {
|
||||
setDeletingServers((prev) => new Set(prev).add(serverId))
|
||||
|
||||
try {
|
||||
await deleteServer(workspaceId, serverId)
|
||||
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
await refreshTools(true)
|
||||
|
||||
logger.info(`Removed MCP server: ${serverId}`)
|
||||
@@ -226,15 +238,10 @@ export function MCP() {
|
||||
})
|
||||
}
|
||||
},
|
||||
[deleteServer, refreshTools, workspaceId]
|
||||
[deleteServerMutation, refreshTools, workspaceId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers(workspaceId)
|
||||
refreshTools()
|
||||
}, [fetchServers, refreshTools, workspaceId])
|
||||
|
||||
const toolsByServer = (mcpTools || []).reduce(
|
||||
const toolsByServer = (mcpToolsData || []).reduce(
|
||||
(acc, tool) => {
|
||||
if (!tool || !tool.serverId) {
|
||||
return acc
|
||||
@@ -245,7 +252,7 @@ export function MCP() {
|
||||
acc[tool.serverId].push(tool)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, typeof mcpTools>
|
||||
{} as Record<string, typeof mcpToolsData>
|
||||
)
|
||||
|
||||
const filteredServers = (servers || []).filter((server) =>
|
||||
@@ -275,7 +282,13 @@ export function MCP() {
|
||||
{(toolsError || serversError) && (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>{toolsError || serversError}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{toolsError instanceof Error
|
||||
? toolsError.message
|
||||
: serversError instanceof Error
|
||||
? serversError.message
|
||||
: 'An error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
|
||||
const TOOLTIPS = {
|
||||
telemetry:
|
||||
@@ -15,31 +13,29 @@ const TOOLTIPS = {
|
||||
}
|
||||
|
||||
export function Privacy() {
|
||||
const isLoading = useGeneralStore((state) => state.isLoading)
|
||||
const telemetryEnabled = useGeneralStore((state) => state.telemetryEnabled)
|
||||
const setTelemetryEnabled = useGeneralStore((state) => state.setTelemetryEnabled)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
// React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!)
|
||||
const { data: settings } = useGeneralSettings()
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
const handleTelemetryToggle = async (checked: boolean) => {
|
||||
if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
|
||||
|
||||
const handleTelemetryToggle = (checked: boolean) => {
|
||||
setTelemetryEnabled(checked)
|
||||
|
||||
if (checked) {
|
||||
if (typeof window !== 'undefined') {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: 'consent',
|
||||
action: 'enable_from_settings',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just telemetry
|
||||
})
|
||||
// Send telemetry event when enabling
|
||||
if (checked) {
|
||||
if (typeof window !== 'undefined') {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: 'consent',
|
||||
action: 'enable_from_settings',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just telemetry
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,38 +43,35 @@ export function Privacy() {
|
||||
return (
|
||||
<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.Root>
|
||||
<Tooltip.Trigger 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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Switch
|
||||
id='telemetry'
|
||||
checked={telemetryEnabled}
|
||||
onCheckedChange={handleTelemetryToggle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<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.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about telemetry data collection'
|
||||
disabled={updateSetting.isPending}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
<Switch
|
||||
id='telemetry'
|
||||
checked={settings?.telemetryEnabled ?? true}
|
||||
onCheckedChange={handleTelemetryToggle}
|
||||
disabled={updateSetting.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='border-t pt-4'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
@@ -91,23 +84,3 @@ export function Privacy() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-7 w-7 rounded' />}
|
||||
</div>
|
||||
{isSwitch ? (
|
||||
<Skeleton className='h-6 w-11 rounded-full' />
|
||||
) : (
|
||||
<Skeleton className='h-9 w-[180px]' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bot,
|
||||
CreditCard,
|
||||
FileCode,
|
||||
Files,
|
||||
@@ -18,10 +20,13 @@ import {
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { getUserRole } from '@/lib/organization/helpers'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { generalSettingsKeys } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
@@ -69,6 +74,7 @@ type NavigationItem = {
|
||||
requiresTeam?: boolean
|
||||
requiresEnterprise?: boolean
|
||||
requiresOwner?: boolean
|
||||
requiresHosted?: boolean
|
||||
}
|
||||
|
||||
const allNavigationItems: NavigationItem[] = [
|
||||
@@ -117,11 +123,12 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Files',
|
||||
icon: Files,
|
||||
},
|
||||
// {
|
||||
// id: 'copilot',
|
||||
// label: 'Copilot',
|
||||
// icon: Bot,
|
||||
// },
|
||||
{
|
||||
id: 'copilot',
|
||||
label: 'Copilot',
|
||||
icon: Bot,
|
||||
requiresHosted: true,
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
label: 'Privacy',
|
||||
@@ -156,120 +163,200 @@ export function SettingsNavigation({
|
||||
hasOrganization,
|
||||
}: SettingsNavigationProps) {
|
||||
const { data: session } = useSession()
|
||||
const { hasEnterprisePlan, getUserRole } = useOrganizationStore()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const activeOrg = orgsData?.activeOrganization
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
const userRole = getUserRole(userEmail)
|
||||
const userRole = getUserRole(activeOrg, userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
const canManageSSO = isOwner || isAdmin
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
|
||||
const [isSSOProviderOwner, setIsSSOProviderOwner] = useState<boolean | null>(null)
|
||||
// Use React Query to check SSO provider ownership (with proper caching)
|
||||
// Only fetch if not hosted (hosted uses billing/org checks)
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHosted && userId) {
|
||||
fetch('/api/auth/sso/providers')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch providers')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
const ownsProvider = data.providers?.some((p: any) => p.userId === userId) || false
|
||||
setIsSSOProviderOwner(ownsProvider)
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSSOProviderOwner(false)
|
||||
})
|
||||
} else if (isHosted) {
|
||||
setIsSSOProviderOwner(null)
|
||||
}
|
||||
}, [userId, isHosted])
|
||||
// Memoize SSO provider ownership check
|
||||
const isSSOProviderOwner = useMemo(() => {
|
||||
if (isHosted) return null
|
||||
if (!userId || isLoadingSSO) return null
|
||||
return ssoProvidersData?.providers?.some((p: any) => p.userId === userId) || false
|
||||
}, [userId, isHosted, ssoProvidersData?.providers, isLoadingSSO])
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresTeam && !hasOrganization) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresEnterprise && !hasEnterprisePlan) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.id === 'sso') {
|
||||
if (isHosted) {
|
||||
return hasOrganization && hasEnterprisePlan && canManageSSO
|
||||
// Memoize navigation items to avoid filtering on every render
|
||||
const navigationItems = useMemo(() => {
|
||||
return allNavigationItems.filter((item) => {
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return false
|
||||
}
|
||||
return isSSOProviderOwner === true
|
||||
}
|
||||
|
||||
if (item.requiresOwner && !isOwner) {
|
||||
return false
|
||||
}
|
||||
if (item.requiresTeam && !hasOrganization) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
if (item.requiresEnterprise && !hasEnterprisePlan) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresHosted && !isHosted) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.id === 'sso') {
|
||||
if (isHosted) {
|
||||
return hasOrganization && hasEnterprisePlan && canManageSSO
|
||||
}
|
||||
// For non-hosted, only show if we know the ownership status
|
||||
return isSSOProviderOwner === true
|
||||
}
|
||||
|
||||
if (item.requiresOwner && !isOwner) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner])
|
||||
|
||||
// Prefetch functions for React Query
|
||||
const prefetchGeneral = () => {
|
||||
// Prefetch general settings using React Query
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/users/me/settings')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch general settings')
|
||||
}
|
||||
const { data } = await response.json()
|
||||
return {
|
||||
autoConnect: data.autoConnect ?? true,
|
||||
autoPan: data.autoPan ?? true,
|
||||
consoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
|
||||
showFloatingControls: data.showFloatingControls ?? true,
|
||||
showTrainingControls: data.showTrainingControls ?? false,
|
||||
superUserModeEnabled: data.superUserModeEnabled ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled ?? true,
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
})
|
||||
}
|
||||
|
||||
const prefetchSubscription = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: subscriptionKeys.user(),
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/billing?context=user')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscription data')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
const prefetchOrganization = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: organizationKeys.lists(),
|
||||
queryFn: async () => {
|
||||
const { client } = await import('@/lib/auth-client')
|
||||
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
|
||||
client.organization.list(),
|
||||
client.organization.getFullOrganization(),
|
||||
fetch('/api/billing?context=user').then((r) => r.json()),
|
||||
])
|
||||
|
||||
return {
|
||||
organizations: orgsResponse.data || [],
|
||||
activeOrganization: activeOrgResponse.data,
|
||||
billingData: billingResponse,
|
||||
}
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
const prefetchSSO = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ssoKeys.providers(),
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/auth/sso/providers')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch SSO providers')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
const handleHomepageClick = () => {
|
||||
window.location.href = '/?from=settings'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex-1 overflow-y-auto px-2 py-4'>
|
||||
{navigationItems.map((item) => (
|
||||
<div key={item.id} className='mb-1'>
|
||||
<button
|
||||
onMouseEnter={() => {
|
||||
switch (item.id) {
|
||||
case 'general':
|
||||
useGeneralStore.getState().loadSettings()
|
||||
break
|
||||
case 'subscription':
|
||||
useSubscriptionStore.getState().loadData()
|
||||
break
|
||||
case 'team':
|
||||
useOrganizationStore.getState().loadData()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
data-section={item.id}
|
||||
<div className='h-full overflow-y-auto px-2 py-4'>
|
||||
{navigationItems.map((item) => (
|
||||
<div key={item.id} className='mb-1'>
|
||||
<button
|
||||
onMouseEnter={() => {
|
||||
switch (item.id) {
|
||||
case 'general':
|
||||
prefetchGeneral()
|
||||
break
|
||||
case 'subscription':
|
||||
prefetchSubscription()
|
||||
break
|
||||
case 'team':
|
||||
prefetchOrganization()
|
||||
break
|
||||
case 'sso':
|
||||
prefetchSSO()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
data-section={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(
|
||||
'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'
|
||||
'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.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>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Homepage link */}
|
||||
{isHosted && (
|
||||
<div className='px-2 pb-4'>
|
||||
<div className='mb-1'>
|
||||
<button
|
||||
onClick={handleHomepageClick}
|
||||
className='group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors hover:bg-muted'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Label } from '@/components/ui'
|
||||
@@ -8,9 +8,13 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserRole } from '@/lib/organization/helpers'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('SSO')
|
||||
|
||||
@@ -71,13 +75,33 @@ interface SSOProvider {
|
||||
|
||||
export function SSO() {
|
||||
const { data: session } = useSession()
|
||||
const { activeOrganization, getUserRole, hasEnterprisePlan } = useOrganizationStore()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
|
||||
// Determine if we should fetch SSO providers
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
const canManageSSO = isOwner || isAdmin
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
|
||||
// Use React Query to fetch SSO providers
|
||||
const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders()
|
||||
|
||||
const providers = providersData?.providers || []
|
||||
const isSSOProviderOwner =
|
||||
!isBillingEnabled && userId ? providers.some((p: any) => p.userId === userId) : null
|
||||
|
||||
// Use mutation hook for configuring SSO
|
||||
const configureSSOMutation = useConfigureSSO()
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showClientSecret, setShowClientSecret] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [providers, setProviders] = useState<SSOProvider[]>([])
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(true)
|
||||
const [showConfigForm, setShowConfigForm] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
@@ -116,52 +140,6 @@ export function SSO() {
|
||||
})
|
||||
const [showErrors, setShowErrors] = useState(false)
|
||||
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
const userRole = getUserRole(userEmail)
|
||||
const isOwner = userRole === 'owner'
|
||||
const isAdmin = userRole === 'admin'
|
||||
const canManageSSO = isOwner || isAdmin
|
||||
|
||||
const [isSSOProviderOwner, setIsSSOProviderOwner] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/sso/providers')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch providers: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setProviders(data.providers || [])
|
||||
|
||||
if (!isBillingEnabled && userId) {
|
||||
const ownsProvider = data.providers.some((p: any) => p.userId === userId)
|
||||
setIsSSOProviderOwner(ownsProvider)
|
||||
} else {
|
||||
setIsSSOProviderOwner(null)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch SSO providers', { error })
|
||||
setProviders([])
|
||||
setIsSSOProviderOwner(false)
|
||||
} finally {
|
||||
setIsLoadingProviders(false)
|
||||
}
|
||||
}
|
||||
|
||||
const shouldFetch = !isBillingEnabled
|
||||
? true
|
||||
: canManageSSO && activeOrganization && hasEnterprisePlan
|
||||
|
||||
if (shouldFetch) {
|
||||
fetchProviders()
|
||||
} else {
|
||||
setIsLoadingProviders(false)
|
||||
}
|
||||
}, [canManageSSO, activeOrganization, hasEnterprisePlan, userId, isBillingEnabled])
|
||||
|
||||
if (isBillingEnabled) {
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
@@ -311,13 +289,11 @@ export function SSO() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
setShowErrors(true)
|
||||
const validation = validateAll(formData)
|
||||
if (hasAnyErrors(validation)) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -327,6 +303,7 @@ export function SSO() {
|
||||
issuer: formData.issuerUrl,
|
||||
domain: formData.domain,
|
||||
providerType: formData.providerType,
|
||||
orgId: activeOrganization?.id,
|
||||
mapping: {
|
||||
id: 'sub',
|
||||
email: 'email',
|
||||
@@ -354,22 +331,12 @@ export function SSO() {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/sso/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
// Use the mutation hook - this will automatically invalidate the cache
|
||||
await configureSSOMutation.mutateAsync(requestBody)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.details || 'Failed to configure SSO provider')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
logger.info('SSO provider configured', { providerId: result.providerId })
|
||||
logger.info('SSO provider configured', { providerId: formData.providerId })
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
providerType: 'oidc',
|
||||
providerId: '',
|
||||
@@ -387,25 +354,12 @@ export function SSO() {
|
||||
showAdvanced: false,
|
||||
})
|
||||
|
||||
const providersResponse = await fetch('/api/auth/sso/providers')
|
||||
if (providersResponse.ok) {
|
||||
const providersData = await providersResponse.json()
|
||||
setProviders(providersData.providers || [])
|
||||
|
||||
if (!isBillingEnabled && userId) {
|
||||
const ownsProvider = providersData.providers.some((p: any) => p.userId === userId)
|
||||
setIsSSOProviderOwner(ownsProvider)
|
||||
}
|
||||
}
|
||||
|
||||
setShowConfigForm(false)
|
||||
setIsEditing(false)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(message)
|
||||
logger.error('Failed to configure SSO provider', { error: err })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +489,7 @@ export function SSO() {
|
||||
{showStatus ? (
|
||||
// SSO Provider Status View
|
||||
<div className='space-y-4'>
|
||||
{providers.map((provider) => (
|
||||
{providers.map((provider: SSOProvider) => (
|
||||
<div key={provider.id} className='rounded-[8px] bg-muted/30 p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex-1'>
|
||||
@@ -1013,9 +967,11 @@ export function SSO() {
|
||||
<Button
|
||||
type='submit'
|
||||
className='h-9 w-full'
|
||||
disabled={isLoading || hasAnyErrors(errors) || !isFormValid()}
|
||||
disabled={
|
||||
configureSSOMutation.isPending || hasAnyErrors(errors) || !isFormValid()
|
||||
}
|
||||
>
|
||||
{isLoading
|
||||
{configureSSOMutation.isPending
|
||||
? isEditing
|
||||
? 'Updating...'
|
||||
: 'Configuring...'
|
||||
@@ -1056,61 +1012,64 @@ function SsoSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex-1 overflow-y-auto px-6 pt-4 pb-4'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
{/* Provider type toggle */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-28' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-9 w-20 rounded-[8px]' />
|
||||
<Skeleton className='h-9 w-20 rounded-[8px]' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
<Skeleton className='h-3 w-64' />
|
||||
</div>
|
||||
|
||||
{/* Provider ID */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
<Skeleton className='h-3 w-80' />
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<div className='relative'>
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
<Skeleton className='-translate-y-1/2 absolute top-1/2 right-3 h-4 w-4 rounded' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scopes */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
<Skeleton className='h-3 w-56' />
|
||||
</div>
|
||||
|
||||
{/* Core fields */}
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OIDC section (client id/secret/scopes) */}
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<div className='relative'>
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
<Skeleton className='-translate-y-1/2 absolute top-1/2 right-3 h-4 w-4 rounded' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
|
||||
{/* Callback URL */}
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-20' />
|
||||
<div className='relative'>
|
||||
<Skeleton className='h-9 w-full rounded-[10px]' />
|
||||
<Skeleton className='-translate-y-1/2 absolute top-1/2 right-3 h-4 w-4 rounded' />
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='h-3 w-96' />
|
||||
<div className='relative flex h-9 items-center rounded-[8px] bg-muted px-3'>
|
||||
<Skeleton className='h-3 w-64' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,10 +15,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('CancelSubscription')
|
||||
|
||||
@@ -40,9 +42,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { activeOrganization, loadOrganizationSubscription, refreshOrganization } =
|
||||
useOrganizationStore()
|
||||
const { getSubscriptionStatus, refresh } = useSubscriptionStore()
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const { data: subData } = useSubscriptionData()
|
||||
const queryClient = useQueryClient()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const currentSubscriptionStatus = getSubscriptionStatus(subData?.data)
|
||||
|
||||
// Clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
@@ -66,7 +70,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
const subscriptionStatus = currentSubscriptionStatus
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
let referenceId = session.user.id
|
||||
@@ -75,8 +79,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||
referenceId = activeOrgId
|
||||
// Get subscription ID for team/enterprise
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
subscriptionId = orgSubscription?.id
|
||||
subscriptionId = subData?.data?.id
|
||||
}
|
||||
|
||||
logger.info('Canceling subscription', {
|
||||
@@ -124,7 +127,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
const subscriptionStatus = currentSubscriptionStatus
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
if (isCancelAtPeriodEnd) {
|
||||
@@ -136,9 +139,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
let subscriptionId: string | undefined
|
||||
|
||||
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
referenceId = activeOrgId
|
||||
subscriptionId = orgSubscription?.id
|
||||
subscriptionId = subData?.data?.id
|
||||
} else {
|
||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
||||
referenceId = session.user.id
|
||||
@@ -158,10 +160,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
logger.info('Subscription restored successfully', result)
|
||||
}
|
||||
|
||||
await refresh()
|
||||
// Invalidate queries to refresh data
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||
if (activeOrgId) {
|
||||
await loadOrganizationSubscription(activeOrgId)
|
||||
await refreshOrganization().catch(() => {})
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.billing(activeOrgId) })
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
}
|
||||
|
||||
setIsDialogOpen(false)
|
||||
@@ -278,7 +282,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
</AlertDialogCancel>
|
||||
|
||||
{(() => {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
const subscriptionStatus = currentSubscriptionStatus
|
||||
if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
|
||||
return (
|
||||
<AlertDialogAction
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserRole } from '@/lib/organization/helpers'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -33,9 +34,10 @@ import {
|
||||
getSubscriptionPermissions,
|
||||
getVisiblePlans,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription-permissions'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData, useUsageData, useUsageLimitData } from '@/hooks/queries/subscription'
|
||||
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const CONSTANTS = {
|
||||
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
|
||||
@@ -63,7 +65,7 @@ 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 */}
|
||||
{/* Current Plan & Usage Header */}
|
||||
<div className='mb-2'>
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
@@ -72,10 +74,10 @@ function SubscriptionSkeleton() {
|
||||
<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 className='flex items-center gap-1'>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
<span className='text-muted-foreground text-xs'>/</span>
|
||||
<Skeleton className='h-4 w-12' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-2 w-full rounded' />
|
||||
@@ -83,94 +85,67 @@ function SubscriptionSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan cards skeleton */}
|
||||
{/* Plan Cards */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
{/* Pro and Team skeleton grid */}
|
||||
{/* Pro and Team Cards Grid */}
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{/* Pro Plan Card Skeleton */}
|
||||
{/* Pro Plan Card */}
|
||||
<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' />
|
||||
<Skeleton className='mb-2 h-5 w-10' />
|
||||
<div className='flex items-baseline gap-1'>
|
||||
<Skeleton className='h-6 w-12' />
|
||||
<Skeleton className='h-3 w-14' />
|
||||
</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>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className='flex items-start gap-2'>
|
||||
<Skeleton className='mt-0.5 h-3 w-3 rounded-full' />
|
||||
<Skeleton className='h-3 w-24' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-9 w-full rounded-[8px]' />
|
||||
</div>
|
||||
|
||||
{/* Team Plan Card Skeleton */}
|
||||
{/* Team Plan Card */}
|
||||
<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' />
|
||||
<Skeleton className='mb-2 h-5 w-12' />
|
||||
<div className='flex items-baseline gap-1'>
|
||||
<Skeleton className='h-6 w-12' />
|
||||
<Skeleton className='h-3 w-14' />
|
||||
</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>
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className='flex items-start gap-2'>
|
||||
<Skeleton className='mt-0.5 h-3 w-3 rounded-full' />
|
||||
<Skeleton className='h-3 w-28' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-9 w-full rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise skeleton - horizontal layout */}
|
||||
{/* Enterprise Card - 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-1'>
|
||||
<Skeleton className='mb-2 h-5 w-24' />
|
||||
<Skeleton className='mb-3 h-3 w-80' />
|
||||
<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>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className='flex items-center gap-2'>
|
||||
<Skeleton className='h-3 w-3 rounded-full' />
|
||||
<Skeleton className='h-3 w-20' />
|
||||
{i < 2 && <div className='ml-2 h-4 w-px bg-border' />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-9 w-16 rounded-[8px]' />
|
||||
<Skeleton className='h-9 w-20 rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,112 +168,78 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
getBillingStatus,
|
||||
usageLimitData,
|
||||
subscriptionData,
|
||||
} = useSubscriptionStore()
|
||||
// React Query hooks for data fetching
|
||||
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
|
||||
const { data: usageResponse, isLoading: isUsageLoading } = useUsageData()
|
||||
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
|
||||
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
|
||||
const updateWorkspaceMutation = useUpdateWorkspaceSettings()
|
||||
|
||||
const { activeOrganization, organizationBillingData, loadOrganizationBillingData, getUserRole } =
|
||||
useOrganizationStore()
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
// Fetch organization billing data with React Query
|
||||
const { data: organizationBillingData, isLoading: isOrgBillingLoading } = useOrganizationBilling(
|
||||
activeOrgId || ''
|
||||
)
|
||||
|
||||
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
// Workspace billing state
|
||||
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
|
||||
const [workspaceAdmins, setWorkspaceAdmins] = useState<
|
||||
Array<{ userId: string; name: string; email: string; permissionType: string }>
|
||||
>([])
|
||||
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
|
||||
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
|
||||
// Combine all loading states
|
||||
const isLoading =
|
||||
isSubscriptionLoading || isUsageLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
|
||||
// Get real subscription data from store
|
||||
const subscription = getSubscriptionStatus()
|
||||
const usage = getUsage()
|
||||
const billingStatus = getBillingStatus()
|
||||
const activeOrgId = activeOrganization?.id
|
||||
// Extract subscription status from data
|
||||
const subscription = {
|
||||
isFree: subscriptionData?.plan === 'free' || !subscriptionData?.plan,
|
||||
isPro: subscriptionData?.plan === 'pro',
|
||||
isTeam: subscriptionData?.plan === 'team',
|
||||
isEnterprise: subscriptionData?.plan === 'enterprise',
|
||||
isPaid:
|
||||
subscriptionData?.plan &&
|
||||
['pro', 'team', 'enterprise'].includes(subscriptionData.plan) &&
|
||||
subscriptionData?.status === 'active',
|
||||
plan: subscriptionData?.plan || 'free',
|
||||
status: subscriptionData?.status || 'inactive',
|
||||
seats: subscriptionData?.seats || 1,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if ((subscription.isTeam || subscription.isEnterprise) && activeOrgId) {
|
||||
loadOrganizationBillingData(activeOrgId)
|
||||
}
|
||||
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])
|
||||
// Extract usage data
|
||||
const usage = {
|
||||
current: usageResponse?.usage?.current || 0,
|
||||
limit: usageResponse?.usage?.limit || 0,
|
||||
percentUsed: usageResponse?.usage?.percentUsed || 0,
|
||||
}
|
||||
|
||||
// Fetch workspace billing settings
|
||||
const fetchWorkspaceSettings = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
const usageLimitData = {
|
||||
currentLimit: usageLimitResponse?.usage?.limit || 0,
|
||||
minimumLimit: usageLimitResponse?.usage?.minimumLimit || (subscription.isPro ? 20 : 40),
|
||||
}
|
||||
|
||||
setWorkspaceSettingsLoading(true)
|
||||
try {
|
||||
const [workspaceResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
fetch(`/api/workspaces/${workspaceId}/permissions`),
|
||||
])
|
||||
// Extract billing status
|
||||
const billingStatus = subscriptionData?.billingBlocked ? 'blocked' : 'ok'
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const data = await workspaceResponse.json()
|
||||
const workspaceData = data.workspace ?? {}
|
||||
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (permissionsResponse.ok) {
|
||||
const data = await permissionsResponse.json()
|
||||
const users = Array.isArray(data.users) ? data.users : []
|
||||
const admins = users.filter((user: any) => user.permissionType === 'admin')
|
||||
setWorkspaceAdmins(admins)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace permissions', {
|
||||
status: permissionsResponse.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace settings:', { error })
|
||||
} finally {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
}, [workspaceId, logger])
|
||||
// Extract workspace settings
|
||||
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
|
||||
const workspaceAdmins =
|
||||
workspaceData?.permissions?.users?.filter((user: any) => user.permissionType === 'admin') || []
|
||||
|
||||
// Update workspace settings handler
|
||||
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
|
||||
if (!workspaceId) return
|
||||
setWorkspaceSettingsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
await updateWorkspaceMutation.mutateAsync({
|
||||
workspaceId,
|
||||
...updates,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
await fetchWorkspaceSettings()
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace settings:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setWorkspaceSettingsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchWorkspaceSettings()
|
||||
} else {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId])
|
||||
|
||||
// Auto-clear upgrade error
|
||||
useEffect(() => {
|
||||
if (upgradeError) {
|
||||
@@ -310,7 +251,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}, [upgradeError])
|
||||
|
||||
// User role and permissions
|
||||
const userRole = getUserRole(session?.user?.email)
|
||||
const userRole = getUserRole(activeOrganization, session?.user?.email)
|
||||
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
|
||||
|
||||
// Get permissions based on subscription state and user role
|
||||
@@ -466,7 +407,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
: usage.limit
|
||||
}
|
||||
isBlocked={Boolean(subscriptionData?.billingBlocked)}
|
||||
status={billingStatus === 'unknown' ? 'ok' : billingStatus}
|
||||
status={billingStatus}
|
||||
percentUsed={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalUsageLimit &&
|
||||
@@ -508,7 +449,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
currentLimit={
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.totalUsageLimit || usage.limit
|
||||
: usageLimitData?.currentLimit || usage.limit
|
||||
: usageLimitData.currentLimit || usage.limit
|
||||
}
|
||||
currentUsage={usage.current}
|
||||
canEdit={permissions.canEditUsageLimit}
|
||||
@@ -516,14 +457,12 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
subscription.isTeam && isTeamAdmin
|
||||
? organizationBillingData?.minimumBillingAmount ||
|
||||
(subscription.isPro ? 20 : 40)
|
||||
: usageLimitData?.minimumLimit || (subscription.isPro ? 20 : 40)
|
||||
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
|
||||
}
|
||||
context={subscription.isTeam && isTeamAdmin ? 'organization' : 'user'}
|
||||
organizationId={subscription.isTeam && isTeamAdmin ? activeOrgId : undefined}
|
||||
onLimitUpdated={async () => {
|
||||
if (subscription.isTeam && isTeamAdmin && activeOrgId) {
|
||||
await loadOrganizationBillingData(activeOrgId, true)
|
||||
}
|
||||
// React Query will automatically refetch when the mutation completes
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
@@ -646,7 +585,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
{canManageWorkspaceKeys && (
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
<span className='font-medium text-sm'>Billed Account for Workspace</span>
|
||||
{workspaceSettingsLoading ? (
|
||||
{isWorkspaceLoading ? (
|
||||
<Skeleton className='h-8 w-[200px] rounded-md' />
|
||||
) : workspaceAdmins.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed px-3 py-1.5 text-muted-foreground text-xs'>
|
||||
@@ -657,15 +596,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
value={billedAccountUserId ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
if (value === billedAccountUserId) return
|
||||
const previous = billedAccountUserId
|
||||
setBilledAccountUserId(value)
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
||||
} catch (error) {
|
||||
setBilledAccountUserId(previous ?? null)
|
||||
// Error is already logged in updateWorkspaceSettings
|
||||
}
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
|
||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
||||
<SelectValue placeholder='Select admin' />
|
||||
@@ -675,7 +612,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
<SelectLabel className='px-3 py-1 text-[11px] text-muted-foreground uppercase'>
|
||||
Workspace admins
|
||||
</SelectLabel>
|
||||
{workspaceAdmins.map((admin) => (
|
||||
{workspaceAdmins.map((admin: any) => (
|
||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
||||
{admin.email}
|
||||
</SelectItem>
|
||||
@@ -695,11 +632,9 @@ function BillingUsageNotificationsToggle() {
|
||||
const isLoading = useGeneralStore((s) => s.isBillingUsageNotificationsLoading)
|
||||
const enabled = useGeneralStore((s) => s.isBillingUsageNotificationsEnabled)
|
||||
const setEnabled = useGeneralStore((s) => s.setBillingUsageNotificationsEnabled)
|
||||
const loadSettings = useGeneralStore((s) => s.loadSettings)
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings()
|
||||
}, [loadSettings])
|
||||
// Settings are automatically loaded by SettingsLoader provider
|
||||
// No need to load here - Zustand is synced from React Query
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { UserX, X } from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { Button as UIButton } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { UserAvatar } from '@/components/user-avatar/user-avatar'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { Invitation, Member, Organization } from '@/stores/organization'
|
||||
import type { Invitation, Member, Organization } from '@/lib/organization'
|
||||
import { useCancelInvitation, useOrganizationMembers } from '@/hooks/queries/organization'
|
||||
|
||||
const logger = createLogger('TeamMembers')
|
||||
|
||||
@@ -13,7 +12,6 @@ interface TeamMembersProps {
|
||||
currentUserEmail: string
|
||||
isAdminOrOwner: boolean
|
||||
onRemoveMember: (member: Member) => void
|
||||
onCancelInvitation: (invitationId: string) => void
|
||||
}
|
||||
|
||||
interface BaseItem {
|
||||
@@ -44,43 +42,23 @@ export function TeamMembers({
|
||||
currentUserEmail,
|
||||
isAdminOrOwner,
|
||||
onRemoveMember,
|
||||
onCancelInvitation,
|
||||
}: TeamMembersProps) {
|
||||
const [memberUsageData, setMemberUsageData] = useState<Record<string, number>>({})
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false)
|
||||
const [cancellingInvitations, setCancellingInvitations] = useState<Set<string>>(new Set())
|
||||
// Fetch member usage data using React Query
|
||||
const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers(
|
||||
organization?.id || ''
|
||||
)
|
||||
|
||||
// Fetch member usage data when organization changes and user is admin
|
||||
useEffect(() => {
|
||||
const fetchMemberUsage = async () => {
|
||||
if (!organization?.id || !isAdminOrOwner) return
|
||||
const cancelInvitationMutation = useCancelInvitation()
|
||||
|
||||
setIsLoadingUsage(true)
|
||||
try {
|
||||
const response = await fetch(`/api/organizations/${organization.id}/members?include=usage`)
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const usageMap: Record<string, number> = {}
|
||||
|
||||
if (result.data) {
|
||||
result.data.forEach((member: any) => {
|
||||
if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) {
|
||||
usageMap[member.userId] = Number.parseFloat(member.currentPeriodCost.toString())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setMemberUsageData(usageMap)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch member usage data', { error })
|
||||
} finally {
|
||||
setIsLoadingUsage(false)
|
||||
// Build usage data map from response
|
||||
const memberUsageData: Record<string, number> = {}
|
||||
if (memberUsageResponse?.data) {
|
||||
memberUsageResponse.data.forEach((member: any) => {
|
||||
if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) {
|
||||
memberUsageData[member.userId] = Number.parseFloat(member.currentPeriodCost.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fetchMemberUsage()
|
||||
}, [organization?.id, isAdminOrOwner])
|
||||
})
|
||||
}
|
||||
|
||||
// Combine members and pending invitations into a single list
|
||||
const teamItems: TeamMemberItem[] = []
|
||||
@@ -142,11 +120,20 @@ export function TeamMembers({
|
||||
const canLeaveOrganization =
|
||||
currentUserMember && currentUserMember.role !== 'owner' && currentUserMember.user?.id
|
||||
|
||||
// Wrap onCancelInvitation to manage loading state
|
||||
// Track which invitations are being cancelled for individual loading states
|
||||
const [cancellingInvitations, setCancellingInvitations] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleCancelInvitation = async (invitationId: string) => {
|
||||
if (!organization?.id) return
|
||||
|
||||
setCancellingInvitations((prev) => new Set([...prev, invitationId]))
|
||||
try {
|
||||
await onCancelInvitation(invitationId)
|
||||
await cancelInvitationMutation.mutateAsync({
|
||||
invitationId,
|
||||
orgId: organization.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel invitation', { error })
|
||||
} finally {
|
||||
setCancellingInvitations((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -225,45 +212,25 @@ export function TeamMembers({
|
||||
item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<UIButton
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
<UserX className='h-4 w-4' />
|
||||
</UIButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='left'>Remove Member</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{isAdminOrOwner && item.type === 'invitation' && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<UIButton
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 w-8 rounded-[8px] p-0'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? (
|
||||
<span className='h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent' />
|
||||
) : (
|
||||
<X className='h-4 w-4' />
|
||||
)}
|
||||
</UIButton>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='left'>
|
||||
{cancellingInvitations.has(item.invitation.id)
|
||||
? 'Cancelling...'
|
||||
: 'Cancel Invitation'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge, Button } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -70,22 +68,19 @@ export function TeamSeatsOverview({
|
||||
if (!subscriptionData) {
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-4 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-3 text-center'>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-medium text-sm'>No Team Subscription Found</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your subscription may need to be transferred to this organization.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
onConfirmTeamUpgrade(2) // Start with 2 seats as default
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className='h-9 rounded-[8px]'
|
||||
>
|
||||
Set Up Team Subscription
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox, type ComboboxOption, Tooltip } from '@/components/emcn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,13 +9,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
@@ -63,6 +55,11 @@ export function TeamSeats({
|
||||
await onConfirm(selectedSeats)
|
||||
}
|
||||
|
||||
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
||||
value: num.toString(),
|
||||
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@@ -73,21 +70,12 @@ export function TeamSeats({
|
||||
|
||||
<div className='py-4'>
|
||||
<Label htmlFor='seats'>Number of seats</Label>
|
||||
<Select
|
||||
<Combobox
|
||||
options={seatOptions}
|
||||
value={selectedSeats.toString()}
|
||||
onValueChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id='seats' className='rounded-[8px]'>
|
||||
<SelectValue placeholder='Select number of seats' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} {num === 1 ? 'seat' : 'seats'} (${num * costPerSeat}/month)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
||||
placeholder='Select number of seats'
|
||||
/>
|
||||
|
||||
<p className='mt-2 text-muted-foreground text-sm'>
|
||||
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
||||
@@ -123,6 +111,7 @@ export function TeamSeats({
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
isLoading ||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header'
|
||||
import {
|
||||
UsageLimit,
|
||||
type UsageLimitRef,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { organizationKeys, useOrganizationBilling } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('TeamUsage')
|
||||
|
||||
@@ -21,29 +23,27 @@ interface TeamUsageProps {
|
||||
|
||||
export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const queryClient = useQueryClient()
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
|
||||
// Fetch organization billing data using React Query
|
||||
const {
|
||||
organizationBillingData: billingData,
|
||||
loadOrganizationBillingData,
|
||||
isLoadingOrgBilling,
|
||||
data: billingData,
|
||||
isLoading: isLoadingOrgBilling,
|
||||
error,
|
||||
} = useOrganizationStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (activeOrg?.id) {
|
||||
loadOrganizationBillingData(activeOrg.id)
|
||||
}
|
||||
}, [activeOrg?.id, loadOrganizationBillingData])
|
||||
} = useOrganizationBilling(activeOrg?.id || '')
|
||||
|
||||
const handleLimitUpdated = useCallback(
|
||||
async (newLimit: number) => {
|
||||
// Reload the organization billing data to reflect the new limit
|
||||
// Invalidate the billing query to refetch with the new limit
|
||||
if (activeOrg?.id) {
|
||||
await loadOrganizationBillingData(activeOrg.id, true)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(activeOrg.id),
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeOrg?.id, loadOrganizationBillingData]
|
||||
[activeOrg?.id, queryClient]
|
||||
)
|
||||
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
@@ -74,7 +74,9 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : 'Failed to load billing data'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -92,7 +94,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
const status: 'ok' | 'warning' | 'exceeded' =
|
||||
percentUsed >= 100 ? 'exceeded' : percentUsed >= 80 ? 'warning' : 'ok'
|
||||
|
||||
const subscription = getSubscriptionStatus()
|
||||
const subscription = subscriptionStatus
|
||||
const title = subscription.isEnterprise
|
||||
? 'Enterprise'
|
||||
: subscription.isTeam
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
generateSlug,
|
||||
getUsedSeats,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
type Workspace,
|
||||
} from '@/lib/organization'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription'
|
||||
import {
|
||||
MemberInvitationCard,
|
||||
NoOrganizationView,
|
||||
@@ -13,42 +22,53 @@ import {
|
||||
TeamSeats,
|
||||
TeamSeatsOverview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components'
|
||||
import { generateSlug, useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import {
|
||||
organizationKeys,
|
||||
useInviteMember,
|
||||
useOrganization,
|
||||
useOrganizationSubscription,
|
||||
useOrganizations,
|
||||
useRemoveMember,
|
||||
useUpdateSeats,
|
||||
} from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
export function TeamManagement() {
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch organizations and billing data using React Query
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const billingData = organizationsData?.billingData?.data
|
||||
const hasTeamPlan = billingData?.isTeam ?? false
|
||||
const hasEnterprisePlan = billingData?.isEnterprise ?? false
|
||||
|
||||
// Fetch user subscription data
|
||||
const { data: userSubscriptionData } = useSubscriptionData()
|
||||
const subscriptionStatus = getSubscriptionStatus(userSubscriptionData?.data)
|
||||
|
||||
// Use React Query hooks for data fetching and mutations
|
||||
const {
|
||||
data: organization,
|
||||
isLoading,
|
||||
error: orgError,
|
||||
} = useOrganization(activeOrganization?.id || '')
|
||||
|
||||
const {
|
||||
organizations,
|
||||
activeOrganization,
|
||||
subscriptionData,
|
||||
userWorkspaces,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
isLoading,
|
||||
isLoadingSubscription,
|
||||
isCreatingOrg,
|
||||
isInviting,
|
||||
error,
|
||||
inviteSuccess,
|
||||
loadData,
|
||||
createOrganization,
|
||||
setActiveOrganization,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
cancelInvitation,
|
||||
addSeats,
|
||||
reduceSeats,
|
||||
loadUserWorkspaces,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
getUsedSeats,
|
||||
} = useOrganizationStore()
|
||||
data: subscriptionData,
|
||||
isLoading: isLoadingSubscription,
|
||||
error: subscriptionError,
|
||||
} = useOrganizationSubscription(activeOrganization?.id || '')
|
||||
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const inviteMutation = useInviteMember()
|
||||
const removeMemberMutation = useRemoveMember()
|
||||
const updateSeatsMutation = useUpdateSeats()
|
||||
|
||||
// Track invitation success for UI feedback
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false)
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [showWorkspaceInvite, setShowWorkspaceInvite] = useState(false)
|
||||
@@ -68,10 +88,69 @@ export function TeamManagement() {
|
||||
const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false)
|
||||
const [newSeatCount, setNewSeatCount] = useState(1)
|
||||
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
|
||||
const [isCreatingOrg, setIsCreatingOrg] = useState(false)
|
||||
const [userWorkspaces, setUserWorkspaces] = useState<Workspace[]>([])
|
||||
|
||||
const userRole = getUserRole(session?.user?.email)
|
||||
const adminOrOwner = isAdminOrOwner(session?.user?.email)
|
||||
const usedSeats = getUsedSeats()
|
||||
// Compute user role and permissions
|
||||
const userRole = getUserRole(organization, session?.user?.email)
|
||||
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
|
||||
const usedSeats = getUsedSeats(organization)
|
||||
|
||||
// Load user workspaces
|
||||
const loadUserWorkspaces = useCallback(async (userId?: string) => {
|
||||
try {
|
||||
const workspacesResponse = await fetch('/api/workspaces')
|
||||
if (!workspacesResponse.ok) {
|
||||
logger.error('Failed to fetch workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
const workspacesData = await workspacesResponse.json()
|
||||
const allUserWorkspaces = workspacesData.workspaces || []
|
||||
|
||||
// Filter to only show workspaces where user has admin permissions
|
||||
const adminWorkspaces = []
|
||||
|
||||
for (const workspace of allUserWorkspaces) {
|
||||
try {
|
||||
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
|
||||
if (permissionResponse.ok) {
|
||||
const permissionData = await permissionResponse.json()
|
||||
|
||||
let hasAdminAccess = false
|
||||
|
||||
if (userId && permissionData.users) {
|
||||
const currentUserPermission = permissionData.users.find(
|
||||
(user: any) => user.id === userId || user.userId === userId
|
||||
)
|
||||
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
||||
}
|
||||
|
||||
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
||||
|
||||
if (hasAdminAccess || isOwner) {
|
||||
adminWorkspaces.push({
|
||||
...workspace,
|
||||
isOwner: isOwner,
|
||||
canInvite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setUserWorkspaces(adminWorkspaces)
|
||||
logger.info('Loaded admin workspaces for invitation', {
|
||||
total: allUserWorkspaces.length,
|
||||
adminWorkspaces: adminWorkspaces.length,
|
||||
userId: userId || 'not provided',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to load workspaces:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
|
||||
@@ -86,7 +165,7 @@ export function TeamManagement() {
|
||||
if (session?.user?.id && activeOrgId && adminOrOwner) {
|
||||
loadUserWorkspaces(session.user.id)
|
||||
}
|
||||
}, [session?.user?.id, activeOrgId, adminOrOwner])
|
||||
}, [session?.user?.id, activeOrgId, adminOrOwner, loadUserWorkspaces])
|
||||
|
||||
const handleOrgNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
@@ -97,6 +176,7 @@ export function TeamManagement() {
|
||||
const handleCreateOrganization = useCallback(async () => {
|
||||
if (!session?.user || !orgName.trim()) return
|
||||
|
||||
setIsCreatingOrg(true)
|
||||
try {
|
||||
const response = await fetch('/api/organizations', {
|
||||
method: 'POST',
|
||||
@@ -119,29 +199,57 @@ export function TeamManagement() {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
// Refresh organization data
|
||||
await loadData()
|
||||
// Refresh organization data using React Query
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
|
||||
setCreateOrgDialogOpen(false)
|
||||
setOrgName('')
|
||||
setOrgSlug('')
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization', error)
|
||||
} finally {
|
||||
setIsCreatingOrg(false)
|
||||
}
|
||||
}, [session?.user?.id, orgName, orgSlug, loadData])
|
||||
}, [session?.user?.id, orgName, orgSlug, queryClient])
|
||||
|
||||
const handleInviteMember = useCallback(async () => {
|
||||
if (!session?.user || !activeOrgId || !inviteEmail.trim()) return
|
||||
|
||||
await inviteMember(
|
||||
inviteEmail.trim(),
|
||||
selectedWorkspaces.length > 0 ? selectedWorkspaces : undefined
|
||||
)
|
||||
try {
|
||||
// Map selectedWorkspaces to the format expected by the API
|
||||
const workspaceInvitations =
|
||||
selectedWorkspaces.length > 0
|
||||
? selectedWorkspaces.map((w) => ({
|
||||
id: w.workspaceId,
|
||||
name: userWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
|
||||
}))
|
||||
: undefined
|
||||
|
||||
setInviteEmail('')
|
||||
setSelectedWorkspaces([])
|
||||
setShowWorkspaceInvite(false)
|
||||
}, [session?.user?.id, activeOrgId, inviteEmail, selectedWorkspaces])
|
||||
await inviteMutation.mutateAsync({
|
||||
email: inviteEmail.trim(),
|
||||
orgId: activeOrgId,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
// Show success state
|
||||
setInviteSuccess(true)
|
||||
setTimeout(() => setInviteSuccess(false), 3000)
|
||||
|
||||
// Reset form
|
||||
setInviteEmail('')
|
||||
setSelectedWorkspaces([])
|
||||
setShowWorkspaceInvite(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite member', error)
|
||||
}
|
||||
}, [
|
||||
session?.user?.id,
|
||||
activeOrgId,
|
||||
inviteEmail,
|
||||
selectedWorkspaces,
|
||||
userWorkspaces,
|
||||
inviteMutation,
|
||||
])
|
||||
|
||||
const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
|
||||
setSelectedWorkspaces((prev) => {
|
||||
@@ -190,10 +298,23 @@ export function TeamManagement() {
|
||||
const { memberId } = removeMemberDialog
|
||||
if (!session?.user || !activeOrgId || !memberId) return
|
||||
|
||||
await removeMember(memberId, shouldReduceSeats)
|
||||
setRemoveMemberDialog({ open: false, memberId: '', memberName: '', shouldReduceSeats: false })
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
memberId,
|
||||
orgId: activeOrgId,
|
||||
shouldReduceSeats,
|
||||
})
|
||||
setRemoveMemberDialog({
|
||||
open: false,
|
||||
memberId: '',
|
||||
memberName: '',
|
||||
shouldReduceSeats: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', error)
|
||||
}
|
||||
},
|
||||
[removeMemberDialog.memberId, session?.user?.id, activeOrgId]
|
||||
[removeMemberDialog.memberId, session?.user?.id, activeOrgId, removeMemberMutation]
|
||||
)
|
||||
|
||||
const handleReduceSeats = useCallback(async () => {
|
||||
@@ -206,8 +327,16 @@ export function TeamManagement() {
|
||||
const { used: totalCount } = usedSeats
|
||||
if (totalCount >= currentSeats) return
|
||||
|
||||
await reduceSeats(currentSeats - 1)
|
||||
}, [session?.user?.id, activeOrgId, subscriptionData?.seats, usedSeats.used])
|
||||
try {
|
||||
await updateSeatsMutation.mutateAsync({
|
||||
orgId: activeOrgId,
|
||||
seats: currentSeats - 1,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reduce seats', error)
|
||||
}
|
||||
}, [session?.user?.id, activeOrgId, subscriptionData, usedSeats, updateSeatsMutation])
|
||||
|
||||
const handleAddSeatDialog = useCallback(() => {
|
||||
if (subscriptionData) {
|
||||
@@ -224,13 +353,19 @@ export function TeamManagement() {
|
||||
setIsUpdatingSeats(true)
|
||||
|
||||
try {
|
||||
await addSeats(seatsToUse)
|
||||
await updateSeatsMutation.mutateAsync({
|
||||
orgId: activeOrgId,
|
||||
seats: seatsToUse,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
setIsAddSeatDialogOpen(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add seats', error)
|
||||
} finally {
|
||||
setIsUpdatingSeats(false)
|
||||
}
|
||||
},
|
||||
[subscriptionData?.id, activeOrgId, newSeatCount]
|
||||
[subscriptionData, activeOrgId, newSeatCount, updateSeatsMutation]
|
||||
)
|
||||
|
||||
const confirmTeamUpgrade = useCallback(
|
||||
@@ -242,19 +377,78 @@ export function TeamManagement() {
|
||||
[session?.user?.id, activeOrgId]
|
||||
)
|
||||
|
||||
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
|
||||
// Combine errors from different sources
|
||||
const queryError = orgError || subscriptionError
|
||||
const errorMessage = queryError instanceof Error ? queryError.message : null
|
||||
const displayOrganization = organization || activeOrganization
|
||||
|
||||
if (isLoading && !displayOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
|
||||
return (
|
||||
<div className='px-6 pt-4 pb-4'>
|
||||
<div className='space-y-4'>
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
<Skeleton className='h-4 w-3/4' />
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto px-6 pt-4 pb-4'>
|
||||
{/* Team Seats Overview */}
|
||||
<div className='mb-4'>
|
||||
<div className='rounded-[8px] border bg-background p-4 shadow-xs'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-5 w-24' />
|
||||
<Skeleton className='h-8 w-20 rounded-[6px]' />
|
||||
</div>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Skeleton className='h-3 w-16' />
|
||||
<Skeleton className='h-6 w-8' />
|
||||
</div>
|
||||
<div className='h-8 w-px bg-border' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Skeleton className='h-3 w-20' />
|
||||
<Skeleton className='h-6 w-8' />
|
||||
</div>
|
||||
<div className='h-8 w-px bg-border' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Skeleton className='h-3 w-24' />
|
||||
<Skeleton className='h-6 w-12' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members */}
|
||||
<div className='mb-4'>
|
||||
<Skeleton className='mb-3 h-5 w-32' />
|
||||
<div className='space-y-2'>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className='flex items-center justify-between rounded-[8px] border p-3'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Skeleton className='h-10 w-10 rounded-full' />
|
||||
<div className='space-y-1'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-3 w-24' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-6 w-16 rounded-full' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Member Card */}
|
||||
<div className='mb-4'>
|
||||
<div className='rounded-[8px] border bg-background p-4'>
|
||||
<Skeleton className='mb-3 h-5 w-32' />
|
||||
<div className='space-y-3'>
|
||||
<Skeleton className='h-9 w-full rounded-[8px]' />
|
||||
<Skeleton className='h-9 w-full rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeOrganization) {
|
||||
if (!displayOrganization) {
|
||||
return (
|
||||
<NoOrganizationView
|
||||
hasTeamPlan={hasTeamPlan}
|
||||
@@ -266,7 +460,7 @@ export function TeamManagement() {
|
||||
onOrgNameChange={handleOrgNameChange}
|
||||
onCreateOrganization={handleCreateOrganization}
|
||||
isCreatingOrg={isCreatingOrg}
|
||||
error={error}
|
||||
error={errorMessage}
|
||||
createOrgDialogOpen={createOrgDialogOpen}
|
||||
setCreateOrgDialogOpen={setCreateOrgDialogOpen}
|
||||
/>
|
||||
@@ -276,10 +470,48 @@ export function TeamManagement() {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto px-6 pt-4 pb-4'>
|
||||
{error && (
|
||||
{queryError && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{queryError instanceof Error
|
||||
? queryError.message
|
||||
: 'Failed to load organization data'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Mutation errors */}
|
||||
{inviteMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Invitation Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{inviteMutation.error instanceof Error
|
||||
? inviteMutation.error.message
|
||||
: 'Failed to invite member'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{removeMemberMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Remove Member Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{removeMemberMutation.error instanceof Error
|
||||
? removeMemberMutation.error.message
|
||||
: 'Failed to remove member'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateSeatsMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Update Seats Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{updateSeatsMutation.error instanceof Error
|
||||
? updateSeatsMutation.error.message
|
||||
: 'Failed to update seats'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -287,7 +519,7 @@ export function TeamManagement() {
|
||||
{adminOrOwner && (
|
||||
<div className='mb-4'>
|
||||
<TeamSeatsOverview
|
||||
subscriptionData={subscriptionData}
|
||||
subscriptionData={subscriptionData || null}
|
||||
isLoadingSubscription={isLoadingSubscription}
|
||||
usedSeats={usedSeats.used}
|
||||
isLoading={isLoading}
|
||||
@@ -301,11 +533,10 @@ export function TeamManagement() {
|
||||
{/* Main Content: Team Members */}
|
||||
<div className='mb-4'>
|
||||
<TeamMembers
|
||||
organization={activeOrganization}
|
||||
organization={displayOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onCancelInvitation={cancelInvitation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +546,7 @@ export function TeamManagement() {
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
isInviting={isInviting}
|
||||
isInviting={inviteMutation.isPending}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
@@ -363,11 +594,11 @@ export function TeamManagement() {
|
||||
<div className='space-y-2 border-[var(--border-muted)] border-t p-3 text-xs'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Team ID:</span>
|
||||
<span className='font-mono text-[10px]'>{activeOrganization.id}</span>
|
||||
<span className='font-mono text-[10px]'>{displayOrganization.id}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Created:</span>
|
||||
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
|
||||
<span>{new Date(displayOrganization.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Your Role:</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { Modal, ModalContent, ModalDescription, ModalTitle } from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
TeamManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
|
||||
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
|
||||
const logger = createLogger('SettingsModal')
|
||||
|
||||
@@ -52,38 +52,23 @@ type SettingsSection =
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
const hasLoadedGeneral = useRef(false)
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const environmentCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
const credentialsCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadGeneralIfNeeded() {
|
||||
if (!open) return
|
||||
if (activeSection !== 'general') return
|
||||
if (hasLoadedGeneral.current) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await loadSettings()
|
||||
hasLoadedGeneral.current = true
|
||||
hasLoadedInitialData.current = true
|
||||
} catch (error) {
|
||||
logger.error('Error loading general settings:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
const registerEnvironmentCloseHandler = useCallback((handler: (open: boolean) => void) => {
|
||||
environmentCloseHandler.current = handler
|
||||
}, [])
|
||||
|
||||
if (open) {
|
||||
void loadGeneralIfNeeded()
|
||||
} else {
|
||||
hasLoadedInitialData.current = false
|
||||
hasLoadedGeneral.current = false
|
||||
}
|
||||
}, [open, activeSection, loadSettings])
|
||||
const registerCredentialsCloseHandler = useCallback((handler: (open: boolean) => void) => {
|
||||
credentialsCloseHandler.current = handler
|
||||
}, [])
|
||||
|
||||
// React Query hook automatically loads and syncs settings
|
||||
// No need for manual loading logic - placeholderData provides instant UI
|
||||
useGeneralSettings()
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
@@ -111,8 +96,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [activeSection])
|
||||
|
||||
const isSubscriptionEnabled = isBillingEnabled
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && activeSection === 'environment' && environmentCloseHandler.current) {
|
||||
@@ -162,9 +145,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<div className='h-full'>
|
||||
<EnvironmentVariables
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
environmentCloseHandler.current = handler
|
||||
}}
|
||||
registerCloseHandler={registerEnvironmentCloseHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -182,9 +163,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<div className='h-full'>
|
||||
<Credentials
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
credentialsCloseHandler.current = handler
|
||||
}}
|
||||
registerCloseHandler={registerCredentialsCloseHandler}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -198,7 +177,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<FileUploads />
|
||||
</div>
|
||||
)}
|
||||
{isSubscriptionEnabled && activeSection === 'subscription' && (
|
||||
{isBillingEnabled && activeSection === 'subscription' && (
|
||||
<div className='h-full'>
|
||||
<Subscription onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
canUpgrade,
|
||||
getBillingStatus,
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
} from '@/lib/subscription/helpers'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
const logger = createLogger('UsageIndicator')
|
||||
|
||||
@@ -39,13 +45,9 @@ interface UsageIndicatorProps {
|
||||
}
|
||||
|
||||
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const { getUsage, getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
useEffect(() => {
|
||||
useSubscriptionStore.getState().loadData()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Calculate pill count based on sidebar width
|
||||
* Starts at MIN_PILL_COUNT at minimum width, adds 1 pill per WIDTH_PER_PILL increase
|
||||
@@ -57,8 +59,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
return Math.max(MIN_PILL_COUNT, Math.min(MAX_PILL_COUNT, calculatedCount))
|
||||
}, [sidebarWidth])
|
||||
|
||||
const usage = getUsage()
|
||||
const subscription = getSubscriptionStatus()
|
||||
const usage = getUsage(subscriptionData?.data)
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -92,7 +94,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
? 'pro'
|
||||
: 'free'
|
||||
|
||||
const billingStatus = useSubscriptionStore.getState().getBillingStatus()
|
||||
const billingStatus = getBillingStatus(subscriptionData?.data)
|
||||
const isBlocked = billingStatus === 'blocked'
|
||||
const showUpgradeButton = planType === 'free' || isBlocked
|
||||
|
||||
@@ -109,14 +111,13 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptionStore = useSubscriptionStore.getState()
|
||||
const blocked = subscriptionStore.getBillingStatus() === 'blocked'
|
||||
const canUpgrade = subscriptionStore.canUpgrade()
|
||||
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
|
||||
const canUpg = canUpgrade(subscriptionData?.data)
|
||||
|
||||
// Open Settings modal to the subscription tab (upgrade UI lives there)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } }))
|
||||
logger.info('Opened settings to subscription tab', { blocked, canUpgrade })
|
||||
logger.info('Opened settings to subscription tab', { blocked, canUpgrade: canUpg })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle usage indicator click', { error })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('SubscriptionModal')
|
||||
|
||||
@@ -46,17 +47,10 @@ interface PlanFeature {
|
||||
export function SubscriptionModal({ open, onOpenChange }: SubscriptionModalProps) {
|
||||
const { data: session } = useSession()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const { loadData, getSubscriptionStatus, isLoading } = useSubscriptionStore()
|
||||
|
||||
// Load subscription data when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadData()
|
||||
}
|
||||
}, [open, loadData])
|
||||
|
||||
const subscription = getSubscriptionStatus()
|
||||
const { data: orgsData } = useOrganizations()
|
||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
|
||||
const handleUpgradeWithErrorHandling = useCallback(
|
||||
async (targetPlan: 'pro' | 'team') => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { canUpgrade, getBillingStatus } from '@/lib/subscription/helpers'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
CreateMenu,
|
||||
@@ -25,8 +26,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-auto-scroll'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
@@ -112,6 +113,9 @@ export function Sidebar() {
|
||||
// Knowledge bases for search modal
|
||||
const { knowledgeBases } = useKnowledgeBasesList(workspaceId)
|
||||
|
||||
// Subscription data for usage indicator
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
|
||||
// Refs
|
||||
const workflowScrollAreaRef = useRef<HTMLDivElement | null>(null)
|
||||
const workspaceIdRef = useRef<string>(workspaceId)
|
||||
@@ -998,11 +1002,10 @@ export function Sidebar() {
|
||||
>
|
||||
<UsageIndicator
|
||||
onClick={() => {
|
||||
const subscriptionStore = useSubscriptionStore.getState()
|
||||
const isBlocked = subscriptionStore.getBillingStatus() === 'blocked'
|
||||
const canUpgrade = subscriptionStore.canUpgrade()
|
||||
const isBlocked = getBillingStatus(subscriptionData?.data) === 'blocked'
|
||||
const canUpg = canUpgrade(subscriptionData?.data)
|
||||
|
||||
if (isBlocked || !canUpgrade) {
|
||||
if (isBlocked || !canUpg) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'subscription' } })
|
||||
|
||||
757
apps/sim/blocks/blocks/apollo.ts
Normal file
757
apps/sim/blocks/blocks/apollo.ts
Normal file
@@ -0,0 +1,757 @@
|
||||
import { ApolloIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { ApolloResponse } from '@/tools/apollo/types'
|
||||
|
||||
export const ApolloBlock: BlockConfig<ApolloResponse> = {
|
||||
type: 'apollo',
|
||||
name: 'Apollo',
|
||||
description: 'Search, enrich, and manage contacts with Apollo.io',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Integrates Apollo.io into the workflow. Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks.',
|
||||
docsLink: 'https://docs.sim.ai/tools/apollo',
|
||||
category: 'tools',
|
||||
bgColor: '#EBF212',
|
||||
icon: ApolloIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Search People', id: 'people_search' },
|
||||
{ label: 'Enrich Person', id: 'people_enrich' },
|
||||
{ label: 'Bulk Enrich People', id: 'people_bulk_enrich' },
|
||||
{ label: 'Search Organizations', id: 'organization_search' },
|
||||
{ label: 'Enrich Organization', id: 'organization_enrich' },
|
||||
{ label: 'Bulk Enrich Organizations', id: 'organization_bulk_enrich' },
|
||||
{ label: 'Create Contact', id: 'contact_create' },
|
||||
{ label: 'Update Contact', id: 'contact_update' },
|
||||
{ label: 'Search Contacts', id: 'contact_search' },
|
||||
{ label: 'Bulk Create Contacts', id: 'contact_bulk_create' },
|
||||
{ label: 'Bulk Update Contacts', id: 'contact_bulk_update' },
|
||||
{ label: 'Create Account', id: 'account_create' },
|
||||
{ label: 'Update Account', id: 'account_update' },
|
||||
{ label: 'Search Accounts', id: 'account_search' },
|
||||
{ label: 'Bulk Create Accounts', id: 'account_bulk_create' },
|
||||
{ label: 'Bulk Update Accounts', id: 'account_bulk_update' },
|
||||
{ label: 'Create Opportunity', id: 'opportunity_create' },
|
||||
{ label: 'Search Opportunities', id: 'opportunity_search' },
|
||||
{ label: 'Get Opportunity', id: 'opportunity_get' },
|
||||
{ label: 'Update Opportunity', id: 'opportunity_update' },
|
||||
{ label: 'Search Sequences', id: 'sequence_search' },
|
||||
{ label: 'Add to Sequence', id: 'sequence_add' },
|
||||
{ label: 'Create Task', id: 'task_create' },
|
||||
{ label: 'Search Tasks', id: 'task_search' },
|
||||
{ label: 'Get Email Accounts', id: 'email_accounts' },
|
||||
],
|
||||
value: () => 'people_search',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'Apollo API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Apollo API key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// People Search Fields
|
||||
{
|
||||
id: 'person_titles',
|
||||
title: 'Job Titles',
|
||||
type: 'code',
|
||||
placeholder: '["CEO", "VP of Sales"]',
|
||||
condition: { field: 'operation', value: 'people_search' },
|
||||
},
|
||||
{
|
||||
id: 'person_locations',
|
||||
title: 'Locations',
|
||||
type: 'code',
|
||||
placeholder: '["San Francisco, CA", "New York, NY"]',
|
||||
condition: { field: 'operation', value: 'people_search' },
|
||||
},
|
||||
{
|
||||
id: 'organization_names',
|
||||
title: 'Company Names',
|
||||
type: 'code',
|
||||
placeholder: '["Company A", "Company B"]',
|
||||
condition: { field: 'operation', value: 'people_search' },
|
||||
},
|
||||
{
|
||||
id: 'person_seniorities',
|
||||
title: 'Seniority Levels',
|
||||
type: 'code',
|
||||
placeholder: '["senior", "manager", "director"]',
|
||||
condition: { field: 'operation', value: 'people_search' },
|
||||
},
|
||||
{
|
||||
id: 'contact_stage_ids',
|
||||
title: 'Contact Stage IDs',
|
||||
type: 'code',
|
||||
placeholder: '["stage_id_1", "stage_id_2"]',
|
||||
condition: { field: 'operation', value: 'contact_search' },
|
||||
},
|
||||
|
||||
// People Enrich Fields
|
||||
{
|
||||
id: 'first_name',
|
||||
title: 'First Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'First name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'contact_create', 'contact_update'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'contact_create',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'last_name',
|
||||
title: 'Last Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Last name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'contact_create', 'contact_update'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'contact_create',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email',
|
||||
type: 'short-input',
|
||||
placeholder: 'email@example.com',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'contact_create', 'contact_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'organization_name',
|
||||
title: 'Company Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Company name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'organization_enrich'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'example.com',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'organization_enrich'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reveal_personal_emails',
|
||||
title: 'Reveal Personal Emails',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'people_bulk_enrich'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'reveal_phone_number',
|
||||
title: 'Reveal Phone Numbers',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_enrich', 'people_bulk_enrich'],
|
||||
},
|
||||
},
|
||||
|
||||
// Bulk Enrich Fields
|
||||
{
|
||||
id: 'people',
|
||||
title: 'People (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder: '[{"first_name": "John", "last_name": "Doe", "email": "john@example.com"}]',
|
||||
condition: { field: 'operation', value: 'people_bulk_enrich' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'organizations',
|
||||
title: 'Organizations (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder: '[{"organization_name": "Company A", "domain": "companya.com"}]',
|
||||
condition: { field: 'operation', value: 'organization_bulk_enrich' },
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Organization Search Fields
|
||||
{
|
||||
id: 'organization_locations',
|
||||
title: 'Organization Locations',
|
||||
type: 'code',
|
||||
placeholder: '["San Francisco, CA"]',
|
||||
condition: { field: 'operation', value: 'organization_search' },
|
||||
},
|
||||
{
|
||||
id: 'organization_num_employees_ranges',
|
||||
title: 'Employee Count Ranges',
|
||||
type: 'code',
|
||||
placeholder: '["1-10", "11-50", "51-200"]',
|
||||
condition: { field: 'operation', value: 'organization_search' },
|
||||
},
|
||||
{
|
||||
id: 'q_organization_keyword_tags',
|
||||
title: 'Keyword Tags',
|
||||
type: 'code',
|
||||
placeholder: '["saas", "b2b", "enterprise"]',
|
||||
condition: { field: 'operation', value: 'organization_search' },
|
||||
},
|
||||
{
|
||||
id: 'q_organization_name',
|
||||
title: 'Organization Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Company name to search',
|
||||
condition: { field: 'operation', value: 'organization_search' },
|
||||
},
|
||||
|
||||
// Contact Fields
|
||||
{
|
||||
id: 'contact_id',
|
||||
title: 'Contact ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Apollo contact ID',
|
||||
condition: { field: 'operation', value: 'contact_update' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Job Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Job title',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['contact_create', 'contact_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'account_id',
|
||||
title: 'Account ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Apollo account ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'contact_create',
|
||||
'contact_update',
|
||||
'account_update',
|
||||
'task_create',
|
||||
'opportunity_create',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['account_update', 'opportunity_create'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'owner_id',
|
||||
title: 'Owner ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Apollo user ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'contact_create',
|
||||
'contact_update',
|
||||
'account_create',
|
||||
'account_update',
|
||||
'account_search',
|
||||
'opportunity_create',
|
||||
'opportunity_update',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Contact Bulk Operations
|
||||
{
|
||||
id: 'contacts',
|
||||
title: 'Contacts (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder:
|
||||
'[{"first_name": "John", "last_name": "Doe", "email": "john@example.com", "title": "CEO"}]',
|
||||
condition: { field: 'operation', value: 'contact_bulk_create' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
title: 'Contacts (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder: '[{"id": "contact_id_1", "first_name": "John", "last_name": "Doe"}]',
|
||||
condition: { field: 'operation', value: 'contact_bulk_update' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'run_dedupe',
|
||||
title: 'Run Deduplication',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'contact_bulk_create' },
|
||||
},
|
||||
|
||||
// Account Fields
|
||||
{
|
||||
id: 'account_name',
|
||||
title: 'Account Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Company name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['account_create', 'account_update'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'account_create',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'website_url',
|
||||
title: 'Website URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://example.com',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['account_create', 'account_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
title: 'Phone Number',
|
||||
type: 'short-input',
|
||||
placeholder: 'Company phone',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['account_create', 'account_update'],
|
||||
},
|
||||
},
|
||||
|
||||
// Account Search Fields
|
||||
{
|
||||
id: 'q_keywords',
|
||||
title: 'Keywords',
|
||||
type: 'short-input',
|
||||
placeholder: 'Search keywords',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['people_search', 'contact_search', 'account_search', 'opportunity_search'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'account_stage_ids',
|
||||
title: 'Account Stage IDs',
|
||||
type: 'code',
|
||||
placeholder: '["stage_id_1", "stage_id_2"]',
|
||||
condition: { field: 'operation', value: 'account_search' },
|
||||
},
|
||||
|
||||
// Account Bulk Operations
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'Accounts (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder:
|
||||
'[{"name": "Company A", "website_url": "https://companya.com", "phone": "+1234567890"}]',
|
||||
condition: { field: 'operation', value: 'account_bulk_create' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'accounts',
|
||||
title: 'Accounts (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder: '[{"id": "account_id_1", "name": "Updated Company Name"}]',
|
||||
condition: { field: 'operation', value: 'account_bulk_update' },
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Opportunity Fields
|
||||
{
|
||||
id: 'opportunity_name',
|
||||
title: 'Opportunity Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Opportunity name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_create', 'opportunity_update'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'opportunity_create',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'amount',
|
||||
title: 'Amount',
|
||||
type: 'short-input',
|
||||
placeholder: 'Deal amount (e.g., 50000)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_create', 'opportunity_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stage_id',
|
||||
title: 'Stage ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Opportunity stage ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_create', 'opportunity_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'close_date',
|
||||
title: 'Close Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'ISO date (e.g., 2024-12-31)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_create', 'opportunity_update'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Opportunity description',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_create', 'opportunity_update'],
|
||||
},
|
||||
},
|
||||
|
||||
// Opportunity Get
|
||||
{
|
||||
id: 'opportunity_id',
|
||||
title: 'Opportunity ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Apollo opportunity ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['opportunity_get', 'opportunity_update'],
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Opportunity Search Fields
|
||||
{
|
||||
id: 'account_ids',
|
||||
title: 'Account IDs',
|
||||
type: 'code',
|
||||
placeholder: '["account_id_1", "account_id_2"]',
|
||||
condition: { field: 'operation', value: 'opportunity_search' },
|
||||
},
|
||||
{
|
||||
id: 'stage_ids',
|
||||
title: 'Stage IDs',
|
||||
type: 'code',
|
||||
placeholder: '["stage_id_1", "stage_id_2"]',
|
||||
condition: { field: 'operation', value: 'opportunity_search' },
|
||||
},
|
||||
{
|
||||
id: 'owner_ids',
|
||||
title: 'Owner IDs',
|
||||
type: 'code',
|
||||
placeholder: '["user_id_1", "user_id_2"]',
|
||||
condition: { field: 'operation', value: 'opportunity_search' },
|
||||
},
|
||||
|
||||
// Sequence Search Fields
|
||||
{
|
||||
id: 'q_name',
|
||||
title: 'Sequence Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Search by sequence name',
|
||||
condition: { field: 'operation', value: 'sequence_search' },
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
title: 'Active Only',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'sequence_search' },
|
||||
},
|
||||
|
||||
// Sequence Fields
|
||||
{
|
||||
id: 'sequence_id',
|
||||
title: 'Sequence ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Apollo sequence ID',
|
||||
condition: { field: 'operation', value: 'sequence_add' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'contact_ids',
|
||||
title: 'Contact IDs (JSON Array)',
|
||||
type: 'code',
|
||||
placeholder: '["contact_id_1", "contact_id_2"]',
|
||||
condition: { field: 'operation', value: 'sequence_add' },
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Task Fields
|
||||
{
|
||||
id: 'note',
|
||||
title: 'Task Note',
|
||||
type: 'long-input',
|
||||
placeholder: 'Task description',
|
||||
condition: { field: 'operation', value: 'task_create' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'due_at',
|
||||
title: 'Due Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'ISO date (e.g., 2024-12-31T23:59:59Z)',
|
||||
condition: { field: 'operation', value: 'task_create' },
|
||||
},
|
||||
{
|
||||
id: 'completed',
|
||||
title: 'Completed',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'task_search' },
|
||||
},
|
||||
|
||||
// Pagination
|
||||
{
|
||||
id: 'page',
|
||||
title: 'Page Number',
|
||||
type: 'short-input',
|
||||
placeholder: '1',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'people_search',
|
||||
'organization_search',
|
||||
'contact_search',
|
||||
'account_search',
|
||||
'opportunity_search',
|
||||
'sequence_search',
|
||||
'task_search',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'per_page',
|
||||
title: 'Results Per Page',
|
||||
type: 'short-input',
|
||||
placeholder: '25 (max: 100)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'people_search',
|
||||
'organization_search',
|
||||
'contact_search',
|
||||
'account_search',
|
||||
'opportunity_search',
|
||||
'sequence_search',
|
||||
'task_search',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'apollo_people_search',
|
||||
'apollo_people_enrich',
|
||||
'apollo_people_bulk_enrich',
|
||||
'apollo_organization_search',
|
||||
'apollo_organization_enrich',
|
||||
'apollo_organization_bulk_enrich',
|
||||
'apollo_contact_create',
|
||||
'apollo_contact_update',
|
||||
'apollo_contact_search',
|
||||
'apollo_contact_bulk_create',
|
||||
'apollo_contact_bulk_update',
|
||||
'apollo_account_create',
|
||||
'apollo_account_update',
|
||||
'apollo_account_search',
|
||||
'apollo_account_bulk_create',
|
||||
'apollo_account_bulk_update',
|
||||
'apollo_opportunity_create',
|
||||
'apollo_opportunity_search',
|
||||
'apollo_opportunity_get',
|
||||
'apollo_opportunity_update',
|
||||
'apollo_sequence_search',
|
||||
'apollo_sequence_add_contacts',
|
||||
'apollo_task_create',
|
||||
'apollo_task_search',
|
||||
'apollo_email_accounts',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'people_search':
|
||||
return 'apollo_people_search'
|
||||
case 'people_enrich':
|
||||
return 'apollo_people_enrich'
|
||||
case 'people_bulk_enrich':
|
||||
return 'apollo_people_bulk_enrich'
|
||||
case 'organization_search':
|
||||
return 'apollo_organization_search'
|
||||
case 'organization_enrich':
|
||||
return 'apollo_organization_enrich'
|
||||
case 'organization_bulk_enrich':
|
||||
return 'apollo_organization_bulk_enrich'
|
||||
case 'contact_create':
|
||||
return 'apollo_contact_create'
|
||||
case 'contact_update':
|
||||
return 'apollo_contact_update'
|
||||
case 'contact_search':
|
||||
return 'apollo_contact_search'
|
||||
case 'contact_bulk_create':
|
||||
return 'apollo_contact_bulk_create'
|
||||
case 'contact_bulk_update':
|
||||
return 'apollo_contact_bulk_update'
|
||||
case 'account_create':
|
||||
return 'apollo_account_create'
|
||||
case 'account_update':
|
||||
return 'apollo_account_update'
|
||||
case 'account_search':
|
||||
return 'apollo_account_search'
|
||||
case 'account_bulk_create':
|
||||
return 'apollo_account_bulk_create'
|
||||
case 'account_bulk_update':
|
||||
return 'apollo_account_bulk_update'
|
||||
case 'opportunity_create':
|
||||
return 'apollo_opportunity_create'
|
||||
case 'opportunity_search':
|
||||
return 'apollo_opportunity_search'
|
||||
case 'opportunity_get':
|
||||
return 'apollo_opportunity_get'
|
||||
case 'opportunity_update':
|
||||
return 'apollo_opportunity_update'
|
||||
case 'sequence_search':
|
||||
return 'apollo_sequence_search'
|
||||
case 'sequence_add':
|
||||
return 'apollo_sequence_add_contacts'
|
||||
case 'task_create':
|
||||
return 'apollo_task_create'
|
||||
case 'task_search':
|
||||
return 'apollo_task_search'
|
||||
case 'email_accounts':
|
||||
return 'apollo_email_accounts'
|
||||
default:
|
||||
throw new Error(`Invalid Apollo operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { apiKey, ...rest } = params
|
||||
|
||||
// Parse JSON inputs safely
|
||||
const parsedParams: any = { apiKey, ...rest }
|
||||
|
||||
try {
|
||||
if (rest.person_titles && typeof rest.person_titles === 'string') {
|
||||
parsedParams.person_titles = JSON.parse(rest.person_titles)
|
||||
}
|
||||
if (rest.person_locations && typeof rest.person_locations === 'string') {
|
||||
parsedParams.person_locations = JSON.parse(rest.person_locations)
|
||||
}
|
||||
if (rest.person_seniorities && typeof rest.person_seniorities === 'string') {
|
||||
parsedParams.person_seniorities = JSON.parse(rest.person_seniorities)
|
||||
}
|
||||
if (rest.organization_names && typeof rest.organization_names === 'string') {
|
||||
parsedParams.organization_names = JSON.parse(rest.organization_names)
|
||||
}
|
||||
if (rest.organization_locations && typeof rest.organization_locations === 'string') {
|
||||
parsedParams.organization_locations = JSON.parse(rest.organization_locations)
|
||||
}
|
||||
if (
|
||||
rest.organization_num_employees_ranges &&
|
||||
typeof rest.organization_num_employees_ranges === 'string'
|
||||
) {
|
||||
parsedParams.organization_num_employees_ranges = JSON.parse(
|
||||
rest.organization_num_employees_ranges
|
||||
)
|
||||
}
|
||||
if (
|
||||
rest.q_organization_keyword_tags &&
|
||||
typeof rest.q_organization_keyword_tags === 'string'
|
||||
) {
|
||||
parsedParams.q_organization_keyword_tags = JSON.parse(rest.q_organization_keyword_tags)
|
||||
}
|
||||
if (rest.contact_stage_ids && typeof rest.contact_stage_ids === 'string') {
|
||||
parsedParams.contact_stage_ids = JSON.parse(rest.contact_stage_ids)
|
||||
}
|
||||
if (rest.account_stage_ids && typeof rest.account_stage_ids === 'string') {
|
||||
parsedParams.account_stage_ids = JSON.parse(rest.account_stage_ids)
|
||||
}
|
||||
if (rest.people && typeof rest.people === 'string') {
|
||||
parsedParams.people = JSON.parse(rest.people)
|
||||
}
|
||||
if (rest.organizations && typeof rest.organizations === 'string') {
|
||||
parsedParams.organizations = JSON.parse(rest.organizations)
|
||||
}
|
||||
if (rest.contacts && typeof rest.contacts === 'string') {
|
||||
parsedParams.contacts = JSON.parse(rest.contacts)
|
||||
}
|
||||
if (rest.accounts && typeof rest.accounts === 'string') {
|
||||
parsedParams.accounts = JSON.parse(rest.accounts)
|
||||
}
|
||||
if (rest.contact_ids && typeof rest.contact_ids === 'string') {
|
||||
parsedParams.contact_ids = JSON.parse(rest.contact_ids)
|
||||
}
|
||||
if (rest.account_ids && typeof rest.account_ids === 'string') {
|
||||
parsedParams.account_ids = JSON.parse(rest.account_ids)
|
||||
}
|
||||
if (rest.stage_ids && typeof rest.stage_ids === 'string') {
|
||||
parsedParams.stage_ids = JSON.parse(rest.stage_ids)
|
||||
}
|
||||
if (rest.owner_ids && typeof rest.owner_ids === 'string') {
|
||||
parsedParams.owner_ids = JSON.parse(rest.owner_ids)
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(`Invalid JSON input: ${error.message}`)
|
||||
}
|
||||
|
||||
// Map UI field names to API parameter names
|
||||
if (params.operation === 'account_create' || params.operation === 'account_update') {
|
||||
if (rest.account_name) parsedParams.name = rest.account_name
|
||||
parsedParams.account_name = undefined
|
||||
}
|
||||
|
||||
if (params.operation === 'account_update') {
|
||||
parsedParams.account_id = rest.account_id
|
||||
}
|
||||
|
||||
if (
|
||||
params.operation === 'opportunity_create' ||
|
||||
params.operation === 'opportunity_update'
|
||||
) {
|
||||
if (rest.opportunity_name) parsedParams.name = rest.opportunity_name
|
||||
parsedParams.opportunity_name = undefined
|
||||
}
|
||||
|
||||
// Convert page/per_page to numbers if provided
|
||||
if (parsedParams.page) parsedParams.page = Number(parsedParams.page)
|
||||
if (parsedParams.per_page) parsedParams.per_page = Number(parsedParams.per_page)
|
||||
|
||||
// Convert amount to number if provided
|
||||
if (parsedParams.amount) parsedParams.amount = Number(parsedParams.amount)
|
||||
|
||||
return parsedParams
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Apollo operation to perform' },
|
||||
},
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'Whether the operation was successful' },
|
||||
output: { type: 'json', description: 'Output data from the Apollo operation' },
|
||||
},
|
||||
}
|
||||
@@ -38,8 +38,9 @@ export const WorkflowBlock: BlockConfig = {
|
||||
{
|
||||
id: 'workflowId',
|
||||
title: 'Select Workflow',
|
||||
type: 'dropdown',
|
||||
type: 'combobox',
|
||||
options: getAvailableWorkflows,
|
||||
placeholder: 'Search workflows...',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -32,8 +32,9 @@ export const WorkflowInputBlock: BlockConfig = {
|
||||
{
|
||||
id: 'workflowId',
|
||||
title: 'Select Workflow',
|
||||
type: 'dropdown',
|
||||
type: 'combobox',
|
||||
options: getAvailableWorkflows,
|
||||
placeholder: 'Search workflows...',
|
||||
required: true,
|
||||
},
|
||||
// Renders dynamic mapping UI based on selected child workflow's Start trigger inputFormat
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AgentBlock } from '@/blocks/blocks/agent'
|
||||
import { AirtableBlock } from '@/blocks/blocks/airtable'
|
||||
import { ApiBlock } from '@/blocks/blocks/api'
|
||||
import { ApiTriggerBlock } from '@/blocks/blocks/api_trigger'
|
||||
import { ApolloBlock } from '@/blocks/blocks/apollo'
|
||||
import { ArxivBlock } from '@/blocks/blocks/arxiv'
|
||||
import { AsanaBlock } from '@/blocks/blocks/asana'
|
||||
import { BrowserUseBlock } from '@/blocks/blocks/browser_use'
|
||||
@@ -102,6 +103,7 @@ import type { BlockConfig } from '@/blocks/types'
|
||||
export const registry: Record<string, BlockConfig> = {
|
||||
agent: AgentBlock,
|
||||
airtable: AirtableBlock,
|
||||
apollo: ApolloBlock,
|
||||
api: ApiBlock,
|
||||
arxiv: ArxivBlock,
|
||||
asana: AsanaBlock,
|
||||
|
||||
@@ -135,7 +135,28 @@ export interface SubBlockConfig {
|
||||
type: SubBlockType
|
||||
mode?: 'basic' | 'advanced' | 'both' | 'trigger' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode
|
||||
canonicalParamId?: string
|
||||
required?: boolean
|
||||
required?:
|
||||
| boolean
|
||||
| {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean>
|
||||
not?: boolean
|
||||
and?: {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean> | undefined
|
||||
not?: boolean
|
||||
}
|
||||
}
|
||||
| (() => {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean>
|
||||
not?: boolean
|
||||
and?: {
|
||||
field: string
|
||||
value: string | number | boolean | Array<string | number | boolean> | undefined
|
||||
not?: boolean
|
||||
}
|
||||
})
|
||||
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
|
||||
options?:
|
||||
| {
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface ComboboxProps
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>
|
||||
/** Whether to filter options based on input value (default: true for editable mode) */
|
||||
filterOptions?: boolean
|
||||
/** Explicitly control which option is marked as selected (defaults to `value`) */
|
||||
selectedValue?: string
|
||||
/** Enable multi-select mode */
|
||||
multiSelect?: boolean
|
||||
/** Loading state */
|
||||
@@ -101,6 +103,7 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
inputProps = {},
|
||||
inputRef: externalInputRef,
|
||||
filterOptions = editable,
|
||||
selectedValue,
|
||||
multiSelect = false,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
@@ -116,9 +119,11 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
const internalInputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = externalInputRef || internalInputRef
|
||||
|
||||
const effectiveSelectedValue = selectedValue ?? value
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((opt) => opt.value === value),
|
||||
[options, value]
|
||||
() => options.find((opt) => opt.value === effectiveSelectedValue),
|
||||
[options, effectiveSelectedValue]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -479,7 +484,7 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
||||
filteredOptions.map((option, index) => {
|
||||
const isSelected = multiSelect
|
||||
? multiSelectValues?.includes(option.value)
|
||||
: value === option.value
|
||||
: effectiveSelectedValue === option.value
|
||||
const isHighlighted = index === highlightedIndex
|
||||
const OptionIcon = option.icon
|
||||
|
||||
|
||||
@@ -4003,3 +4003,32 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
height='26'
|
||||
viewBox='0 0 36 36'
|
||||
fill='currentColor'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M19.5993 0.0862365L19.605 13.2568C19.6058 15.3375 17.4222 16.6715 15.6079 15.6986L2.58376 8.7153C3.57706 7.05795 4.82616 5.57609 6.27427 4.32386L16.489 13.8945C17.0303 14.4015 17.8835 13.8518 17.6605 13.1398L13.6992 0.493553C15.0326 0.17147 16.4233 0 17.8536 0C18.4428 0 19.0248 0.0296814 19.5993 0.0862365Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M16.0635 36.1087L16.0578 23.0046C16.057 20.9239 18.2407 19.5898 20.0549 20.5627L33.0838 27.5486C32.0838 29.2016 30.8289 30.6786 29.3751 31.925L19.1738 22.3668C18.6326 21.8598 17.7793 22.4095 18.0023 23.1215L21.9486 35.72C20.6338 36.0329 19.263 36.1989 17.8539 36.1989C17.2497 36.1989 16.6523 36.1683 16.0635 36.1087Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M22.0105 16.77L31.4705 6.39392C30.2362 4.92008 28.7742 3.6486 27.1384 2.63702L20.2306 15.8767C19.2709 17.716 20.5871 19.9298 22.6396 19.9288L35.6183 19.923C35.6775 19.3234 35.7082 18.7151 35.7082 18.0996C35.7082 16.6683 35.5436 15.2761 35.2338 13.9406L22.7549 17.9576C22.0526 18.1837 21.5103 17.3187 22.0105 16.77Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
<path
|
||||
d='M0.0842758 16.3383L13.0237 16.3325C15.0764 16.3317 16.3923 18.5454 15.4327 20.3846L8.56047 33.5561C6.93095 32.547 5.47394 31.2801 4.24344 29.8121L13.653 19.4914C14.1531 18.9427 13.6107 18.0777 12.9084 18.3037L0.485078 22.3029C0.168551 20.954 0 19.5467 0 18.0994C0 17.5051 0.0290814 16.9177 0.0842758 16.3383Z'
|
||||
fill='#000000'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[10000030] bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
|
||||
className
|
||||
)}
|
||||
style={{ backdropFilter: 'blur(1.5px)', ...style }}
|
||||
@@ -87,7 +87,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] px-6 py-5 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
|
||||
'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-[10000031] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] px-6 py-5 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
|
||||
className
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
|
||||
211
apps/sim/hooks/queries/api-keys.ts
Normal file
211
apps/sim/hooks/queries/api-keys.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { workspaceKeys } from './workspace'
|
||||
|
||||
/**
|
||||
* Query key factories for API keys-related queries
|
||||
*/
|
||||
export const apiKeysKeys = {
|
||||
all: ['apiKeys'] as const,
|
||||
workspace: (workspaceId: string) => [...apiKeysKeys.all, 'workspace', workspaceId] as const,
|
||||
personal: () => [...apiKeysKeys.all, 'personal'] as const,
|
||||
combined: (workspaceId: string) => [...apiKeysKeys.all, 'combined', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key type definition
|
||||
*/
|
||||
export interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
displayKey?: string
|
||||
lastUsed?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined API keys response
|
||||
*/
|
||||
interface ApiKeysResponse {
|
||||
workspaceKeys: ApiKey[]
|
||||
personalKeys: ApiKey[]
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch both workspace and personal API keys
|
||||
*/
|
||||
async function fetchApiKeys(workspaceId: string): Promise<ApiKeysResponse> {
|
||||
const [workspaceResponse, personalResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}/api-keys`),
|
||||
fetch('/api/users/me/api-keys'),
|
||||
])
|
||||
|
||||
let workspaceKeys: ApiKey[] = []
|
||||
let personalKeys: ApiKey[] = []
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const workspaceData = await workspaceResponse.json()
|
||||
workspaceKeys = workspaceData.keys || []
|
||||
}
|
||||
|
||||
if (personalResponse.ok) {
|
||||
const personalData = await personalResponse.json()
|
||||
personalKeys = personalData.keys || []
|
||||
}
|
||||
|
||||
// Client-side conflict detection
|
||||
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
|
||||
const conflicts = personalKeys
|
||||
.filter((key) => workspaceKeyNames.has(key.name))
|
||||
.map((key) => key.name)
|
||||
|
||||
return {
|
||||
workspaceKeys,
|
||||
personalKeys,
|
||||
conflicts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch API keys (both workspace and personal)
|
||||
* API keys change infrequently, cache for 60 seconds
|
||||
*/
|
||||
export function useApiKeys(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: apiKeysKeys.combined(workspaceId),
|
||||
queryFn: () => fetchApiKeys(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000, // 60 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API key mutation params
|
||||
*/
|
||||
interface CreateApiKeyParams {
|
||||
workspaceId: string
|
||||
name: string
|
||||
keyType: 'personal' | 'workspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new API key
|
||||
*/
|
||||
export function useCreateApiKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, name, keyType }: CreateApiKeyParams) => {
|
||||
const url =
|
||||
keyType === 'workspace'
|
||||
? `/api/workspaces/${workspaceId}/api-keys`
|
||||
: '/api/users/me/api-keys'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to create API key' }))
|
||||
throw new Error(error.error || 'Failed to create API key')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate API keys cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: apiKeysKeys.combined(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key mutation params
|
||||
*/
|
||||
interface DeleteApiKeyParams {
|
||||
workspaceId: string
|
||||
keyId: string
|
||||
keyType: 'personal' | 'workspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete an API key
|
||||
*/
|
||||
export function useDeleteApiKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, keyId, keyType }: DeleteApiKeyParams) => {
|
||||
const url =
|
||||
keyType === 'workspace'
|
||||
? `/api/workspaces/${workspaceId}/api-keys/${keyId}`
|
||||
: `/api/users/me/api-keys/${keyId}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to delete API key' }))
|
||||
throw new Error(error.error || 'Failed to delete API key')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate API keys cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: apiKeysKeys.combined(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace API key settings mutation params
|
||||
*/
|
||||
interface UpdateWorkspaceApiKeySettingsParams {
|
||||
workspaceId: string
|
||||
allowPersonalApiKeys: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update workspace API key settings
|
||||
*/
|
||||
export function useUpdateWorkspaceApiKeySettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
allowPersonalApiKeys,
|
||||
}: UpdateWorkspaceApiKeySettingsParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowPersonalApiKeys }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to update settings' }))
|
||||
throw new Error(error.error || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate workspace settings cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.settings(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
147
apps/sim/hooks/queries/copilot-keys.ts
Normal file
147
apps/sim/hooks/queries/copilot-keys.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotKeysQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for Copilot API keys
|
||||
*/
|
||||
export const copilotKeysKeys = {
|
||||
all: ['copilot'] as const,
|
||||
keys: () => [...copilotKeysKeys.all, 'api-keys'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot API key type
|
||||
*/
|
||||
export interface CopilotKey {
|
||||
id: string
|
||||
displayKey: string // "•••••{last6}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate key response type
|
||||
*/
|
||||
export interface GenerateKeyResponse {
|
||||
success: boolean
|
||||
key: {
|
||||
id: string
|
||||
apiKey: string // Full key (only shown once)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Copilot API keys
|
||||
*/
|
||||
async function fetchCopilotKeys(): Promise<CopilotKey[]> {
|
||||
const response = await fetch('/api/copilot/api-keys')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Copilot API keys')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.keys || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch Copilot API keys
|
||||
* Only fetches when in hosted environment
|
||||
*/
|
||||
export function useCopilotKeys() {
|
||||
return useQuery({
|
||||
queryKey: copilotKeysKeys.keys(),
|
||||
queryFn: fetchCopilotKeys,
|
||||
enabled: isHosted, // Only fetch in hosted environments
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new Copilot API key mutation
|
||||
*/
|
||||
export function useGenerateCopilotKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<GenerateKeyResponse> => {
|
||||
const response = await fetch('/api/copilot/api-keys/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to generate Copilot API key')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Force refetch even if query is disabled (enabled: isHosted check)
|
||||
// Using refetchQueries ensures it runs regardless of enabled state
|
||||
queryClient.refetchQueries({
|
||||
queryKey: copilotKeysKeys.keys(),
|
||||
type: 'active', // Only refetch if query is currently subscribed/active
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to generate Copilot API key:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Copilot API key mutation with optimistic updates
|
||||
*/
|
||||
interface DeleteKeyParams {
|
||||
keyId: string
|
||||
}
|
||||
|
||||
export function useDeleteCopilotKey() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId }: DeleteKeyParams) => {
|
||||
const response = await fetch(`/api/copilot/api-keys?id=${keyId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete Copilot API key')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async ({ keyId }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: copilotKeysKeys.keys() })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousKeys = queryClient.getQueryData<CopilotKey[]>(copilotKeysKeys.keys())
|
||||
|
||||
// Optimistically remove the key from the list
|
||||
queryClient.setQueryData<CopilotKey[]>(copilotKeysKeys.keys(), (old) => {
|
||||
return old?.filter((k) => k.id !== keyId) || []
|
||||
})
|
||||
|
||||
return { previousKeys }
|
||||
},
|
||||
onError: (error, _variables, context) => {
|
||||
// Rollback to previous value on error
|
||||
if (context?.previousKeys) {
|
||||
queryClient.setQueryData(copilotKeysKeys.keys(), context.previousKeys)
|
||||
}
|
||||
logger.error('Failed to delete Copilot API key:', error)
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success to ensure server state
|
||||
queryClient.invalidateQueries({ queryKey: copilotKeysKeys.keys() })
|
||||
},
|
||||
})
|
||||
}
|
||||
173
apps/sim/hooks/queries/creator-profile.ts
Normal file
173
apps/sim/hooks/queries/creator-profile.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { CreatorProfileDetails } from '@/types/creator-profile'
|
||||
|
||||
const logger = createLogger('CreatorProfileQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for creator profiles
|
||||
*/
|
||||
export const creatorProfileKeys = {
|
||||
all: ['creatorProfile'] as const,
|
||||
profile: (userId: string) => [...creatorProfileKeys.all, 'profile', userId] as const,
|
||||
organizations: () => [...creatorProfileKeys.all, 'organizations'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization type
|
||||
*/
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creator profile type
|
||||
*/
|
||||
export interface CreatorProfile {
|
||||
id: string
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
name: string
|
||||
profileImageUrl: string
|
||||
details?: CreatorProfileDetails
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organizations where user is owner or admin
|
||||
* Note: Filtering is done server-side in the API route
|
||||
*/
|
||||
async function fetchOrganizations(): Promise<Organization[]> {
|
||||
const response = await fetch('/api/organizations')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch organizations')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.organizations || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organizations
|
||||
*/
|
||||
export function useOrganizations() {
|
||||
return useQuery({
|
||||
queryKey: creatorProfileKeys.organizations(),
|
||||
queryFn: fetchOrganizations,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - organizations don't change often
|
||||
placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch creator profile for a user
|
||||
*/
|
||||
async function fetchCreatorProfile(userId: string): Promise<CreatorProfile | null> {
|
||||
const response = await fetch(`/api/creator-profiles?userId=${userId}`)
|
||||
|
||||
// Treat 404 as "no profile"
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch creator profile')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.profiles && data.profiles.length > 0) {
|
||||
return data.profiles[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch creator profile
|
||||
*/
|
||||
export function useCreatorProfile(userId: string) {
|
||||
return useQuery({
|
||||
queryKey: creatorProfileKeys.profile(userId),
|
||||
queryFn: () => fetchCreatorProfile(userId),
|
||||
enabled: !!userId,
|
||||
retry: false, // Don't retry on 404
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save creator profile mutation
|
||||
*/
|
||||
interface SaveProfileParams {
|
||||
referenceType: 'user' | 'organization'
|
||||
referenceId: string
|
||||
name: string
|
||||
profileImageUrl: string
|
||||
details?: CreatorProfileDetails
|
||||
existingProfileId?: string
|
||||
}
|
||||
|
||||
export function useSaveCreatorProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
referenceType,
|
||||
referenceId,
|
||||
name,
|
||||
profileImageUrl,
|
||||
details,
|
||||
existingProfileId,
|
||||
}: SaveProfileParams) => {
|
||||
const payload = {
|
||||
referenceType,
|
||||
referenceId,
|
||||
name,
|
||||
profileImageUrl,
|
||||
details: details && Object.keys(details).length > 0 ? details : undefined,
|
||||
}
|
||||
|
||||
const url = existingProfileId
|
||||
? `/api/creator-profiles/${existingProfileId}`
|
||||
: '/api/creator-profiles'
|
||||
const method = existingProfileId ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData.error || 'Failed to save creator profile'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate the profile query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: creatorProfileKeys.profile(variables.referenceId),
|
||||
})
|
||||
|
||||
// Dispatch event to notify that a creator profile was saved
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('creator-profile-saved'))
|
||||
}
|
||||
|
||||
logger.info('Creator profile saved successfully')
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to save creator profile:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
315
apps/sim/hooks/queries/custom-tools.ts
Normal file
315
apps/sim/hooks/queries/custom-tools.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CustomToolsQueries')
|
||||
const API_ENDPOINT = '/api/tools/custom'
|
||||
|
||||
/**
|
||||
* Query key factories for custom tools queries
|
||||
*/
|
||||
export const customToolsKeys = {
|
||||
all: ['customTools'] as const,
|
||||
lists: () => [...customToolsKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...customToolsKeys.lists(), workspaceId] as const,
|
||||
detail: (toolId: string) => [...customToolsKeys.all, 'detail', toolId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Tool Types
|
||||
*/
|
||||
export interface CustomToolSchema {
|
||||
function?: {
|
||||
name?: string
|
||||
description?: string
|
||||
parameters?: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomTool {
|
||||
id: string
|
||||
title: string
|
||||
schema?: CustomToolSchema
|
||||
code: string
|
||||
workspaceId?: string
|
||||
userId?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch custom tools for a workspace
|
||||
*/
|
||||
async function fetchCustomTools(workspaceId: string): Promise<CustomTool[]> {
|
||||
const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || `Failed to fetch custom tools: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Invalid response format')
|
||||
}
|
||||
|
||||
// Filter and validate tools
|
||||
const validTools = data.filter((tool, index) => {
|
||||
if (!tool || typeof tool !== 'object') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: not an object`)
|
||||
return false
|
||||
}
|
||||
if (!tool.id || typeof tool.id !== 'string') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid id`)
|
||||
return false
|
||||
}
|
||||
if (!tool.title || typeof tool.title !== 'string') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid title`)
|
||||
return false
|
||||
}
|
||||
if (!tool.schema || typeof tool.schema !== 'object') {
|
||||
logger.warn(`Skipping invalid tool at index ${index}: missing or invalid schema`)
|
||||
return false
|
||||
}
|
||||
if (!tool.code || typeof tool.code !== 'string') {
|
||||
logger.warn(`Tool at index ${index} missing code field, defaulting to empty string`)
|
||||
tool.code = ''
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return validTools
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch custom tools
|
||||
*/
|
||||
export function useCustomTools(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: customToolsKeys.list(workspaceId),
|
||||
queryFn: () => fetchCustomTools(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000, // 1 minute - tools don't change frequently
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom tool mutation
|
||||
*/
|
||||
interface CreateCustomToolParams {
|
||||
workspaceId: string
|
||||
tool: {
|
||||
title: string
|
||||
schema: CustomToolSchema
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, tool }: CreateCustomToolParams) => {
|
||||
logger.info(`Creating custom tool: ${tool.title} in workspace ${workspaceId}`)
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
title: tool.title,
|
||||
schema: tool.schema,
|
||||
code: tool.code,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create tool')
|
||||
}
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid API response: missing tools data')
|
||||
}
|
||||
|
||||
logger.info(`Created custom tool: ${tool.title}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate tools list for the workspace
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom tool mutation
|
||||
*/
|
||||
interface UpdateCustomToolParams {
|
||||
workspaceId: string
|
||||
toolId: string
|
||||
updates: {
|
||||
title?: string
|
||||
schema?: CustomToolSchema
|
||||
code?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, toolId, updates }: UpdateCustomToolParams) => {
|
||||
logger.info(`Updating custom tool: ${toolId} in workspace ${workspaceId}`)
|
||||
|
||||
// Get the current tool to merge with updates
|
||||
const currentTools = queryClient.getQueryData<CustomTool[]>(customToolsKeys.list(workspaceId))
|
||||
const currentTool = currentTools?.find((t) => t.id === toolId)
|
||||
|
||||
if (!currentTool) {
|
||||
throw new Error('Tool not found')
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
id: toolId,
|
||||
title: updates.title ?? currentTool.title,
|
||||
schema: updates.schema ?? currentTool.schema,
|
||||
code: updates.code ?? currentTool.code,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update tool')
|
||||
}
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid API response: missing tools data')
|
||||
}
|
||||
|
||||
logger.info(`Updated custom tool: ${toolId}`)
|
||||
return data.data
|
||||
},
|
||||
onMutate: async ({ workspaceId, toolId, updates }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTools = queryClient.getQueryData<CustomTool[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
// Optimistically update to the new value
|
||||
if (previousTools) {
|
||||
queryClient.setQueryData<CustomTool[]>(
|
||||
customToolsKeys.list(workspaceId),
|
||||
previousTools.map((tool) =>
|
||||
tool.id === toolId
|
||||
? {
|
||||
...tool,
|
||||
title: updates.title ?? tool.title,
|
||||
schema: updates.schema ?? tool.schema,
|
||||
code: updates.code ?? tool.code,
|
||||
}
|
||||
: tool
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousTools }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousTools) {
|
||||
queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete custom tool mutation
|
||||
*/
|
||||
interface DeleteCustomToolParams {
|
||||
workspaceId: string | null
|
||||
toolId: string
|
||||
}
|
||||
|
||||
export function useDeleteCustomTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, toolId }: DeleteCustomToolParams) => {
|
||||
logger.info(`Deleting custom tool: ${toolId}`)
|
||||
|
||||
const url = workspaceId
|
||||
? `${API_ENDPOINT}?id=${toolId}&workspaceId=${workspaceId}`
|
||||
: `${API_ENDPOINT}?id=${toolId}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete tool')
|
||||
}
|
||||
|
||||
logger.info(`Deleted custom tool: ${toolId}`)
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ workspaceId, toolId }) => {
|
||||
if (!workspaceId) return
|
||||
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTools = queryClient.getQueryData<CustomTool[]>(
|
||||
customToolsKeys.list(workspaceId)
|
||||
)
|
||||
|
||||
// Optimistically update to the new value
|
||||
if (previousTools) {
|
||||
queryClient.setQueryData<CustomTool[]>(
|
||||
customToolsKeys.list(workspaceId),
|
||||
previousTools.filter((tool) => tool.id !== toolId)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousTools, workspaceId }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousTools && context?.workspaceId) {
|
||||
queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Always refetch after error or success
|
||||
if (variables.workspaceId) {
|
||||
queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
221
apps/sim/hooks/queries/environment.ts
Normal file
221
apps/sim/hooks/queries/environment.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { API_ENDPOINTS } from '@/stores/constants'
|
||||
|
||||
const logger = createLogger('EnvironmentQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for environment variable queries
|
||||
*/
|
||||
export const environmentKeys = {
|
||||
all: ['environment'] as const,
|
||||
personal: () => [...environmentKeys.all, 'personal'] as const,
|
||||
workspace: (workspaceId: string) => [...environmentKeys.all, 'workspace', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment Variable Types
|
||||
*/
|
||||
export interface EnvironmentVariable {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface WorkspaceEnvironmentData {
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch personal environment variables
|
||||
*/
|
||||
async function fetchPersonalEnvironment(): Promise<Record<string, EnvironmentVariable>> {
|
||||
const response = await fetch(API_ENDPOINTS.ENVIRONMENT)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load environment variables: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
return data
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch personal environment variables
|
||||
*/
|
||||
export function usePersonalEnvironment() {
|
||||
return useQuery({
|
||||
queryKey: environmentKeys.personal(),
|
||||
queryFn: fetchPersonalEnvironment,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace environment variables
|
||||
*/
|
||||
async function fetchWorkspaceEnvironment(workspaceId: string): Promise<WorkspaceEnvironmentData> {
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load workspace environment: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
return {
|
||||
workspace: data.workspace || {},
|
||||
personal: data.personal || {},
|
||||
conflicts: data.conflicts || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace environment variables
|
||||
*/
|
||||
export function useWorkspaceEnvironment<TData = WorkspaceEnvironmentData>(
|
||||
workspaceId: string,
|
||||
options?: { select?: (data: WorkspaceEnvironmentData) => TData }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: environmentKeys.workspace(workspaceId),
|
||||
queryFn: () => fetchWorkspaceEnvironment(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save personal environment variables mutation
|
||||
*/
|
||||
interface SavePersonalEnvironmentParams {
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export function useSavePersonalEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ variables }: SavePersonalEnvironmentParams) => {
|
||||
const transformedVariables = Object.entries(variables).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: { key, value },
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.ENVIRONMENT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables: Object.entries(transformedVariables).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: (value as EnvironmentVariable).value,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save environment variables: ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info('Saved personal environment variables')
|
||||
return transformedVariables
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate personal environment queries
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
// Invalidate all workspace environments as they may have conflicts
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert workspace environment variables mutation
|
||||
*/
|
||||
interface UpsertWorkspaceEnvironmentParams {
|
||||
workspaceId: string
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export function useUpsertWorkspaceEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, variables }: UpsertWorkspaceEnvironmentParams) => {
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update workspace environment: ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info(`Upserted workspace environment variables for workspace: ${workspaceId}`)
|
||||
return await response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate workspace environment
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
})
|
||||
// Invalidate personal environment as conflicts may have changed
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove workspace environment variables mutation
|
||||
*/
|
||||
interface RemoveWorkspaceEnvironmentParams {
|
||||
workspaceId: string
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export function useRemoveWorkspaceEnvironment() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, keys }: RemoveWorkspaceEnvironmentParams) => {
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keys }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info(`Removed ${keys.length} workspace environment keys for workspace: ${workspaceId}`)
|
||||
return await response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate workspace environment
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: environmentKeys.workspace(variables.workspaceId),
|
||||
})
|
||||
// Invalidate personal environment as conflicts may have changed
|
||||
queryClient.invalidateQueries({ queryKey: environmentKeys.personal() })
|
||||
},
|
||||
})
|
||||
}
|
||||
160
apps/sim/hooks/queries/general-settings.ts
Normal file
160
apps/sim/hooks/queries/general-settings.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const logger = createLogger('GeneralSettingsQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for general settings
|
||||
*/
|
||||
export const generalSettingsKeys = {
|
||||
all: ['generalSettings'] as const,
|
||||
settings: () => [...generalSettingsKeys.all, 'settings'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* General settings type
|
||||
*/
|
||||
export interface GeneralSettings {
|
||||
autoConnect: boolean
|
||||
autoPan: boolean
|
||||
consoleExpandedByDefault: boolean
|
||||
showFloatingControls: boolean
|
||||
showTrainingControls: boolean
|
||||
superUserModeEnabled: boolean
|
||||
theme: 'light' | 'dark' | 'system'
|
||||
telemetryEnabled: boolean
|
||||
billingUsageNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch general settings from API
|
||||
*/
|
||||
async function fetchGeneralSettings(): Promise<GeneralSettings> {
|
||||
const response = await fetch('/api/users/me/settings')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch general settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
return {
|
||||
autoConnect: data.autoConnect ?? true,
|
||||
autoPan: data.autoPan ?? true,
|
||||
consoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
|
||||
showFloatingControls: data.showFloatingControls ?? true,
|
||||
showTrainingControls: data.showTrainingControls ?? false,
|
||||
superUserModeEnabled: data.superUserModeEnabled ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled ?? true,
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync React Query cache to Zustand store
|
||||
* This ensures the rest of the app (which uses Zustand) stays in sync
|
||||
*/
|
||||
function syncSettingsToZustand(settings: GeneralSettings) {
|
||||
const store = useGeneralStore.getState()
|
||||
|
||||
// Update Zustand store to match React Query cache
|
||||
// This allows the rest of the app to continue using Zustand for reading values
|
||||
useGeneralStore.setState({
|
||||
isAutoConnectEnabled: settings.autoConnect,
|
||||
isAutoPanEnabled: settings.autoPan,
|
||||
isConsoleExpandedByDefault: settings.consoleExpandedByDefault,
|
||||
showFloatingControls: settings.showFloatingControls,
|
||||
showTrainingControls: settings.showTrainingControls,
|
||||
superUserModeEnabled: settings.superUserModeEnabled,
|
||||
theme: settings.theme,
|
||||
telemetryEnabled: settings.telemetryEnabled,
|
||||
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch general settings
|
||||
* Also syncs to Zustand store to keep the rest of the app in sync
|
||||
*/
|
||||
export function useGeneralSettings() {
|
||||
const query = useQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
queryFn: fetchGeneralSettings,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour - settings rarely change
|
||||
placeholderData: keepPreviousData, // Show cached data immediately while refetching
|
||||
})
|
||||
|
||||
// Sync to Zustand whenever React Query cache updates
|
||||
if (query.data) {
|
||||
syncSettingsToZustand(query.data)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
/**
|
||||
* Update general settings mutation
|
||||
*/
|
||||
interface UpdateSettingParams {
|
||||
key: keyof GeneralSettings
|
||||
value: any
|
||||
}
|
||||
|
||||
export function useUpdateGeneralSetting() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ key, value }: UpdateSettingParams) => {
|
||||
const response = await fetch('/api/users/me/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update setting: ${key}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async ({ key, value }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: generalSettingsKeys.settings() })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousSettings = queryClient.getQueryData<GeneralSettings>(
|
||||
generalSettingsKeys.settings()
|
||||
)
|
||||
|
||||
// Optimistically update to the new value
|
||||
if (previousSettings) {
|
||||
const newSettings = {
|
||||
...previousSettings,
|
||||
[key]: value,
|
||||
}
|
||||
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
|
||||
|
||||
// Immediately sync to Zustand for optimistic update throughout the app
|
||||
syncSettingsToZustand(newSettings)
|
||||
}
|
||||
|
||||
return { previousSettings }
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousSettings) {
|
||||
queryClient.setQueryData(generalSettingsKeys.settings(), context.previousSettings)
|
||||
// Also rollback Zustand store
|
||||
syncSettingsToZustand(context.previousSettings)
|
||||
}
|
||||
logger.error('Failed to update setting:', err)
|
||||
},
|
||||
onSuccess: (_data, _variables, _context) => {
|
||||
// Invalidate to ensure we have the latest from server
|
||||
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
|
||||
// Sync will happen automatically when the query refetches in useGeneralSettings hook
|
||||
},
|
||||
})
|
||||
}
|
||||
305
apps/sim/hooks/queries/mcp.ts
Normal file
305
apps/sim/hooks/queries/mcp.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for MCP-related queries
|
||||
*/
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
|
||||
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server Types
|
||||
*/
|
||||
export interface McpServer {
|
||||
id: string
|
||||
workspaceId: string
|
||||
name: string
|
||||
transport: 'streamable-http' | 'stdio'
|
||||
url?: string
|
||||
timeout: number
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string
|
||||
toolCount?: number
|
||||
lastToolsRefresh?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
name: string
|
||||
transport: 'streamable-http' | 'stdio'
|
||||
url?: string
|
||||
timeout: number
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface McpTool {
|
||||
id: string
|
||||
serverId: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MCP servers for a workspace
|
||||
*/
|
||||
async function fetchMcpServers(workspaceId: string): Promise<McpServer[]> {
|
||||
const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`)
|
||||
|
||||
// Treat 404 as "no servers configured" - return empty array
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch MCP servers')
|
||||
}
|
||||
|
||||
return data.data?.servers || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch MCP servers
|
||||
*/
|
||||
export function useMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.servers(workspaceId),
|
||||
queryFn: () => fetchMcpServers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false, // Don't retry on 404 (no servers configured)
|
||||
staleTime: 60 * 1000, // 1 minute - servers don't change frequently
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MCP tools for a workspace
|
||||
*/
|
||||
async function fetchMcpTools(workspaceId: string): Promise<McpTool[]> {
|
||||
const response = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`)
|
||||
|
||||
// Treat 404 as "no tools available" - return empty array
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch MCP tools')
|
||||
}
|
||||
|
||||
return data.data?.tools || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch MCP tools
|
||||
*/
|
||||
export function useMcpToolsQuery(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: mcpKeys.tools(workspaceId),
|
||||
queryFn: () => fetchMcpTools(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false, // Don't retry on 404 (no tools available)
|
||||
staleTime: 30 * 1000, // 30 seconds - tools can change when servers are added/removed
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MCP server mutation
|
||||
*/
|
||||
interface CreateMcpServerParams {
|
||||
workspaceId: string
|
||||
config: McpServerConfig
|
||||
}
|
||||
|
||||
export function useCreateMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, config }: CreateMcpServerParams) => {
|
||||
const serverData = {
|
||||
...config,
|
||||
workspaceId,
|
||||
id: `mcp-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/mcp/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Created MCP server: ${config.name} in workspace: ${workspaceId}`)
|
||||
return { ...serverData, connectionStatus: 'disconnected' as const }
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate servers list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
// Invalidate tools as new server may provide new tools
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete MCP server mutation
|
||||
*/
|
||||
interface DeleteMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: DeleteMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/servers?serverId=${serverId}&workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Deleted MCP server: ${serverId} from workspace: ${workspaceId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate servers list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
// Invalidate tools as deleted server's tools should be removed
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update MCP server mutation
|
||||
*/
|
||||
interface UpdateMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
updates: Partial<McpServerConfig>
|
||||
}
|
||||
|
||||
export function useUpdateMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
|
||||
// For now, this is optimistic-only since there's no PATCH endpoint
|
||||
// The component would need a PATCH endpoint for full implementation
|
||||
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
|
||||
return { serverId, updates }
|
||||
},
|
||||
onMutate: async ({ workspaceId, serverId, updates }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(mcpKeys.servers(workspaceId))
|
||||
|
||||
// Optimistically update to the new value
|
||||
if (previousServers) {
|
||||
queryClient.setQueryData<McpServer[]>(
|
||||
mcpKeys.servers(workspaceId),
|
||||
previousServers.map((server) =>
|
||||
server.id === serverId
|
||||
? { ...server, ...updates, updatedAt: new Date().toISOString() }
|
||||
: server
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return { previousServers }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousServers) {
|
||||
queryClient.setQueryData(mcpKeys.servers(variables.workspaceId), context.previousServers)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test MCP server connection
|
||||
*/
|
||||
export interface McpServerTestParams {
|
||||
name: string
|
||||
transport: 'streamable-http' | 'stdio'
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
timeout: number
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export interface McpServerTestResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
tools?: Array<{ name: string; description?: string }>
|
||||
}
|
||||
|
||||
export function useTestMcpServer() {
|
||||
return useMutation({
|
||||
mutationFn: async (params: McpServerTestParams): Promise<McpServerTestResult> => {
|
||||
try {
|
||||
const response = await fetch('/api/mcp/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || 'Failed to test connection',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tools: data.tools || [],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Connection test failed',
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
239
apps/sim/hooks/queries/oauth-connections.ts
Normal file
239
apps/sim/hooks/queries/oauth-connections.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for OAuth connections
|
||||
*/
|
||||
export const oauthConnectionsKeys = {
|
||||
all: ['oauthConnections'] as const,
|
||||
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Service info type
|
||||
*/
|
||||
export interface ServiceInfo extends OAuthServiceConfig {
|
||||
isConnected: boolean
|
||||
lastConnected?: string
|
||||
accounts?: { id: string; name: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Define available services from standardized OAuth providers
|
||||
*/
|
||||
function defineServices(): ServiceInfo[] {
|
||||
const servicesList: ServiceInfo[] = []
|
||||
|
||||
Object.values(OAUTH_PROVIDERS).forEach((provider) => {
|
||||
Object.values(provider.services).forEach((service) => {
|
||||
servicesList.push({
|
||||
...service,
|
||||
isConnected: false,
|
||||
scopes: service.scopes || [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return servicesList
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch OAuth connections and merge with service definitions
|
||||
*/
|
||||
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
// Start with the base service definitions
|
||||
const serviceDefinitions = defineServices()
|
||||
|
||||
// Fetch all OAuth connections for the user
|
||||
const response = await fetch('/api/auth/oauth/connections')
|
||||
|
||||
// Treat 404 as "no connections"
|
||||
if (response.status === 404) {
|
||||
return serviceDefinitions
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch OAuth connections')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const connections = data.connections || []
|
||||
|
||||
// Update services with connection status and account info
|
||||
const updatedServices = serviceDefinitions.map((service) => {
|
||||
// Find matching connection - exact match on providerId
|
||||
const connection = connections.find((conn: any) => conn.provider === service.providerId)
|
||||
|
||||
// If we found an exact match, use it
|
||||
if (connection) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: connection.accounts?.length > 0,
|
||||
accounts: connection.accounts || [],
|
||||
lastConnected: connection.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, check if any connection has all the required scopes
|
||||
const connectionWithScopes = connections.find((conn: any) => {
|
||||
// Only consider connections from the same base provider
|
||||
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if all required scopes for this service are included in the connection
|
||||
if (conn.scopes && service.scopes) {
|
||||
return service.scopes.every((scope) => conn.scopes.includes(scope))
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (connectionWithScopes) {
|
||||
return {
|
||||
...service,
|
||||
isConnected: connectionWithScopes.accounts?.length > 0,
|
||||
accounts: connectionWithScopes.accounts || [],
|
||||
lastConnected: connectionWithScopes.lastConnected,
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
})
|
||||
|
||||
return updatedServices
|
||||
} catch (error) {
|
||||
logger.error('Error fetching OAuth connections:', error)
|
||||
// Return base definitions on error
|
||||
return defineServices()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch OAuth connections
|
||||
*/
|
||||
export function useOAuthConnections() {
|
||||
return useQuery({
|
||||
queryKey: oauthConnectionsKeys.connections(),
|
||||
queryFn: fetchOAuthConnections,
|
||||
staleTime: 30 * 1000, // 30 seconds - connections don't change often
|
||||
retry: false, // Don't retry on 404
|
||||
placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect OAuth service mutation
|
||||
*/
|
||||
interface ConnectServiceParams {
|
||||
providerId: string
|
||||
callbackURL: string
|
||||
}
|
||||
|
||||
export function useConnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => {
|
||||
// Handle Trello specially
|
||||
if (providerId === 'trello') {
|
||||
window.location.href = '/api/auth/trello/authorize'
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate connections to refetch
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('OAuth connection error:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect OAuth service mutation
|
||||
*/
|
||||
interface DisconnectServiceParams {
|
||||
provider: string
|
||||
providerId: string
|
||||
serviceId: string
|
||||
accountId: string
|
||||
}
|
||||
|
||||
export function useDisconnectOAuthService() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ provider, providerId }: DisconnectServiceParams) => {
|
||||
const response = await fetch('/api/auth/oauth/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
providerId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to disconnect service')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async ({ serviceId, accountId }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousServices = queryClient.getQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections()
|
||||
)
|
||||
|
||||
// Optimistically update by removing the disconnected account
|
||||
if (previousServices) {
|
||||
queryClient.setQueryData<ServiceInfo[]>(
|
||||
oauthConnectionsKeys.connections(),
|
||||
previousServices.map((svc) => {
|
||||
if (svc.id === serviceId) {
|
||||
const updatedAccounts = svc.accounts?.filter((acc) => acc.id !== accountId) || []
|
||||
return {
|
||||
...svc,
|
||||
accounts: updatedAccounts,
|
||||
isConnected: updatedAccounts.length > 0,
|
||||
}
|
||||
}
|
||||
return svc
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { previousServices }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousServices) {
|
||||
queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
|
||||
}
|
||||
logger.error('Failed to disconnect service')
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
|
||||
},
|
||||
})
|
||||
}
|
||||
399
apps/sim/hooks/queries/organization.ts
Normal file
399
apps/sim/hooks/queries/organization.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client } from '@/lib/auth-client'
|
||||
|
||||
/**
|
||||
* Query key factories for organization-related queries
|
||||
* This ensures consistent cache invalidation across the app
|
||||
*/
|
||||
export const organizationKeys = {
|
||||
all: ['organizations'] as const,
|
||||
lists: () => [...organizationKeys.all, 'list'] as const,
|
||||
details: () => [...organizationKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...organizationKeys.details(), id] as const,
|
||||
subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const,
|
||||
billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const,
|
||||
members: (id: string) => [...organizationKeys.detail(id), 'members'] as const,
|
||||
memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all organizations for the current user
|
||||
*/
|
||||
async function fetchOrganizations() {
|
||||
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
|
||||
client.organization.list(),
|
||||
client.organization.getFullOrganization(),
|
||||
fetch('/api/billing?context=user').then((r) => r.json()),
|
||||
])
|
||||
|
||||
return {
|
||||
organizations: orgsResponse.data || [],
|
||||
activeOrganization: activeOrgResponse.data,
|
||||
billingData: billingResponse,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all organizations
|
||||
*/
|
||||
export function useOrganizations() {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.lists(),
|
||||
queryFn: fetchOrganizations,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific organization by ID
|
||||
*/
|
||||
async function fetchOrganization() {
|
||||
const response = await client.organization.getFullOrganization()
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific organization
|
||||
*/
|
||||
export function useOrganization(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.detail(orgId),
|
||||
queryFn: fetchOrganization,
|
||||
enabled: !!orgId,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization subscription data
|
||||
*/
|
||||
async function fetchOrganizationSubscription(orgId: string) {
|
||||
if (!orgId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Pass query parameter to filter by referenceId (matches old store behavior)
|
||||
const response = await client.subscription.list({
|
||||
query: { referenceId: orgId },
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
console.error('Error fetching organization subscription:', response.error)
|
||||
return null
|
||||
}
|
||||
|
||||
// Find active team or enterprise subscription (same logic as old store)
|
||||
const teamSubscription = response.data?.find(
|
||||
(sub: any) => sub.status === 'active' && sub.plan === 'team'
|
||||
)
|
||||
const enterpriseSubscription = response.data?.find(
|
||||
(sub: any) => sub.plan === 'enterprise' || sub.plan === 'enterprise-plus'
|
||||
)
|
||||
const activeSubscription = enterpriseSubscription || teamSubscription
|
||||
|
||||
// React Query requires non-undefined return values, use null instead
|
||||
return activeSubscription || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization subscription
|
||||
*/
|
||||
export function useOrganizationSubscription(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.subscription(orgId),
|
||||
queryFn: () => fetchOrganizationSubscription(orgId),
|
||||
enabled: !!orgId,
|
||||
retry: false, // Don't retry when no organization exists
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization billing data
|
||||
*/
|
||||
async function fetchOrganizationBilling(orgId: string) {
|
||||
const response = await fetch(`/api/billing?context=organization&id=${orgId}`)
|
||||
|
||||
// Treat 404 as "no billing data available"
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch organization billing data')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization billing data
|
||||
*/
|
||||
export function useOrganizationBilling(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.billing(orgId),
|
||||
queryFn: () => fetchOrganizationBilling(orgId),
|
||||
enabled: !!orgId,
|
||||
retry: false, // Don't retry when no billing data exists
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization member usage data
|
||||
*/
|
||||
async function fetchOrganizationMembers(orgId: string) {
|
||||
const response = await fetch(`/api/organizations/${orgId}/members?include=usage`)
|
||||
|
||||
// Treat 404 as "no members found"
|
||||
if (response.status === 404) {
|
||||
return { members: [] }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch organization members')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch organization members with usage data
|
||||
*/
|
||||
export function useOrganizationMembers(orgId: string) {
|
||||
return useQuery({
|
||||
queryKey: organizationKeys.memberUsage(orgId),
|
||||
queryFn: () => fetchOrganizationMembers(orgId),
|
||||
enabled: !!orgId,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite member mutation
|
||||
*/
|
||||
interface InviteMemberParams {
|
||||
email: string
|
||||
workspaceInvitations?: Array<{ id: string; name: string }>
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useInviteMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ email, workspaceInvitations, orgId }: InviteMemberParams) => {
|
||||
const response = await fetch(`/api/organizations/${orgId}/invitations?batch=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
emails: [email],
|
||||
workspaceInvitations,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to invite member')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
||||
// Also refetch the org list to update counts
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member mutation
|
||||
*/
|
||||
interface RemoveMemberParams {
|
||||
memberId: string
|
||||
orgId: string
|
||||
shouldReduceSeats?: boolean
|
||||
}
|
||||
|
||||
export function useRemoveMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ memberId, orgId, shouldReduceSeats }: RemoveMemberParams) => {
|
||||
const response = await fetch(
|
||||
`/api/organizations/${orgId}/members/${memberId}?shouldReduceSeats=${shouldReduceSeats}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to remove member')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.memberUsage(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel invitation mutation
|
||||
*/
|
||||
interface CancelInvitationParams {
|
||||
invitationId: string
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export function useCancelInvitation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ invitationId, orgId }: CancelInvitationParams) => {
|
||||
const response = await fetch(
|
||||
`/api/organizations/${orgId}/invitations?invitationId=${invitationId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to cancel invitation')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update seats mutation (handles both add and reduce)
|
||||
*/
|
||||
interface UpdateSeatsParams {
|
||||
orgId: string
|
||||
seats: number
|
||||
subscriptionId: string
|
||||
}
|
||||
|
||||
export function useUpdateSeats() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ seats, orgId, subscriptionId }: UpdateSeatsParams) => {
|
||||
const response = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: orgId,
|
||||
subscriptionId,
|
||||
seats,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || 'Failed to update seats')
|
||||
}
|
||||
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate all related queries
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.subscription(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.billing(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization settings mutation
|
||||
*/
|
||||
interface UpdateOrganizationParams {
|
||||
orgId: string
|
||||
name?: string
|
||||
slug?: string
|
||||
logo?: string | null
|
||||
}
|
||||
|
||||
export function useUpdateOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ orgId, ...updates }: UpdateOrganizationParams) => {
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to update organization')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate organization details
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(variables.orgId) })
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization mutation
|
||||
*/
|
||||
interface CreateOrganizationParams {
|
||||
name: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export function useCreateOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, slug }: CreateOrganizationParams) => {
|
||||
const response = await client.organization.create({
|
||||
name,
|
||||
slug: slug || name.toLowerCase().replace(/\s+/g, '-'),
|
||||
})
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
// Set as active organization
|
||||
await client.organization.setActive({
|
||||
organizationId: response.data.id,
|
||||
})
|
||||
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Refetch all organizations
|
||||
queryClient.invalidateQueries({ queryKey: organizationKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
118
apps/sim/hooks/queries/sso.ts
Normal file
118
apps/sim/hooks/queries/sso.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { organizationKeys } from './organization'
|
||||
|
||||
/**
|
||||
* Query key factories for SSO-related queries
|
||||
*/
|
||||
export const ssoKeys = {
|
||||
all: ['sso'] as const,
|
||||
providers: () => [...ssoKeys.all, 'providers'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch SSO providers
|
||||
*/
|
||||
async function fetchSSOProviders() {
|
||||
const response = await fetch('/api/auth/sso/providers')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch SSO providers')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch SSO providers
|
||||
* Cache for 5 minutes since SSO config rarely changes
|
||||
*/
|
||||
export function useSSOProviders() {
|
||||
return useQuery({
|
||||
queryKey: ssoKeys.providers(),
|
||||
queryFn: fetchSSOProviders,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure SSO provider mutation
|
||||
*/
|
||||
interface ConfigureSSOParams {
|
||||
provider: string
|
||||
domain: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useConfigureSSO() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (config: ConfigureSSOParams) => {
|
||||
const response = await fetch('/api/auth/sso/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to configure SSO')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate SSO providers list
|
||||
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
|
||||
|
||||
// Also invalidate organization data if org context
|
||||
if (variables.orgId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.detail(variables.orgId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.lists(),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete SSO provider mutation
|
||||
*/
|
||||
interface DeleteSSOParams {
|
||||
providerId: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useDeleteSSO() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ providerId }: DeleteSSOParams) => {
|
||||
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to delete SSO provider')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate SSO providers list
|
||||
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
|
||||
|
||||
// Also invalidate organization data if org context
|
||||
if (variables.orgId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.detail(variables.orgId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
138
apps/sim/hooks/queries/subscription.ts
Normal file
138
apps/sim/hooks/queries/subscription.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { organizationKeys } from './organization'
|
||||
|
||||
/**
|
||||
* Query key factories for subscription-related queries
|
||||
*/
|
||||
export const subscriptionKeys = {
|
||||
all: ['subscription'] as const,
|
||||
user: () => [...subscriptionKeys.all, 'user'] as const,
|
||||
usage: () => [...subscriptionKeys.all, 'usage'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user subscription data
|
||||
*/
|
||||
async function fetchSubscriptionData() {
|
||||
const response = await fetch('/api/billing?context=user')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscription data')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user subscription data
|
||||
*/
|
||||
export function useSubscriptionData() {
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.user(),
|
||||
queryFn: fetchSubscriptionData,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user usage data
|
||||
*/
|
||||
async function fetchUsageData() {
|
||||
const response = await fetch('/api/usage?context=user')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch usage data')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base hook to fetch user usage data (single query)
|
||||
*/
|
||||
function useUsageDataBase() {
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.usage(),
|
||||
queryFn: fetchUsageData,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user usage data
|
||||
*/
|
||||
export function useUsageData() {
|
||||
return useUsageDataBase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch usage limit data
|
||||
*/
|
||||
export function useUsageLimitData() {
|
||||
return useUsageDataBase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage limit mutation
|
||||
*/
|
||||
interface UpdateUsageLimitParams {
|
||||
limit: number
|
||||
}
|
||||
|
||||
export function useUpdateUsageLimit() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ limit }: UpdateUsageLimitParams) => {
|
||||
const response = await fetch('/api/usage?context=user', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ limit }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to update usage limit')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all subscription-related queries
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.usage() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade subscription mutation
|
||||
*/
|
||||
interface UpgradeSubscriptionParams {
|
||||
plan: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useUpgradeSubscription() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ plan }: UpgradeSubscriptionParams) => {
|
||||
// This will be handled by the existing subscription upgrade flow
|
||||
// We just need to ensure proper cache invalidation
|
||||
return { plan }
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate all subscription queries
|
||||
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
|
||||
|
||||
// Also invalidate organization billing if org context
|
||||
if (variables.orgId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.billing(variables.orgId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationKeys.subscription(variables.orgId),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
116
apps/sim/hooks/queries/user-profile.ts
Normal file
116
apps/sim/hooks/queries/user-profile.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UserProfileQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for user profile
|
||||
*/
|
||||
export const userProfileKeys = {
|
||||
all: ['userProfile'] as const,
|
||||
profile: () => [...userProfileKeys.all, 'profile'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile type
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile from API
|
||||
*/
|
||||
async function fetchUserProfile(): Promise<UserProfile> {
|
||||
const response = await fetch('/api/users/me/profile')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user profile')
|
||||
}
|
||||
|
||||
const { user } = await response.json()
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
email: user.email || '',
|
||||
image: user.image || null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user profile
|
||||
*/
|
||||
export function useUserProfile() {
|
||||
return useQuery({
|
||||
queryKey: userProfileKeys.profile(),
|
||||
queryFn: fetchUserProfile,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - profile data doesn't change often
|
||||
placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile mutation
|
||||
*/
|
||||
interface UpdateProfileParams {
|
||||
name?: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
export function useUpdateUserProfile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (updates: UpdateProfileParams) => {
|
||||
const response = await fetch('/api/users/me/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update profile')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onMutate: async (updates) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: userProfileKeys.profile() })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousProfile = queryClient.getQueryData<UserProfile>(userProfileKeys.profile())
|
||||
|
||||
// Optimistically update to the new value
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData<UserProfile>(userProfileKeys.profile(), {
|
||||
...previousProfile,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return { previousProfile }
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousProfile) {
|
||||
queryClient.setQueryData(userProfileKeys.profile(), context.previousProfile)
|
||||
}
|
||||
logger.error('Failed to update profile:', err)
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate to ensure we have the latest from server
|
||||
queryClient.invalidateQueries({ queryKey: userProfileKeys.profile() })
|
||||
},
|
||||
})
|
||||
}
|
||||
217
apps/sim/hooks/queries/workspace-files.ts
Normal file
217
apps/sim/hooks/queries/workspace-files.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
|
||||
const logger = createLogger('WorkspaceFilesQuery')
|
||||
|
||||
/**
|
||||
* Query key factories for workspace files
|
||||
*/
|
||||
export const workspaceFilesKeys = {
|
||||
all: ['workspaceFiles'] as const,
|
||||
lists: () => [...workspaceFilesKeys.all, 'list'] as const,
|
||||
list: (workspaceId: string) => [...workspaceFilesKeys.lists(), workspaceId] as const,
|
||||
storageInfo: (workspaceId: string) =>
|
||||
[...workspaceFilesKeys.all, 'storage', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage info type
|
||||
*/
|
||||
export interface StorageInfo {
|
||||
usedBytes: number
|
||||
limitBytes: number
|
||||
percentUsed: number
|
||||
plan?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace files from API
|
||||
*/
|
||||
async function fetchWorkspaceFiles(workspaceId: string): Promise<WorkspaceFileRecord[]> {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch workspace files')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return data.success ? data.files : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace files
|
||||
*/
|
||||
export function useWorkspaceFiles(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceFilesKeys.list(workspaceId),
|
||||
queryFn: () => fetchWorkspaceFiles(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 30 * 1000, // 30 seconds - files can change frequently
|
||||
placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch storage info from API
|
||||
*/
|
||||
async function fetchStorageInfo(): Promise<StorageInfo | null> {
|
||||
const response = await fetch('/api/users/me/usage-limits')
|
||||
|
||||
// Treat 404 as "no storage info available"
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch storage info')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.storage) {
|
||||
return {
|
||||
usedBytes: data.storage.usedBytes,
|
||||
limitBytes: data.storage.limitBytes,
|
||||
percentUsed: data.storage.percentUsed,
|
||||
plan: data.usage?.plan || 'free',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch storage info
|
||||
*/
|
||||
export function useStorageInfo(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['storageInfo'],
|
||||
queryFn: fetchStorageInfo,
|
||||
enabled,
|
||||
retry: false, // Don't retry on 404
|
||||
staleTime: 60 * 1000, // 1 minute - storage info doesn't change often
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload workspace file mutation
|
||||
*/
|
||||
interface UploadFileParams {
|
||||
workspaceId: string
|
||||
file: File
|
||||
}
|
||||
|
||||
export function useUploadWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, file }: UploadFileParams) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Upload failed')
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate files list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) })
|
||||
// Invalidate storage info to update usage
|
||||
queryClient.invalidateQueries({ queryKey: ['storageInfo'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to upload file:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workspace file mutation
|
||||
*/
|
||||
interface DeleteFileParams {
|
||||
workspaceId: string
|
||||
fileId: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export function useDeleteWorkspaceFile() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, fileId }: DeleteFileParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/files/${fileId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Delete failed')
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
onMutate: async ({ workspaceId, fileId, fileSize }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.list(workspaceId) })
|
||||
await queryClient.cancelQueries({ queryKey: ['storageInfo'] })
|
||||
|
||||
// Snapshot the previous values
|
||||
const previousFiles = queryClient.getQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId)
|
||||
)
|
||||
const previousStorage = queryClient.getQueryData<StorageInfo>(['storageInfo'])
|
||||
|
||||
// Optimistically update files list
|
||||
if (previousFiles) {
|
||||
queryClient.setQueryData<WorkspaceFileRecord[]>(
|
||||
workspaceFilesKeys.list(workspaceId),
|
||||
previousFiles.filter((f) => f.id !== fileId)
|
||||
)
|
||||
}
|
||||
|
||||
// Optimistically update storage info
|
||||
if (previousStorage) {
|
||||
const newUsedBytes = Math.max(0, previousStorage.usedBytes - fileSize)
|
||||
const newPercentUsed = (newUsedBytes / previousStorage.limitBytes) * 100
|
||||
queryClient.setQueryData<StorageInfo>(['storageInfo'], {
|
||||
...previousStorage,
|
||||
usedBytes: newUsedBytes,
|
||||
percentUsed: newPercentUsed,
|
||||
})
|
||||
}
|
||||
|
||||
return { previousFiles, previousStorage }
|
||||
},
|
||||
onError: (_err, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousFiles) {
|
||||
queryClient.setQueryData(
|
||||
workspaceFilesKeys.list(variables.workspaceId),
|
||||
context.previousFiles
|
||||
)
|
||||
}
|
||||
if (context?.previousStorage) {
|
||||
queryClient.setQueryData(['storageInfo'], context.previousStorage)
|
||||
}
|
||||
logger.error('Failed to delete file')
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
// Always refetch after error or success
|
||||
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: ['storageInfo'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
85
apps/sim/hooks/queries/workspace.ts
Normal file
85
apps/sim/hooks/queries/workspace.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* Query key factories for workspace-related queries
|
||||
*/
|
||||
export const workspaceKeys = {
|
||||
all: ['workspace'] as const,
|
||||
details: () => [...workspaceKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...workspaceKeys.details(), id] as const,
|
||||
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
|
||||
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspace settings
|
||||
*/
|
||||
async function fetchWorkspaceSettings(workspaceId: string) {
|
||||
const [settingsResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
fetch(`/api/workspaces/${workspaceId}/permissions`),
|
||||
])
|
||||
|
||||
if (!settingsResponse.ok || !permissionsResponse.ok) {
|
||||
throw new Error('Failed to fetch workspace settings')
|
||||
}
|
||||
|
||||
const [settings, permissions] = await Promise.all([
|
||||
settingsResponse.json(),
|
||||
permissionsResponse.json(),
|
||||
])
|
||||
|
||||
return {
|
||||
settings,
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspace settings
|
||||
*/
|
||||
export function useWorkspaceSettings(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.settings(workspaceId),
|
||||
queryFn: () => fetchWorkspaceSettings(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace settings mutation
|
||||
*/
|
||||
interface UpdateWorkspaceSettingsParams {
|
||||
workspaceId: string
|
||||
billedAccountUserId?: string
|
||||
billingAccountUserEmail?: string
|
||||
}
|
||||
|
||||
export function useUpdateWorkspaceSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, ...updates }: UpdateWorkspaceSettingsParams) => {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate workspace settings
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workspaceKeys.settings(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { WrenchIcon } from 'lucide-react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { useMcpServersStore } from '@/stores/mcp-servers/store'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
|
||||
const logger = createLogger('useMcpTools')
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const servers = useMcpServersStore((state) => state.servers)
|
||||
const { data: servers = [] } = useMcpServers(workspaceId)
|
||||
|
||||
// Track the last fingerprint
|
||||
const lastProcessedFingerprintRef = useRef<string>('')
|
||||
|
||||
@@ -186,6 +186,7 @@ export const env = createEnv({
|
||||
ASANA_CLIENT_SECRET: z.string().optional(), // Asana OAuth client secret
|
||||
AIRTABLE_CLIENT_ID: z.string().optional(), // Airtable OAuth client ID
|
||||
AIRTABLE_CLIENT_SECRET: z.string().optional(), // Airtable OAuth client secret
|
||||
APOLLO_API_KEY: z.string().optional(), // Apollo API key (optional system-wide config)
|
||||
SUPABASE_CLIENT_ID: z.string().optional(), // Supabase OAuth client ID
|
||||
SUPABASE_CLIENT_SECRET: z.string().optional(), // Supabase OAuth client secret
|
||||
NOTION_CLIENT_ID: z.string().optional(), // Notion OAuth client ID
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, getEnv, isTruthy } from './env'
|
||||
import { env, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,9 +21,7 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted =
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
export const isHosted = true
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Utility functions for generating names for all entities (workspaces, folders, workflows)
|
||||
*/
|
||||
|
||||
import type { Workspace } from '@/lib/organization/types'
|
||||
import type { WorkflowFolder } from '@/stores/folders/store'
|
||||
import type { Workspace } from '@/stores/organization/types'
|
||||
|
||||
export interface NameableEntity {
|
||||
name: string
|
||||
|
||||
62
apps/sim/lib/organization/helpers.ts
Normal file
62
apps/sim/lib/organization/helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Helper functions for organization-related computations
|
||||
* These are pure functions that compute values from organization data
|
||||
*/
|
||||
|
||||
import type { Organization } from '@/lib/organization/types'
|
||||
|
||||
/**
|
||||
* Get the role of a user in an organization
|
||||
*/
|
||||
export function getUserRole(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): string {
|
||||
if (!userEmail || !organization?.members) {
|
||||
return 'member'
|
||||
}
|
||||
const currentMember = organization.members.find((m) => m.user?.email === userEmail)
|
||||
return currentMember?.role ?? 'member'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is an admin or owner in an organization
|
||||
*/
|
||||
export function isAdminOrOwner(
|
||||
organization: Organization | null | undefined,
|
||||
userEmail?: string
|
||||
): boolean {
|
||||
const role = getUserRole(organization, userEmail)
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate seat usage for an organization
|
||||
*/
|
||||
export function calculateSeatUsage(organization: Organization | null | undefined): {
|
||||
used: number
|
||||
members: number
|
||||
pending: number
|
||||
} {
|
||||
if (!organization) {
|
||||
return { used: 0, members: 0, pending: 0 }
|
||||
}
|
||||
|
||||
const membersCount = organization.members?.length || 0
|
||||
const pendingInvitationsCount =
|
||||
organization.invitations?.filter((inv) => inv.status === 'pending').length || 0
|
||||
|
||||
return {
|
||||
used: membersCount + pendingInvitationsCount,
|
||||
members: membersCount,
|
||||
pending: pendingInvitationsCount,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used seats from an organization
|
||||
* Alias for calculateSeatUsage for backward compatibility
|
||||
*/
|
||||
export function getUsedSeats(organization: Organization | null | undefined) {
|
||||
return calculateSeatUsage(organization)
|
||||
}
|
||||
27
apps/sim/lib/organization/index.ts
Normal file
27
apps/sim/lib/organization/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Export helper functions
|
||||
export {
|
||||
calculateSeatUsage as calculateSeatUsageHelper,
|
||||
getUsedSeats,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
} from '@/lib/organization/helpers'
|
||||
// Export types
|
||||
export type {
|
||||
Invitation,
|
||||
Member,
|
||||
MemberUsageData,
|
||||
Organization,
|
||||
OrganizationBillingData,
|
||||
OrganizationFormData,
|
||||
Subscription,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@/lib/organization/types'
|
||||
// Export utility functions
|
||||
export {
|
||||
calculateSeatUsage,
|
||||
generateSlug,
|
||||
validateEmail,
|
||||
validateSlug,
|
||||
} from '@/lib/organization/utils'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import type { Organization } from '@/stores/organization/types'
|
||||
import type { Organization } from '@/lib/organization/types'
|
||||
|
||||
/**
|
||||
* Calculate seat usage for an organization
|
||||
105
apps/sim/lib/subscription/helpers.ts
Normal file
105
apps/sim/lib/subscription/helpers.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Helper functions for subscription-related computations
|
||||
* These are pure functions that compute values from subscription data
|
||||
*/
|
||||
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import type { BillingStatus, SubscriptionData, UsageData } from '@/lib/subscription/types'
|
||||
|
||||
const defaultUsage: UsageData = {
|
||||
current: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription status flags from subscription data
|
||||
*/
|
||||
export function getSubscriptionStatus(subscriptionData: SubscriptionData | null | undefined) {
|
||||
return {
|
||||
isPaid: subscriptionData?.isPaid ?? false,
|
||||
isPro: subscriptionData?.isPro ?? false,
|
||||
isTeam: subscriptionData?.isTeam ?? false,
|
||||
isEnterprise: subscriptionData?.isEnterprise ?? false,
|
||||
isFree: !(subscriptionData?.isPaid ?? false),
|
||||
plan: subscriptionData?.plan ?? 'free',
|
||||
status: subscriptionData?.status ?? null,
|
||||
seats: subscriptionData?.seats ?? null,
|
||||
metadata: subscriptionData?.metadata ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage data from subscription data
|
||||
*/
|
||||
export function getUsage(subscriptionData: SubscriptionData | null | undefined): UsageData {
|
||||
return subscriptionData?.usage ?? defaultUsage
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing status based on usage and blocked state
|
||||
*/
|
||||
export function getBillingStatus(
|
||||
subscriptionData: SubscriptionData | null | undefined
|
||||
): BillingStatus {
|
||||
const usage = getUsage(subscriptionData)
|
||||
const blocked = subscriptionData?.billingBlocked
|
||||
if (blocked) return 'blocked'
|
||||
if (usage.isExceeded) return 'exceeded'
|
||||
if (usage.isWarning) return 'warning'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining budget
|
||||
*/
|
||||
export function getRemainingBudget(subscriptionData: SubscriptionData | null | undefined): number {
|
||||
const usage = getUsage(subscriptionData)
|
||||
return Math.max(0, usage.limit - usage.current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days remaining in billing period
|
||||
*/
|
||||
export function getDaysRemainingInPeriod(
|
||||
subscriptionData: SubscriptionData | null | undefined
|
||||
): number | null {
|
||||
const usage = getUsage(subscriptionData)
|
||||
if (!usage.billingPeriodEnd) return null
|
||||
|
||||
const now = new Date()
|
||||
const endDate = usage.billingPeriodEnd
|
||||
const diffTime = endDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription is at least Pro tier
|
||||
*/
|
||||
export function isAtLeastPro(subscriptionData: SubscriptionData | null | undefined): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.isPro || status.isTeam || status.isEnterprise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscription is at least Team tier
|
||||
*/
|
||||
export function isAtLeastTeam(subscriptionData: SubscriptionData | null | undefined): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.isTeam || status.isEnterprise
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can upgrade
|
||||
*/
|
||||
export function canUpgrade(subscriptionData: SubscriptionData | null | undefined): boolean {
|
||||
const status = getSubscriptionStatus(subscriptionData)
|
||||
return status.plan === 'free' || status.plan === 'pro'
|
||||
}
|
||||
18
apps/sim/lib/subscription/index.ts
Normal file
18
apps/sim/lib/subscription/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Export helper functions for subscription data
|
||||
export {
|
||||
canUpgrade,
|
||||
getBillingStatus,
|
||||
getDaysRemainingInPeriod,
|
||||
getRemainingBudget,
|
||||
getSubscriptionStatus,
|
||||
getUsage,
|
||||
isAtLeastPro,
|
||||
isAtLeastTeam,
|
||||
} from '@/lib/subscription/helpers'
|
||||
// Export types
|
||||
export type {
|
||||
BillingStatus,
|
||||
SubscriptionData,
|
||||
UsageData,
|
||||
UsageLimitData,
|
||||
} from '@/lib/subscription/types'
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { client, useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useOrganizationStore } from '@/stores/organization'
|
||||
import { organizationKeys } from '@/hooks/queries/organization'
|
||||
|
||||
const logger = createLogger('SubscriptionUpgrade')
|
||||
|
||||
@@ -17,7 +18,7 @@ const CONSTANTS = {
|
||||
export function useSubscriptionUpgrade() {
|
||||
const { data: session } = useSession()
|
||||
const betterAuthSubscription = useSubscription()
|
||||
const { loadData: loadOrganizationData } = useOrganizationStore()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleUpgrade = useCallback(
|
||||
async (targetPlan: TargetPlan) => {
|
||||
@@ -139,7 +140,7 @@ export function useSubscriptionUpgrade() {
|
||||
// For team plans, refresh organization data to ensure UI updates
|
||||
if (targetPlan === 'team') {
|
||||
try {
|
||||
await loadOrganizationData()
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
logger.info('Refreshed organization data after team upgrade')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refresh organization data after upgrade', error)
|
||||
@@ -167,7 +168,7 @@ export function useSubscriptionUpgrade() {
|
||||
)
|
||||
}
|
||||
},
|
||||
[session?.user?.id, betterAuthSubscription, loadOrganizationData]
|
||||
[session?.user?.id, betterAuthSubscription, queryClient]
|
||||
)
|
||||
|
||||
return { handleUpgrade }
|
||||
|
||||
@@ -483,6 +483,52 @@ export class Serializer {
|
||||
// Check required user-only parameters for the current tool
|
||||
const missingFields: string[] = []
|
||||
|
||||
// Helper function to evaluate conditions
|
||||
const evalCond = (
|
||||
condition:
|
||||
| {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
}
|
||||
| (() => {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
})
|
||||
| undefined,
|
||||
values: Record<string, any>
|
||||
): boolean => {
|
||||
if (!condition) return true
|
||||
const actual = typeof condition === 'function' ? condition() : condition
|
||||
const fieldValue = values[actual.field]
|
||||
|
||||
const valueMatch = Array.isArray(actual.value)
|
||||
? fieldValue != null &&
|
||||
(actual.not ? !actual.value.includes(fieldValue) : actual.value.includes(fieldValue))
|
||||
: actual.not
|
||||
? fieldValue !== actual.value
|
||||
: fieldValue === actual.value
|
||||
|
||||
const andMatch = !actual.and
|
||||
? true
|
||||
: (() => {
|
||||
const andFieldValue = values[actual.and!.field]
|
||||
return Array.isArray(actual.and!.value)
|
||||
? andFieldValue != null &&
|
||||
(actual.and!.not
|
||||
? !actual.and!.value.includes(andFieldValue)
|
||||
: actual.and!.value.includes(andFieldValue))
|
||||
: actual.and!.not
|
||||
? andFieldValue !== actual.and!.value
|
||||
: andFieldValue === actual.and!.value
|
||||
})()
|
||||
|
||||
return valueMatch && andMatch
|
||||
}
|
||||
|
||||
// Iterate through the tool's parameters, not the block's subBlocks
|
||||
Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => {
|
||||
if (paramConfig.required && paramConfig.visibility === 'user-only') {
|
||||
@@ -494,58 +540,18 @@ export class Serializer {
|
||||
const isAdvancedMode = block.advancedMode ?? false
|
||||
const includedByMode = shouldIncludeField(subBlockConfig, isAdvancedMode)
|
||||
|
||||
const includedByCondition = (() => {
|
||||
const evalCond = (
|
||||
condition:
|
||||
| {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
}
|
||||
| (() => {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
})
|
||||
| undefined,
|
||||
values: Record<string, any>
|
||||
): boolean => {
|
||||
if (!condition) return true
|
||||
const actual = typeof condition === 'function' ? condition() : condition
|
||||
const fieldValue = values[actual.field]
|
||||
// Check visibility condition
|
||||
const includedByCondition = evalCond(subBlockConfig.condition, params)
|
||||
|
||||
const valueMatch = Array.isArray(actual.value)
|
||||
? fieldValue != null &&
|
||||
(actual.not
|
||||
? !actual.value.includes(fieldValue)
|
||||
: actual.value.includes(fieldValue))
|
||||
: actual.not
|
||||
? fieldValue !== actual.value
|
||||
: fieldValue === actual.value
|
||||
|
||||
const andMatch = !actual.and
|
||||
? true
|
||||
: (() => {
|
||||
const andFieldValue = values[actual.and!.field]
|
||||
return Array.isArray(actual.and!.value)
|
||||
? andFieldValue != null &&
|
||||
(actual.and!.not
|
||||
? !actual.and!.value.includes(andFieldValue)
|
||||
: actual.and!.value.includes(andFieldValue))
|
||||
: actual.and!.not
|
||||
? andFieldValue !== actual.and!.value
|
||||
: andFieldValue === actual.and!.value
|
||||
})()
|
||||
|
||||
return valueMatch && andMatch
|
||||
}
|
||||
|
||||
return evalCond(subBlockConfig.condition, params)
|
||||
// Check if field is required based on its required condition (if it's a condition object)
|
||||
const isRequired = (() => {
|
||||
if (!subBlockConfig.required) return false
|
||||
if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required
|
||||
// If required is a condition object, evaluate it
|
||||
return evalCond(subBlockConfig.required, params)
|
||||
})()
|
||||
|
||||
shouldValidateParam = includedByMode && includedByCondition
|
||||
shouldValidateParam = includedByMode && includedByCondition && isRequired
|
||||
}
|
||||
|
||||
if (!shouldValidateParam) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import { useCopilotStore } from '@/stores/panel-new/copilot/store'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -200,7 +199,6 @@ export {
|
||||
useCustomToolsStore,
|
||||
useVariablesStore,
|
||||
useSubBlockStore,
|
||||
useSubscriptionStore,
|
||||
}
|
||||
|
||||
// Helper function to reset all stores
|
||||
@@ -220,7 +218,6 @@ export const resetAllStores = () => {
|
||||
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })
|
||||
useCustomToolsStore.getState().reset()
|
||||
// Variables store has no tracking to reset; registry hydrates
|
||||
useSubscriptionStore.getState().reset() // Reset subscription store
|
||||
}
|
||||
|
||||
// Helper function to log all store states
|
||||
@@ -235,7 +232,6 @@ export const logAllStores = () => {
|
||||
customTools: useCustomToolsStore.getState(),
|
||||
subBlock: useSubBlockStore.getState(),
|
||||
variables: useVariablesStore.getState(),
|
||||
subscription: useSubscriptionStore.getState(),
|
||||
}
|
||||
|
||||
return state
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { initialState, type McpServersActions, type McpServersState } from './types'
|
||||
|
||||
const logger = createLogger('McpServersStore')
|
||||
|
||||
export const useMcpServersStore = create<McpServersState & McpServersActions>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
fetchServers: async (workspaceId: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch servers')
|
||||
}
|
||||
|
||||
set({ servers: data.data?.servers || [], isLoading: false })
|
||||
logger.info(
|
||||
`Fetched ${data.data?.servers?.length || 0} MCP servers for workspace ${workspaceId}`
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch servers'
|
||||
logger.error('Failed to fetch MCP servers:', error)
|
||||
set({ error: errorMessage, isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
createServer: async (workspaceId: string, config) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const serverData = {
|
||||
...config,
|
||||
workspaceId,
|
||||
id: `mcp-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/mcp/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(serverData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create server')
|
||||
}
|
||||
|
||||
const newServer = { ...serverData, connectionStatus: 'disconnected' as const }
|
||||
set((state) => ({
|
||||
servers: [...state.servers, newServer],
|
||||
isLoading: false,
|
||||
}))
|
||||
|
||||
logger.info(`Created MCP server: ${config.name} in workspace: ${workspaceId}`)
|
||||
return newServer
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to create server'
|
||||
logger.error('Failed to create MCP server:', error)
|
||||
set({ error: errorMessage, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
updateServer: async (workspaceId: string, id: string, updates) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
// For now, update locally only - server updates would require a PATCH endpoint
|
||||
set((state) => ({
|
||||
servers: state.servers.map((server) =>
|
||||
server.id === id && server.workspaceId === workspaceId
|
||||
? { ...server, ...updates, updatedAt: new Date().toISOString() }
|
||||
: server
|
||||
),
|
||||
isLoading: false,
|
||||
}))
|
||||
|
||||
logger.info(`Updated MCP server: ${id} in workspace: ${workspaceId}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update server'
|
||||
logger.error('Failed to update MCP server:', error)
|
||||
set({ error: errorMessage, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
deleteServer: async (workspaceId: string, id: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/mcp/servers?serverId=${id}&workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete server')
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
servers: state.servers.filter((server) => server.id !== id),
|
||||
isLoading: false,
|
||||
}))
|
||||
|
||||
logger.info(`Deleted MCP server: ${id} from workspace: ${workspaceId}`)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete server'
|
||||
logger.error('Failed to delete MCP server:', error)
|
||||
set({ error: errorMessage, isLoading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
refreshServer: async (workspaceId: string, id: string) => {
|
||||
const server = get().servers.find((s) => s.id === id && s.workspaceId === workspaceId)
|
||||
if (!server) return
|
||||
|
||||
try {
|
||||
// For now, just update the last refresh time - actual refresh would require an endpoint
|
||||
set((state) => ({
|
||||
servers: state.servers.map((s) =>
|
||||
s.id === id && s.workspaceId === workspaceId
|
||||
? {
|
||||
...s,
|
||||
lastToolsRefresh: new Date().toISOString(),
|
||||
}
|
||||
: s
|
||||
),
|
||||
}))
|
||||
|
||||
logger.info(`Refreshed MCP server: ${id} in workspace: ${workspaceId}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to refresh MCP server ${id}:`, error)
|
||||
|
||||
set((state) => ({
|
||||
servers: state.servers.map((s) =>
|
||||
s.id === id && s.workspaceId === workspaceId
|
||||
? {
|
||||
...s,
|
||||
connectionStatus: 'error',
|
||||
lastError: error instanceof Error ? error.message : 'Refresh failed',
|
||||
}
|
||||
: s
|
||||
),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'mcp-servers-store',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
export const useIsConnectedServer = (serverId: string) => {
|
||||
return useMcpServersStore(
|
||||
(state) => state.servers.find((s) => s.id === serverId)?.connectionStatus === 'connected'
|
||||
)
|
||||
}
|
||||
|
||||
export const useServerToolCount = (serverId: string) => {
|
||||
return useMcpServersStore((state) => state.servers.find((s) => s.id === serverId)?.toolCount || 0)
|
||||
}
|
||||
|
||||
export const useEnabledServers = () => {
|
||||
return useMcpServersStore((state) => state.servers.filter((s) => s.enabled && !s.deletedAt))
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { McpTransport } from '@/lib/mcp/types'
|
||||
|
||||
export interface McpServerWithStatus {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
transport: McpTransport
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string
|
||||
toolCount?: number
|
||||
lastConnected?: string
|
||||
totalRequests?: number
|
||||
lastUsed?: string
|
||||
lastToolsRefresh?: string
|
||||
deletedAt?: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export interface McpServersState {
|
||||
servers: McpServerWithStatus[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface McpServersActions {
|
||||
fetchServers: (workspaceId: string) => Promise<void>
|
||||
createServer: (
|
||||
workspaceId: string,
|
||||
config: Omit<
|
||||
McpServerWithStatus,
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'connectionStatus'
|
||||
| 'lastError'
|
||||
| 'toolCount'
|
||||
| 'lastConnected'
|
||||
| 'totalRequests'
|
||||
| 'lastUsed'
|
||||
| 'lastToolsRefresh'
|
||||
| 'deletedAt'
|
||||
| 'workspaceId'
|
||||
>
|
||||
) => Promise<McpServerWithStatus>
|
||||
updateServer: (
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
updates: Partial<McpServerWithStatus>
|
||||
) => Promise<void>
|
||||
deleteServer: (workspaceId: string, id: string) => Promise<void>
|
||||
refreshServer: (workspaceId: string, id: string) => Promise<void>
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const initialState: McpServersState = {
|
||||
servers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export { useOrganizationStore } from '@/stores/organization/store'
|
||||
export type {
|
||||
Invitation,
|
||||
Member,
|
||||
MemberUsageData,
|
||||
Organization,
|
||||
OrganizationBillingData,
|
||||
OrganizationFormData,
|
||||
OrganizationState,
|
||||
OrganizationStore,
|
||||
Subscription,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@/stores/organization/types'
|
||||
export {
|
||||
calculateSeatUsage,
|
||||
generateSlug,
|
||||
validateEmail,
|
||||
validateSlug,
|
||||
} from '@/stores/organization/utils'
|
||||
@@ -1,849 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type {
|
||||
OrganizationStore,
|
||||
Subscription,
|
||||
WorkspaceInvitation,
|
||||
} from '@/stores/organization/types'
|
||||
import {
|
||||
calculateSeatUsage,
|
||||
generateSlug,
|
||||
validateEmail,
|
||||
validateSlug,
|
||||
} from '@/stores/organization/utils'
|
||||
|
||||
const logger = createLogger('OrganizationStore')
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
const CACHE_DURATION = 30 * 1000
|
||||
|
||||
export const useOrganizationStore = create<OrganizationStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
subscriptionData: null,
|
||||
userWorkspaces: [],
|
||||
organizationBillingData: null,
|
||||
orgFormData: {
|
||||
name: '',
|
||||
slug: '',
|
||||
logo: '',
|
||||
},
|
||||
isLoading: false,
|
||||
isLoadingSubscription: false,
|
||||
isLoadingOrgBilling: false,
|
||||
isCreatingOrg: false,
|
||||
isInviting: false,
|
||||
isSavingOrgSettings: false,
|
||||
error: null,
|
||||
orgSettingsError: null,
|
||||
inviteSuccess: false,
|
||||
orgSettingsSuccess: null,
|
||||
lastFetched: null,
|
||||
lastSubscriptionFetched: null,
|
||||
lastOrgBillingFetched: null,
|
||||
hasTeamPlan: false,
|
||||
hasEnterprisePlan: false,
|
||||
|
||||
loadData: async () => {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing disabled, skipping organization data loading')
|
||||
set({
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
hasTeamPlan: false,
|
||||
hasEnterprisePlan: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const state = get()
|
||||
|
||||
if (state.lastFetched && Date.now() - state.lastFetched < CACHE_DURATION) {
|
||||
logger.debug('Using cached data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
logger.debug('Data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
// Load organizations, active organization, and user subscription info in parallel
|
||||
const [orgsResponse, activeOrgResponse, billingResponse] = await Promise.all([
|
||||
client.organization.list(),
|
||||
client.organization.getFullOrganization().catch(() => ({ data: null })),
|
||||
fetch('/api/billing?context=user'),
|
||||
])
|
||||
|
||||
const organizations = orgsResponse.data || []
|
||||
const activeOrganization = activeOrgResponse.data || null
|
||||
|
||||
let hasTeamPlan = false
|
||||
let hasEnterprisePlan = false
|
||||
|
||||
if (billingResponse.ok) {
|
||||
const billingResult = await billingResponse.json()
|
||||
const billingData = billingResult.data
|
||||
hasTeamPlan = billingData.isTeam
|
||||
hasEnterprisePlan = billingData.isEnterprise
|
||||
}
|
||||
|
||||
set({
|
||||
organizations,
|
||||
activeOrganization,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Organization data loaded successfully', {
|
||||
organizationCount: organizations.length,
|
||||
activeOrganizationId: activeOrganization?.id,
|
||||
hasTeamPlan,
|
||||
hasEnterprisePlan,
|
||||
})
|
||||
|
||||
// Load subscription data for the active organization
|
||||
if (activeOrganization?.id) {
|
||||
await get().loadOrganizationSubscription(activeOrganization.id)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to load organization data'
|
||||
logger.error('Failed to load organization data', { error })
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadOrganizationSubscription: async (orgId: string) => {
|
||||
const state = get()
|
||||
|
||||
if (
|
||||
state.subscriptionData &&
|
||||
state.lastSubscriptionFetched &&
|
||||
Date.now() - state.lastSubscriptionFetched < CACHE_DURATION
|
||||
) {
|
||||
logger.debug('Using cached subscription data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoadingSubscription) {
|
||||
logger.debug('Subscription data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoadingSubscription: true })
|
||||
|
||||
try {
|
||||
logger.info('Loading subscription for organization', { orgId })
|
||||
|
||||
const { data, error } = await client.subscription.list({
|
||||
query: { referenceId: orgId },
|
||||
})
|
||||
|
||||
if (error) {
|
||||
logger.error('Error fetching organization subscription', { error })
|
||||
set({ error: 'Failed to load subscription data' })
|
||||
return
|
||||
}
|
||||
|
||||
// Find active team or enterprise subscription
|
||||
const teamSubscription = data?.find(
|
||||
(sub) => sub.status === 'active' && sub.plan === 'team'
|
||||
)
|
||||
const enterpriseSubscription = data?.find((sub) => checkEnterprisePlan(sub))
|
||||
const activeSubscription = enterpriseSubscription || teamSubscription
|
||||
|
||||
if (activeSubscription) {
|
||||
logger.info('Found active subscription', {
|
||||
id: activeSubscription.id,
|
||||
plan: activeSubscription.plan,
|
||||
seats: activeSubscription.seats,
|
||||
})
|
||||
set({
|
||||
subscriptionData: activeSubscription,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
} else {
|
||||
logger.warn('No active subscription found for organization', { orgId })
|
||||
set({
|
||||
subscriptionData: null,
|
||||
isLoadingSubscription: false,
|
||||
lastSubscriptionFetched: Date.now(),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading subscription data', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load subscription data',
|
||||
isLoadingSubscription: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
loadOrganizationBillingData: async (organizationId: string, force?: boolean) => {
|
||||
const state = get()
|
||||
|
||||
if (
|
||||
state.organizationBillingData &&
|
||||
state.lastOrgBillingFetched &&
|
||||
Date.now() - state.lastOrgBillingFetched < CACHE_DURATION &&
|
||||
!force
|
||||
) {
|
||||
logger.debug('Using cached organization billing data')
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isLoadingOrgBilling) {
|
||||
logger.debug('Organization billing data already loading, skipping duplicate request')
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoadingOrgBilling: true })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/billing?context=organization&id=${organizationId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const data = result.data
|
||||
|
||||
set({
|
||||
organizationBillingData: { ...data, userRole: result.userRole },
|
||||
isLoadingOrgBilling: false,
|
||||
lastOrgBillingFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Organization billing data loaded successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to load organization billing data', { error })
|
||||
set({ isLoadingOrgBilling: false })
|
||||
}
|
||||
},
|
||||
|
||||
loadUserWorkspaces: async (userId?: string) => {
|
||||
try {
|
||||
// Get all workspaces the user is a member of
|
||||
const workspacesResponse = await fetch('/api/workspaces')
|
||||
if (!workspacesResponse.ok) {
|
||||
logger.error('Failed to fetch workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
const workspacesData = await workspacesResponse.json()
|
||||
const allUserWorkspaces = workspacesData.workspaces || []
|
||||
|
||||
// Filter to only show workspaces where user has admin permissions
|
||||
const adminWorkspaces = []
|
||||
|
||||
for (const workspace of allUserWorkspaces) {
|
||||
try {
|
||||
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
|
||||
if (permissionResponse.ok) {
|
||||
const permissionData = await permissionResponse.json()
|
||||
|
||||
// Check if current user has admin permission
|
||||
// Use userId if provided, otherwise fall back to checking isOwner from workspace data
|
||||
let hasAdminAccess = false
|
||||
|
||||
if (userId && permissionData.users) {
|
||||
const currentUserPermission = permissionData.users.find(
|
||||
(user: any) => user.id === userId || user.userId === userId
|
||||
)
|
||||
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
||||
}
|
||||
|
||||
// Also check if user is the workspace owner
|
||||
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
||||
|
||||
if (hasAdminAccess || isOwner) {
|
||||
adminWorkspaces.push({
|
||||
...workspace,
|
||||
isOwner: isOwner,
|
||||
canInvite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
set({ userWorkspaces: adminWorkspaces })
|
||||
|
||||
logger.info('Loaded admin workspaces for invitation', {
|
||||
total: allUserWorkspaces.length,
|
||||
adminWorkspaces: adminWorkspaces.length,
|
||||
userId: userId || 'not provided',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to load workspaces:', error)
|
||||
}
|
||||
},
|
||||
|
||||
refreshOrganization: async () => {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing disabled, skipping organization refresh')
|
||||
return
|
||||
}
|
||||
|
||||
const { activeOrganization } = get()
|
||||
if (!activeOrganization?.id) return
|
||||
|
||||
try {
|
||||
const fullOrgResponse = await client.organization.getFullOrganization()
|
||||
const updatedOrg = fullOrgResponse.data
|
||||
|
||||
logger.info('Refreshed organization data', {
|
||||
orgId: updatedOrg?.id,
|
||||
members: updatedOrg?.members?.length ?? 0,
|
||||
invitations: updatedOrg?.invitations?.length ?? 0,
|
||||
pendingInvitations:
|
||||
updatedOrg?.invitations?.filter((inv: any) => inv.status === 'pending').length ?? 0,
|
||||
})
|
||||
|
||||
set({ activeOrganization: updatedOrg })
|
||||
|
||||
// Also refresh subscription data
|
||||
if (updatedOrg?.id) {
|
||||
await get().loadOrganizationSubscription(updatedOrg.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh organization data', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to refresh organization data',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Organization management
|
||||
createOrganization: async (name: string, slug: string) => {
|
||||
if (!isBillingEnabled) {
|
||||
logger.debug('Billing disabled, skipping organization creation')
|
||||
set({
|
||||
error: 'Organizations are only available when billing is enabled',
|
||||
isCreatingOrg: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
set({ isCreatingOrg: true, error: null })
|
||||
|
||||
try {
|
||||
logger.info('Creating team organization', { name, slug })
|
||||
|
||||
const result = await client.organization.create({ name, slug })
|
||||
if (!result.data?.id) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
const orgId = result.data.id
|
||||
logger.info('Organization created', { orgId })
|
||||
|
||||
// Set as active organization
|
||||
await client.organization.setActive({ organizationId: orgId })
|
||||
|
||||
// Handle subscription transfer if needed
|
||||
const { hasTeamPlan, hasEnterprisePlan } = get()
|
||||
if (hasTeamPlan || hasEnterprisePlan) {
|
||||
await get().transferSubscriptionToOrganization(orgId)
|
||||
}
|
||||
|
||||
// Refresh data
|
||||
await get().loadData()
|
||||
|
||||
set({ isCreatingOrg: false })
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create organization',
|
||||
isCreatingOrg: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setActiveOrganization: async (orgId: string) => {
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.setActive({ organizationId: orgId })
|
||||
|
||||
const activeOrgResponse = await client.organization.getFullOrganization()
|
||||
const activeOrganization = activeOrgResponse.data
|
||||
|
||||
set({ activeOrganization })
|
||||
|
||||
if (activeOrganization?.id) {
|
||||
await get().loadOrganizationSubscription(activeOrganization.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set active organization', { error })
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to set active organization',
|
||||
})
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
updateOrganizationSettings: async () => {
|
||||
const { activeOrganization, orgFormData } = get()
|
||||
if (!activeOrganization?.id) return
|
||||
|
||||
// Validate form
|
||||
if (!orgFormData.name.trim()) {
|
||||
set({ orgSettingsError: 'Organization name is required' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!orgFormData.slug.trim()) {
|
||||
set({ orgSettingsError: 'Organization slug is required' })
|
||||
return
|
||||
}
|
||||
|
||||
// Validate slug format
|
||||
if (!validateSlug(orgFormData.slug)) {
|
||||
set({
|
||||
orgSettingsError:
|
||||
'Slug can only contain lowercase letters, numbers, hyphens, and underscores',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
set({ isSavingOrgSettings: true, orgSettingsError: null, orgSettingsSuccess: null })
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/organizations/${activeOrganization.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: orgFormData.name.trim(),
|
||||
slug: orgFormData.slug.trim(),
|
||||
logo: orgFormData.logo.trim() || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update organization settings')
|
||||
}
|
||||
|
||||
set({ orgSettingsSuccess: 'Organization settings updated successfully' })
|
||||
|
||||
// Refresh organization data
|
||||
await get().refreshOrganization()
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
set({ orgSettingsSuccess: null })
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization settings', { error })
|
||||
set({
|
||||
orgSettingsError: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
})
|
||||
} finally {
|
||||
set({ isSavingOrgSettings: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Team management
|
||||
inviteMember: async (email: string, workspaceInvitations?: WorkspaceInvitation[]) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
set({ isInviting: true, error: null, inviteSuccess: false })
|
||||
|
||||
try {
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
const seatLimit = subscriptionData?.seats || 0
|
||||
|
||||
if (totalCount >= seatLimit) {
|
||||
throw new Error(
|
||||
`You've reached your team seat limit of ${seatLimit}. Please upgrade your plan for more seats.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
throw new Error('Please enter a valid email address')
|
||||
}
|
||||
|
||||
logger.info('Sending invitation to member', {
|
||||
email,
|
||||
organizationId: activeOrganization.id,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
// Use direct API call with workspace invitations if selected
|
||||
if (workspaceInvitations && workspaceInvitations.length > 0) {
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/invitations?batch=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
role: 'member',
|
||||
workspaceInvitations,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send invitation')
|
||||
}
|
||||
} else {
|
||||
// Use existing client method for organization-only invitations
|
||||
const inviteResult = await client.organization.inviteMember({
|
||||
email,
|
||||
role: 'member',
|
||||
organizationId: activeOrganization.id,
|
||||
})
|
||||
|
||||
if (inviteResult.error) {
|
||||
throw new Error(inviteResult.error.message || 'Failed to send invitation')
|
||||
}
|
||||
}
|
||||
|
||||
set({ inviteSuccess: true })
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Error inviting member', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to invite member' })
|
||||
} finally {
|
||||
set({ isInviting: false })
|
||||
}
|
||||
},
|
||||
|
||||
removeMember: async (memberId: string, shouldReduceSeats = false) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
logger.info('Removing member', {
|
||||
memberId,
|
||||
organizationId: activeOrganization.id,
|
||||
shouldReduceSeats,
|
||||
})
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
// Use our custom API endpoint for member removal instead of better-auth client
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/members/${memberId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // Ensure cookies are sent
|
||||
body: JSON.stringify({ shouldReduceSeats }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}) as any)
|
||||
throw new Error((data as any).error || 'Failed to remove member')
|
||||
}
|
||||
|
||||
// If the user opted to reduce seats as well (handled by the API endpoint)
|
||||
// No need to call reduceSeats separately as it's handled in the endpoint
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove member', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to remove member' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
cancelInvitation: async (invitationId: string) => {
|
||||
const { activeOrganization } = get()
|
||||
if (!activeOrganization) return
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/invitations?invitationId=${encodeURIComponent(
|
||||
invitationId
|
||||
)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}) as any)
|
||||
|
||||
// If the invitation is not found (404), it might have already been processed
|
||||
// Just refresh the organization data to get the latest state
|
||||
if (response.status === 404) {
|
||||
logger.info(
|
||||
'Invitation not found or already processed, refreshing organization data',
|
||||
{ invitationId }
|
||||
)
|
||||
await get().refreshOrganization()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error((data as any).error || 'Failed to cancel invitation')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel invitation', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to cancel invitation' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Seat management
|
||||
addSeats: async (newSeatCount: number) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization || !subscriptionData) return
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: newSeatCount,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to update seats')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to add seats', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to update seats' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
reduceSeats: async (newSeatCount: number) => {
|
||||
const { activeOrganization, subscriptionData } = get()
|
||||
if (!activeOrganization || !subscriptionData) return
|
||||
|
||||
// Don't allow enterprise users to modify seats
|
||||
if (checkEnterprisePlan(subscriptionData)) {
|
||||
set({ error: 'Enterprise plan seats can only be modified by contacting support' })
|
||||
return
|
||||
}
|
||||
|
||||
if (newSeatCount <= 0) {
|
||||
set({ error: 'Cannot reduce seats below 1' })
|
||||
return
|
||||
}
|
||||
|
||||
const { used: totalCount } = calculateSeatUsage(activeOrganization)
|
||||
if (totalCount > newSeatCount) {
|
||||
set({
|
||||
error: `You have ${totalCount} active members/invitations. Please remove members or cancel invitations before reducing seats.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
plan: 'team',
|
||||
referenceId: activeOrganization.id,
|
||||
subscriptionId: subscriptionData.id,
|
||||
seats: newSeatCount,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to reduce seats')
|
||||
}
|
||||
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to reduce seats', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Failed to reduce seats' })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Private helper method for subscription transfer
|
||||
transferSubscriptionToOrganization: async (orgId: string) => {
|
||||
const { hasTeamPlan, hasEnterprisePlan } = get()
|
||||
|
||||
try {
|
||||
const userSubResponse = await client.subscription.list()
|
||||
let teamSubscription: Subscription | null =
|
||||
(userSubResponse.data?.find(
|
||||
(sub) => (sub.plan === 'team' || sub.plan === 'enterprise') && sub.status === 'active'
|
||||
) as Subscription | undefined) || null
|
||||
|
||||
// If no subscription found through client API but user has enterprise plan
|
||||
if (!teamSubscription && hasEnterprisePlan) {
|
||||
const billingResponse = await fetch('/api/billing?context=user')
|
||||
if (billingResponse.ok) {
|
||||
const billingData = await billingResponse.json()
|
||||
if (billingData.success && billingData.data.isEnterprise && billingData.data.status) {
|
||||
teamSubscription = {
|
||||
id: `subscription_${Date.now()}`,
|
||||
plan: billingData.data.plan,
|
||||
status: billingData.data.status,
|
||||
seats: billingData.data.seats,
|
||||
referenceId: billingData.data.organizationId || 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
const transferResponse = await fetch(
|
||||
`/api/users/me/subscription/${teamSubscription.id}/transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
organizationId: orgId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!transferResponse.ok) {
|
||||
const errorText = await transferResponse.text()
|
||||
let errorMessage = 'Failed to transfer subscription'
|
||||
|
||||
try {
|
||||
if (errorText?.trim().startsWith('{')) {
|
||||
const errorData = JSON.parse(errorText)
|
||||
errorMessage = errorData.error || errorMessage
|
||||
}
|
||||
} catch (_e) {
|
||||
errorMessage = errorText || errorMessage
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Subscription transfer failed', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Computed getters (keep only those that are used)
|
||||
getUserRole: (userEmail?: string) => {
|
||||
const { activeOrganization } = get()
|
||||
if (!userEmail || !activeOrganization?.members) {
|
||||
return 'member'
|
||||
}
|
||||
const currentMember = activeOrganization.members.find((m) => m.user?.email === userEmail)
|
||||
return currentMember?.role ?? 'member'
|
||||
},
|
||||
|
||||
isAdminOrOwner: (userEmail?: string) => {
|
||||
const role = get().getUserRole(userEmail)
|
||||
return role === 'owner' || role === 'admin'
|
||||
},
|
||||
|
||||
getUsedSeats: () => {
|
||||
const { activeOrganization } = get()
|
||||
return calculateSeatUsage(activeOrganization)
|
||||
},
|
||||
|
||||
// Form handlers
|
||||
setOrgFormData: (data) => {
|
||||
set((state) => ({
|
||||
orgFormData: { ...state.orgFormData, ...data },
|
||||
}))
|
||||
|
||||
// Auto-generate slug from name if name is being set
|
||||
if (data.name) {
|
||||
const autoSlug = generateSlug(data.name)
|
||||
set((state) => ({
|
||||
orgFormData: { ...state.orgFormData, slug: autoSlug },
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
// Utility methods
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
},
|
||||
|
||||
clearSuccessMessages: () => {
|
||||
set({ inviteSuccess: false, orgSettingsSuccess: null })
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
organizations: [],
|
||||
activeOrganization: null,
|
||||
subscriptionData: null,
|
||||
userWorkspaces: [],
|
||||
organizationBillingData: null,
|
||||
orgFormData: {
|
||||
name: '',
|
||||
slug: '',
|
||||
logo: '',
|
||||
},
|
||||
isLoading: false,
|
||||
isLoadingSubscription: false,
|
||||
isLoadingOrgBilling: false,
|
||||
isCreatingOrg: false,
|
||||
isInviting: false,
|
||||
isSavingOrgSettings: false,
|
||||
error: null,
|
||||
orgSettingsError: null,
|
||||
inviteSuccess: false,
|
||||
orgSettingsSuccess: null,
|
||||
lastFetched: null,
|
||||
lastSubscriptionFetched: null,
|
||||
lastOrgBillingFetched: null,
|
||||
hasTeamPlan: false,
|
||||
hasEnterprisePlan: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{ name: 'organization-store' }
|
||||
)
|
||||
)
|
||||
|
||||
// Auto-load organization data when store is first accessed
|
||||
if (typeof window !== 'undefined') {
|
||||
useOrganizationStore.getState().loadData()
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type {
|
||||
BillingStatus,
|
||||
SubscriptionData,
|
||||
SubscriptionStore,
|
||||
UsageData,
|
||||
UsageLimitData,
|
||||
} from '@/stores/subscription/types'
|
||||
|
||||
const logger = createLogger('SubscriptionStore')
|
||||
|
||||
const CACHE_DURATION = 30 * 1000
|
||||
|
||||
const defaultUsage: UsageData = {
|
||||
current: 0,
|
||||
limit: DEFAULT_FREE_CREDITS,
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
}
|
||||
|
||||
export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// State
|
||||
subscriptionData: null,
|
||||
usageLimitData: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
|
||||
// Core actions
|
||||
loadSubscriptionData: async () => {
|
||||
const state = get()
|
||||
|
||||
// Check cache validity
|
||||
if (
|
||||
state.subscriptionData &&
|
||||
state.lastFetched &&
|
||||
Date.now() - state.lastFetched < CACHE_DURATION
|
||||
) {
|
||||
logger.debug('Using cached subscription data')
|
||||
return state.subscriptionData
|
||||
}
|
||||
|
||||
// Don't start multiple concurrent requests
|
||||
if (state.isLoading) {
|
||||
logger.debug('Subscription data already loading, skipping duplicate request')
|
||||
return get().subscriptionData
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/billing?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const data = { ...result.data, billingBlocked: result.data?.billingBlocked ?? false }
|
||||
|
||||
// Transform dates with error handling
|
||||
const transformedData: SubscriptionData = {
|
||||
...data,
|
||||
periodEnd: data.periodEnd
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(data.periodEnd)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
usage: {
|
||||
...data.usage,
|
||||
billingPeriodStart: data.usage?.billingPeriodStart
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(data.usage.billingPeriodStart)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
billingPeriodEnd: data.usage?.billingPeriodEnd
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(data.usage.billingPeriodEnd)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
},
|
||||
billingBlocked: !!data.billingBlocked,
|
||||
}
|
||||
|
||||
// Debug logging for billing periods
|
||||
logger.debug('Billing period data', {
|
||||
raw: {
|
||||
billingPeriodStart: data.usage?.billingPeriodStart,
|
||||
billingPeriodEnd: data.usage?.billingPeriodEnd,
|
||||
},
|
||||
transformed: {
|
||||
billingPeriodStart: transformedData.usage.billingPeriodStart,
|
||||
billingPeriodEnd: transformedData.usage.billingPeriodEnd,
|
||||
},
|
||||
})
|
||||
|
||||
set({
|
||||
subscriptionData: transformedData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Subscription data loaded successfully')
|
||||
return transformedData
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to load subscription data'
|
||||
logger.error('Failed to load subscription data', { error })
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
loadUsageLimitData: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/usage?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Transform dates
|
||||
const transformedData: UsageLimitData = {
|
||||
...data,
|
||||
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
||||
}
|
||||
|
||||
set({ usageLimitData: transformedData })
|
||||
logger.debug('Usage limit data loaded successfully')
|
||||
return transformedData
|
||||
} catch (error) {
|
||||
logger.error('Failed to load usage limit data', { error })
|
||||
// Don't set error state for usage limit failures - subscription data is more critical
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateUsageLimit: async (newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/usage?context=user', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ limit: newLimit }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update usage limit')
|
||||
}
|
||||
|
||||
// Refresh the store state to ensure consistency
|
||||
await get().refresh()
|
||||
|
||||
logger.debug('Usage limit updated successfully', { newLimit })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update usage limit'
|
||||
logger.error('Failed to update usage limit', { error, newLimit })
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
// Force refresh by clearing cache
|
||||
set({ lastFetched: null })
|
||||
await get().loadData()
|
||||
},
|
||||
|
||||
// Load both subscription and usage limit data in parallel
|
||||
loadData: async () => {
|
||||
const state = get()
|
||||
|
||||
// Check cache validity for subscription data
|
||||
if (
|
||||
state.subscriptionData &&
|
||||
state.lastFetched &&
|
||||
Date.now() - state.lastFetched < CACHE_DURATION
|
||||
) {
|
||||
logger.debug('Using cached data')
|
||||
// Still load usage limit if not present
|
||||
if (!state.usageLimitData) {
|
||||
const usageLimitData = await get().loadUsageLimitData()
|
||||
return {
|
||||
subscriptionData: state.subscriptionData,
|
||||
usageLimitData: usageLimitData,
|
||||
}
|
||||
}
|
||||
return {
|
||||
subscriptionData: state.subscriptionData,
|
||||
usageLimitData: state.usageLimitData,
|
||||
}
|
||||
}
|
||||
|
||||
// Don't start multiple concurrent requests
|
||||
if (state.isLoading) {
|
||||
logger.debug('Data already loading, skipping duplicate request')
|
||||
return {
|
||||
subscriptionData: get().subscriptionData,
|
||||
usageLimitData: get().usageLimitData,
|
||||
}
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
try {
|
||||
// Load both subscription and usage limit data in parallel
|
||||
const [subscriptionResponse, usageLimitResponse] = await Promise.all([
|
||||
fetch('/api/billing?context=user'),
|
||||
fetch('/api/usage?context=user'),
|
||||
])
|
||||
|
||||
if (!subscriptionResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${subscriptionResponse.status}`)
|
||||
}
|
||||
|
||||
const subscriptionResult = await subscriptionResponse.json()
|
||||
const subscriptionData = subscriptionResult.data
|
||||
let usageLimitData = null
|
||||
|
||||
if (usageLimitResponse.ok) {
|
||||
usageLimitData = await usageLimitResponse.json()
|
||||
} else {
|
||||
logger.warn('Failed to load usage limit data, using defaults')
|
||||
}
|
||||
|
||||
// Transform subscription data dates with error handling
|
||||
const transformedSubscriptionData: SubscriptionData = {
|
||||
...subscriptionData,
|
||||
periodEnd: subscriptionData.periodEnd
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(subscriptionData.periodEnd)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
usage: {
|
||||
...subscriptionData.usage,
|
||||
billingPeriodStart: subscriptionData.usage?.billingPeriodStart
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(subscriptionData.usage.billingPeriodStart)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
billingPeriodEnd: subscriptionData.usage?.billingPeriodEnd
|
||||
? (() => {
|
||||
try {
|
||||
const date = new Date(subscriptionData.usage.billingPeriodEnd)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
: null,
|
||||
},
|
||||
}
|
||||
|
||||
// Debug logging for parallel billing periods
|
||||
logger.debug('Parallel billing period data', {
|
||||
raw: {
|
||||
billingPeriodStart: subscriptionData.usage?.billingPeriodStart,
|
||||
billingPeriodEnd: subscriptionData.usage?.billingPeriodEnd,
|
||||
},
|
||||
transformed: {
|
||||
billingPeriodStart: transformedSubscriptionData.usage.billingPeriodStart,
|
||||
billingPeriodEnd: transformedSubscriptionData.usage.billingPeriodEnd,
|
||||
},
|
||||
})
|
||||
|
||||
// Transform usage limit data dates if present
|
||||
const transformedUsageLimitData: UsageLimitData | null = usageLimitData
|
||||
? {
|
||||
...usageLimitData,
|
||||
updatedAt: usageLimitData.updatedAt
|
||||
? new Date(usageLimitData.updatedAt)
|
||||
: undefined,
|
||||
}
|
||||
: null
|
||||
|
||||
set({
|
||||
subscriptionData: transformedSubscriptionData,
|
||||
usageLimitData: transformedUsageLimitData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: Date.now(),
|
||||
})
|
||||
|
||||
logger.debug('Data loaded successfully in parallel')
|
||||
return {
|
||||
subscriptionData: transformedSubscriptionData,
|
||||
usageLimitData: transformedUsageLimitData,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load data'
|
||||
logger.error('Failed to load data', { error })
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
return {
|
||||
subscriptionData: null,
|
||||
usageLimitData: null,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
subscriptionData: null,
|
||||
usageLimitData: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
})
|
||||
},
|
||||
|
||||
// Computed getters
|
||||
getSubscriptionStatus: () => {
|
||||
const data = get().subscriptionData
|
||||
return {
|
||||
isPaid: data?.isPaid ?? false,
|
||||
isPro: data?.isPro ?? false,
|
||||
isTeam: data?.isTeam ?? false,
|
||||
isEnterprise: data?.isEnterprise ?? false,
|
||||
isFree: !(data?.isPaid ?? false),
|
||||
plan: data?.plan ?? 'free',
|
||||
status: data?.status ?? null,
|
||||
seats: data?.seats ?? null,
|
||||
metadata: data?.metadata ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
getUsage: () => {
|
||||
return get().subscriptionData?.usage ?? defaultUsage
|
||||
},
|
||||
|
||||
getBillingStatus: (): BillingStatus => {
|
||||
const usage = get().getUsage()
|
||||
const blocked = get().subscriptionData?.billingBlocked
|
||||
if (blocked) return 'blocked'
|
||||
if (usage.isExceeded) return 'exceeded'
|
||||
if (usage.isWarning) return 'warning'
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
getRemainingBudget: () => {
|
||||
const usage = get().getUsage()
|
||||
return Math.max(0, usage.limit - usage.current)
|
||||
},
|
||||
|
||||
getDaysRemainingInPeriod: () => {
|
||||
const usage = get().getUsage()
|
||||
if (!usage.billingPeriodEnd) return null
|
||||
|
||||
const now = new Date()
|
||||
const endDate = usage.billingPeriodEnd
|
||||
const diffTime = endDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
},
|
||||
|
||||
isAtLeastPro: () => {
|
||||
const status = get().getSubscriptionStatus()
|
||||
return status.isPro || status.isTeam || status.isEnterprise
|
||||
},
|
||||
|
||||
isAtLeastTeam: () => {
|
||||
const status = get().getSubscriptionStatus()
|
||||
return status.isTeam || status.isEnterprise
|
||||
},
|
||||
|
||||
canUpgrade: () => {
|
||||
const status = get().getSubscriptionStatus()
|
||||
return status.plan === 'free' || status.plan === 'pro'
|
||||
},
|
||||
}),
|
||||
{ name: 'subscription-store' }
|
||||
)
|
||||
)
|
||||
79
apps/sim/tools/apollo/account_bulk_create.ts
Normal file
79
apps/sim/tools/apollo/account_bulk_create.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloAccountBulkCreateParams, ApolloAccountBulkCreateResponse } from './types'
|
||||
|
||||
export const apolloAccountBulkCreateTool: ToolConfig<
|
||||
ApolloAccountBulkCreateParams,
|
||||
ApolloAccountBulkCreateResponse
|
||||
> = {
|
||||
id: 'apollo_account_bulk_create',
|
||||
name: 'Apollo Bulk Create Accounts',
|
||||
description:
|
||||
'Create up to 100 accounts at once in your Apollo database. Note: Apollo does not apply deduplication - duplicate accounts may be created if entries share similar names or domains. Master key required.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
accounts: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of accounts to create (max 100). Each account should include name (required), and optionally website_url, phone, owner_id',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/accounts/bulk_create',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloAccountBulkCreateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloAccountBulkCreateParams) => ({
|
||||
accounts: params.accounts.slice(0, 100),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
created_accounts: data.accounts || data.created_accounts || [],
|
||||
failed_accounts: data.failed_accounts || [],
|
||||
metadata: {
|
||||
total_submitted: data.accounts?.length || 0,
|
||||
created: data.created_accounts?.length || data.accounts?.length || 0,
|
||||
failed: data.failed_accounts?.length || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
created_accounts: {
|
||||
type: 'json',
|
||||
description: 'Array of newly created accounts',
|
||||
},
|
||||
failed_accounts: {
|
||||
type: 'json',
|
||||
description: 'Array of accounts that failed to create',
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Bulk creation metadata including counts of created and failed accounts',
|
||||
},
|
||||
},
|
||||
}
|
||||
79
apps/sim/tools/apollo/account_bulk_update.ts
Normal file
79
apps/sim/tools/apollo/account_bulk_update.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloAccountBulkUpdateParams, ApolloAccountBulkUpdateResponse } from './types'
|
||||
|
||||
export const apolloAccountBulkUpdateTool: ToolConfig<
|
||||
ApolloAccountBulkUpdateParams,
|
||||
ApolloAccountBulkUpdateResponse
|
||||
> = {
|
||||
id: 'apollo_account_bulk_update',
|
||||
name: 'Apollo Bulk Update Accounts',
|
||||
description:
|
||||
'Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
accounts: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of accounts to update (max 1000). Each account must include id field, and optionally name, website_url, phone, owner_id',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/accounts/bulk_update',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloAccountBulkUpdateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloAccountBulkUpdateParams) => ({
|
||||
accounts: params.accounts.slice(0, 1000),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updated_accounts: data.accounts || data.updated_accounts || [],
|
||||
failed_accounts: data.failed_accounts || [],
|
||||
metadata: {
|
||||
total_submitted: data.accounts?.length || 0,
|
||||
updated: data.updated_accounts?.length || data.accounts?.length || 0,
|
||||
failed: data.failed_accounts?.length || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
updated_accounts: {
|
||||
type: 'json',
|
||||
description: 'Array of successfully updated accounts',
|
||||
},
|
||||
failed_accounts: {
|
||||
type: 'json',
|
||||
description: 'Array of accounts that failed to update',
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Bulk update metadata including counts of updated and failed accounts',
|
||||
},
|
||||
},
|
||||
}
|
||||
86
apps/sim/tools/apollo/account_create.ts
Normal file
86
apps/sim/tools/apollo/account_create.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloAccountCreateParams, ApolloAccountCreateResponse } from './types'
|
||||
|
||||
export const apolloAccountCreateTool: ToolConfig<
|
||||
ApolloAccountCreateParams,
|
||||
ApolloAccountCreateResponse
|
||||
> = {
|
||||
id: 'apollo_account_create',
|
||||
name: 'Apollo Create Account',
|
||||
description: 'Create a new account (company) in your Apollo database',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company name',
|
||||
},
|
||||
website_url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company website URL',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company phone number',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'User ID of the account owner',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/accounts',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloAccountCreateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloAccountCreateParams) => {
|
||||
const body: any = { name: params.name }
|
||||
if (params.website_url) body.website_url = params.website_url
|
||||
if (params.phone) body.phone = params.phone
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
account: data.account || {},
|
||||
metadata: {
|
||||
created: !!data.account,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
account: { type: 'json', description: 'Created account data from Apollo' },
|
||||
metadata: { type: 'json', description: 'Creation metadata including created status' },
|
||||
},
|
||||
}
|
||||
103
apps/sim/tools/apollo/account_search.ts
Normal file
103
apps/sim/tools/apollo/account_search.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloAccountSearchParams, ApolloAccountSearchResponse } from './types'
|
||||
|
||||
export const apolloAccountSearchTool: ToolConfig<
|
||||
ApolloAccountSearchParams,
|
||||
ApolloAccountSearchResponse
|
||||
> = {
|
||||
id: 'apollo_account_search',
|
||||
name: 'Apollo Search Accounts',
|
||||
description:
|
||||
"Search your team's accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.",
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
q_keywords: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Keywords to search for in account data',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Filter by account owner user ID',
|
||||
},
|
||||
account_stage_ids: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Filter by account stage IDs',
|
||||
},
|
||||
page: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page number for pagination',
|
||||
},
|
||||
per_page: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Results per page (max: 100)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/accounts/search',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloAccountSearchParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloAccountSearchParams) => {
|
||||
const body: any = {
|
||||
page: params.page || 1,
|
||||
per_page: Math.min(params.per_page || 25, 100),
|
||||
}
|
||||
if (params.q_keywords) body.q_keywords = params.q_keywords
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
if (params.account_stage_ids?.length) {
|
||||
body.account_stage_ids = params.account_stage_ids
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
accounts: data.accounts || [],
|
||||
metadata: {
|
||||
page: data.pagination?.page || 1,
|
||||
per_page: data.pagination?.per_page || 25,
|
||||
total_entries: data.pagination?.total_entries || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
accounts: { type: 'json', description: 'Array of accounts matching the search criteria' },
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Pagination information including page, per_page, and total_entries',
|
||||
},
|
||||
},
|
||||
}
|
||||
94
apps/sim/tools/apollo/account_update.ts
Normal file
94
apps/sim/tools/apollo/account_update.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloAccountUpdateParams, ApolloAccountUpdateResponse } from './types'
|
||||
|
||||
export const apolloAccountUpdateTool: ToolConfig<
|
||||
ApolloAccountUpdateParams,
|
||||
ApolloAccountUpdateResponse
|
||||
> = {
|
||||
id: 'apollo_account_update',
|
||||
name: 'Apollo Update Account',
|
||||
description: 'Update an existing account in your Apollo database',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key',
|
||||
},
|
||||
account_id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'ID of the account to update',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company name',
|
||||
},
|
||||
website_url: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company website URL',
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Company phone number',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'User ID of the account owner',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: ApolloAccountUpdateParams) =>
|
||||
`https://api.apollo.io/api/v1/accounts/${params.account_id}`,
|
||||
method: 'PATCH',
|
||||
headers: (params: ApolloAccountUpdateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloAccountUpdateParams) => {
|
||||
const body: any = {}
|
||||
if (params.name) body.name = params.name
|
||||
if (params.website_url) body.website_url = params.website_url
|
||||
if (params.phone) body.phone = params.phone
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
account: data.account || {},
|
||||
metadata: {
|
||||
updated: !!data.account,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
account: { type: 'json', description: 'Updated account data from Apollo' },
|
||||
metadata: { type: 'json', description: 'Update metadata including updated status' },
|
||||
},
|
||||
}
|
||||
92
apps/sim/tools/apollo/contact_bulk_create.ts
Normal file
92
apps/sim/tools/apollo/contact_bulk_create.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloContactBulkCreateParams, ApolloContactBulkCreateResponse } from './types'
|
||||
|
||||
export const apolloContactBulkCreateTool: ToolConfig<
|
||||
ApolloContactBulkCreateParams,
|
||||
ApolloContactBulkCreateResponse
|
||||
> = {
|
||||
id: 'apollo_contact_bulk_create',
|
||||
name: 'Apollo Bulk Create Contacts',
|
||||
description:
|
||||
'Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of contacts to create (max 100). Each contact should include first_name, last_name, and optionally email, title, account_id, owner_id',
|
||||
},
|
||||
run_dedupe: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description:
|
||||
'Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/contacts/bulk_create',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloContactBulkCreateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloContactBulkCreateParams) => {
|
||||
const body: any = {
|
||||
contacts: params.contacts.slice(0, 100),
|
||||
}
|
||||
if (params.run_dedupe !== undefined) {
|
||||
body.run_dedupe = params.run_dedupe
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
created_contacts: data.contacts || data.created_contacts || [],
|
||||
existing_contacts: data.existing_contacts || [],
|
||||
metadata: {
|
||||
total_submitted: data.contacts?.length || 0,
|
||||
created: data.created_contacts?.length || data.contacts?.length || 0,
|
||||
existing: data.existing_contacts?.length || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
created_contacts: {
|
||||
type: 'json',
|
||||
description: 'Array of newly created contacts',
|
||||
},
|
||||
existing_contacts: {
|
||||
type: 'json',
|
||||
description: 'Array of existing contacts (when deduplication is enabled)',
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Bulk creation metadata including counts of created and existing contacts',
|
||||
},
|
||||
},
|
||||
}
|
||||
79
apps/sim/tools/apollo/contact_bulk_update.ts
Normal file
79
apps/sim/tools/apollo/contact_bulk_update.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloContactBulkUpdateParams, ApolloContactBulkUpdateResponse } from './types'
|
||||
|
||||
export const apolloContactBulkUpdateTool: ToolConfig<
|
||||
ApolloContactBulkUpdateParams,
|
||||
ApolloContactBulkUpdateResponse
|
||||
> = {
|
||||
id: 'apollo_contact_bulk_update',
|
||||
name: 'Apollo Bulk Update Contacts',
|
||||
description:
|
||||
'Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Array of contacts to update (max 100). Each contact must include id field, and optionally first_name, last_name, email, title, account_id, owner_id',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/contacts/bulk_update',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloContactBulkUpdateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloContactBulkUpdateParams) => ({
|
||||
contacts: params.contacts.slice(0, 100),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
updated_contacts: data.contacts || data.updated_contacts || [],
|
||||
failed_contacts: data.failed_contacts || [],
|
||||
metadata: {
|
||||
total_submitted: data.contacts?.length || 0,
|
||||
updated: data.updated_contacts?.length || data.contacts?.length || 0,
|
||||
failed: data.failed_contacts?.length || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
updated_contacts: {
|
||||
type: 'json',
|
||||
description: 'Array of successfully updated contacts',
|
||||
},
|
||||
failed_contacts: {
|
||||
type: 'json',
|
||||
description: 'Array of contacts that failed to update',
|
||||
},
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Bulk update metadata including counts of updated and failed contacts',
|
||||
},
|
||||
},
|
||||
}
|
||||
102
apps/sim/tools/apollo/contact_create.ts
Normal file
102
apps/sim/tools/apollo/contact_create.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloContactCreateParams, ApolloContactCreateResponse } from './types'
|
||||
|
||||
export const apolloContactCreateTool: ToolConfig<
|
||||
ApolloContactCreateParams,
|
||||
ApolloContactCreateResponse
|
||||
> = {
|
||||
id: 'apollo_contact_create',
|
||||
name: 'Apollo Create Contact',
|
||||
description: 'Create a new contact in your Apollo database',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key',
|
||||
},
|
||||
first_name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'First name of the contact',
|
||||
},
|
||||
last_name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Last name of the contact',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email address of the contact',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Job title',
|
||||
},
|
||||
account_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Apollo account ID to associate with',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'User ID of the contact owner',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/contacts',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloContactCreateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloContactCreateParams) => {
|
||||
const body: any = {
|
||||
first_name: params.first_name,
|
||||
last_name: params.last_name,
|
||||
}
|
||||
if (params.email) body.email = params.email
|
||||
if (params.title) body.title = params.title
|
||||
if (params.account_id) body.account_id = params.account_id
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
contact: data.contact || {},
|
||||
metadata: {
|
||||
created: !!data.contact,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contact: { type: 'json', description: 'Created contact data from Apollo' },
|
||||
metadata: { type: 'json', description: 'Creation metadata including created status' },
|
||||
},
|
||||
}
|
||||
95
apps/sim/tools/apollo/contact_search.ts
Normal file
95
apps/sim/tools/apollo/contact_search.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloContactSearchParams, ApolloContactSearchResponse } from './types'
|
||||
|
||||
export const apolloContactSearchTool: ToolConfig<
|
||||
ApolloContactSearchParams,
|
||||
ApolloContactSearchResponse
|
||||
> = {
|
||||
id: 'apollo_contact_search',
|
||||
name: 'Apollo Search Contacts',
|
||||
description: "Search your team's contacts in Apollo",
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key',
|
||||
},
|
||||
q_keywords: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Keywords to search for',
|
||||
},
|
||||
contact_stage_ids: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Filter by contact stage IDs',
|
||||
},
|
||||
page: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page number for pagination',
|
||||
},
|
||||
per_page: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Results per page (max: 100)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/contacts/search',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloContactSearchParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloContactSearchParams) => {
|
||||
const body: any = {
|
||||
page: params.page || 1,
|
||||
per_page: Math.min(params.per_page || 25, 100),
|
||||
}
|
||||
if (params.q_keywords) body.q_keywords = params.q_keywords
|
||||
if (params.contact_stage_ids?.length) {
|
||||
body.contact_stage_ids = params.contact_stage_ids
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
contacts: data.contacts || [],
|
||||
metadata: {
|
||||
page: data.pagination?.page || 1,
|
||||
per_page: data.pagination?.per_page || 25,
|
||||
total_entries: data.pagination?.total_entries || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contacts: { type: 'json', description: 'Array of contacts matching the search criteria' },
|
||||
metadata: {
|
||||
type: 'json',
|
||||
description: 'Pagination information including page, per_page, and total_entries',
|
||||
},
|
||||
},
|
||||
}
|
||||
108
apps/sim/tools/apollo/contact_update.ts
Normal file
108
apps/sim/tools/apollo/contact_update.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloContactUpdateParams, ApolloContactUpdateResponse } from './types'
|
||||
|
||||
export const apolloContactUpdateTool: ToolConfig<
|
||||
ApolloContactUpdateParams,
|
||||
ApolloContactUpdateResponse
|
||||
> = {
|
||||
id: 'apollo_contact_update',
|
||||
name: 'Apollo Update Contact',
|
||||
description: 'Update an existing contact in your Apollo database',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key',
|
||||
},
|
||||
contact_id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'ID of the contact to update',
|
||||
},
|
||||
first_name: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'First name of the contact',
|
||||
},
|
||||
last_name: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Last name of the contact',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Email address',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Job title',
|
||||
},
|
||||
account_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Apollo account ID',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'User ID of the contact owner',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: ApolloContactUpdateParams) =>
|
||||
`https://api.apollo.io/api/v1/contacts/${params.contact_id}`,
|
||||
method: 'PATCH',
|
||||
headers: (params: ApolloContactUpdateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloContactUpdateParams) => {
|
||||
const body: any = {}
|
||||
if (params.first_name) body.first_name = params.first_name
|
||||
if (params.last_name) body.last_name = params.last_name
|
||||
if (params.email) body.email = params.email
|
||||
if (params.title) body.title = params.title
|
||||
if (params.account_id) body.account_id = params.account_id
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
contact: data.contact || {},
|
||||
metadata: {
|
||||
updated: !!data.contact,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contact: { type: 'json', description: 'Updated contact data from Apollo' },
|
||||
metadata: { type: 'json', description: 'Update metadata including updated status' },
|
||||
},
|
||||
}
|
||||
55
apps/sim/tools/apollo/email_accounts.ts
Normal file
55
apps/sim/tools/apollo/email_accounts.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloEmailAccountsParams, ApolloEmailAccountsResponse } from './types'
|
||||
|
||||
export const apolloEmailAccountsTool: ToolConfig<
|
||||
ApolloEmailAccountsParams,
|
||||
ApolloEmailAccountsResponse
|
||||
> = {
|
||||
id: 'apollo_email_accounts',
|
||||
name: 'Apollo Get Email Accounts',
|
||||
description: "Get list of team's linked email accounts in Apollo",
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/email_accounts',
|
||||
method: 'GET',
|
||||
headers: (params: ApolloEmailAccountsParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
email_accounts: data.email_accounts || [],
|
||||
metadata: {
|
||||
total: data.email_accounts?.length || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
email_accounts: { type: 'json', description: 'Array of team email accounts linked in Apollo' },
|
||||
metadata: { type: 'json', description: 'Metadata including total count of email accounts' },
|
||||
},
|
||||
}
|
||||
26
apps/sim/tools/apollo/index.ts
Normal file
26
apps/sim/tools/apollo/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { apolloAccountBulkCreateTool } from './account_bulk_create'
|
||||
export { apolloAccountBulkUpdateTool } from './account_bulk_update'
|
||||
export { apolloAccountCreateTool } from './account_create'
|
||||
export { apolloAccountSearchTool } from './account_search'
|
||||
export { apolloAccountUpdateTool } from './account_update'
|
||||
export { apolloContactBulkCreateTool } from './contact_bulk_create'
|
||||
export { apolloContactBulkUpdateTool } from './contact_bulk_update'
|
||||
export { apolloContactCreateTool } from './contact_create'
|
||||
export { apolloContactSearchTool } from './contact_search'
|
||||
export { apolloContactUpdateTool } from './contact_update'
|
||||
export { apolloEmailAccountsTool } from './email_accounts'
|
||||
export { apolloOpportunityCreateTool } from './opportunity_create'
|
||||
export { apolloOpportunityGetTool } from './opportunity_get'
|
||||
export { apolloOpportunitySearchTool } from './opportunity_search'
|
||||
export { apolloOpportunityUpdateTool } from './opportunity_update'
|
||||
export { apolloOrganizationBulkEnrichTool } from './organization_bulk_enrich'
|
||||
export { apolloOrganizationEnrichTool } from './organization_enrich'
|
||||
export { apolloOrganizationSearchTool } from './organization_search'
|
||||
export { apolloPeopleBulkEnrichTool } from './people_bulk_enrich'
|
||||
export { apolloPeopleEnrichTool } from './people_enrich'
|
||||
export { apolloPeopleSearchTool } from './people_search'
|
||||
export { apolloSequenceAddContactsTool } from './sequence_add_contacts'
|
||||
export { apolloSequenceSearchTool } from './sequence_search'
|
||||
export { apolloTaskCreateTool } from './task_create'
|
||||
export { apolloTaskSearchTool } from './task_search'
|
||||
export type * from './types'
|
||||
109
apps/sim/tools/apollo/opportunity_create.ts
Normal file
109
apps/sim/tools/apollo/opportunity_create.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ApolloOpportunityCreateParams, ApolloOpportunityCreateResponse } from './types'
|
||||
|
||||
export const apolloOpportunityCreateTool: ToolConfig<
|
||||
ApolloOpportunityCreateParams,
|
||||
ApolloOpportunityCreateResponse
|
||||
> = {
|
||||
id: 'apollo_opportunity_create',
|
||||
name: 'Apollo Create Opportunity',
|
||||
description: 'Create a new deal for an account in your Apollo database (master key required)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Apollo API key (master key required)',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name of the opportunity/deal',
|
||||
},
|
||||
account_id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'ID of the account this opportunity belongs to',
|
||||
},
|
||||
amount: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Monetary value of the opportunity',
|
||||
},
|
||||
stage_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'ID of the deal stage',
|
||||
},
|
||||
owner_id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'User ID of the opportunity owner',
|
||||
},
|
||||
close_date: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Expected close date (ISO 8601 format)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Description or notes about the opportunity',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.apollo.io/api/v1/opportunities',
|
||||
method: 'POST',
|
||||
headers: (params: ApolloOpportunityCreateParams) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Api-Key': params.apiKey,
|
||||
}),
|
||||
body: (params: ApolloOpportunityCreateParams) => {
|
||||
const body: any = {
|
||||
name: params.name,
|
||||
account_id: params.account_id,
|
||||
}
|
||||
if (params.amount !== undefined) body.amount = params.amount
|
||||
if (params.stage_id) body.stage_id = params.stage_id
|
||||
if (params.owner_id) body.owner_id = params.owner_id
|
||||
if (params.close_date) body.close_date = params.close_date
|
||||
if (params.description) body.description = params.description
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Apollo API error: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
opportunity: data.opportunity || {},
|
||||
metadata: {
|
||||
created: !!data.opportunity,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
opportunity: { type: 'json', description: 'Created opportunity data from Apollo' },
|
||||
metadata: { type: 'json', description: 'Creation metadata including created status' },
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user