mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
v0.5.29: chat voice mode, opengraph for docs, option to disable auth
This commit is contained in:
@@ -243,6 +243,9 @@ export async function generateMetadata(props: {
|
||||
const baseUrl = 'https://docs.sim.ai'
|
||||
const fullUrl = `${baseUrl}${page.url}`
|
||||
|
||||
const description = page.data.description || ''
|
||||
const ogImageUrl = `${baseUrl}/api/og?title=${encodeURIComponent(page.data.title)}&category=DOCUMENTATION${description ? `&description=${encodeURIComponent(description)}` : ''}`
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description:
|
||||
@@ -272,12 +275,23 @@ export async function generateMetadata(props: {
|
||||
alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh']
|
||||
.filter((lang) => lang !== params.lang)
|
||||
.map((lang) => (lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`)),
|
||||
images: [
|
||||
{
|
||||
url: ogImageUrl,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: page.data.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
card: 'summary_large_image',
|
||||
title: page.data.title,
|
||||
description:
|
||||
page.data.description || 'Sim visual workflow builder for AI applications documentation',
|
||||
images: [ogImageUrl],
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
173
apps/docs/app/api/og/route.tsx
Normal file
173
apps/docs/app/api/og/route.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
const TITLE_FONT_SIZE = {
|
||||
large: 64,
|
||||
medium: 56,
|
||||
small: 48,
|
||||
} as const
|
||||
|
||||
function getTitleFontSize(title: string): number {
|
||||
if (title.length > 45) return TITLE_FONT_SIZE.small
|
||||
if (title.length > 30) return TITLE_FONT_SIZE.medium
|
||||
return TITLE_FONT_SIZE.large
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Google Font dynamically by fetching the CSS and extracting the font URL.
|
||||
*/
|
||||
async function loadGoogleFont(font: string, weights: string, text: string): Promise<ArrayBuffer> {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
|
||||
const css = await (await fetch(url)).text()
|
||||
const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)
|
||||
|
||||
if (resource) {
|
||||
const response = await fetch(resource[1])
|
||||
if (response.status === 200) {
|
||||
return await response.arrayBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to load font data')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates dynamic Open Graph images for documentation pages.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const title = searchParams.get('title') || 'Documentation'
|
||||
const category = searchParams.get('category') || 'DOCUMENTATION'
|
||||
const description = searchParams.get('description') || ''
|
||||
|
||||
const baseUrl = new URL(request.url).origin
|
||||
const backgroundImageUrl = `${baseUrl}/static/og-background.png`
|
||||
|
||||
const allText = `${title}${category}${description}docs.sim.ai`
|
||||
const fontData = await loadGoogleFont('Geist', '400;500;600', allText)
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(315deg, #1e1e3f 0%, #1a1a2e 40%, #0f0f0f 100%)',
|
||||
position: 'relative',
|
||||
fontFamily: 'Geist',
|
||||
}}
|
||||
>
|
||||
{/* Background texture */}
|
||||
<img
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: 0.04,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle purple glow from bottom right */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
background:
|
||||
'radial-gradient(ellipse at bottom right, rgba(112, 31, 252, 0.1) 0%, transparent 50%)',
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '56px 72px',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<img src={`${baseUrl}/static/logo.png`} alt='sim' height={32} />
|
||||
|
||||
{/* Category + Title + Description */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#802fff',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: getTitleFontSize(title),
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
color: '#a1a1aa',
|
||||
lineHeight: 1.4,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{description.length > 100 ? `${description.slice(0, 100)}...` : description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: '#52525b',
|
||||
}}
|
||||
>
|
||||
docs.sim.ai
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Geist',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,14 @@ export const metadata = {
|
||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
||||
description:
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Sim Documentation',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -64,7 +72,7 @@ export const metadata = {
|
||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
|
||||
creator: '@simdotai',
|
||||
site: '@simdotai',
|
||||
images: ['/og-image.png'],
|
||||
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation&category=DOCUMENTATION'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -119,116 +119,116 @@ import {
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
calendly: CalendlyIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
postgresql: PostgresIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
rds: RDSIcon,
|
||||
translate: TranslateIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
wordpress: WordpressIcon,
|
||||
tavily: TavilyIcon,
|
||||
zoom: ZoomIcon,
|
||||
zep: ZepIcon,
|
||||
zendesk: ZendeskIcon,
|
||||
youtube: YouTubeIcon,
|
||||
supabase: SupabaseIcon,
|
||||
vision: EyeIcon,
|
||||
zoom: ZoomIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
arxiv: ArxivIcon,
|
||||
webflow: WebflowIcon,
|
||||
pinecone: PineconeIcon,
|
||||
apollo: ApolloIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
typeform: TypeformIcon,
|
||||
qdrant: QdrantIcon,
|
||||
shopify: ShopifyIcon,
|
||||
asana: AsanaIcon,
|
||||
sqs: SQSIcon,
|
||||
apify: ApifyIcon,
|
||||
memory: BrainIcon,
|
||||
gitlab: GitLabIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
serper: SerperIcon,
|
||||
linear: LinearIcon,
|
||||
exa: ExaAIIcon,
|
||||
telegram: TelegramIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
hunter: HunterIOIcon,
|
||||
linkup: LinkupIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
airtable: AirtableIcon,
|
||||
discord: DiscordIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
tts: TTSIcon,
|
||||
jina: JinaAIIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
google_search: GoogleIcon,
|
||||
x: xIcon,
|
||||
kalshi: KalshiIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
zep: ZepIcon,
|
||||
posthog: PosthogIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
thinking: BrainIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
dropbox: DropboxIcon,
|
||||
stagehand: StagehandIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
file: DocumentIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
gmail: GmailIcon,
|
||||
openai: OpenAIIcon,
|
||||
outlook: OutlookIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
resend: ResendIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
video_generator: VideoIcon,
|
||||
smtp: SmtpIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
mailgun: MailgunIcon,
|
||||
clay: ClayIcon,
|
||||
jira: JiraIcon,
|
||||
search: SearchIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
notion: NotionIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
github: GithubIcon,
|
||||
sftp: SftpIcon,
|
||||
ssh: SshIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
sentry: SentryIcon,
|
||||
reddit: RedditIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
spotify: SpotifyIcon,
|
||||
stripe: StripeIcon,
|
||||
s3: S3Icon,
|
||||
trello: TrelloIcon,
|
||||
mem0: Mem0Icon,
|
||||
knowledge: PackageSearchIcon,
|
||||
intercom: IntercomIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
slack: SlackIcon,
|
||||
datadog: DatadogIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
image_generator: ImageIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
wordpress: WordpressIcon,
|
||||
wikipedia: WikipediaIcon,
|
||||
cursor: CursorIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
mysql: MySQLIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
webflow: WebflowIcon,
|
||||
wealthbox: WealthboxIcon,
|
||||
vision: EyeIcon,
|
||||
video_generator: VideoIcon,
|
||||
typeform: TypeformIcon,
|
||||
twilio_voice: TwilioIcon,
|
||||
twilio_sms: TwilioIcon,
|
||||
tts: TTSIcon,
|
||||
trello: TrelloIcon,
|
||||
translate: TranslateIcon,
|
||||
thinking: BrainIcon,
|
||||
telegram: TelegramIcon,
|
||||
tavily: TavilyIcon,
|
||||
supabase: SupabaseIcon,
|
||||
stt: STTIcon,
|
||||
stripe: StripeIcon,
|
||||
stagehand: StagehandIcon,
|
||||
ssh: SshIcon,
|
||||
sqs: SQSIcon,
|
||||
spotify: SpotifyIcon,
|
||||
smtp: SmtpIcon,
|
||||
slack: SlackIcon,
|
||||
shopify: ShopifyIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
sftp: SftpIcon,
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
search: SearchIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
reddit: RedditIcon,
|
||||
rds: RDSIcon,
|
||||
qdrant: QdrantIcon,
|
||||
posthog: PosthogIcon,
|
||||
postgresql: PostgresIcon,
|
||||
polymarket: PolymarketIcon,
|
||||
pipedrive: PipedriveIcon,
|
||||
pinecone: PineconeIcon,
|
||||
perplexity: PerplexityIcon,
|
||||
parallel_ai: ParallelIcon,
|
||||
outlook: OutlookIcon,
|
||||
openai: OpenAIIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
notion: NotionIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
mysql: MySQLIcon,
|
||||
mongodb: MongoDBIcon,
|
||||
mistral_parse: MistralIcon,
|
||||
microsoft_teams: MicrosoftTeamsIcon,
|
||||
microsoft_planner: MicrosoftPlannerIcon,
|
||||
microsoft_excel: MicrosoftExcelIcon,
|
||||
memory: BrainIcon,
|
||||
mem0: Mem0Icon,
|
||||
mailgun: MailgunIcon,
|
||||
mailchimp: MailchimpIcon,
|
||||
linkup: LinkupIcon,
|
||||
linkedin: LinkedInIcon,
|
||||
linear: LinearIcon,
|
||||
knowledge: PackageSearchIcon,
|
||||
kalshi: KalshiIcon,
|
||||
jira: JiraIcon,
|
||||
jina: JinaAIIcon,
|
||||
intercom: IntercomIcon,
|
||||
incidentio: IncidentioIcon,
|
||||
image_generator: ImageIcon,
|
||||
hunter: HunterIOIcon,
|
||||
huggingface: HuggingFaceIcon,
|
||||
hubspot: HubspotIcon,
|
||||
grafana: GrafanaIcon,
|
||||
google_vault: GoogleVaultIcon,
|
||||
google_slides: GoogleSlidesIcon,
|
||||
google_sheets: GoogleSheetsIcon,
|
||||
google_groups: GoogleGroupsIcon,
|
||||
google_forms: GoogleFormsIcon,
|
||||
google_drive: GoogleDriveIcon,
|
||||
google_docs: GoogleDocsIcon,
|
||||
google_calendar: GoogleCalendarIcon,
|
||||
google_search: GoogleIcon,
|
||||
gmail: GmailIcon,
|
||||
gitlab: GitLabIcon,
|
||||
github: GithubIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
file: DocumentIcon,
|
||||
exa: ExaAIIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
dynamodb: DynamoDBIcon,
|
||||
duckduckgo: DuckDuckGoIcon,
|
||||
dropbox: DropboxIcon,
|
||||
discord: DiscordIcon,
|
||||
datadog: DatadogIcon,
|
||||
cursor: CursorIcon,
|
||||
confluence: ConfluenceIcon,
|
||||
clay: ClayIcon,
|
||||
calendly: CalendlyIcon,
|
||||
browser_use: BrowserUseIcon,
|
||||
asana: AsanaIcon,
|
||||
arxiv: ArxivIcon,
|
||||
apollo: ApolloIcon,
|
||||
apify: ApifyIcon,
|
||||
airtable: AirtableIcon,
|
||||
ahrefs: AhrefsIcon,
|
||||
}
|
||||
|
||||
@@ -39,9 +39,10 @@ Alle Elemente aus einer Webflow CMS-Sammlung auflisten
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `offset` | number | Nein | Offset für Paginierung \(optional\) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente \(optional, Standard: 100\) |
|
||||
| `offset` | number | Nein | Offset für Paginierung (optional) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Elemente (optional, Standard: 100) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -58,6 +59,7 @@ Ein einzelnes Element aus einer Webflow CMS-Sammlung abrufen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des abzurufenden Elements |
|
||||
|
||||
@@ -76,8 +78,9 @@ Ein neues Element in einer Webflow CMS-Sammlung erstellen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Die Schlüssel sollten mit den Feldnamen der Sammlung übereinstimmen. |
|
||||
| `fieldData` | json | Ja | Felddaten für das neue Element als JSON-Objekt. Schlüssel sollten mit den Sammlungsfeldnamen übereinstimmen. |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
@@ -94,6 +97,7 @@ Ein vorhandenes Element in einer Webflow CMS-Sammlung aktualisieren
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des zu aktualisierenden Elements |
|
||||
| `fieldData` | json | Ja | Zu aktualisierende Felddaten als JSON-Objekt. Nur Felder einschließen, die geändert werden sollen. |
|
||||
@@ -113,6 +117,7 @@ Ein Element aus einer Webflow CMS-Sammlung löschen
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Ja | ID der Webflow-Website |
|
||||
| `collectionId` | string | Ja | ID der Sammlung |
|
||||
| `itemId` | string | Ja | ID des zu löschenden Elements |
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ List all items from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `offset` | number | No | Offset for pagination \(optional\) |
|
||||
| `limit` | number | No | Maximum number of items to return \(optional, default: 100\) |
|
||||
@@ -61,6 +62,7 @@ Get a single item from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to retrieve |
|
||||
|
||||
@@ -79,6 +81,7 @@ Create a new item in a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `fieldData` | json | Yes | Field data for the new item as a JSON object. Keys should match collection field names. |
|
||||
|
||||
@@ -97,6 +100,7 @@ Update an existing item in a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to update |
|
||||
| `fieldData` | json | Yes | Field data to update as a JSON object. Only include fields you want to change. |
|
||||
@@ -116,6 +120,7 @@ Delete an item from a Webflow CMS collection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Yes | ID of the Webflow site |
|
||||
| `collectionId` | string | Yes | ID of the collection |
|
||||
| `itemId` | string | Yes | ID of the item to delete |
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ Listar todos los elementos de una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `offset` | number | No | Desplazamiento para paginación \(opcional\) |
|
||||
| `limit` | number | No | Número máximo de elementos a devolver \(opcional, predeterminado: 100\) |
|
||||
@@ -58,6 +59,7 @@ Obtener un solo elemento de una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a recuperar |
|
||||
|
||||
@@ -76,6 +78,7 @@ Crear un nuevo elemento en una colección del CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `fieldData` | json | Sí | Datos de campo para el nuevo elemento como objeto JSON. Las claves deben coincidir con los nombres de campo de la colección. |
|
||||
|
||||
@@ -94,6 +97,7 @@ Actualizar un elemento existente en una colección CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a actualizar |
|
||||
| `fieldData` | json | Sí | Datos de campo para actualizar como objeto JSON. Solo incluye los campos que quieres cambiar. |
|
||||
@@ -113,6 +117,7 @@ Eliminar un elemento de una colección CMS de Webflow
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | Sí | ID del sitio de Webflow |
|
||||
| `collectionId` | string | Sí | ID de la colección |
|
||||
| `itemId` | string | Sí | ID del elemento a eliminar |
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ Lister tous les éléments d'une collection CMS Webflow
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `offset` | number | Non | Décalage pour la pagination \(facultatif\) |
|
||||
| `limit` | number | Non | Nombre maximum d'éléments à retourner \(facultatif, par défaut : 100\) |
|
||||
@@ -57,7 +58,8 @@ Obtenir un seul élément d'une collection CMS Webflow
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à récupérer |
|
||||
|
||||
@@ -76,8 +78,9 @@ Créer un nouvel élément dans une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `fieldData` | json | Oui | Données de champ pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
|
||||
| `fieldData` | json | Oui | Données des champs pour le nouvel élément sous forme d'objet JSON. Les clés doivent correspondre aux noms des champs de la collection. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -94,9 +97,10 @@ Mettre à jour un élément existant dans une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à mettre à jour |
|
||||
| `fieldData` | json | Oui | Données de champ à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
|
||||
| `fieldData` | json | Oui | Données des champs à mettre à jour sous forme d'objet JSON. N'incluez que les champs que vous souhaitez modifier. |
|
||||
|
||||
#### Sortie
|
||||
|
||||
@@ -113,6 +117,7 @@ Supprimer un élément d'une collection CMS Webflow
|
||||
|
||||
| Paramètre | Type | Obligatoire | Description |
|
||||
| --------- | ---- | ---------- | ----------- |
|
||||
| `siteId` | string | Oui | ID du site Webflow |
|
||||
| `collectionId` | string | Oui | ID de la collection |
|
||||
| `itemId` | string | Oui | ID de l'élément à supprimer |
|
||||
|
||||
|
||||
@@ -39,9 +39,10 @@ Webflow CMSコレクションからすべてのアイテムを一覧表示する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `offset` | number | いいえ | ページネーション用のオフセット(オプション) |
|
||||
| `limit` | number | いいえ | 返すアイテムの最大数(オプション、デフォルト:100) |
|
||||
| `limit` | number | いいえ | 返す最大アイテム数(オプション、デフォルト:100) |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -58,6 +59,7 @@ Webflow CMSコレクションから単一のアイテムを取得する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 取得するアイテムのID |
|
||||
|
||||
@@ -76,8 +78,9 @@ Webflow CMSコレクションに新しいアイテムを作成する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト形式)。キーはコレクションのフィールド名と一致する必要があります。 |
|
||||
| `fieldData` | json | はい | 新しいアイテムのフィールドデータ(JSONオブジェクト)。キーはコレクションフィールド名と一致する必要があります。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -94,9 +97,10 @@ Webflow CMSコレクション内の既存アイテムを更新する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 更新するアイテムのID |
|
||||
| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト形式)。変更したいフィールドのみを含めてください。 |
|
||||
| `fieldData` | json | はい | 更新するフィールドデータ(JSONオブジェクト)。変更したいフィールドのみを含めてください。 |
|
||||
|
||||
#### 出力
|
||||
|
||||
@@ -113,6 +117,7 @@ Webflow CMSコレクションからアイテムを削除する
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | はい | WebflowサイトのID |
|
||||
| `collectionId` | string | はい | コレクションのID |
|
||||
| `itemId` | string | はい | 削除するアイテムのID |
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `offset` | number | 否 | 分页偏移量(可选) |
|
||||
| `limit` | number | 否 | 返回的最大项目数(可选,默认值:100) |
|
||||
| `offset` | number | 否 | 分页的偏移量(可选) |
|
||||
| `limit` | number | 否 | 要返回的最大项目数(可选,默认值:100) |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -57,8 +58,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要检索的项目 ID |
|
||||
| `itemId` | string | 是 | 要检索项目的 ID |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -75,8 +77,9 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名称匹配。 |
|
||||
| `fieldData` | json | 是 | 新项目的字段数据,格式为 JSON 对象。键名应与集合字段名匹配。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -93,9 +96,10 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要更新项目的 ID |
|
||||
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含您想更改的字段。 |
|
||||
| `fieldData` | json | 是 | 要更新的字段数据,格式为 JSON 对象。仅包含需要更改的字段。 |
|
||||
|
||||
#### 输出
|
||||
|
||||
@@ -112,6 +116,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
| 参数 | 类型 | 必需 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | 是 | Webflow 网站的 ID |
|
||||
| `collectionId` | string | 是 | 集合的 ID |
|
||||
| `itemId` | string | 是 | 要删除项目的 ID |
|
||||
|
||||
|
||||
@@ -5973,31 +5973,31 @@ checksums:
|
||||
content/9: 5914baadfaf2ca26d54130a36dd5ed29
|
||||
content/10: 25507380ac7d9c7f8cf9f5256c6a0dbb
|
||||
content/11: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/12: e7fb612c3323c1e6b05eacfcea360d34
|
||||
content/12: e034523b05e8c7bd1723ef0ba96c5332
|
||||
content/13: bcadfc362b69078beee0088e5936c98b
|
||||
content/14: e5f830d6049ff79a318110098e5e0130
|
||||
content/15: 711e90714806b91f93923018e82ad2e9
|
||||
content/16: 0f3f7d9699d7397cb3a094c3229329ee
|
||||
content/17: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/18: c53b5b8f901066e63fe159ad2fa5e6e0
|
||||
content/18: 4b0c581b30f4449b0bfa3cdd4af69e02
|
||||
content/19: bcadfc362b69078beee0088e5936c98b
|
||||
content/20: 5f2afdd49c3ac13381401c69d1eca22a
|
||||
content/21: cc4baa9096fafa4c6276f6136412ba66
|
||||
content/22: 676f76e8a7154a576d7fa20b245cef70
|
||||
content/23: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/24: c67c387eb7e274ee7c07b7e1748afce1
|
||||
content/24: d26dd24c5398fd036d1f464ba3789002
|
||||
content/25: bcadfc362b69078beee0088e5936c98b
|
||||
content/26: a6ffebda549ad5b903a66c7d9ac03a20
|
||||
content/27: 0dadd51cde48d6ea75b29ec3ee4ade56
|
||||
content/28: cdc74f6483a0b4e9933ecdd92ed7480f
|
||||
content/29: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/30: 4cda10aa374e1a46d60ad14eeaa79100
|
||||
content/30: cec3953ee52d1d3c8b1a495f9684d35b
|
||||
content/31: bcadfc362b69078beee0088e5936c98b
|
||||
content/32: 5f221421953a0e760ead7388cbf66561
|
||||
content/33: a3c0372590cef72d5d983dbc8dbbc2cb
|
||||
content/34: 1402e53c08bdd8a741f44b2d66fcd003
|
||||
content/35: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/36: 028e579a28e55def4fbc59f39f4610b7
|
||||
content/36: db921b05a9e5ddceb28a4f3f1af2a377
|
||||
content/37: bcadfc362b69078beee0088e5936c98b
|
||||
content/38: 4fe4260da2f137679ce2fa42cffcf56a
|
||||
content/39: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
|
||||
BIN
apps/docs/public/static/og-background.png
Normal file
BIN
apps/docs/public/static/og-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 583 KiB |
@@ -4,10 +4,13 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
|
||||
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
|
||||
# POSTGRES_PORT=5432
|
||||
|
||||
# Authentication (Required)
|
||||
# Authentication (Required unless DISABLE_AUTH=true)
|
||||
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
|
||||
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
|
||||
|
||||
# NextJS (Required)
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
if (isTruthy(env.DISABLE_REGISTRATION)) {
|
||||
if (isRegistrationDisabled) {
|
||||
return <div>Registration is disabled, please contact your admin.</div>
|
||||
}
|
||||
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<SignupForm
|
||||
githubAvailable={githubAvailable}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/environment'
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/feature-flags'
|
||||
import { hasEmailService } from '@/lib/messaging/email/mailer'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const DEFAULT_STARS = '18.6k'
|
||||
const DEFAULT_STARS = '19.4k'
|
||||
|
||||
const logger = createLogger('GitHubStars')
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import Footer from '@/app/(landing)/components/footer/footer'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname.replace('/api/auth/', '')
|
||||
|
||||
if (path === 'get-session' && isAuthDisabled) {
|
||||
await ensureAnonymousUserExists()
|
||||
return NextResponse.json(createAnonymousSession())
|
||||
}
|
||||
|
||||
return betterAuthGET(request)
|
||||
}
|
||||
|
||||
export const POST = betterAuthPOST
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
if (isAuthDisabled) {
|
||||
return NextResponse.json({ token: 'anonymous-socket-token' })
|
||||
}
|
||||
|
||||
const hdrs = await headers()
|
||||
const response = await auth.api.generateOneTimeToken({
|
||||
headers: hdrs,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { db, ssoProvider } from '@sim/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSO-Providers')
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
const session = await getSession()
|
||||
|
||||
let providers
|
||||
if (session?.user?.id) {
|
||||
@@ -38,8 +38,6 @@ export async function GET(req: NextRequest) {
|
||||
: ('oidc' as 'oidc' | 'saml'),
|
||||
}))
|
||||
} else {
|
||||
// Unauthenticated users can only see basic info (domain only)
|
||||
// This is needed for SSO login flow to check if a domain has SSO enabled
|
||||
const results = await db
|
||||
.select({
|
||||
domain: ssoProvider.domain,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function POST(
|
||||
if ((password || email) && !input) {
|
||||
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
|
||||
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType)
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export async function GET(
|
||||
if (
|
||||
deployment.authType !== 'public' &&
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id)
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.password)
|
||||
) {
|
||||
return addCorsHeaders(
|
||||
createSuccessResponse({
|
||||
|
||||
@@ -6,6 +6,12 @@ import { NextRequest } from 'next/server'
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
describe('Chat Edit API Route', () => {
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
@@ -24,7 +30,6 @@ describe('Chat Edit API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
// Set default return values
|
||||
mockLimit.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
@@ -77,10 +82,6 @@ describe('Chat Edit API Route', () => {
|
||||
getEmailDomain: vi.fn().mockReturnValue('localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/environment', () => ({
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/chat/utils', () => ({
|
||||
checkChatAccess: mockCheckChatAccess,
|
||||
}))
|
||||
@@ -254,7 +255,6 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
// Reset and reconfigure mockLimit to return the conflict
|
||||
mockLimit.mockReset()
|
||||
mockLimit.mockResolvedValue([{ id: 'other-chat-id', identifier: 'new-identifier' }])
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
@@ -291,7 +291,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ authType: 'password' }), // No password provided
|
||||
body: JSON.stringify({ authType: 'password' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
@@ -316,9 +316,8 @@ describe('Chat Edit API Route', () => {
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -399,7 +398,6 @@ describe('Chat Edit API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getEmailDomain } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -44,6 +44,12 @@ vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
describe('Chat API Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
@@ -62,11 +68,6 @@ describe('Chat API Utils', () => {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/core/config/environment', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('ChatAuthUtils')
|
||||
|
||||
function hashPassword(encryptedPassword: string): string {
|
||||
return createHash('sha256').update(encryptedPassword).digest('hex').substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has permission to create a chat for a specific workflow
|
||||
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
|
||||
@@ -77,14 +82,20 @@ export async function checkChatAccess(
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
const encryptAuthToken = (chatId: string, type: string): string => {
|
||||
return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64')
|
||||
function encryptAuthToken(chatId: string, type: string, encryptedPassword?: string | null): string {
|
||||
const pwHash = encryptedPassword ? hashPassword(encryptedPassword) : ''
|
||||
return Buffer.from(`${chatId}:${type}:${Date.now()}:${pwHash}`).toString('base64')
|
||||
}
|
||||
|
||||
export const validateAuthToken = (token: string, chatId: string): boolean => {
|
||||
export function validateAuthToken(
|
||||
token: string,
|
||||
chatId: string,
|
||||
encryptedPassword?: string | null
|
||||
): boolean {
|
||||
try {
|
||||
const decoded = Buffer.from(token, 'base64').toString()
|
||||
const [storedId, _type, timestamp] = decoded.split(':')
|
||||
const parts = decoded.split(':')
|
||||
const [storedId, _type, timestamp, storedPwHash] = parts
|
||||
|
||||
if (storedId !== chatId) {
|
||||
return false
|
||||
@@ -92,20 +103,32 @@ export const validateAuthToken = (token: string, chatId: string): boolean => {
|
||||
|
||||
const createdAt = Number.parseInt(timestamp)
|
||||
const now = Date.now()
|
||||
const expireTime = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const expireTime = 24 * 60 * 60 * 1000
|
||||
|
||||
if (now - createdAt > expireTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (encryptedPassword) {
|
||||
const currentPwHash = hashPassword(encryptedPassword)
|
||||
if (storedPwHash !== currentPwHash) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (_e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => {
|
||||
const token = encryptAuthToken(chatId, type)
|
||||
export function setChatAuthCookie(
|
||||
response: NextResponse,
|
||||
chatId: string,
|
||||
type: string,
|
||||
encryptedPassword?: string | null
|
||||
): void {
|
||||
const token = encryptAuthToken(chatId, type, encryptedPassword)
|
||||
response.cookies.set({
|
||||
name: `chat_auth_${chatId}`,
|
||||
value: token,
|
||||
@@ -113,7 +136,7 @@ export const setChatAuthCookie = (response: NextResponse, chatId: string, type:
|
||||
secure: !isDev,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
maxAge: 60 * 60 * 24,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,7 +168,7 @@ export async function validateChatAuth(
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (authCookie && validateAuthToken(authCookie.value, deployment.id)) {
|
||||
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
@@ -259,8 +282,8 @@ export async function validateChatAuth(
|
||||
return { authorized: false, error: 'Email not authorized for SSO access' }
|
||||
}
|
||||
|
||||
const { auth } = await import('@/lib/auth')
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
||||
import { settings } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
@@ -10,9 +10,9 @@ const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
/**
|
||||
* GET - Fetch user's auto-allowed integration tools
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -31,7 +31,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ autoAllowedTools })
|
||||
}
|
||||
|
||||
// If no settings record exists, create one with empty array
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -50,7 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -65,13 +64,11 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const toolId = body.toolId
|
||||
|
||||
// Get existing settings
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
|
||||
// Add tool if not already present
|
||||
if (!currentTools.includes(toolId)) {
|
||||
const updatedTools = [...currentTools, toolId]
|
||||
await db
|
||||
@@ -89,7 +86,6 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
|
||||
}
|
||||
|
||||
// Create new settings record with the tool
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -109,7 +105,7 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -123,7 +119,6 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get existing settings
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/../../packages/db'
|
||||
import { settings } from '@/../../packages/db/schema'
|
||||
@@ -32,7 +32,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
|
||||
// GET - Fetch user's enabled models
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -40,7 +40,6 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Try to fetch existing settings record
|
||||
const [userSettings] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
@@ -50,13 +49,11 @@ export async function GET(request: NextRequest) {
|
||||
if (userSettings) {
|
||||
const userModelsMap = (userSettings.copilotEnabledModels as Record<string, boolean>) || {}
|
||||
|
||||
// Merge: start with defaults, then override with user's existing preferences
|
||||
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
|
||||
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
|
||||
mergedModels[modelId] = enabled
|
||||
}
|
||||
|
||||
// If we added any new models, update the database
|
||||
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(
|
||||
(key) => !(key in userModelsMap)
|
||||
)
|
||||
@@ -76,7 +73,6 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// If no settings record exists, create one with defaults
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
@@ -97,7 +93,7 @@ export async function GET(request: NextRequest) {
|
||||
// PUT - Update user's enabled models
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
@@ -110,11 +106,9 @@ export async function PUT(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'enabledModels must be an object' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if settings record exists
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
// Update existing record
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
@@ -123,7 +117,6 @@ export async function PUT(request: NextRequest) {
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
} else {
|
||||
// Create new settings record
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createContext, Script } from 'vm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
@@ -701,7 +701,6 @@ export async function POST(req: NextRequest) {
|
||||
resolvedCode = codeResolution.resolvedCode
|
||||
const contextVariables = codeResolution.contextVariables
|
||||
|
||||
const e2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
|
||||
|
||||
// Extract imports once for JavaScript code (reuse later to avoid double extraction)
|
||||
@@ -722,14 +721,14 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Python always requires E2B
|
||||
if (lang === CodeLanguage.Python && !e2bEnabled) {
|
||||
if (lang === CodeLanguage.Python && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// JavaScript with imports requires E2B
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !e2bEnabled) {
|
||||
if (lang === CodeLanguage.JavaScript && hasImports && !isE2bEnabled) {
|
||||
throw new Error(
|
||||
'JavaScript code with import statements requires E2B to be enabled. Please remove the import statements, or contact your administrator to enable E2B.'
|
||||
)
|
||||
@@ -740,13 +739,13 @@ export async function POST(req: NextRequest) {
|
||||
// - Not a custom tool AND
|
||||
// - (Python OR JavaScript with imports)
|
||||
const useE2B =
|
||||
e2bEnabled &&
|
||||
isE2bEnabled &&
|
||||
!isCustomTool &&
|
||||
(lang === CodeLanguage.Python || (lang === CodeLanguage.JavaScript && hasImports))
|
||||
|
||||
if (useE2B) {
|
||||
logger.info(`[${requestId}] E2B status`, {
|
||||
enabled: e2bEnabled,
|
||||
enabled: isE2bEnabled,
|
||||
hasApiKey: Boolean(process.env.E2B_API_KEY),
|
||||
language: lang,
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationSeatsAPI')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { isDev } from '@/lib/core/config/environment'
|
||||
import { isDev } from '@/lib/core/config/feature-flags'
|
||||
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -1,26 +1,81 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateAuthToken } from '@/app/api/chat/utils'
|
||||
|
||||
const logger = createLogger('ProxyTTSStreamAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
/**
|
||||
* Validates chat-based authentication for deployed chat voice mode
|
||||
* Checks if the user has a valid chat auth cookie for the given chatId
|
||||
*/
|
||||
async function validateChatAuth(request: NextRequest, chatId: string): Promise<boolean> {
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error('Authentication failed for TTS stream proxy:', authResult.error)
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
const chatResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
isActive: chat.isActive,
|
||||
authType: chat.authType,
|
||||
password: chat.password,
|
||||
})
|
||||
.from(chat)
|
||||
.where(eq(chat.id, chatId))
|
||||
.limit(1)
|
||||
|
||||
if (chatResult.length === 0 || !chatResult[0].isActive) {
|
||||
logger.warn('Chat not found or inactive for TTS auth:', chatId)
|
||||
return false
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body
|
||||
const chatData = chatResult[0]
|
||||
|
||||
if (chatData.authType === 'public') {
|
||||
return true
|
||||
}
|
||||
|
||||
const cookieName = `chat_auth_${chatId}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Error validating chat auth for TTS:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
let body: any
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('Invalid request body', { status: 400 })
|
||||
}
|
||||
|
||||
const { text, voiceId, modelId = 'eleven_turbo_v2_5', chatId } = body
|
||||
|
||||
if (!chatId) {
|
||||
return new Response('chatId is required', { status: 400 })
|
||||
}
|
||||
|
||||
if (!text || !voiceId) {
|
||||
return new Response('Missing required parameters', { status: 400 })
|
||||
}
|
||||
|
||||
const isChatAuthed = await validateChatAuth(request, chatId)
|
||||
if (!isChatAuthed) {
|
||||
logger.warn('Chat authentication failed for TTS, chatId:', chatId)
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255)
|
||||
if (!voiceIdValidation.isValid) {
|
||||
logger.error(`Invalid voice ID: ${voiceIdValidation.error}`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { StorageService } from '@/lib/uploads'
|
||||
@@ -147,6 +148,10 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const voiceIdValidation = validateAlphanumericId(body.voiceId, 'voiceId')
|
||||
if (!voiceIdValidation.isValid) {
|
||||
return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
|
||||
}
|
||||
const result = await synthesizeWithElevenLabs({
|
||||
text,
|
||||
apiKey,
|
||||
|
||||
@@ -42,11 +42,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: mockExecuteScheduleJob,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -119,11 +119,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: true,
|
||||
},
|
||||
isTruthy: vi.fn(() => true),
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: true,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -191,11 +191,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
@@ -250,11 +250,11 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
executeScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/core/config/env', () => ({
|
||||
env: {
|
||||
TRIGGER_DEV_ENABLED: false,
|
||||
},
|
||||
isTruthy: vi.fn(() => false),
|
||||
vi.doMock('@/lib/core/config/feature-flags', () => ({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { executeScheduleJob } from '@/background/schedule-execution'
|
||||
@@ -54,9 +54,7 @@ export async function GET(request: NextRequest) {
|
||||
logger.debug(`[${requestId}] Successfully queried schedules: ${dueSchedules.length} found`)
|
||||
logger.info(`[${requestId}] Processing ${dueSchedules.length} due scheduled workflows`)
|
||||
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
if (isTriggerDevEnabled) {
|
||||
const triggerPromises = dueSchedules.map(async (schedule) => {
|
||||
const queueTime = schedule.lastQueuedAt ?? queuedAt
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@ export async function GET() {
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('GitHub API request failed:', response.status)
|
||||
return NextResponse.json({ stars: formatStarCount(14500) })
|
||||
return NextResponse.json({ stars: formatStarCount(19400) })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 14500)) })
|
||||
return NextResponse.json({ stars: formatStarCount(Number(data?.stargazers_count ?? 19400)) })
|
||||
} catch (error) {
|
||||
console.warn('Error fetching GitHub stars:', error)
|
||||
return NextResponse.json({ stars: formatStarCount(14500) })
|
||||
return NextResponse.json({ stars: formatStarCount(19400) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('TelemetryAPI')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { validateNumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
@@ -41,6 +42,17 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const validatedData = DiscordSendMessageSchema.parse(body)
|
||||
|
||||
const channelIdValidation = validateNumericId(validatedData.channelId, 'channelId')
|
||||
if (!channelIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid channelId format`, {
|
||||
error: channelIdValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: channelIdValidation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Sending Discord message`, {
|
||||
channelId: validatedData.channelId,
|
||||
hasFiles: !!(validatedData.files && validatedData.files.length > 0),
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowCollectionsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, siteId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const siteId = searchParams.get('siteId')
|
||||
|
||||
if (!siteId) {
|
||||
return NextResponse.json({ error: 'Missing siteId parameter' }, { status: 400 })
|
||||
const siteIdValidation = validateAlphanumericId(siteId, 'siteId')
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error('Invalid siteId', { error: siteIdValidation.error })
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(session.user.id, 'webflow')
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
|
||||
{ status: 404 }
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,11 +81,11 @@ export async function GET(request: NextRequest) {
|
||||
name: collection.displayName || collection.slug || collection.id,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ collections: formattedCollections }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching Webflow collections', error)
|
||||
return NextResponse.json({ collections: formattedCollections })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow collections request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', details: error.message },
|
||||
{ error: 'Failed to retrieve Webflow collections', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
106
apps/sim/app/api/tools/webflow/items/route.ts
Normal file
106
apps/sim/app/api/tools/webflow/items/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowItemsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, collectionId, search } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId')
|
||||
if (!collectionIdValidation.isValid) {
|
||||
logger.error('Invalid collectionId', { error: collectionIdValidation.error })
|
||||
return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.webflow.com/v2/collections/${collectionId}/items?limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Webflow items', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
collectionId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Webflow items', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
|
||||
let formattedItems = items.map((item: any) => {
|
||||
const fieldData = item.fieldData || {}
|
||||
const name = fieldData.name || fieldData.title || fieldData.slug || item.id
|
||||
return {
|
||||
id: item.id,
|
||||
name,
|
||||
}
|
||||
})
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
formattedItems = formattedItems.filter((item: { id: string; name: string }) =>
|
||||
item.name.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: formattedItems })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow items request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Webflow items', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,48 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getOAuthToken } from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebflowSitesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const requestId = generateRequestId()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const accessToken = await getOAuthToken(session.user.id, 'webflow')
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'No Webflow access token found. Please connect your Webflow account.' },
|
||||
{ status: 404 }
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,11 +73,11 @@ export async function GET(request: NextRequest) {
|
||||
name: site.displayName || site.shortName || site.id,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ sites: formattedSites }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching Webflow sites', error)
|
||||
return NextResponse.json({ sites: formattedSites })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Webflow sites request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error', details: error.message },
|
||||
{ error: 'Failed to retrieve Webflow sites', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,19 +35,18 @@
|
||||
* GET /api/v1/admin/organizations/:id - Get organization details
|
||||
* PATCH /api/v1/admin/organizations/:id - Update organization
|
||||
* GET /api/v1/admin/organizations/:id/members - List organization members
|
||||
* POST /api/v1/admin/organizations/:id/members - Add/update member in organization
|
||||
* POST /api/v1/admin/organizations/:id/members - Add/update member (validates seat availability)
|
||||
* GET /api/v1/admin/organizations/:id/members/:mid - Get member details
|
||||
* PATCH /api/v1/admin/organizations/:id/members/:mid - Update member role
|
||||
* DELETE /api/v1/admin/organizations/:id/members/:mid - Remove member
|
||||
* GET /api/v1/admin/organizations/:id/billing - Get org billing summary
|
||||
* PATCH /api/v1/admin/organizations/:id/billing - Update org usage limit
|
||||
* GET /api/v1/admin/organizations/:id/seats - Get seat analytics
|
||||
* PATCH /api/v1/admin/organizations/:id/seats - Update seat count
|
||||
*
|
||||
* Subscriptions:
|
||||
* GET /api/v1/admin/subscriptions - List all subscriptions
|
||||
* GET /api/v1/admin/subscriptions/:id - Get subscription details
|
||||
* PATCH /api/v1/admin/subscriptions/:id - Update subscription
|
||||
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
|
||||
*/
|
||||
|
||||
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* POST /api/v1/admin/organizations/[id]/members
|
||||
*
|
||||
* Add a user to an organization with full billing logic.
|
||||
* Validates seat availability before adding (uses same logic as invitation flow):
|
||||
* - Team plans: checks seats column
|
||||
* - Enterprise plans: checks metadata.seats
|
||||
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
|
||||
* If user is already a member, updates their role if different.
|
||||
*
|
||||
@@ -29,6 +32,7 @@ import { db } from '@sim/db'
|
||||
import { member, organization, user, userStats } from '@sim/db/schema'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -223,6 +227,29 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
return badRequestResponse(result.error || 'Failed to add member')
|
||||
}
|
||||
|
||||
// Sync Pro subscription cancellation with Stripe (same as invitation flow)
|
||||
if (result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) {
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(
|
||||
result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: true }
|
||||
)
|
||||
logger.info('Admin API: Synced Pro cancellation with Stripe', {
|
||||
userId: body.userId,
|
||||
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
|
||||
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Admin API: Failed to sync Pro cancellation with Stripe', {
|
||||
userId: body.userId,
|
||||
subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId,
|
||||
stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const data: AdminMember = {
|
||||
id: result.memberId!,
|
||||
userId: body.userId,
|
||||
|
||||
@@ -4,26 +4,12 @@
|
||||
* Get organization seat analytics including member activity.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSeatAnalytics>
|
||||
*
|
||||
* PATCH /api/v1/admin/organizations/[id]/seats
|
||||
*
|
||||
* Update organization seat count with Stripe sync (matches user flow).
|
||||
*
|
||||
* Body:
|
||||
* - seats: number - New seat count (positive integer)
|
||||
*
|
||||
* Response: AdminSingleResponse<{ success: true, seats: number, plan: string, stripeUpdated?: boolean }>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { organization, subscription } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
@@ -75,122 +61,3 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
return internalErrorResponse('Failed to get organization seats')
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
|
||||
const [orgData] = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!orgData) {
|
||||
return notFoundResponse('Organization')
|
||||
}
|
||||
|
||||
const [subData] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
if (!subData) {
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
const newSeatCount = body.seats
|
||||
let stripeUpdated = false
|
||||
|
||||
if (subData.plan === 'enterprise') {
|
||||
const currentMetadata = (subData.metadata as Record<string, unknown>) || {}
|
||||
const newMetadata = {
|
||||
...currentMetadata,
|
||||
seats: newSeatCount,
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ metadata: newMetadata })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated enterprise seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
})
|
||||
} else if (subData.plan === 'team') {
|
||||
if (subData.stripeSubscriptionId) {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(subData.stripeSubscriptionId)
|
||||
|
||||
if (stripeSubscription.status !== 'active') {
|
||||
return badRequestResponse('Stripe subscription is not active')
|
||||
}
|
||||
|
||||
const subscriptionItem = stripeSubscription.items.data[0]
|
||||
if (!subscriptionItem) {
|
||||
return internalErrorResponse('No subscription item found in Stripe subscription')
|
||||
}
|
||||
|
||||
const currentSeats = subData.seats || 1
|
||||
|
||||
logger.info('Admin API: Updating Stripe subscription quantity', {
|
||||
organizationId,
|
||||
stripeSubscriptionId: subData.stripeSubscriptionId,
|
||||
subscriptionItemId: subscriptionItem.id,
|
||||
currentSeats,
|
||||
newSeatCount,
|
||||
})
|
||||
|
||||
await stripe.subscriptions.update(subData.stripeSubscriptionId, {
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItem.id,
|
||||
quantity: newSeatCount,
|
||||
},
|
||||
],
|
||||
proration_behavior: 'create_prorations',
|
||||
})
|
||||
|
||||
stripeUpdated = true
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ seats: newSeatCount })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated team seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
stripeUpdated,
|
||||
})
|
||||
} else {
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ seats: newSeatCount })
|
||||
.where(eq(subscription.id, subData.id))
|
||||
|
||||
logger.info(`Admin API: Updated seats for organization ${organizationId}`, {
|
||||
seats: newSeatCount,
|
||||
plan: subData.plan,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
seats: newSeatCount,
|
||||
plan: subData.plan,
|
||||
stripeUpdated,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update organization seats', { error, organizationId })
|
||||
return internalErrorResponse('Failed to update organization seats')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription>
|
||||
*
|
||||
* PATCH /api/v1/admin/subscriptions/[id]
|
||||
* DELETE /api/v1/admin/subscriptions/[id]
|
||||
*
|
||||
* Update subscription details with optional side effects.
|
||||
* Cancel a subscription by triggering Stripe cancellation.
|
||||
* The Stripe webhook handles all cleanup (same as platform cancellation):
|
||||
* - Updates subscription status to canceled
|
||||
* - Bills final period overages
|
||||
* - Resets usage
|
||||
* - Restores member Pro subscriptions (for team/enterprise)
|
||||
* - Deletes organization (for team/enterprise)
|
||||
* - Syncs usage limits to free tier
|
||||
*
|
||||
* Body:
|
||||
* - plan?: string - New plan (free, pro, team, enterprise)
|
||||
* - status?: string - New status (active, canceled, etc.)
|
||||
* - seats?: number - Seat count (for team plans)
|
||||
* - metadata?: object - Subscription metadata (for enterprise)
|
||||
* - periodStart?: string - Period start (ISO date)
|
||||
* - periodEnd?: string - Period end (ISO date)
|
||||
* - cancelAtPeriodEnd?: boolean - Cancel at period end flag
|
||||
* - syncLimits?: boolean - Sync usage limits for affected users (default: false)
|
||||
* - reason?: string - Reason for the change (for audit logging)
|
||||
* Query Parameters:
|
||||
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
|
||||
* - reason?: string - Reason for cancellation (for audit logging)
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminSubscription & { sideEffects }>
|
||||
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
@@ -43,9 +43,6 @@ interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
const VALID_PLANS = ['free', 'pro', 'team', 'enterprise']
|
||||
const VALID_STATUSES = ['active', 'canceled', 'past_due', 'unpaid', 'trialing', 'incomplete']
|
||||
|
||||
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
|
||||
@@ -69,14 +66,13 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
}
|
||||
})
|
||||
|
||||
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const { id: subscriptionId } = await context.params
|
||||
const url = new URL(request.url)
|
||||
const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true'
|
||||
const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)'
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const syncLimits = body.syncLimits === true
|
||||
const reason = body.reason || 'Admin update (no reason provided)'
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
@@ -87,150 +83,70 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
return notFoundResponse('Subscription')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
const warnings: string[] = []
|
||||
|
||||
if (body.plan !== undefined) {
|
||||
if (!VALID_PLANS.includes(body.plan)) {
|
||||
return badRequestResponse(`plan must be one of: ${VALID_PLANS.join(', ')}`)
|
||||
}
|
||||
if (body.plan !== existing.plan) {
|
||||
warnings.push(
|
||||
`Plan change from ${existing.plan} to ${body.plan}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.plan = body.plan
|
||||
if (existing.status === 'canceled') {
|
||||
return badRequestResponse('Subscription is already canceled')
|
||||
}
|
||||
|
||||
if (body.status !== undefined) {
|
||||
if (!VALID_STATUSES.includes(body.status)) {
|
||||
return badRequestResponse(`status must be one of: ${VALID_STATUSES.join(', ')}`)
|
||||
}
|
||||
if (body.status !== existing.status) {
|
||||
warnings.push(
|
||||
`Status change from ${existing.status} to ${body.status}. This does NOT update Stripe - manual sync required.`
|
||||
)
|
||||
}
|
||||
updateData.status = body.status
|
||||
if (!existing.stripeSubscriptionId) {
|
||||
return badRequestResponse('Subscription has no Stripe subscription ID')
|
||||
}
|
||||
|
||||
if (body.seats !== undefined) {
|
||||
if (typeof body.seats !== 'number' || body.seats < 1 || !Number.isInteger(body.seats)) {
|
||||
return badRequestResponse('seats must be a positive integer')
|
||||
}
|
||||
updateData.seats = body.seats
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
if (atPeriodEnd) {
|
||||
// Schedule cancellation at period end
|
||||
await stripe.subscriptions.update(existing.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
|
||||
// Update DB (webhooks don't sync cancelAtPeriodEnd)
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ cancelAtPeriodEnd: true })
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
|
||||
logger.info('Admin API: Scheduled subscription cancellation at period end', {
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
periodEnd: existing.periodEnd,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
success: true,
|
||||
message: 'Subscription scheduled to cancel at period end.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: true,
|
||||
periodEnd: existing.periodEnd?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.metadata !== undefined) {
|
||||
if (typeof body.metadata !== 'object' || body.metadata === null) {
|
||||
return badRequestResponse('metadata must be an object')
|
||||
}
|
||||
updateData.metadata = {
|
||||
...((existing.metadata as Record<string, unknown>) || {}),
|
||||
...body.metadata,
|
||||
}
|
||||
}
|
||||
// Immediate cancellation
|
||||
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
|
||||
prorate: true,
|
||||
invoice_now: true,
|
||||
})
|
||||
|
||||
if (body.periodStart !== undefined) {
|
||||
const date = new Date(body.periodStart)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodStart must be a valid ISO date')
|
||||
}
|
||||
updateData.periodStart = date
|
||||
}
|
||||
|
||||
if (body.periodEnd !== undefined) {
|
||||
const date = new Date(body.periodEnd)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return badRequestResponse('periodEnd must be a valid ISO date')
|
||||
}
|
||||
updateData.periodEnd = date
|
||||
}
|
||||
|
||||
if (body.cancelAtPeriodEnd !== undefined) {
|
||||
if (typeof body.cancelAtPeriodEnd !== 'boolean') {
|
||||
return badRequestResponse('cancelAtPeriodEnd must be a boolean')
|
||||
}
|
||||
updateData.cancelAtPeriodEnd = body.cancelAtPeriodEnd
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return badRequestResponse('No valid fields to update')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(subscription)
|
||||
.set(updateData)
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.returning()
|
||||
|
||||
const sideEffects: {
|
||||
limitsSynced: boolean
|
||||
usersAffected: string[]
|
||||
errors: string[]
|
||||
} = {
|
||||
limitsSynced: false,
|
||||
usersAffected: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
if (syncLimits) {
|
||||
try {
|
||||
const referenceId = updated.referenceId
|
||||
|
||||
if (['free', 'pro'].includes(updated.plan)) {
|
||||
await syncUsageLimitsFromSubscription(referenceId)
|
||||
sideEffects.usersAffected.push(referenceId)
|
||||
sideEffects.limitsSynced = true
|
||||
} else if (['team', 'enterprise'].includes(updated.plan)) {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, referenceId))
|
||||
|
||||
for (const m of members) {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
sideEffects.usersAffected.push(m.userId)
|
||||
} catch (memberError) {
|
||||
sideEffects.errors.push(`Failed to sync limits for user ${m.userId}`)
|
||||
logger.error('Admin API: Failed to sync limits for member', {
|
||||
userId: m.userId,
|
||||
error: memberError,
|
||||
})
|
||||
}
|
||||
}
|
||||
sideEffects.limitsSynced = members.length > 0
|
||||
}
|
||||
|
||||
logger.info('Admin API: Synced usage limits after subscription update', {
|
||||
subscriptionId,
|
||||
usersAffected: sideEffects.usersAffected.length,
|
||||
})
|
||||
} catch (syncError) {
|
||||
sideEffects.errors.push('Failed to sync usage limits')
|
||||
logger.error('Admin API: Failed to sync usage limits', {
|
||||
subscriptionId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated subscription ${subscriptionId}`, {
|
||||
fields: Object.keys(updateData),
|
||||
previousPlan: existing.plan,
|
||||
previousStatus: existing.status,
|
||||
syncLimits,
|
||||
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
plan: existing.plan,
|
||||
referenceId: existing.referenceId,
|
||||
reason,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
...toAdminSubscription(updated),
|
||||
sideEffects,
|
||||
warnings,
|
||||
success: true,
|
||||
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
|
||||
subscriptionId,
|
||||
stripeSubscriptionId: existing.stripeSubscriptionId,
|
||||
atPeriodEnd: false,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to update subscription')
|
||||
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
|
||||
return internalErrorResponse('Failed to cancel subscription')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { isAuthDisabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('V1Auth')
|
||||
@@ -13,6 +15,14 @@ export interface AuthResult {
|
||||
}
|
||||
|
||||
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
|
||||
if (isAuthDisabled) {
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
keyType: 'personal',
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getModelPricing } from '@/providers/utils'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -236,9 +236,8 @@ type AsyncExecutionParams = {
|
||||
*/
|
||||
async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextResponse> {
|
||||
const { requestId, workflowId, userId, input, triggerType } = params
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (!useTrigger) {
|
||||
if (!isTriggerDevEnabled) {
|
||||
logger.warn(`[${requestId}] Async mode requested but TRIGGER_DEV_ENABLED is false`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Async execution is not enabled. Set TRIGGER_DEV_ENABLED=true to use async mode.' },
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ChatConfig {
|
||||
|
||||
interface AudioStreamingOptions {
|
||||
voiceId: string
|
||||
chatId?: string
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
@@ -62,16 +63,19 @@ function fileToBase64(file: File): Promise<string> {
|
||||
* Creates an audio stream handler for text-to-speech conversion
|
||||
* @param streamTextToAudio - Function to stream text to audio
|
||||
* @param voiceId - The voice ID to use for TTS
|
||||
* @param chatId - Optional chat ID for deployed chat authentication
|
||||
* @returns Audio stream handler function or undefined
|
||||
*/
|
||||
function createAudioStreamHandler(
|
||||
streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise<void>,
|
||||
voiceId: string
|
||||
voiceId: string,
|
||||
chatId?: string
|
||||
) {
|
||||
return async (text: string) => {
|
||||
try {
|
||||
await streamTextToAudio(text, {
|
||||
voiceId,
|
||||
chatId,
|
||||
onError: (error: Error) => {
|
||||
logger.error('Audio streaming error:', error)
|
||||
},
|
||||
@@ -113,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [starCount, setStarCount] = useState('3.4k')
|
||||
const [starCount, setStarCount] = useState('19.4k')
|
||||
const [conversationId, setConversationId] = useState('')
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
@@ -391,7 +395,11 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
// Use the streaming hook with audio support
|
||||
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
|
||||
const audioHandler = shouldPlayAudio
|
||||
? createAudioStreamHandler(streamTextToAudio, DEFAULT_VOICE_SETTINGS.voiceId)
|
||||
? createAudioStreamHandler(
|
||||
streamTextToAudio,
|
||||
DEFAULT_VOICE_SETTINGS.voiceId,
|
||||
chatConfig?.id
|
||||
)
|
||||
: undefined
|
||||
|
||||
logger.info('Starting to handle streamed response:', { shouldPlayAudio })
|
||||
|
||||
@@ -68,7 +68,6 @@ export function VoiceInterface({
|
||||
messages = [],
|
||||
className,
|
||||
}: VoiceInterfaceProps) {
|
||||
// Simple state machine
|
||||
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
@@ -76,12 +75,10 @@ export function VoiceInterface({
|
||||
const [permissionStatus, setPermissionStatus] = useState<'prompt' | 'granted' | 'denied'>(
|
||||
'prompt'
|
||||
)
|
||||
|
||||
// Current turn transcript (subtitle)
|
||||
const [currentTranscript, setCurrentTranscript] = useState('')
|
||||
|
||||
// State tracking
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const isCallEndedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
currentStateRef.current = state
|
||||
@@ -98,12 +95,10 @@ export function VoiceInterface({
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
||||
|
||||
// Update muted ref
|
||||
useEffect(() => {
|
||||
isMutedRef.current = isMuted
|
||||
}, [isMuted])
|
||||
|
||||
// Timeout to handle cases where agent doesn't provide audio response
|
||||
const setResponseTimeout = useCallback(() => {
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
@@ -113,7 +108,7 @@ export function VoiceInterface({
|
||||
if (currentStateRef.current === 'listening') {
|
||||
setState('idle')
|
||||
}
|
||||
}, 5000) // 5 second timeout (increased from 3)
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
const clearResponseTimeout = useCallback(() => {
|
||||
@@ -123,14 +118,12 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Sync with external state
|
||||
useEffect(() => {
|
||||
if (isPlayingAudio && state !== 'agent_speaking') {
|
||||
clearResponseTimeout() // Clear timeout since agent is responding
|
||||
clearResponseTimeout()
|
||||
setState('agent_speaking')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Mute microphone immediately
|
||||
setIsMuted(true)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -138,7 +131,6 @@ export function VoiceInterface({
|
||||
})
|
||||
}
|
||||
|
||||
// Stop speech recognition completely
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
@@ -150,7 +142,6 @@ export function VoiceInterface({
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Re-enable microphone
|
||||
setIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -160,7 +151,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [isPlayingAudio, state, clearResponseTimeout])
|
||||
|
||||
// Audio setup
|
||||
const setupAudio = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -175,7 +165,6 @@ export function VoiceInterface({
|
||||
setPermissionStatus('granted')
|
||||
mediaStreamRef.current = stream
|
||||
|
||||
// Setup audio context for visualization
|
||||
if (!audioContextRef.current) {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
audioContextRef.current = new AudioContext()
|
||||
@@ -194,7 +183,6 @@ export function VoiceInterface({
|
||||
source.connect(analyser)
|
||||
analyserRef.current = analyser
|
||||
|
||||
// Start visualization
|
||||
const updateVisualization = () => {
|
||||
if (!analyserRef.current) return
|
||||
|
||||
@@ -223,7 +211,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Speech recognition setup
|
||||
const setupSpeechRecognition = useCallback(() => {
|
||||
if (!isSupported) return
|
||||
|
||||
@@ -259,14 +246,11 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
|
||||
// Update live transcript
|
||||
setCurrentTranscript(interimTranscript || finalTranscript)
|
||||
|
||||
// Send final transcript (but keep listening state until agent responds)
|
||||
if (finalTranscript.trim()) {
|
||||
setCurrentTranscript('') // Clear transcript
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Stop recognition to avoid interference while waiting for response
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
@@ -275,7 +259,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
|
||||
// Start timeout in case agent doesn't provide audio response
|
||||
setResponseTimeout()
|
||||
|
||||
onVoiceTranscript?.(finalTranscript)
|
||||
@@ -283,13 +266,14 @@ export function VoiceInterface({
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
const currentState = currentStateRef.current
|
||||
|
||||
// Only restart recognition if we're in listening state and not muted
|
||||
if (currentState === 'listening' && !isMutedRef.current) {
|
||||
// Add a delay to avoid immediate restart after sending transcript
|
||||
setTimeout(() => {
|
||||
// Double-check state hasn't changed during delay
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
if (
|
||||
recognitionRef.current &&
|
||||
currentStateRef.current === 'listening' &&
|
||||
@@ -301,14 +285,12 @@ export function VoiceInterface({
|
||||
logger.debug('Error restarting speech recognition:', error)
|
||||
}
|
||||
}
|
||||
}, 1000) // Longer delay to give agent time to respond
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||
// Filter out "aborted" errors - these are expected when we intentionally stop recognition
|
||||
if (event.error === 'aborted') {
|
||||
// Ignore
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,7 +302,6 @@ export function VoiceInterface({
|
||||
recognitionRef.current = recognition
|
||||
}, [isSupported, onVoiceTranscript, setResponseTimeout])
|
||||
|
||||
// Start/stop listening
|
||||
const startListening = useCallback(() => {
|
||||
if (!isInitialized || isMuted || state !== 'idle') {
|
||||
return
|
||||
@@ -351,17 +332,12 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle interrupt
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
// Clear any subtitle timeouts and text
|
||||
// (No longer needed after removing subtitle system)
|
||||
|
||||
onInterrupt?.()
|
||||
setState('listening')
|
||||
setCurrentTranscript('')
|
||||
|
||||
// Unmute microphone for user input
|
||||
setIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
@@ -369,7 +345,6 @@ export function VoiceInterface({
|
||||
})
|
||||
}
|
||||
|
||||
// Start listening immediately
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.start()
|
||||
@@ -380,14 +355,13 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [state, onInterrupt])
|
||||
|
||||
// Handle call end with proper cleanup
|
||||
const handleCallEnd = useCallback(() => {
|
||||
// Stop everything immediately
|
||||
isCallEndedRef.current = true
|
||||
|
||||
setState('idle')
|
||||
setCurrentTranscript('')
|
||||
setIsMuted(false)
|
||||
|
||||
// Stop speech recognition
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
@@ -396,17 +370,11 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear timeouts
|
||||
clearResponseTimeout()
|
||||
|
||||
// Stop audio playback and streaming immediately
|
||||
onInterrupt?.()
|
||||
|
||||
// Call the original onCallEnd
|
||||
onCallEnd?.()
|
||||
}, [onCallEnd, onInterrupt, clearResponseTimeout])
|
||||
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
@@ -419,7 +387,6 @@ export function VoiceInterface({
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleInterrupt])
|
||||
|
||||
// Mute toggle
|
||||
const toggleMute = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
handleInterrupt()
|
||||
@@ -442,7 +409,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening])
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (isSupported) {
|
||||
setupSpeechRecognition()
|
||||
@@ -450,47 +416,40 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [isSupported, setupSpeechRecognition, setupAudio])
|
||||
|
||||
// Auto-start listening when ready
|
||||
useEffect(() => {
|
||||
if (isInitialized && !isMuted && state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isInitialized, isMuted, state, startListening])
|
||||
|
||||
// Cleanup when call ends or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Stop speech recognition
|
||||
isCallEndedRef.current = true
|
||||
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort()
|
||||
} catch (error) {
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
recognitionRef.current = null
|
||||
}
|
||||
|
||||
// Stop media stream
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
// Stop audio context
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close()
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
// Cancel animation frame
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
// Clear timeouts
|
||||
if (responseTimeoutRef.current) {
|
||||
clearTimeout(responseTimeoutRef.current)
|
||||
responseTimeoutRef.current = null
|
||||
@@ -498,7 +457,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get status text
|
||||
const getStatusText = () => {
|
||||
switch (state) {
|
||||
case 'listening':
|
||||
@@ -510,7 +468,6 @@ export function VoiceInterface({
|
||||
}
|
||||
}
|
||||
|
||||
// Get button content
|
||||
const getButtonContent = () => {
|
||||
if (state === 'agent_speaking') {
|
||||
return (
|
||||
@@ -524,9 +481,7 @@ export function VoiceInterface({
|
||||
|
||||
return (
|
||||
<div className={cn('fixed inset-0 z-[100] flex flex-col bg-white text-gray-900', className)}>
|
||||
{/* Main content */}
|
||||
<div className='flex flex-1 flex-col items-center justify-center px-8'>
|
||||
{/* Voice visualization */}
|
||||
<div className='relative mb-16'>
|
||||
<ParticlesVisualization
|
||||
audioLevels={audioLevels}
|
||||
@@ -538,7 +493,6 @@ export function VoiceInterface({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live transcript - subtitle style */}
|
||||
<div className='mb-16 flex h-24 items-center justify-center'>
|
||||
{currentTranscript && (
|
||||
<div className='max-w-2xl px-8'>
|
||||
@@ -549,17 +503,14 @@ export function VoiceInterface({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<p className='mb-8 text-center text-gray-600 text-lg'>
|
||||
{getStatusText()}
|
||||
{isMuted && <span className='ml-2 text-gray-400 text-sm'>(Muted)</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className='px-8 pb-12'>
|
||||
<div className='flex items-center justify-center space-x-12'>
|
||||
{/* End call */}
|
||||
<Button
|
||||
onClick={handleCallEnd}
|
||||
variant='outline'
|
||||
@@ -569,7 +520,6 @@ export function VoiceInterface({
|
||||
<Phone className='h-6 w-6 rotate-[135deg]' />
|
||||
</Button>
|
||||
|
||||
{/* Mic/Stop button */}
|
||||
<Button
|
||||
onClick={toggleMute}
|
||||
variant='outline'
|
||||
|
||||
@@ -14,6 +14,7 @@ declare global {
|
||||
interface AudioStreamingOptions {
|
||||
voiceId: string
|
||||
modelId?: string
|
||||
chatId?: string
|
||||
onAudioStart?: () => void
|
||||
onAudioEnd?: () => void
|
||||
onError?: (error: Error) => void
|
||||
@@ -76,7 +77,14 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
|
||||
}
|
||||
|
||||
const { text, options } = item
|
||||
const { voiceId, modelId = 'eleven_turbo_v2_5', onAudioStart, onAudioEnd, onError } = options
|
||||
const {
|
||||
voiceId,
|
||||
modelId = 'eleven_turbo_v2_5',
|
||||
chatId,
|
||||
onAudioStart,
|
||||
onAudioEnd,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
try {
|
||||
const audioContext = getAudioContext()
|
||||
@@ -93,6 +101,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
|
||||
text,
|
||||
voiceId,
|
||||
modelId,
|
||||
chatId,
|
||||
}),
|
||||
signal: abortControllerRef.current?.signal,
|
||||
})
|
||||
|
||||
@@ -262,6 +262,24 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'sharing.write': 'Share files and folders with others',
|
||||
// WordPress.com scopes
|
||||
global: 'Full access to manage your WordPress.com sites, posts, pages, media, and settings',
|
||||
// Spotify scopes
|
||||
'user-read-private': 'View your Spotify account details',
|
||||
'user-read-email': 'View your email address on Spotify',
|
||||
'user-library-read': 'View your saved tracks and albums',
|
||||
'user-library-modify': 'Save and remove tracks and albums from your library',
|
||||
'playlist-read-private': 'View your private playlists',
|
||||
'playlist-read-collaborative': 'View collaborative playlists you have access to',
|
||||
'playlist-modify-public': 'Create and manage your public playlists',
|
||||
'playlist-modify-private': 'Create and manage your private playlists',
|
||||
'user-read-playback-state': 'View your current playback state',
|
||||
'user-modify-playback-state': 'Control playback on your Spotify devices',
|
||||
'user-read-currently-playing': 'View your currently playing track',
|
||||
'user-read-recently-played': 'View your recently played tracks',
|
||||
'user-top-read': 'View your top artists and tracks',
|
||||
'user-follow-read': 'View artists and users you follow',
|
||||
'user-follow-modify': 'Follow and unfollow artists and users',
|
||||
'user-read-playback-position': 'View your playback position in podcasts',
|
||||
'ugc-image-upload': 'Upload images to your Spotify playlists',
|
||||
}
|
||||
|
||||
function getScopeDescription(scope: string): string {
|
||||
|
||||
@@ -47,12 +47,16 @@ export function FileSelectorInput({
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -75,6 +79,8 @@ export function FileSelectorInput({
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
siteId: (siteIdValue as string) || undefined,
|
||||
collectionId: (collectionIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
@@ -84,6 +90,8 @@ export function FileSelectorInput({
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
siteIdValue,
|
||||
collectionIdValue,
|
||||
])
|
||||
|
||||
const missingCredential = !normalizedCredentialId
|
||||
@@ -97,6 +105,10 @@ export function FileSelectorInput({
|
||||
!selectorResolution.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution.context.planId
|
||||
const missingSite =
|
||||
selectorResolution?.key === 'webflow.collections' && !selectorResolution.context.siteId
|
||||
const missingCollection =
|
||||
selectorResolution?.key === 'webflow.items' && !selectorResolution.context.collectionId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
@@ -105,6 +117,8 @@ export function FileSelectorInput({
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
missingSite ||
|
||||
missingCollection ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
|
||||
@@ -43,14 +43,12 @@ export function ProjectSelectorInput({
|
||||
|
||||
// Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore
|
||||
const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore
|
||||
|
||||
// Derive provider from serviceId using OAuth config
|
||||
const serviceId = subBlock.serviceId || ''
|
||||
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
|
||||
const isLinear = serviceId === 'linear'
|
||||
|
||||
const { isForeignCredential } = useForeignCredential(
|
||||
effectiveProviderId,
|
||||
@@ -65,7 +63,6 @@ export function ProjectSelectorInput({
|
||||
})
|
||||
|
||||
// Jira/Discord upstream fields - use values from previewContextValues or store
|
||||
const jiraCredential = connectedCredential
|
||||
const domain = (jiraDomain as string) || ''
|
||||
|
||||
// Verify Jira credential belongs to current user; if not, treat as absent
|
||||
@@ -84,19 +81,11 @@ export function ProjectSelectorInput({
|
||||
const selectorResolution = useMemo(() => {
|
||||
return resolveSelectorForSubBlock(subBlock, {
|
||||
workflowId: workflowIdFromUrl || undefined,
|
||||
credentialId: (isLinear ? linearCredential : jiraCredential) as string | undefined,
|
||||
credentialId: (connectedCredential as string) || undefined,
|
||||
domain,
|
||||
teamId: (linearTeamId as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
workflowIdFromUrl,
|
||||
isLinear,
|
||||
linearCredential,
|
||||
jiraCredential,
|
||||
domain,
|
||||
linearTeamId,
|
||||
])
|
||||
}, [subBlock, workflowIdFromUrl, connectedCredential, domain, linearTeamId])
|
||||
|
||||
const missingCredential = !selectorResolution?.context.credentialId
|
||||
|
||||
|
||||
@@ -47,12 +47,16 @@ export function FileSelectorInput({
|
||||
const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId')
|
||||
const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId')
|
||||
const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId')
|
||||
const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId')
|
||||
const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId')
|
||||
|
||||
const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore
|
||||
const domainValue = previewContextValues?.domain ?? domainValueFromStore
|
||||
const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore
|
||||
const planIdValue = previewContextValues?.planId ?? planIdValueFromStore
|
||||
const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore
|
||||
const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore
|
||||
const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore
|
||||
|
||||
const normalizedCredentialId =
|
||||
typeof connectedCredential === 'string'
|
||||
@@ -75,6 +79,8 @@ export function FileSelectorInput({
|
||||
projectId: (projectIdValue as string) || undefined,
|
||||
planId: (planIdValue as string) || undefined,
|
||||
teamId: (teamIdValue as string) || undefined,
|
||||
siteId: (siteIdValue as string) || undefined,
|
||||
collectionId: (collectionIdValue as string) || undefined,
|
||||
})
|
||||
}, [
|
||||
subBlock,
|
||||
@@ -84,6 +90,8 @@ export function FileSelectorInput({
|
||||
projectIdValue,
|
||||
planIdValue,
|
||||
teamIdValue,
|
||||
siteIdValue,
|
||||
collectionIdValue,
|
||||
])
|
||||
|
||||
const missingCredential = !normalizedCredentialId
|
||||
@@ -97,6 +105,10 @@ export function FileSelectorInput({
|
||||
!selectorResolution?.context.projectId
|
||||
const missingPlan =
|
||||
selectorResolution?.key === 'microsoft.planner' && !selectorResolution?.context.planId
|
||||
const missingSite =
|
||||
selectorResolution?.key === 'webflow.collections' && !selectorResolution?.context.siteId
|
||||
const missingCollection =
|
||||
selectorResolution?.key === 'webflow.items' && !selectorResolution?.context.collectionId
|
||||
|
||||
const disabledReason =
|
||||
finalDisabled ||
|
||||
@@ -105,6 +117,8 @@ export function FileSelectorInput({
|
||||
missingDomain ||
|
||||
missingProject ||
|
||||
missingPlan ||
|
||||
missingSite ||
|
||||
missingCollection ||
|
||||
!selectorResolution?.key
|
||||
|
||||
if (!selectorResolution?.key) {
|
||||
|
||||
@@ -579,8 +579,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
|
||||
// If dropping outside containers, ignore blocks that are inside a container
|
||||
if (!containerAtPoint && blocks[id]?.data?.parentId) return false
|
||||
const blockParentId = blocks[id]?.data?.parentId
|
||||
const dropParentId = containerAtPoint?.loopId
|
||||
if (dropParentId !== blockParentId) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.map(([id, block]) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { signOut, useSession } from '@/lib/auth/auth-client'
|
||||
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -59,6 +60,7 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
const isLoading = isProfileLoading || isSettingsLoading
|
||||
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
|
||||
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
@@ -461,10 +463,12 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Button onClick={handleSignOut}>Sign out</Button>
|
||||
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
|
||||
</div>
|
||||
{!isAuthDisabled && (
|
||||
<div className='mt-auto flex items-center gap-[8px]'>
|
||||
<Button onClick={handleSignOut}>Sign out</Button>
|
||||
<Button onClick={() => setShowResetPasswordModal(true)}>Reset password</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Reset Confirmation Modal */}
|
||||
<Modal open={showResetPasswordModal} onOpenChange={setShowResetPasswordModal}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -26,7 +26,7 @@ import { McpIcon } from '@/components/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getUserRole } from '@/lib/workspaces/organization'
|
||||
import {
|
||||
ApiKeys,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChartBarIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockConfig, ParamType } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ShieldCheckIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getHostedModels, getProviderIcon } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConnectIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
import {
|
||||
|
||||
@@ -153,6 +153,14 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
|
||||
value: () => 'spotify_search',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Spotify Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'spotify',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// === SEARCH ===
|
||||
{
|
||||
id: 'query',
|
||||
@@ -647,15 +655,6 @@ export const SpotifyBlock: BlockConfig<ToolResponse> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// === OAUTH CREDENTIAL ===
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Spotify Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'spotify',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TranslateIcon } from '@/components/icons'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import { getHostedModels, getProviderIcon, providers } from '@/providers/utils'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
@@ -39,19 +39,65 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
placeholder: 'Select Webflow account',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'siteId',
|
||||
title: 'Site',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'siteId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select Webflow site',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualSiteId',
|
||||
title: 'Site ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'siteId',
|
||||
placeholder: 'Enter site ID',
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'collectionId',
|
||||
title: 'Collection',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'collectionId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select collection',
|
||||
dependsOn: ['credential', 'siteId'],
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualCollectionId',
|
||||
title: 'Collection ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'collectionId',
|
||||
placeholder: 'Enter collection ID',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'advanced',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'itemId',
|
||||
title: 'Item',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'itemId',
|
||||
serviceId: 'webflow',
|
||||
placeholder: 'Select item',
|
||||
dependsOn: ['credential', 'collectionId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'manualItemId',
|
||||
title: 'Item ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID of the item',
|
||||
canonicalParamId: 'itemId',
|
||||
placeholder: 'Enter item ID',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['get', 'update', 'delete'] },
|
||||
required: true,
|
||||
},
|
||||
@@ -108,7 +154,17 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { credential, fieldData, ...rest } = params
|
||||
const {
|
||||
credential,
|
||||
fieldData,
|
||||
siteId,
|
||||
manualSiteId,
|
||||
collectionId,
|
||||
manualCollectionId,
|
||||
itemId,
|
||||
manualItemId,
|
||||
...rest
|
||||
} = params
|
||||
let parsedFieldData: any | undefined
|
||||
|
||||
try {
|
||||
@@ -119,15 +175,46 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`)
|
||||
}
|
||||
|
||||
const effectiveSiteId = ((siteId as string) || (manualSiteId as string) || '').trim()
|
||||
const effectiveCollectionId = (
|
||||
(collectionId as string) ||
|
||||
(manualCollectionId as string) ||
|
||||
''
|
||||
).trim()
|
||||
const effectiveItemId = ((itemId as string) || (manualItemId as string) || '').trim()
|
||||
|
||||
if (!effectiveSiteId) {
|
||||
throw new Error('Site ID is required')
|
||||
}
|
||||
|
||||
if (!effectiveCollectionId) {
|
||||
throw new Error('Collection ID is required')
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
credential,
|
||||
siteId: effectiveSiteId,
|
||||
collectionId: effectiveCollectionId,
|
||||
...rest,
|
||||
}
|
||||
|
||||
switch (params.operation) {
|
||||
case 'create':
|
||||
case 'update':
|
||||
return { ...baseParams, fieldData: parsedFieldData }
|
||||
if (params.operation === 'update' && !effectiveItemId) {
|
||||
throw new Error('Item ID is required for update operation')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
itemId: effectiveItemId || undefined,
|
||||
fieldData: parsedFieldData,
|
||||
}
|
||||
case 'get':
|
||||
case 'delete':
|
||||
if (!effectiveItemId) {
|
||||
throw new Error(`Item ID is required for ${params.operation} operation`)
|
||||
}
|
||||
return { ...baseParams, itemId: effectiveItemId }
|
||||
default:
|
||||
return baseParams
|
||||
}
|
||||
@@ -137,12 +224,15 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'Webflow OAuth access token' },
|
||||
siteId: { type: 'string', description: 'Webflow site identifier' },
|
||||
manualSiteId: { type: 'string', description: 'Manual site identifier' },
|
||||
collectionId: { type: 'string', description: 'Webflow collection identifier' },
|
||||
// Conditional inputs
|
||||
itemId: { type: 'string', description: 'Item identifier' }, // Required for get/update/delete
|
||||
offset: { type: 'number', description: 'Pagination offset' }, // Optional for list
|
||||
limit: { type: 'number', description: 'Maximum items to return' }, // Optional for list
|
||||
fieldData: { type: 'json', description: 'Item field data' }, // Required for create/update
|
||||
manualCollectionId: { type: 'string', description: 'Manual collection identifier' },
|
||||
itemId: { type: 'string', description: 'Item identifier' },
|
||||
manualItemId: { type: 'string', description: 'Manual item identifier' },
|
||||
offset: { type: 'number', description: 'Pagination offset' },
|
||||
limit: { type: 'number', description: 'Maximum items to return' },
|
||||
fieldData: { type: 'json', description: 'Item field data' },
|
||||
},
|
||||
outputs: {
|
||||
items: { type: 'json', description: 'Array of items (list operation)' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Container, Img, Link, Section, Text } from '@react-email/components'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface UnsubscribeOptions {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
|
||||
@@ -11,11 +10,11 @@ import { executeTool } from '@/tools'
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
|
||||
|
||||
vi.mock('@/lib/core/config/environment', () => ({
|
||||
isHosted: vi.fn().mockReturnValue(false),
|
||||
isProd: vi.fn().mockReturnValue(false),
|
||||
isDev: vi.fn().mockReturnValue(true),
|
||||
isTest: vi.fn().mockReturnValue(false),
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
isTest: false,
|
||||
getCostMultiplier: vi.fn().mockReturnValue(1),
|
||||
isEmailVerificationEnabled: false,
|
||||
isBillingEnabled: false,
|
||||
@@ -65,7 +64,6 @@ global.fetch = Object.assign(vi.fn(), { preconnect: vi.fn() }) as typeof fetch
|
||||
|
||||
const mockGetAllBlocks = getAllBlocks as Mock
|
||||
const mockExecuteTool = executeTool as Mock
|
||||
const mockIsHosted = isHosted as unknown as Mock
|
||||
const mockGetProviderFromModel = getProviderFromModel as Mock
|
||||
const mockTransformBlockTool = transformBlockTool as Mock
|
||||
const mockFetch = global.fetch as unknown as Mock
|
||||
@@ -120,7 +118,6 @@ describe('AgentBlockHandler', () => {
|
||||
loops: {},
|
||||
} as SerializedWorkflow,
|
||||
}
|
||||
mockIsHosted.mockReturnValue(false)
|
||||
mockGetProviderFromModel.mockReturnValue('mock-provider')
|
||||
|
||||
mockFetch.mockImplementation(() => {
|
||||
@@ -552,8 +549,6 @@ describe('AgentBlockHandler', () => {
|
||||
})
|
||||
|
||||
it('should not require API key for gpt-4o on hosted version', async () => {
|
||||
mockIsHosted.mockReturnValue(true)
|
||||
|
||||
const inputs = {
|
||||
model: 'gpt-4o',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isHosted } from '@/lib/core/config/environment'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CopilotKeysQuery')
|
||||
|
||||
@@ -673,6 +673,99 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
return { id: doc.id, label: doc.filename }
|
||||
},
|
||||
},
|
||||
'webflow.sites': {
|
||||
key: 'webflow.sites',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.sites',
|
||||
context.credentialId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.sites')
|
||||
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
|
||||
const data = await fetchJson<{ sites: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/sites',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.sites || []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'webflow.collections': {
|
||||
key: 'webflow.collections',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.collections',
|
||||
context.credentialId ?? 'none',
|
||||
context.siteId ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
|
||||
fetchList: async ({ context }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.collections')
|
||||
if (!context.siteId) {
|
||||
throw new Error('Missing site ID for webflow.collections selector')
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
siteId: context.siteId,
|
||||
})
|
||||
const data = await fetchJson<{ collections: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/collections',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.collections || []).map((collection) => ({
|
||||
id: collection.id,
|
||||
label: collection.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'webflow.items': {
|
||||
key: 'webflow.items',
|
||||
staleTime: 15 * 1000,
|
||||
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'webflow.items',
|
||||
context.credentialId ?? 'none',
|
||||
context.collectionId ?? 'none',
|
||||
search ?? '',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.credentialId && context.collectionId),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'webflow.items')
|
||||
if (!context.collectionId) {
|
||||
throw new Error('Missing collection ID for webflow.items selector')
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
credential: credentialId,
|
||||
workflowId: context.workflowId,
|
||||
collectionId: context.collectionId,
|
||||
search,
|
||||
})
|
||||
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
|
||||
'/api/tools/webflow/items',
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
)
|
||||
return (data.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SelectorResolutionArgs {
|
||||
planId?: string
|
||||
teamId?: string
|
||||
knowledgeBaseId?: string
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
}
|
||||
|
||||
const defaultContext: SelectorContext = {}
|
||||
@@ -52,6 +54,8 @@ function buildBaseContext(
|
||||
planId: args.planId,
|
||||
teamId: args.teamId,
|
||||
knowledgeBaseId: args.knowledgeBaseId,
|
||||
siteId: args.siteId,
|
||||
collectionId: args.collectionId,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -106,6 +110,14 @@ function resolveFileSelector(
|
||||
}
|
||||
case 'sharepoint':
|
||||
return { key: 'sharepoint.sites', context, allowSearch: true }
|
||||
case 'webflow':
|
||||
if (subBlock.id === 'collectionId') {
|
||||
return { key: 'webflow.collections', context, allowSearch: false }
|
||||
}
|
||||
if (subBlock.id === 'itemId') {
|
||||
return { key: 'webflow.items', context, allowSearch: true }
|
||||
}
|
||||
return { key: null, context, allowSearch: true }
|
||||
default:
|
||||
return { key: null, context, allowSearch: true }
|
||||
}
|
||||
@@ -159,6 +171,8 @@ function resolveProjectSelector(
|
||||
}
|
||||
case 'jira':
|
||||
return { key: 'jira.projects', context, allowSearch: true }
|
||||
case 'webflow':
|
||||
return { key: 'webflow.sites', context, allowSearch: false }
|
||||
default:
|
||||
return { key: null, context, allowSearch: true }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ export type SelectorKey =
|
||||
| 'microsoft.planner'
|
||||
| 'google.drive'
|
||||
| 'knowledge.documents'
|
||||
| 'webflow.sites'
|
||||
| 'webflow.collections'
|
||||
| 'webflow.items'
|
||||
|
||||
export interface SelectorOption {
|
||||
id: string
|
||||
@@ -43,6 +46,8 @@ export interface SelectorContext {
|
||||
planId?: string
|
||||
mimeType?: string
|
||||
fileId?: string
|
||||
siteId?: string
|
||||
collectionId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
104
apps/sim/lib/auth/anonymous.ts
Normal file
104
apps/sim/lib/auth/anonymous.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
|
||||
|
||||
const logger = createLogger('AnonymousAuth')
|
||||
|
||||
let anonymousUserEnsured = false
|
||||
|
||||
/**
|
||||
* Ensures the anonymous user and their stats record exist in the database.
|
||||
* Called when DISABLE_AUTH is enabled to ensure DB operations work.
|
||||
*/
|
||||
export async function ensureAnonymousUserExists(): Promise<void> {
|
||||
if (anonymousUserEnsured) return
|
||||
|
||||
try {
|
||||
const existingUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, ANONYMOUS_USER_ID),
|
||||
})
|
||||
|
||||
if (!existingUser) {
|
||||
const now = new Date()
|
||||
await db.insert(schema.user).values({
|
||||
...ANONYMOUS_USER,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
logger.info('Created anonymous user for DISABLE_AUTH mode')
|
||||
}
|
||||
|
||||
const existingStats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, ANONYMOUS_USER_ID),
|
||||
})
|
||||
|
||||
if (!existingStats) {
|
||||
await db.insert(schema.userStats).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
currentUsageLimit: '10000000000',
|
||||
})
|
||||
logger.info('Created anonymous user stats for DISABLE_AUTH mode')
|
||||
}
|
||||
|
||||
anonymousUserEnsured = true
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('unique') || error.message.includes('duplicate'))
|
||||
) {
|
||||
anonymousUserEnsured = true
|
||||
return
|
||||
}
|
||||
logger.error('Failed to ensure anonymous user exists', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnonymousSession {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
emailVerified: boolean
|
||||
image: null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
session: {
|
||||
id: string
|
||||
userId: string
|
||||
expiresAt: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
token: string
|
||||
ipAddress: null
|
||||
userAgent: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an anonymous session for when auth is disabled.
|
||||
*/
|
||||
export function createAnonymousSession(): AnonymousSession {
|
||||
const now = new Date()
|
||||
return {
|
||||
user: {
|
||||
...ANONYMOUS_USER,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
session: {
|
||||
id: 'anonymous-session',
|
||||
userId: ANONYMOUS_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
token: 'anonymous-token',
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import type { auth } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider'
|
||||
|
||||
@@ -25,9 +25,9 @@ export const client = createAuthClient({
|
||||
stripeClient({
|
||||
subscription: true, // Enable subscription management
|
||||
}),
|
||||
organizationClient(),
|
||||
]
|
||||
: []),
|
||||
organizationClient(),
|
||||
...(env.NEXT_PUBLIC_SSO_ENABLED ? [ssoClient()] : []),
|
||||
],
|
||||
})
|
||||
@@ -42,7 +42,9 @@ export function useSession(): SessionHookResult {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export const { useActiveOrganization } = client
|
||||
export const useActiveOrganization = isBillingEnabled
|
||||
? client.useActiveOrganization
|
||||
: () => ({ data: undefined, isPending: false, error: null })
|
||||
|
||||
export const useSubscription = () => {
|
||||
return {
|
||||
|
||||
@@ -38,13 +38,19 @@ import {
|
||||
handleSubscriptionCreated,
|
||||
handleSubscriptionDeleted,
|
||||
} from '@/lib/billing/webhooks/subscription'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled, isEmailVerificationEnabled } from '@/lib/core/config/environment'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import {
|
||||
isAuthDisabled,
|
||||
isBillingEnabled,
|
||||
isEmailVerificationEnabled,
|
||||
isRegistrationDisabled,
|
||||
} from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
@@ -270,7 +276,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith('/sign-up') && isTruthy(env.DISABLE_REGISTRATION))
|
||||
if (ctx.path.startsWith('/sign-up') && isRegistrationDisabled)
|
||||
throw new Error('Registration is disabled, please contact your admin.')
|
||||
|
||||
if (
|
||||
@@ -2087,14 +2093,6 @@ export const auth = betterAuth({
|
||||
|
||||
try {
|
||||
await handleSubscriptionDeleted(subscription)
|
||||
|
||||
// Reset usage limits to free tier
|
||||
await syncSubscriptionUsageLimits(subscription)
|
||||
|
||||
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
|
||||
subscriptionId: subscription.id,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
|
||||
subscriptionId: subscription.id,
|
||||
@@ -2193,6 +2191,11 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
export async function getSession() {
|
||||
if (isAuthDisabled) {
|
||||
await ensureAnonymousUserExists()
|
||||
return createAnonymousSession()
|
||||
}
|
||||
|
||||
const hdrs = await headers()
|
||||
return await auth.api.getSession({
|
||||
headers: hdrs,
|
||||
|
||||
10
apps/sim/lib/auth/constants.ts
Normal file
10
apps/sim/lib/auth/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Anonymous user ID used when DISABLE_AUTH is enabled */
|
||||
export const ANONYMOUS_USER_ID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
export const ANONYMOUS_USER = {
|
||||
id: ANONYMOUS_USER_ID,
|
||||
name: 'Anonymous',
|
||||
email: 'anonymous@localhost',
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
} as const
|
||||
@@ -1 +1,4 @@
|
||||
export type { AnonymousSession } from './anonymous'
|
||||
export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
export { auth, getSession, signIn, signUp } from './auth'
|
||||
export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
|
||||
import { member, organization, userStats } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageMonitor')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getPerUserMinimumLimit,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { UserSubscriptionState } from '@/lib/billing/types'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getPlanPricing,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
@@ -21,6 +21,131 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OrganizationMembership')
|
||||
|
||||
export interface RestoreProResult {
|
||||
restored: boolean
|
||||
usageRestored: boolean
|
||||
subscriptionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a user's personal Pro subscription if it was paused (cancelAtPeriodEnd=true).
|
||||
* Also restores any snapshotted Pro usage from when they joined a team.
|
||||
*
|
||||
* Called when:
|
||||
* - A member leaves a team (via removeUserFromOrganization)
|
||||
* - A team subscription ends (members stay but get Pro restored)
|
||||
*/
|
||||
export async function restoreUserProSubscription(userId: string): Promise<RestoreProResult> {
|
||||
const result: RestoreProResult = {
|
||||
restored: false,
|
||||
usageRestored: false,
|
||||
}
|
||||
|
||||
try {
|
||||
const [personalPro] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!personalPro?.cancelAtPeriodEnd || !personalPro.stripeSubscriptionId) {
|
||||
return result
|
||||
}
|
||||
|
||||
result.subscriptionId = personalPro.id
|
||||
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
result.restored = true
|
||||
|
||||
logger.info('Restored personal Pro subscription', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (stats) {
|
||||
const currentUsage = stats.currentPeriodCost || '0'
|
||||
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
|
||||
if (snapshotNum > 0) {
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
result.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage snapshot', {
|
||||
userId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage,
|
||||
restoredUsage,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage snapshot', {
|
||||
userId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore user Pro subscription', {
|
||||
userId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface AddMemberParams {
|
||||
userId: string
|
||||
organizationId: string
|
||||
@@ -409,7 +534,6 @@ export async function removeUserFromOrganization(
|
||||
// STEP 3: Restore personal Pro if user has no remaining paid team memberships
|
||||
if (!skipBillingLogic) {
|
||||
try {
|
||||
// Check for remaining paid team memberships
|
||||
const remainingPaidTeams = await db
|
||||
.select({ orgId: member.organizationId })
|
||||
.from(member)
|
||||
@@ -428,104 +552,10 @@ export async function removeUserFromOrganization(
|
||||
)
|
||||
}
|
||||
|
||||
// If no remaining paid teams, try to restore personal Pro
|
||||
if (!hasAnyPaidTeam) {
|
||||
const [personalPro] = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, userId),
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.plan, 'pro')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// Only restore if cancelAtPeriodEnd is true AND stripeSubscriptionId exists
|
||||
if (
|
||||
personalPro &&
|
||||
personalPro.cancelAtPeriodEnd === true &&
|
||||
personalPro.stripeSubscriptionId
|
||||
) {
|
||||
// Call Stripe API first (separate try/catch so failure doesn't prevent DB update)
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
} catch (stripeError) {
|
||||
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
|
||||
userId,
|
||||
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
||||
error: stripeError,
|
||||
})
|
||||
}
|
||||
|
||||
// Update DB (separate try/catch)
|
||||
try {
|
||||
await db
|
||||
.update(subscriptionTable)
|
||||
.set({ cancelAtPeriodEnd: false })
|
||||
.where(eq(subscriptionTable.id, personalPro.id))
|
||||
|
||||
billingActions.proRestored = true
|
||||
|
||||
logger.info('Restored personal Pro after leaving last paid team', {
|
||||
userId,
|
||||
personalSubscriptionId: personalPro.id,
|
||||
})
|
||||
} catch (dbError) {
|
||||
logger.error('DB update failed when restoring personal Pro', {
|
||||
userId,
|
||||
subscriptionId: personalPro.id,
|
||||
error: dbError,
|
||||
})
|
||||
}
|
||||
|
||||
// Restore snapshotted Pro usage (separate try/catch)
|
||||
try {
|
||||
const [stats] = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (stats) {
|
||||
const currentUsage = stats.currentPeriodCost || '0'
|
||||
const snapshotUsage = stats.proPeriodCostSnapshot || '0'
|
||||
|
||||
const currentNum = Number.parseFloat(currentUsage)
|
||||
const snapshotNum = Number.parseFloat(snapshotUsage)
|
||||
const restoredUsage = (currentNum + snapshotNum).toString()
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
currentPeriodCost: restoredUsage,
|
||||
proPeriodCostSnapshot: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
billingActions.usageRestored = true
|
||||
|
||||
logger.info('Restored Pro usage after leaving team', {
|
||||
userId,
|
||||
previousUsage: currentUsage,
|
||||
snapshotUsage: snapshotUsage,
|
||||
restoredUsage: restoredUsage,
|
||||
})
|
||||
}
|
||||
} catch (usageRestoreError) {
|
||||
logger.error('Failed to restore Pro usage after leaving team', {
|
||||
userId,
|
||||
error: usageRestoreError,
|
||||
})
|
||||
}
|
||||
}
|
||||
const restoreResult = await restoreUserProSubscription(userId)
|
||||
billingActions.proRestored = restoreResult.restored
|
||||
billingActions.usageRestored = restoreResult.usageRestored
|
||||
}
|
||||
} catch (postRemoveError) {
|
||||
logger.error('Post-removal personal Pro restore check failed', {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { organization, subscription, userStats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StorageLimits')
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization, userStats } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StorageTracking')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription } from '@sim/db/schema'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import {
|
||||
getBilledOverageForSubscription,
|
||||
@@ -11,6 +13,71 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('StripeSubscriptionWebhooks')
|
||||
|
||||
/**
|
||||
* Restore personal Pro subscriptions for all members of an organization
|
||||
* when the team/enterprise subscription ends.
|
||||
*/
|
||||
async function restoreMemberProSubscriptions(organizationId: string): Promise<number> {
|
||||
let restoredCount = 0
|
||||
|
||||
try {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
for (const m of members) {
|
||||
const result = await restoreUserProSubscription(m.userId)
|
||||
if (result.restored) {
|
||||
restoredCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredCount > 0) {
|
||||
logger.info('Restored Pro subscriptions for team members', {
|
||||
organizationId,
|
||||
restoredCount,
|
||||
totalMembers: members.length,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restore member Pro subscriptions', {
|
||||
organizationId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
return restoredCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup organization when team/enterprise subscription is deleted.
|
||||
* - Restores member Pro subscriptions
|
||||
* - Deletes the organization
|
||||
* - Syncs usage limits for former members (resets to free or Pro tier)
|
||||
*/
|
||||
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
|
||||
restoredProCount: number
|
||||
membersSynced: number
|
||||
}> {
|
||||
// Get member userIds before deletion (needed for limit syncing after org deletion)
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const restoredProCount = await restoreMemberProSubscriptions(organizationId)
|
||||
|
||||
await db.delete(organization).where(eq(organization.id, organizationId))
|
||||
|
||||
// Sync usage limits for former members (now free or Pro tier)
|
||||
for (const m of memberUserIds) {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new subscription creation - reset usage if transitioning from free to paid
|
||||
*/
|
||||
@@ -98,12 +165,24 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
const totalOverage = await calculateSubscriptionOverage(subscription)
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
// Enterprise plans have no overages - just reset usage
|
||||
// Enterprise plans have no overages - reset usage and cleanup org
|
||||
if (subscription.plan === 'enterprise') {
|
||||
await resetUsageForSubscription({
|
||||
plan: subscription.plan,
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||
subscription.referenceId
|
||||
)
|
||||
|
||||
logger.info('Successfully processed enterprise subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
restoredProCount,
|
||||
organizationDeleted: true,
|
||||
membersSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,13 +288,32 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
// Plan-specific cleanup after billing
|
||||
let restoredProCount = 0
|
||||
let organizationDeleted = false
|
||||
let membersSynced = 0
|
||||
|
||||
if (subscription.plan === 'team') {
|
||||
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
restoredProCount = cleanup.restoredProCount
|
||||
membersSynced = cleanup.membersSynced
|
||||
organizationDeleted = true
|
||||
} else if (subscription.plan === 'pro') {
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
membersSynced = 1
|
||||
}
|
||||
|
||||
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
|
||||
// We only need to handle overage billing and usage reset
|
||||
// We handle overage billing, usage reset, Pro restoration, limit syncing, and org cleanup
|
||||
|
||||
logger.info('Successfully processed subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
plan: subscription.plan,
|
||||
totalOverage,
|
||||
restoredProCount,
|
||||
organizationDeleted,
|
||||
membersSynced,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle subscription deletion', {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const env = createEnv({
|
||||
BETTER_AUTH_URL: z.string().url(), // Base URL for Better Auth service
|
||||
BETTER_AUTH_SECRET: z.string().min(32), // Secret key for Better Auth JWT signing
|
||||
DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration
|
||||
DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session)
|
||||
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
|
||||
|
||||
@@ -35,6 +35,31 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED)
|
||||
*/
|
||||
export const isEmailVerificationEnabled = isTruthy(env.EMAIL_VERIFICATION_ENABLED)
|
||||
|
||||
/**
|
||||
* Is authentication disabled (for self-hosted deployments behind private networks)
|
||||
*/
|
||||
export const isAuthDisabled = isTruthy(env.DISABLE_AUTH)
|
||||
|
||||
/**
|
||||
* Is user registration disabled
|
||||
*/
|
||||
export const isRegistrationDisabled = isTruthy(env.DISABLE_REGISTRATION)
|
||||
|
||||
/**
|
||||
* Is Trigger.dev enabled for async job processing
|
||||
*/
|
||||
export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
/**
|
||||
* Is SSO enabled for enterprise authentication
|
||||
*/
|
||||
export const isSsoEnabled = isTruthy(env.SSO_ENABLED)
|
||||
|
||||
/**
|
||||
* Is E2B enabled for remote code execution
|
||||
*/
|
||||
export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
|
||||
/**
|
||||
* Get cost multiplier based on environment
|
||||
*/
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnv } from '@/lib/core/config/env'
|
||||
import { isProd } from '@/lib/core/config/environment'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
|
||||
/**
|
||||
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import {
|
||||
@@ -140,9 +140,7 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog):
|
||||
alertConfig: alertConfig || undefined,
|
||||
}
|
||||
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
if (isTriggerDevEnabled) {
|
||||
await workspaceNotificationDeliveryTask.trigger(payload)
|
||||
logger.info(
|
||||
`Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev`
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
maybeSendUsageThresholdEmail,
|
||||
} from '@/lib/billing/core/usage'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { filterForDisplay } from '@/lib/core/utils/display-filters'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { isTest } from '@/lib/core/config/environment'
|
||||
import { isTest } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { and, eq, gte, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
executeNotificationDelivery,
|
||||
@@ -118,9 +118,7 @@ async function checkWorkflowInactivity(
|
||||
alertConfig,
|
||||
}
|
||||
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
if (isTriggerDevEnabled) {
|
||||
await workspaceNotificationDeliveryTask.trigger(payload)
|
||||
} else {
|
||||
void executeNotificationDelivery(payload).catch((error) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { env, isTruthy } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
||||
@@ -707,9 +707,7 @@ export async function queueWebhookExecution(
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
}
|
||||
|
||||
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
|
||||
|
||||
if (useTrigger) {
|
||||
if (isTriggerDevEnabled) {
|
||||
const handle = await tasks.trigger('webhook-execution', payload)
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${
|
||||
|
||||
@@ -11,7 +11,7 @@ export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
|
||||
/**
|
||||
* Horizontal spacing between layers (columns)
|
||||
*/
|
||||
export const DEFAULT_HORIZONTAL_SPACING = 350
|
||||
export const DEFAULT_HORIZONTAL_SPACING = 250
|
||||
|
||||
/**
|
||||
* Vertical spacing between blocks in the same layer
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user