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:
Waleed
2025-11-13 17:33:05 -08:00
committed by GitHub
parent 32a2e09a14
commit 5457d4bc7b
117 changed files with 9878 additions and 4783 deletions

View File

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

View File

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

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

View File

@@ -2,6 +2,7 @@
"pages": [
"index",
"airtable",
"apollo",
"arxiv",
"asana",
"browser_use",

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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' },
},
}

View File

@@ -38,8 +38,9 @@ export const WorkflowBlock: BlockConfig = {
{
id: 'workflowId',
title: 'Select Workflow',
type: 'dropdown',
type: 'combobox',
options: getAvailableWorkflows,
placeholder: 'Search workflows...',
required: true,
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
},
},
}

View 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',
},
},
}

View 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' },
},
}

View 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',
},
},
}

View 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' },
},
}

View 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',
},
},
}

View 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',
},
},
}

View 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' },
},
}

View 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',
},
},
}

View 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' },
},
}

View 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' },
},
}

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

View 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