Compare commits

...

33 Commits

Author SHA1 Message Date
Vikhyath Mondreti
08b908fdce fix tests 2026-02-14 00:00:03 -08:00
Vikhyath Mondreti
ea42e64540 run lint 2026-02-13 18:05:52 -08:00
Vikhyath Mondreti
d70a5d4271 backfill improvements 2026-02-13 16:26:13 -08:00
Vikhyath Mondreti
93826cbd1a migration readded 2026-02-13 15:25:13 -08:00
Vikhyath Mondreti
7092c88b9b Merge remote-tracking branch 'origin/staging' into feat/mult-credentials-rv 2026-02-13 15:05:12 -08:00
Vikhyath Mondreti
084ff9c9d0 remove migration to prep stagin migration 2026-02-13 14:37:03 -08:00
Vikhyath Mondreti
3ad0f62545 canonical credential id entry 2026-02-13 14:20:57 -08:00
Vikhyath Mondreti
ff13b1f43b remove credential no access marker 2026-02-13 12:17:26 -08:00
Vikhyath Mondreti
fa32b9e687 reconnect option to connect diff account 2026-02-13 12:12:56 -08:00
Waleed
7fbbc7ba7a fix(tool-input): sync cleared subblock values to tool params (#3214) 2026-02-13 00:18:25 -08:00
Waleed
a337aa7dfe feat(internal): added internal api base url for internal calls (#3212)
* feat(internal): added internal api base url for internal calls

* make validation on http more lax
2026-02-12 23:56:35 -08:00
Waleed
022e84c4b1 feat(creators): added referrers, code redemption, campaign tracking, etc (#3198)
* feat(creators): added referrers, code redemption, campaign tracking, etc

* more

* added zod

* remove default

* remove duplicate index

* update admin routes

* reran migrations

* lint

* move userstats record creation inside tx

* added reason for already attributed case

* cleanup referral attributes
2026-02-12 20:07:40 -08:00
Waleed
602e371a7a refactor(tool-input): subblock-first rendering, component extraction, bug fixes (#3207)
* refactor(tool-input): eliminate SyncWrappers, add canonical toggle and dependsOn gating

Replace 17+ individual SyncWrapper components with a single centralized
ToolSubBlockRenderer that bridges the subblock store with StoredTool.params
via synthetic store keys. This reduces ~1000 lines of duplicated wrapper
code and ensures tool-input renders subblock components identically to
the standalone SubBlock path.

- Add ToolSubBlockRenderer with bidirectional store sync
- Add basic/advanced mode toggle (ArrowLeftRight) using collaborative functions
- Add dependsOn gating via useDependsOnGate (fields disable instead of hiding)
- Add paramVisibility field to SubBlockConfig for tool-input visibility control
- Pass canonicalModeOverrides through getSubBlocksForToolInput
- Show (optional) label for non-user-only fields (LLM can inject at runtime)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): restore optional indicator, fix folder selector and canonical toggle, extract components

- Attach resolved paramVisibility to subblocks from getSubBlocksForToolInput
- Add labelSuffix prop to SubBlock for "(optional)" badge on user-or-llm params
- Fix folder selector missing for tools with canonicalParamId (e.g. Google Drive)
- Fix canonical toggle not clickable by letting SubBlock handle dependsOn internally
- Extract ParameterWithLabel, ToolSubBlockRenderer, ToolCredentialSelector to components/tools/
- Extract StoredTool interface to types.ts, selection helpers to utils.ts
- Remove dead code (mcpError, refreshTools, oldParamIds, initialParams)
- Strengthen typing: replace any with proper types on icon components and evaluateParameterCondition

* add sibling values to subblock context since subblock store isn't relevant in tool input, and removed unused param

* cleanup

* fix(tool-input): render uncovered tool params alongside subblocks

The SubBlock-first rendering path was hard-returning after rendering
subblocks, so tool params without matching subblocks (like inputMapping
for workflow tools) were never rendered. Now renders subblocks first,
then any remaining displayParams not covered by subblocks via the legacy
ParameterWithLabel fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): auto-refresh workflow inputs after redeploy

After redeploying a child workflow via the stale badge, the workflow
state cache was not invalidated, so WorkflowInputMapperInput kept
showing stale input fields until page refresh. Now invalidates
workflowKeys.state on deploy success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): correct workflow selector visibility and tighten (optional) spacing

- Set workflowId param to user-only in workflow_executor tool config
  so "Select Workflow" no longer shows "(optional)" indicator
- Tighten (optional) label spacing with -ml-[3px] to counteract
  parent Label's gap-[6px], making it feel inline with the label text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): align (optional) text to baseline instead of center

Use items-baseline instead of items-center on Label flex containers
so the smaller (optional) text aligns with the label text baseline
rather than sitting slightly below it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): increase top padding of expanded tool body

Bump the expanded tool body container's top padding from 8px to 12px
for more breathing room between the header bar and the first parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): apply extra top padding only to SubBlock-first path

Revert container padding to py-[8px] (MCP tools were correct).
Wrap SubBlock-first output in a div with pt-[4px] so only registry
tools get extra breathing room from the container top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tool-input): increase gap between SubBlock params for visual clarity

SubBlock's internal gap (10px between label and input) matched the
between-parameter gap (10px), making them indistinguishable. Increase
the between-parameter gap to 14px so consecutive parameters are
visually distinct, matching the separation seen in ParameterWithLabel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix spacing and optional tag

* update styling + move predeploy checks earlier for first time deploys

* update change detection to account for synthetic tool ids

* fix remaining blocks who had files visibility set to hidden

* cleanup

* add catch

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:01:04 -08:00
Vikhyath Mondreti
dcf40be189 copilot + oauth name comflict 2026-02-12 18:42:52 -08:00
Vikhyath Mondreti
77bb048307 share button 2026-02-12 18:04:02 -08:00
Vikhyath Mondreti
17710b39a5 remove new badge 2026-02-12 17:05:54 -08:00
Vikhyath Mondreti
bdd14839a3 share with workspace for oauth 2026-02-12 17:01:55 -08:00
Vikhyath Mondreti
8ed8a5a1ce more ux improvmeent 2026-02-12 16:52:54 -08:00
Vikhyath Mondreti
5e19226dd1 promote to workspace secret 2026-02-12 16:50:13 -08:00
Vikhyath Mondreti
622023d998 bulk entry of .env 2026-02-12 16:39:10 -08:00
Theodore Li
9a06cae591 Merge pull request #3210 from simstudioai/feat/google-books
feat(google books): Add google books integration
2026-02-12 16:18:42 -08:00
Theodore Li
dce47a101c Migrate last response to types 2026-02-12 15:45:00 -08:00
Theodore Li
1130f8ddb2 Remove redundant error handling, move volume item to types file 2026-02-12 15:31:12 -08:00
Waleed
ebc2ffa1c5 fix(agent): always fetch latest custom tool from DB when customToolId is present (#3208)
* fix(agent): always fetch latest custom tool from DB when customToolId is present

* test(agent): use generic test data for customToolId resolution tests

* fix(agent): mock buildAuthHeaders in tests for CI compatibility

* remove inline mocks in favor of sim/testing ones
2026-02-12 15:31:11 -08:00
Vikhyath Mondreti
319768c2bd remove add member ui for workspace secrets 2026-02-12 15:28:15 -08:00
Theodore Li
fc97ce007d Correct error handling, specify auth mode as api key 2026-02-12 15:26:13 -08:00
Vikhyath Mondreti
aefa281677 improve collaborative UX 2026-02-12 15:18:54 -08:00
Theodore Li
6c006cdfec feat(google books): Add google books integration 2026-02-12 15:01:33 -08:00
Siddharth Ganesan
c380e59cb3 fix(copilot): make default model opus 4.5 (#3209)
* Fix default model

* Fix
2026-02-12 13:17:45 -08:00
Waleed
2944579d21 fix(s3): support get-object region override and robust S3 URL parsing (#3206)
* fix(s3): support get-object region override and robust S3 URL parsing

* ack pr comments
2026-02-12 10:59:22 -08:00
Vikhyath Mondreti
508772cf58 make it autoselect personal secret when create secret is clicked 2026-02-11 20:06:27 -08:00
Vikhyath Mondreti
7314675f50 checkpoint 2026-02-11 19:58:24 -08:00
Vikhyath Mondreti
253161afba feat(mult-credentials): progress 2026-02-11 15:18:31 -08:00
205 changed files with 32810 additions and 2569 deletions

View File

@@ -1157,6 +1157,21 @@ export function AirweaveIcon(props: SVGProps<SVGSVGElement>) {
) )
} }
export function GoogleBooksIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 478.633 540.068'>
<path
fill='#1C51A4'
d='M449.059,218.231L245.519,99.538l-0.061,193.23c0.031,1.504-0.368,2.977-1.166,4.204c-0.798,1.258-1.565,1.995-2.915,2.547c-1.35,0.552-2.792,0.706-4.204,0.399c-1.412-0.307-2.7-1.043-3.713-2.117l-69.166-70.609l-69.381,70.179c-1.013,0.982-2.301,1.657-3.652,1.903c-1.381,0.246-2.792,0.092-4.081-0.491c-1.289-0.583-1.626-0.522-2.394-1.749c-0.767-1.197-1.197-2.608-1.197-4.081L85.031,6.007l-2.915-1.289C43.973-11.638,0,16.409,0,59.891v420.306c0,46.029,49.312,74.782,88.775,51.767l360.285-210.138C488.491,298.782,488.491,241.246,449.059,218.231z'
/>
<path
fill='#80D7FB'
d='M88.805,8.124c-2.179-1.289-4.419-2.363-6.659-3.345l0.123,288.663c0,1.442,0.43,2.854,1.197,4.081c0.767,1.197,1.872,2.148,3.161,2.731c1.289,0.583,2.7,0.736,4.081,0.491c1.381-0.246,2.639-0.921,3.652-1.903l69.749-69.688l69.811,69.749c1.013,1.074,2.301,1.81,3.713,2.117c1.412,0.307,2.884,0.153,4.204-0.399c1.319-0.552,2.455-1.565,3.253-2.792c0.798-1.258,1.197-2.731,1.166-4.204V99.998L88.805,8.124z'
/>
</svg>
)
}
export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) { export function GoogleDocsIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg

View File

@@ -38,6 +38,7 @@ import {
GithubIcon, GithubIcon,
GitLabIcon, GitLabIcon,
GmailIcon, GmailIcon,
GoogleBooksIcon,
GoogleCalendarIcon, GoogleCalendarIcon,
GoogleDocsIcon, GoogleDocsIcon,
GoogleDriveIcon, GoogleDriveIcon,
@@ -172,6 +173,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
github_v2: GithubIcon, github_v2: GithubIcon,
gitlab: GitLabIcon, gitlab: GitLabIcon,
gmail_v2: GmailIcon, gmail_v2: GmailIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon, google_calendar_v2: GoogleCalendarIcon,
google_docs: GoogleDocsIcon, google_docs: GoogleDocsIcon,
google_drive: GoogleDriveIcon, google_drive: GoogleDriveIcon,

View File

@@ -0,0 +1,96 @@
---
title: Google Books
description: Search and retrieve book information
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_books"
color="#FFFFFF"
/>
## Usage Instructions
Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.
## Tools
### `google_books_volume_search`
Search for books using the Google Books API
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Books API key |
| `query` | string | Yes | Search query. Supports special keywords: intitle:, inauthor:, inpublisher:, subject:, isbn: |
| `filter` | string | No | Filter results by availability \(partial, full, free-ebooks, paid-ebooks, ebooks\) |
| `printType` | string | No | Restrict to print type \(all, books, magazines\) |
| `orderBy` | string | No | Sort order \(relevance, newest\) |
| `startIndex` | number | No | Index of the first result to return \(for pagination\) |
| `maxResults` | number | No | Maximum number of results to return \(1-40\) |
| `langRestrict` | string | No | Restrict results to a specific language \(ISO 639-1 code\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `totalItems` | number | Total number of matching results |
| `volumes` | array | List of matching volumes |
| ↳ `id` | string | Volume ID |
| ↳ `title` | string | Book title |
| ↳ `subtitle` | string | Book subtitle |
| ↳ `authors` | array | List of authors |
| ↳ `publisher` | string | Publisher name |
| ↳ `publishedDate` | string | Publication date |
| ↳ `description` | string | Book description |
| ↳ `pageCount` | number | Number of pages |
| ↳ `categories` | array | Book categories |
| ↳ `averageRating` | number | Average rating \(1-5\) |
| ↳ `ratingsCount` | number | Number of ratings |
| ↳ `language` | string | Language code |
| ↳ `previewLink` | string | Link to preview on Google Books |
| ↳ `infoLink` | string | Link to info page |
| ↳ `thumbnailUrl` | string | Book cover thumbnail URL |
| ↳ `isbn10` | string | ISBN-10 identifier |
| ↳ `isbn13` | string | ISBN-13 identifier |
### `google_books_volume_details`
Get detailed information about a specific book volume
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Books API key |
| `volumeId` | string | Yes | The ID of the volume to retrieve |
| `projection` | string | No | Projection level \(full, lite\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `id` | string | Volume ID |
| `title` | string | Book title |
| `subtitle` | string | Book subtitle |
| `authors` | array | List of authors |
| `publisher` | string | Publisher name |
| `publishedDate` | string | Publication date |
| `description` | string | Book description |
| `pageCount` | number | Number of pages |
| `categories` | array | Book categories |
| `averageRating` | number | Average rating \(1-5\) |
| `ratingsCount` | number | Number of ratings |
| `language` | string | Language code |
| `previewLink` | string | Link to preview on Google Books |
| `infoLink` | string | Link to info page |
| `thumbnailUrl` | string | Book cover thumbnail URL |
| `isbn10` | string | ISBN-10 identifier |
| `isbn13` | string | ISBN-13 identifier |

View File

@@ -33,6 +33,7 @@
"github", "github",
"gitlab", "gitlab",
"gmail", "gmail",
"google_books",
"google_calendar", "google_calendar",
"google_docs", "google_docs",
"google_drive", "google_drive",

View File

@@ -13,6 +13,7 @@ BETTER_AUTH_URL=http://localhost:3000
# NextJS (Required) # NextJS (Required)
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
# Security (Required) # Security (Required)
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables

View File

@@ -1,7 +1,7 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk' import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal' import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
/** A2A v0.3 JSON-RPC method names */ /** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = { export const A2A_METHODS = {
@@ -118,7 +118,7 @@ export interface ExecuteRequestResult {
export async function buildExecuteRequest( export async function buildExecuteRequest(
config: ExecuteRequestConfig config: ExecuteRequestConfig
): Promise<ExecuteRequestResult> { ): Promise<ExecuteRequestResult> {
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute` const url = `${getInternalApiBaseUrl()}/api/workflows/${config.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' } const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let useInternalAuth = false let useInternalAuth = false

View File

@@ -0,0 +1,187 @@
/**
* POST /api/attribution
*
* Automatic UTM-based referral attribution.
*
* Reads the `sim_utm` cookie (set by proxy on auth pages), matches a campaign
* by UTM specificity, and atomically inserts an attribution record + applies
* bonus credits.
*
* Idempotent — the unique constraint on `userId` prevents double-attribution.
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('AttributionAPI')
const COOKIE_NAME = 'sim_utm'
const UtmCookieSchema = z.object({
utm_source: z.string().optional(),
utm_medium: z.string().optional(),
utm_campaign: z.string().optional(),
utm_content: z.string().optional(),
referrer_url: z.string().optional(),
landing_page: z.string().optional(),
created_at: z.string().optional(),
})
/**
* Finds the most specific active campaign matching the given UTM params.
* Null fields on a campaign act as wildcards. Ties broken by newest campaign.
*/
async function findMatchingCampaign(utmData: z.infer<typeof UtmCookieSchema>) {
const campaigns = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.isActive, true))
let bestMatch: (typeof campaigns)[number] | null = null
let bestScore = -1
for (const campaign of campaigns) {
let score = 0
let mismatch = false
const fields = [
{ campaignVal: campaign.utmSource, utmVal: utmData.utm_source },
{ campaignVal: campaign.utmMedium, utmVal: utmData.utm_medium },
{ campaignVal: campaign.utmCampaign, utmVal: utmData.utm_campaign },
{ campaignVal: campaign.utmContent, utmVal: utmData.utm_content },
] as const
for (const { campaignVal, utmVal } of fields) {
if (campaignVal === null) continue
if (campaignVal === utmVal) {
score++
} else {
mismatch = true
break
}
}
if (!mismatch && score > 0) {
if (
score > bestScore ||
(score === bestScore &&
bestMatch &&
campaign.createdAt.getTime() > bestMatch.createdAt.getTime())
) {
bestScore = score
bestMatch = campaign
}
}
}
return bestMatch
}
export async function POST() {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const cookieStore = await cookies()
const utmCookie = cookieStore.get(COOKIE_NAME)
if (!utmCookie?.value) {
return NextResponse.json({ attributed: false, reason: 'no_utm_cookie' })
}
let utmData: z.infer<typeof UtmCookieSchema>
try {
let decoded: string
try {
decoded = decodeURIComponent(utmCookie.value)
} catch {
decoded = utmCookie.value
}
utmData = UtmCookieSchema.parse(JSON.parse(decoded))
} catch {
logger.warn('Failed to parse UTM cookie', { userId: session.user.id })
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'invalid_cookie' })
}
const matchedCampaign = await findMatchingCampaign(utmData)
if (!matchedCampaign) {
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({ attributed: false, reason: 'no_matching_campaign' })
}
const bonusAmount = Number(matchedCampaign.bonusCreditAmount)
let attributed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
campaignId: matchedCampaign.id,
utmSource: utmData.utm_source || null,
utmMedium: utmData.utm_medium || null,
utmCampaign: utmData.utm_campaign || null,
utmContent: utmData.utm_content || null,
referrerUrl: utmData.referrer_url || null,
landingPage: utmData.landing_page || null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing({ target: referralAttribution.userId })
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
attributed = true
}
})
if (attributed) {
logger.info('Referral attribution created and bonus credits applied', {
userId: session.user.id,
campaignId: matchedCampaign.id,
campaignName: matchedCampaign.name,
utmSource: utmData.utm_source,
utmCampaign: utmData.utm_campaign,
utmContent: utmData.utm_content,
bonusAmount,
})
} else {
logger.info('User already attributed, skipping', { userId: session.user.id })
}
cookieStore.delete(COOKIE_NAME)
return NextResponse.json({
attributed,
bonusAmount: attributed ? bonusAmount : undefined,
reason: attributed ? undefined : 'already_attributed',
})
} catch (error) {
logger.error('Attribution error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account } from '@sim/db/schema' import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
@@ -31,15 +31,13 @@ export async function GET(request: NextRequest) {
}) })
.from(account) .from(account)
.where(and(...whereConditions)) .where(and(...whereConditions))
.orderBy(desc(account.updatedAt))
// Use the user's email as the display name (consistent with credential selector)
const userEmail = session.user.email
const accountsWithDisplayName = accounts.map((acc) => ({ const accountsWithDisplayName = accounts.map((acc) => ({
id: acc.id, id: acc.id,
accountId: acc.accountId, accountId: acc.accountId,
providerId: acc.providerId, providerId: acc.providerId,
displayName: userEmail || acc.providerId, displayName: acc.accountId || acc.providerId,
})) }))
return NextResponse.json({ accounts: accountsWithDisplayName }) return NextResponse.json({ accounts: accountsWithDisplayName })

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, user } from '@sim/db/schema' import { account, credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
@@ -7,8 +7,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth' import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -18,6 +20,7 @@ const credentialsQuerySchema = z
.object({ .object({
provider: z.string().nullish(), provider: z.string().nullish(),
workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(), workflowId: z.string().uuid('Workflow ID must be a valid UUID').nullish(),
workspaceId: z.string().uuid('Workspace ID must be a valid UUID').nullish(),
credentialId: z credentialId: z
.string() .string()
.min(1, 'Credential ID must not be empty') .min(1, 'Credential ID must not be empty')
@@ -35,6 +38,79 @@ interface GoogleIdToken {
name?: string name?: string
} }
function toCredentialResponse(
id: string,
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
id,
name: displayName,
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}
async function getFallbackDisplayName(
requestId: string,
providerParam: string | null | undefined,
accountRow: {
idToken: string | null
accountId: string
userId: string
}
) {
const providerForParse = (providerParam || 'google') as OAuthProvider
const { baseProvider } = parseProvider(providerForParse)
if (accountRow.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(accountRow.idToken)
if (decoded.email) return decoded.email
if (decoded.name) return decoded.name
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: accountRow.accountId,
})
}
}
if (baseProvider === 'github') {
return `${accountRow.accountId} (GitHub)`
}
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, accountRow.userId))
.limit(1)
if (userRecord.length > 0) {
return userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: accountRow.userId,
})
}
return `${accountRow.accountId} (${baseProvider})`
}
/** /**
* Get credentials for a specific provider * Get credentials for a specific provider
*/ */
@@ -46,6 +122,7 @@ export async function GET(request: NextRequest) {
const rawQuery = { const rawQuery = {
provider: searchParams.get('provider'), provider: searchParams.get('provider'),
workflowId: searchParams.get('workflowId'), workflowId: searchParams.get('workflowId'),
workspaceId: searchParams.get('workspaceId'),
credentialId: searchParams.get('credentialId'), credentialId: searchParams.get('credentialId'),
} }
@@ -78,7 +155,7 @@ export async function GET(request: NextRequest) {
) )
} }
const { provider: providerParam, workflowId, credentialId } = parseResult.data const { provider: providerParam, workflowId, workspaceId, credentialId } = parseResult.data
// Authenticate requester (supports session and internal JWT) // Authenticate requester (supports session and internal JWT)
const authResult = await checkSessionOrInternalAuth(request) const authResult = await checkSessionOrInternalAuth(request)
@@ -88,7 +165,7 @@ export async function GET(request: NextRequest) {
} }
const requesterUserId = authResult.userId const requesterUserId = authResult.userId
const effectiveUserId = requesterUserId let effectiveWorkspaceId = workspaceId ?? undefined
if (workflowId) { if (workflowId) {
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId, workflowId,
@@ -106,101 +183,145 @@ export async function GET(request: NextRequest) {
{ status: workflowAuthorization.status } { status: workflowAuthorization.status }
) )
} }
effectiveWorkspaceId = workflowAuthorization.workflow?.workspaceId || undefined
} }
// Parse the provider to get base provider and feature type (if provider is present) if (effectiveWorkspaceId) {
const { baseProvider } = parseProvider((providerParam || 'google') as OAuthProvider) const workspaceAccess = await checkWorkspaceAccess(effectiveWorkspaceId, requesterUserId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
let accountsData let accountsData
if (credentialId) {
const [platformCredential] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
})
.from(credential)
.leftJoin(account, eq(credential.accountId, account.id))
.where(eq(credential.id, credentialId))
.limit(1)
if (platformCredential) {
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
if (workflowId) {
if (!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
if (!platformCredential.accountProviderId || !platformCredential.accountUpdatedAt) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.accountProviderId,
platformCredential.accountUpdatedAt,
platformCredential.accountScope
),
],
},
{ status: 200 }
)
}
}
if (effectiveWorkspaceId && providerParam) {
await syncWorkspaceOAuthCredentialsForUser({
workspaceId: effectiveWorkspaceId,
userId: requesterUserId,
})
const credentialsData = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: account.providerId,
scope: account.scope,
updatedAt: account.updatedAt,
})
.from(credential)
.innerJoin(account, eq(credential.accountId, account.id))
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'oauth'),
eq(account.providerId, providerParam)
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
)
}
if (credentialId && workflowId) { if (credentialId && workflowId) {
// When both workflowId and credentialId are provided, fetch by ID only.
// Workspace authorization above already proves access; the credential
// may belong to another workspace member (e.g. for display name resolution).
accountsData = await db.select().from(account).where(eq(account.id, credentialId)) accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else if (credentialId) { } else if (credentialId) {
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId))) .where(and(eq(account.userId, requesterUserId), eq(account.id, credentialId)))
} else { } else {
// Fetch all credentials for provider and effective user
accountsData = await db accountsData = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!))) .where(and(eq(account.userId, requesterUserId), eq(account.providerId, providerParam!)))
} }
// Transform accounts into credentials // Transform accounts into credentials
const credentials = await Promise.all( const credentials = await Promise.all(
accountsData.map(async (acc) => { accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default') const displayName = await getFallbackDisplayName(requestId, providerParam, acc)
const [_, featureType = 'default'] = acc.providerId.split('-') return toCredentialResponse(acc.id, displayName, acc.providerId, acc.updatedAt, acc.scope)
// Try multiple methods to get a user-friendly display name
let displayName = ''
// Method 1: Try to extract email from ID token (works for Google, etc.)
if (acc.idToken) {
try {
const decoded = jwtDecode<GoogleIdToken>(acc.idToken)
if (decoded.email) {
displayName = decoded.email
} else if (decoded.name) {
displayName = decoded.name
}
} catch (_error) {
logger.warn(`[${requestId}] Error decoding ID token`, {
accountId: acc.id,
})
}
}
// Method 2: For GitHub, the accountId might be the username
if (!displayName && baseProvider === 'github') {
displayName = `${acc.accountId} (GitHub)`
}
// Method 3: Try to get the user's email from our database
if (!displayName) {
try {
const userRecord = await db
.select({ email: user.email })
.from(user)
.where(eq(user.id, acc.userId))
.limit(1)
if (userRecord.length > 0) {
displayName = userRecord[0].email
}
} catch (_error) {
logger.warn(`[${requestId}] Error fetching user email`, {
userId: acc.userId,
})
}
}
// Fallback: Use accountId with provider type as context
if (!displayName) {
displayName = `${acc.accountId} (${baseProvider})`
}
const storedScope = acc.scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
return {
id: acc.id,
name: displayName,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}) })
) )

View File

@@ -15,6 +15,7 @@ const logger = createLogger('OAuthDisconnectAPI')
const disconnectSchema = z.object({ const disconnectSchema = z.object({
provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'), provider: z.string({ required_error: 'Provider is required' }).min(1, 'Provider is required'),
providerId: z.string().optional(), providerId: z.string().optional(),
accountId: z.string().optional(),
}) })
/** /**
@@ -50,15 +51,20 @@ export async function POST(request: NextRequest) {
) )
} }
const { provider, providerId } = parseResult.data const { provider, providerId, accountId } = parseResult.data
logger.info(`[${requestId}] Processing OAuth disconnect request`, { logger.info(`[${requestId}] Processing OAuth disconnect request`, {
provider, provider,
hasProviderId: !!providerId, hasProviderId: !!providerId,
}) })
// If a specific providerId is provided, delete only that account // If a specific account row ID is provided, delete that exact account
if (providerId) { if (accountId) {
await db
.delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.id, accountId)))
} else if (providerId) {
// If a specific providerId is provided, delete accounts for that provider ID
await db await db
.delete(account) .delete(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId))) .where(and(eq(account.userId, session.user.id), eq(account.providerId, providerId)))

View File

@@ -38,13 +38,18 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
credentialId, resolvedCredentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -37,14 +37,19 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
// Refresh access token if needed using the utility function // Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded( const accessToken = await refreshAccessTokenIfNeeded(
credentialId, resolvedCredentialId,
authz.credentialOwnerUserId, authz.credentialOwnerUserId,
requestId requestId
) )

View File

@@ -351,10 +351,11 @@ describe('OAuth Token API Routes', () => {
*/ */
describe('GET handler', () => { describe('GET handler', () => {
it('should return access token successfully', async () => { it('should return access token successfully', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -380,8 +381,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token') expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockCheckSessionOrInternalAuth).toHaveBeenCalled() expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id') expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled() expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
}) })
@@ -399,8 +400,8 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle authentication failure', async () => { it('should handle authentication failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: false, ok: false,
error: 'Authentication required', error: 'Authentication required',
}) })
@@ -413,15 +414,16 @@ describe('OAuth Token API Routes', () => {
const response = await GET(req as any) const response = await GET(req as any)
const data = await response.json() const data = await response.json()
expect(response.status).toBe(401) expect(response.status).toBe(403)
expect(data).toHaveProperty('error') expect(data).toHaveProperty('error')
}) })
it('should handle credential not found', async () => { it('should handle credential not found', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce(undefined) mockGetCredential.mockResolvedValueOnce(undefined)
@@ -439,10 +441,11 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle missing access token', async () => { it('should handle missing access token', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',
@@ -465,10 +468,11 @@ describe('OAuth Token API Routes', () => {
}) })
it('should handle token refresh failure', async () => { it('should handle token refresh failure', async () => {
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ mockAuthorizeCredentialUse.mockResolvedValueOnce({
success: true, ok: true,
authType: 'session', authType: 'session',
userId: 'test-user-id', requesterUserId: 'test-user-id',
credentialOwnerUserId: 'test-user-id',
}) })
mockGetCredential.mockResolvedValueOnce({ mockGetCredential.mockResolvedValueOnce({
id: 'credential-id', id: 'credential-id',

View File

@@ -110,23 +110,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
} }
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const authz = await authorizeCredentialUse(request, { const authz = await authorizeCredentialUse(request, {
credentialId, credentialId,
workflowId: workflowId ?? undefined, workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false, requireWorkflowIdForInternal: false,
callerUserId,
}) })
if (!authz.ok || !authz.credentialOwnerUserId) { if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
let instanceUrl: string | undefined let instanceUrl: string | undefined
if (credential.providerId === 'salesforce' && credential.scope) { if (credential.providerId === 'salesforce' && credential.scope) {
@@ -186,13 +198,20 @@ export async function GET(request: NextRequest) {
const { credentialId } = parseResult.data const { credentialId } = parseResult.data
// For GET requests, we only support session-based authentication const authz = await authorizeCredentialUse(request, {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) credentialId,
if (!auth.success || auth.authType !== 'session' || !auth.userId) { requireWorkflowIdForInternal: false,
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) })
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
} }
const credential = await getCredential(requestId, credentialId, auth.userId) const resolvedCredentialId = authz.resolvedCredentialId || credentialId
const credential = await getCredential(
requestId,
resolvedCredentialId,
authz.credentialOwnerUserId
)
if (!credential) { if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
@@ -204,7 +223,11 @@ export async function GET(request: NextRequest) {
} }
try { try {
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
resolvedCredentialId
)
// For Salesforce, extract instanceUrl from the scope field // For Salesforce, extract instanceUrl from the scope field
let instanceUrl: string | undefined let instanceUrl: string | undefined

View File

@@ -4,20 +4,10 @@
* @vitest-environment node * @vitest-environment node
*/ */
import { loggerMock } from '@sim/testing' import { databaseMock, loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({ vi.mock('@sim/db', () => databaseMock)
db: {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
},
}))
vi.mock('@/lib/oauth/oauth', () => ({ vi.mock('@/lib/oauth/oauth', () => ({
refreshOAuthToken: vi.fn(), refreshOAuthToken: vi.fn(),
@@ -34,13 +24,36 @@ import {
refreshTokenIfNeeded, refreshTokenIfNeeded,
} from '@/app/api/auth/oauth/utils' } from '@/app/api/auth/oauth/utils'
const mockDbTyped = db as any const mockDb = db as any
const mockRefreshOAuthToken = refreshOAuthToken as any const mockRefreshOAuthToken = refreshOAuthToken as any
/**
* Creates a chainable mock for db.select() calls.
* Returns a nested chain: select() -> from() -> where() -> limit() / orderBy()
*/
function mockSelectChain(limitResult: unknown[]) {
const mockLimit = vi.fn().mockReturnValue(limitResult)
const mockOrderBy = vi.fn().mockReturnValue(limitResult)
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit, orderBy: mockOrderBy })
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.select.mockReturnValueOnce({ from: mockFrom })
return { mockFrom, mockWhere, mockLimit }
}
/**
* Creates a chainable mock for db.update() calls.
* Returns a nested chain: update() -> set() -> where()
*/
function mockUpdateChain() {
const mockWhere = vi.fn().mockResolvedValue({})
const mockSet = vi.fn().mockReturnValue({ where: mockWhere })
mockDb.update.mockReturnValueOnce({ set: mockSet })
return { mockSet, mockWhere }
}
describe('OAuth Utils', () => { describe('OAuth Utils', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockDbTyped.limit.mockReturnValue([])
}) })
afterEach(() => { afterEach(() => {
@@ -49,21 +62,23 @@ describe('OAuth Utils', () => {
describe('getCredential', () => { describe('getCredential', () => {
it('should return credential when found', async () => { it('should return credential when found', async () => {
const mockCredential = { id: 'credential-id', userId: 'test-user-id' } const mockCredentialRow = { type: 'oauth', accountId: 'resolved-account-id' }
mockDbTyped.limit.mockReturnValueOnce([mockCredential]) const mockAccountRow = { id: 'resolved-account-id', userId: 'test-user-id' }
mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const credential = await getCredential('request-id', 'credential-id', 'test-user-id') const credential = await getCredential('request-id', 'credential-id', 'test-user-id')
expect(mockDbTyped.select).toHaveBeenCalled() expect(mockDb.select).toHaveBeenCalledTimes(2)
expect(mockDbTyped.from).toHaveBeenCalled()
expect(mockDbTyped.where).toHaveBeenCalled()
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
expect(credential).toEqual(mockCredential) expect(credential).toMatchObject(mockAccountRow)
expect(credential).toMatchObject({ resolvedCredentialId: 'resolved-account-id' })
}) })
it('should return undefined when credential is not found', async () => { it('should return undefined when credential is not found', async () => {
mockDbTyped.limit.mockReturnValueOnce([]) mockSelectChain([])
mockSelectChain([])
const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id') const credential = await getCredential('request-id', 'nonexistent-id', 'test-user-id')
@@ -102,11 +117,12 @@ describe('OAuth Utils', () => {
refreshToken: 'new-refresh-token', refreshToken: 'new-refresh-token',
}) })
mockUpdateChain()
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDbTyped.update).toHaveBeenCalled() expect(mockDb.update).toHaveBeenCalled()
expect(mockDbTyped.set).toHaveBeenCalled()
expect(result).toEqual({ accessToken: 'new-token', refreshed: true }) expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
}) })
@@ -144,15 +160,17 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => { describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => { it('should return valid access token without refresh if not expired', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token', accessToken: 'valid-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockDbTyped.limit.mockReturnValueOnce([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -161,15 +179,18 @@ describe('OAuth Utils', () => {
}) })
it('should refresh token when expired', async () => { it('should refresh token when expired', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockDbTyped.limit.mockReturnValueOnce([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockUpdateChain()
mockRefreshOAuthToken.mockResolvedValueOnce({ mockRefreshOAuthToken.mockResolvedValueOnce({
accessToken: 'new-token', accessToken: 'new-token',
@@ -180,13 +201,13 @@ describe('OAuth Utils', () => {
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
expect(mockDbTyped.update).toHaveBeenCalled() expect(mockDb.update).toHaveBeenCalled()
expect(mockDbTyped.set).toHaveBeenCalled()
expect(token).toBe('new-token') expect(token).toBe('new-token')
}) })
it('should return null if credential not found', async () => { it('should return null if credential not found', async () => {
mockDbTyped.limit.mockReturnValueOnce([]) mockSelectChain([])
mockSelectChain([])
const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id') const token = await refreshAccessTokenIfNeeded('nonexistent-id', 'test-user-id', 'request-id')
@@ -194,15 +215,17 @@ describe('OAuth Utils', () => {
}) })
it('should return null if refresh fails', async () => { it('should return null if refresh fails', async () => {
const mockCredential = { const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
id: 'credential-id', const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token', accessToken: 'expired-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000),
providerId: 'google', providerId: 'google',
userId: 'test-user-id', userId: 'test-user-id',
} }
mockDbTyped.limit.mockReturnValueOnce([mockCredential]) mockSelectChain([mockCredentialRow])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null) mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { account, credentialSetMember } from '@sim/db/schema' import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm' import { and, desc, eq, inArray } from 'drizzle-orm'
import { refreshOAuthToken } from '@/lib/oauth' import { refreshOAuthToken } from '@/lib/oauth'
@@ -25,6 +25,28 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date accessTokenExpiresAt?: Date
} }
async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; usedCredentialTable: boolean } | null> {
const [credentialRow] = await db
.select({
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (credentialRow) {
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
return { accountId: credentialRow.accountId, usedCredentialTable: true }
}
return { accountId: credentialId, usedCredentialTable: false }
}
/** /**
* Safely inserts an account record, handling duplicate constraint violations gracefully. * Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success. * If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -52,10 +74,16 @@ export async function safeAccountInsert(
* Get a credential by ID and verify it belongs to the user * Get a credential by ID and verify it belongs to the user
*/ */
export async function getCredential(requestId: string, credentialId: string, userId: string) { export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
const credentials = await db const credentials = await db
.select() .select()
.from(account) .from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, userId))) .where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.limit(1) .limit(1)
if (!credentials.length) { if (!credentials.length) {
@@ -63,7 +91,10 @@ export async function getCredential(requestId: string, credentialId: string, use
return undefined return undefined
} }
return credentials[0] return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
}
} }
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> { export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
@@ -238,7 +269,9 @@ export async function refreshAccessTokenIfNeeded(
} }
// Update the token in the database // Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId)) const resolvedCredentialId =
(credential as { resolvedCredentialId?: string }).resolvedCredentialId ?? credentialId
await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token for credential`) logger.info(`[${requestId}] Successfully refreshed access token for credential`)
return refreshedToken.accessToken return refreshedToken.accessToken
@@ -274,6 +307,8 @@ export async function refreshTokenIfNeeded(
credential: any, credential: any,
credentialId: string credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> { ): Promise<{ accessToken: string; refreshed: boolean }> {
const resolvedCredentialId = credential.resolvedCredentialId ?? credentialId
// Decide if we should refresh: token missing OR expired // Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
@@ -334,7 +369,7 @@ export async function refreshTokenIfNeeded(
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry() updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
} }
await db.update(account).set(updateData).where(eq(account.id, credentialId)) await db.update(account).set(updateData).where(eq(account.id, resolvedCredentialId))
logger.info(`[${requestId}] Successfully refreshed access token`) logger.info(`[${requestId}] Successfully refreshed access token`)
return { accessToken: refreshedToken, refreshed: true } return { accessToken: refreshedToken, refreshed: true }
@@ -343,7 +378,7 @@ export async function refreshTokenIfNeeded(
`[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded` `[${requestId}] Refresh attempt failed, checking if another concurrent request succeeded`
) )
const freshCredential = await getCredential(requestId, credentialId, credential.userId) const freshCredential = await getCredential(requestId, resolvedCredentialId, credential.userId)
if (freshCredential?.accessToken) { if (freshCredential?.accessToken) {
const freshExpiresAt = freshCredential.accessTokenExpiresAt const freshExpiresAt = freshCredential.accessTokenExpiresAt
const stillValid = !freshExpiresAt || freshExpiresAt > new Date() const stillValid = !freshExpiresAt || freshExpiresAt > new Date()

View File

@@ -48,16 +48,21 @@ export async function GET(request: NextRequest) {
const shopData = await shopResponse.json() const shopData = await shopResponse.json()
const shopInfo = shopData.shop const shopInfo = shopData.shop
const stableAccountId = shopInfo.id?.toString() || shopDomain
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')), where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'shopify'),
eq(account.accountId, stableAccountId)
),
}) })
const now = new Date() const now = new Date()
const accountData = { const accountData = {
accessToken: accessToken, accessToken: accessToken,
accountId: shopInfo.id?.toString() || shopDomain, accountId: stableAccountId,
scope: scope || '', scope: scope || '',
updatedAt: now, updatedAt: now,
idToken: shopDomain, idToken: shopDomain,

View File

@@ -52,7 +52,11 @@ export async function POST(request: NextRequest) {
const trelloUser = await userResponse.json() const trelloUser = await userResponse.json()
const existing = await db.query.account.findFirst({ const existing = await db.query.account.findFirst({
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')), where: and(
eq(account.userId, session.user.id),
eq(account.providerId, 'trello'),
eq(account.accountId, trelloUser.id)
),
}) })
const now = new Date() const now = new Date()

View File

@@ -85,7 +85,7 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(), chatId: z.string().optional(),
workflowId: z.string().optional(), workflowId: z.string().optional(),
workflowName: z.string().optional(), workflowName: z.string().optional(),
model: z.string().optional().default('claude-opus-4-6'), model: z.string().optional().default('claude-opus-4-5'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(), prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false), createNewChat: z.boolean().optional().default(false),
@@ -238,7 +238,7 @@ export async function POST(req: NextRequest) {
let currentChat: any = null let currentChat: any = null
let conversationHistory: any[] = [] let conversationHistory: any[] = []
let actualChatId = chatId let actualChatId = chatId
const selectedModel = model || 'claude-opus-4-6' const selectedModel = model || 'claude-opus-4-5'
if (chatId || createNewChat) { if (chatId || createNewChat) {
const chatResult = await resolveOrCreateChat({ const chatResult = await resolveOrCreateChat({

View File

@@ -18,9 +18,9 @@ describe('Copilot Checkpoints Revert API Route', () => {
setupCommonApiMocks() setupCommonApiMocks()
mockCryptoUuid() mockCryptoUuid()
// Mock getBaseUrl to return localhost for tests
vi.doMock('@/lib/core/utils/urls', () => ({ vi.doMock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'http://localhost:3000'), getBaseUrl: vi.fn(() => 'http://localhost:3000'),
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
getBaseDomain: vi.fn(() => 'localhost:3000'), getBaseDomain: vi.fn(() => 'localhost:3000'),
getEmailDomain: vi.fn(() => 'localhost:3000'), getEmailDomain: vi.fn(() => 'localhost:3000'),
})) }))

View File

@@ -11,7 +11,7 @@ import {
createRequestTracker, createRequestTracker,
createUnauthorizedResponse, createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers' } from '@/lib/copilot/request-helpers'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { isUuidV4 } from '@/executor/constants' import { isUuidV4 } from '@/executor/constants'
@@ -99,7 +99,7 @@ export async function POST(request: NextRequest) {
} }
const stateResponse = await fetch( const stateResponse = await fetch(
`${getBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`, `${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
{ {
method: 'PUT', method: 'PUT',
headers: { headers: {

View File

@@ -0,0 +1,197 @@
import { db } from '@sim/db'
import { credential, credentialMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembersAPI')
interface RouteContext {
params: Promise<{ id: string }>
}
async function requireAdminMembership(credentialId: string, userId: string) {
const [membership] = await db
.select({ role: credentialMember.role, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
return membership
}
export async function GET(_request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
return NextResponse.json({ members: [] }, { status: 200 })
}
const members = await db
.select({
id: credentialMember.id,
userId: credentialMember.userId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
userName: user.name,
userEmail: user.email,
})
.from(credentialMember)
.innerJoin(user, eq(credentialMember.userId, user.id))
.where(eq(credentialMember.credentialId, credentialId))
return NextResponse.json({ members })
} catch (error) {
logger.error('Failed to fetch credential members', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
const addMemberSchema = z.object({
userId: z.string().min(1),
role: z.enum(['admin', 'member']).default('member'),
})
export async function POST(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const admin = await requireAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const body = await request.json()
const parsed = addMemberSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { userId, role } = parsed.data
const now = new Date()
const [existing] = await db
.select({ id: credentialMember.id, status: credentialMember.status })
.from(credentialMember)
.where(
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
)
.limit(1)
if (existing) {
await db
.update(credentialMember)
.set({ role, status: 'active', updatedAt: now })
.where(eq(credentialMember.id, existing.id))
return NextResponse.json({ success: true })
}
await db.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId,
role,
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
return NextResponse.json({ success: true }, { status: 201 })
} catch (error) {
logger.error('Failed to add credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest, context: RouteContext) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: credentialId } = await context.params
const targetUserId = new URL(request.url).searchParams.get('userId')
if (!targetUserId) {
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
}
const admin = await requireAdminMembership(credentialId, session.user.id)
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
const [target] = await db
.select({
id: credentialMember.id,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, targetUserId)
)
)
.limit(1)
if (!target) {
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
}
if (target.role === 'admin') {
const activeAdmins = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
}
}
await db
.update(credentialMember)
.set({ status: 'revoked', updatedAt: new Date() })
.where(eq(credentialMember.id, target.id))
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to remove credential member', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,258 @@
import { db } from '@sim/db'
import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
syncWorkspaceEnvCredentials,
} from '@/lib/credentials/environment'
const logger = createLogger('CredentialByIdAPI')
const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
accountId: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.accountId !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
status: credentialMember.status,
})
.from(credential)
.innerJoin(
credentialMember,
and(eq(credentialMember.credentialId, credential.id), eq(credentialMember.userId, userId))
)
.where(eq(credential.id, credentialId))
.limit(1)
return row ?? null
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.member) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to fetch credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const parseResult = updateCredentialSchema.safeParse(await request.json())
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
const updates: Record<string, unknown> = {}
if (parseResult.data.description !== undefined) {
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
updates.displayName = parseResult.data.displayName
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
},
{ status: 400 }
)
}
return NextResponse.json(
{
error:
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
},
{ status: 400 }
)
}
updates.updatedAt = new Date()
await db.update(credential).set(updates).where(eq(credential.id, id))
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || !access.isAdmin) {
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
}
if (access.credential.type === 'env_personal' && access.credential.envKey) {
const ownerUserId = access.credential.envOwnerUserId
if (!ownerUserId) {
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
}
const [personalRow] = await db
.select({ variables: environment.variables })
.from(environment)
.where(eq(environment.userId, ownerUserId))
.limit(1)
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(environment)
.values({
id: ownerUserId,
userId: ownerUserId,
variables: current,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [environment.userId],
set: { variables: current, updatedAt: new Date() },
})
await syncPersonalEnvCredentialsForUser({
userId: ownerUserId,
envKeys: Object.keys(current),
})
return NextResponse.json({ success: true }, { status: 200 })
}
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
const [workspaceRow] = await db
.select({
id: workspaceEnvironment.id,
createdAt: workspaceEnvironment.createdAt,
variables: workspaceEnvironment.variables,
})
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
.limit(1)
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
string,
string
>
if (access.credential.envKey in current) {
delete current[access.credential.envKey]
}
await db
.insert(workspaceEnvironment)
.values({
id: workspaceRow?.id || crypto.randomUUID(),
workspaceId: access.credential.workspaceId,
variables: current,
createdAt: workspaceRow?.createdAt || new Date(),
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [workspaceEnvironment.workspaceId],
set: { variables: current, updatedAt: new Date() },
})
await syncWorkspaceEnvCredentials({
workspaceId: access.credential.workspaceId,
envKeys: Object.keys(current),
actingUserId: session.user.id,
})
return NextResponse.json({ success: true }, { status: 200 })
}
await db.delete(credential).where(eq(credential.id, id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { db } from '@sim/db'
import { pendingCredentialDraft } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialDraftAPI')
const DRAFT_TTL_MS = 15 * 60 * 1000
const createDraftSchema = z.object({
workspaceId: z.string().min(1),
providerId: z.string().min(1),
displayName: z.string().min(1),
description: z.string().trim().max(500).optional(),
credentialId: z.string().min(1).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const parsed = createDraftSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const { workspaceId, providerId, displayName, description, credentialId } = parsed.data
const userId = session.user.id
const now = new Date()
await db
.delete(pendingCredentialDraft)
.where(
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
)
await db
.insert(pendingCredentialDraft)
.values({
id: crypto.randomUUID(),
userId,
workspaceId,
providerId,
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
})
.onConflictDoUpdate({
target: [
pendingCredentialDraft.userId,
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
set: {
displayName,
description: description || null,
credentialId: credentialId || null,
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
createdAt: now,
},
})
logger.info('Credential draft saved', {
userId,
workspaceId,
providerId,
displayName,
credentialId: credentialId || null,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to save credential draft', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,112 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
const logger = createLogger('CredentialMembershipsAPI')
const leaveCredentialSchema = z.object({
credentialId: z.string().min(1),
})
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const memberships = await db
.select({
membershipId: credentialMember.id,
credentialId: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
providerId: credential.providerId,
role: credentialMember.role,
status: credentialMember.status,
joinedAt: credentialMember.joinedAt,
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
logger.error('Failed to list credential memberships', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const parseResult = leaveCredentialSchema.safeParse({
credentialId: new URL(request.url).searchParams.get('credentialId'),
})
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { credentialId } = parseResult.data
const [membership] = await db
.select()
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Membership not found' }, { status: 404 })
}
if (membership.status !== 'active') {
return NextResponse.json({ success: true }, { status: 200 })
}
if (membership.role === 'admin') {
const activeAdmins = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, credentialId),
eq(credentialMember.role, 'admin'),
eq(credentialMember.status, 'active')
)
)
if (activeAdmins.length <= 1) {
return NextResponse.json(
{ error: 'Cannot leave credential as the last active admin' },
{ status: 400 }
)
}
}
await db
.update(credentialMember)
.set({
status: 'revoked',
updatedAt: new Date(),
})
.where(eq(credentialMember.id, membership.id))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to leave credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,521 @@
import { db } from '@sim/db'
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
const wrappedMatch = /^\{\{\s*([A-Za-z0-9_]+)\s*\}\}$/.exec(trimmed)
return wrappedMatch ? wrappedMatch[1] : trimmed
}
const listCredentialsSchema = z.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema.optional(),
providerId: z.string().optional(),
credentialId: z.string().optional(),
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: credentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
if (!data.accountId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'accountId is required for oauth credentials',
path: ['accountId'],
})
}
if (!data.providerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'providerId is required for oauth credentials',
path: ['providerId'],
})
}
if (!data.displayName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'displayName is required for oauth credentials',
path: ['displayName'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey is required for env credentials',
path: ['envKey'],
})
return
}
if (!isValidEnvVarName(normalizedEnvKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'envKey must contain only letters, numbers, and underscores',
path: ['envKey'],
})
}
})
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'oauth'),
eq(credential.accountId, accountId)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_workspace' && envKey) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_workspace'),
eq(credential.envKey, envKey)
)
)
.limit(1)
return row ?? null
}
if (type === 'env_personal' && envKey && envOwnerUserId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'env_personal'),
eq(credential.envKey, envKey),
eq(credential.envOwnerUserId, envOwnerUserId)
)
)
.limit(1)
return row ?? null
}
return null
}
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { searchParams } = new URL(request.url)
const rawWorkspaceId = searchParams.get('workspaceId')
const rawType = searchParams.get('type')
const rawProviderId = searchParams.get('providerId')
const rawCredentialId = searchParams.get('credentialId')
const parseResult = listCredentialsSchema.safeParse({
workspaceId: rawWorkspaceId?.trim(),
type: rawType?.trim() || undefined,
providerId: rawProviderId?.trim() || undefined,
credentialId: rawCredentialId?.trim() || undefined,
})
if (!parseResult.success) {
logger.warn(`[${requestId}] Invalid credential list request`, {
workspaceId: rawWorkspaceId,
type: rawType,
providerId: rawProviderId,
errors: parseResult.error.errors,
})
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (lookupCredentialId) {
let [row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.limit(1)
if (!row) {
;[row] = await db
.select({
id: credential.id,
displayName: credential.displayName,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
)
)
.limit(1)
}
return NextResponse.json({ credential: row ?? null })
}
if (!type || type === 'oauth') {
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
}
const whereClauses = [
eq(credential.workspaceId, workspaceId),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active'),
]
if (type) {
whereClauses.push(eq(credential.type, type))
}
if (providerId) {
whereClauses.push(eq(credential.providerId, providerId))
}
const credentials = await db
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
displayName: credential.displayName,
description: credential.description,
providerId: credential.providerId,
accountId: credential.accountId,
envKey: credential.envKey,
envOwnerUserId: credential.envOwnerUserId,
createdBy: credential.createdBy,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
role: credentialMember.role,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, session.user.id),
eq(credentialMember.status, 'active')
)
)
.where(and(...whereClauses))
return NextResponse.json({ credentials })
} catch (error) {
logger.error(`[${requestId}] Failed to list credentials`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const parseResult = createCredentialSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
}
const {
workspaceId,
type,
displayName,
description,
providerId,
accountId,
envKey,
envOwnerUserId,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
}
let resolvedDisplayName = displayName?.trim() ?? ''
const resolvedDescription = description?.trim() || null
let resolvedProviderId: string | null = providerId ?? null
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
.select({
id: account.id,
userId: account.userId,
providerId: account.providerId,
accountId: account.accountId,
})
.from(account)
.where(eq(account.id, accountId!))
.limit(1)
if (!accountRow) {
return NextResponse.json({ error: 'OAuth account not found' }, { status: 404 })
}
if (accountRow.userId !== session.user.id) {
return NextResponse.json(
{ error: 'Only account owners can create oauth credentials for an account' },
{ status: 403 }
)
}
if (providerId !== accountRow.providerId) {
return NextResponse.json(
{ error: 'providerId does not match the selected OAuth account' },
{ status: 400 }
)
}
if (!resolvedDisplayName) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
return NextResponse.json(
{ error: 'Only the current user can create personal env credentials for themselves' },
{ status: 403 }
)
}
resolvedProviderId = null
resolvedAccountId = null
resolvedDisplayName = resolvedEnvKey || ''
} else {
resolvedProviderId = null
resolvedAccountId = null
resolvedEnvOwnerUserId = null
resolvedDisplayName = resolvedEnvKey || ''
}
if (!resolvedDisplayName) {
return NextResponse.json({ error: 'Display name is required' }, { status: 400 })
}
const existingCredential = await findExistingCredentialBySource({
workspaceId,
type,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
})
if (existingCredential) {
const [membership] = await db
.select({
id: credentialMember.id,
status: credentialMember.status,
role: credentialMember.role,
})
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, existingCredential.id),
eq(credentialMember.userId, session.user.id)
)
)
.limit(1)
if (!membership || membership.status !== 'active') {
return NextResponse.json(
{ error: 'A credential with this source already exists in this workspace' },
{ status: 409 }
)
}
const canUpdateExistingCredential = membership.role === 'admin'
const shouldUpdateDisplayName =
type === 'oauth' &&
resolvedDisplayName &&
resolvedDisplayName !== existingCredential.displayName
const shouldUpdateDescription =
typeof description !== 'undefined' &&
(existingCredential.description ?? null) !== resolvedDescription
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
await db
.update(credential)
.set({
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
updatedAt: new Date(),
})
.where(eq(credential.id, existingCredential.id))
const [updatedCredential] = await db
.select()
.from(credential)
.where(eq(credential.id, existingCredential.id))
.limit(1)
return NextResponse.json(
{ credential: updatedCredential ?? existingCredential },
{ status: 200 }
)
}
return NextResponse.json({ credential: existingCredential }, { status: 200 })
}
const now = new Date()
const credentialId = crypto.randomUUID()
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
await db.transaction(async (tx) => {
await tx.insert(credential).values({
id: credentialId,
workspaceId,
type,
displayName: resolvedDisplayName,
description: resolvedDescription,
providerId: resolvedProviderId,
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: memberUserId,
role: memberUserId === workspaceRow.ownerId ? 'admin' : 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
}
} else {
await tx.insert(credentialMember).values({
id: crypto.randomUUID(),
credentialId,
userId: session.user.id,
role: 'admin',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
createdAt: now,
updatedAt: now,
})
}
})
const [created] = await db
.select()
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
{ status: 409 }
)
}
if (error?.code === '23503') {
return NextResponse.json(
{ error: 'Invalid credential reference or membership target' },
{ status: 400 }
)
}
if (error?.code === '23514') {
return NextResponse.json(
{ error: 'Credential source data failed validation checks' },
{ status: 400 }
)
}
logger.error(`[${requestId}] Credential create failure details`, {
code: error?.code,
detail: error?.detail,
constraint: error?.constraint,
table: error?.table,
message: error?.message,
})
logger.error(`[${requestId}] Failed to create credential`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
import type { EnvironmentVariable } from '@/stores/settings/environment' import type { EnvironmentVariable } from '@/stores/settings/environment'
const logger = createLogger('EnvironmentAPI') const logger = createLogger('EnvironmentAPI')
@@ -53,6 +54,11 @@ export async function POST(req: NextRequest) {
}, },
}) })
await syncPersonalEnvCredentialsForUser({
userId: session.user.id,
envKeys: Object.keys(variables),
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (validationError) { } catch (validationError) {
if (validationError instanceof z.ZodError) { if (validationError instanceof z.ZodError) {

View File

@@ -4,16 +4,12 @@
* *
* @vitest-environment node * @vitest-environment node
*/ */
import { createEnvMock, createMockLogger } from '@sim/testing' import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
const loggerMock = vi.hoisted(() => ({
createLogger: () => createMockLogger(),
}))
vi.mock('drizzle-orm') vi.mock('drizzle-orm')
vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/logger', () => loggerMock)
vi.mock('@sim/db') vi.mock('@sim/db', () => databaseMock)
vi.mock('@/lib/knowledge/documents/utils', () => ({ vi.mock('@/lib/knowledge/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(), retryWithExponentialBackoff: (fn: any) => fn(),
})) }))

View File

@@ -38,7 +38,7 @@ import {
const logger = createLogger('CopilotMcpAPI') const logger = createLogger('CopilotMcpAPI')
const mcpRateLimiter = new RateLimiter() const mcpRateLimiter = new RateLimiter()
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'

View File

@@ -72,6 +72,7 @@ describe('MCP Serve Route', () => {
})) }))
vi.doMock('@/lib/core/utils/urls', () => ({ vi.doMock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000', getBaseUrl: () => 'http://localhost:3000',
getInternalApiBaseUrl: () => 'http://localhost:3000',
})) }))
vi.doMock('@/lib/core/execution-limits', () => ({ vi.doMock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: () => 10_000, getMaxExecutionTimeout: () => 10_000,

View File

@@ -22,7 +22,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal' import { generateInternalToken } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowMcpServeAPI') const logger = createLogger('WorkflowMcpServeAPI')
@@ -285,7 +285,7 @@ async function handleToolsCall(
) )
} }
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute` const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' } const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (publicServerOwnerId) { if (publicServerOwnerId) {

View File

@@ -11,6 +11,7 @@ import {
user, user,
userStats, userStats,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -23,6 +24,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client' import { requireStripeClient } from '@/lib/billing/stripe-client'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
const logger = createLogger('OrganizationInvitation') const logger = createLogger('OrganizationInvitation')
@@ -495,6 +497,34 @@ export async function PUT(
} }
}) })
if (status === 'accepted') {
const acceptedWsInvitations = await db
.select({ workspaceId: workspaceInvitation.workspaceId })
.from(workspaceInvitation)
.where(
and(
eq(workspaceInvitation.orgInvitationId, invitationId),
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
)
)
for (const wsInv of acceptedWsInvitations) {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: wsInv.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
}
}
// Handle Pro subscription cancellation after transaction commits // Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) { if (personalProToCancel) {
try { try {

View File

@@ -0,0 +1,170 @@
/**
* POST /api/referral-code/redeem
*
* Redeem a referral/promo code to receive bonus credits.
*
* Body:
* - code: string — The referral code to redeem
*
* Response: { redeemed: boolean, bonusAmount?: number, error?: string }
*
* Constraints:
* - Enterprise users cannot redeem codes
* - One redemption per user, ever (unique constraint on userId)
* - One redemption per organization for team users (partial unique on organizationId)
*/
import { db } from '@sim/db'
import { referralAttribution, referralCampaigns, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { applyBonusCredits } from '@/lib/billing/credits/bonus'
const logger = createLogger('ReferralCodeRedemption')
const RedeemCodeSchema = z.object({
code: z.string().min(1, 'Code is required'),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { code } = RedeemCodeSchema.parse(body)
const subscription = await getHighestPrioritySubscription(session.user.id)
if (subscription?.plan === 'enterprise') {
return NextResponse.json({
redeemed: false,
error: 'Enterprise accounts cannot redeem referral codes',
})
}
const isTeam = subscription?.plan === 'team'
const orgId = isTeam ? subscription.referenceId : null
const normalizedCode = code.trim().toUpperCase()
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(and(eq(referralCampaigns.code, normalizedCode), eq(referralCampaigns.isActive, true)))
.limit(1)
if (!campaign) {
logger.info('Invalid code redemption attempt', {
userId: session.user.id,
code: normalizedCode,
})
return NextResponse.json({ error: 'Invalid or expired code' }, { status: 404 })
}
const [existingUserAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.userId, session.user.id))
.limit(1)
if (existingUserAttribution) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
if (orgId) {
const [existingOrgAttribution] = await db
.select({ id: referralAttribution.id })
.from(referralAttribution)
.where(eq(referralAttribution.organizationId, orgId))
.limit(1)
if (existingOrgAttribution) {
return NextResponse.json({
redeemed: false,
error: 'A code has already been redeemed for your organization',
})
}
}
const bonusAmount = Number(campaign.bonusCreditAmount)
let redeemed = false
await db.transaction(async (tx) => {
const [existingStats] = await tx
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, session.user.id))
.limit(1)
if (!existingStats) {
await tx.insert(userStats).values({
id: nanoid(),
userId: session.user.id,
})
}
const result = await tx
.insert(referralAttribution)
.values({
id: nanoid(),
userId: session.user.id,
organizationId: orgId,
campaignId: campaign.id,
utmSource: null,
utmMedium: null,
utmCampaign: null,
utmContent: null,
referrerUrl: null,
landingPage: null,
bonusCreditAmount: bonusAmount.toString(),
})
.onConflictDoNothing()
.returning({ id: referralAttribution.id })
if (result.length > 0) {
await applyBonusCredits(session.user.id, bonusAmount, tx)
redeemed = true
}
})
if (redeemed) {
logger.info('Referral code redeemed', {
userId: session.user.id,
organizationId: orgId,
code: normalizedCode,
campaignId: campaign.id,
campaignName: campaign.name,
bonusAmount,
})
}
if (!redeemed) {
return NextResponse.json({
redeemed: false,
error: 'You have already redeemed a code',
})
}
return NextResponse.json({
redeemed: true,
bonusAmount,
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
}
logger.error('Referral code redemption error', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -3,17 +3,14 @@
* *
* @vitest-environment node * @vitest-environment node
*/ */
import { loggerMock } from '@sim/testing' import { databaseMock, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect, mockDbUpdate } = const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({
vi.hoisted(() => ({
mockGetSession: vi.fn(), mockGetSession: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockDbSelect: vi.fn(), }))
mockDbUpdate: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/auth', () => ({
getSession: mockGetSession, getSession: mockGetSession,
@@ -23,12 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
})) }))
vi.mock('@sim/db', () => ({ vi.mock('@sim/db', () => databaseMock)
db: {
select: mockDbSelect,
update: mockDbUpdate,
},
}))
vi.mock('@sim/db/schema', () => ({ vi.mock('@sim/db/schema', () => ({
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
@@ -59,6 +51,9 @@ function createParams(id: string): { params: Promise<{ id: string }> } {
return { params: Promise.resolve({ id }) } return { params: Promise.resolve({ id }) }
} }
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
const mockDbUpdate = databaseMock.db.update as ReturnType<typeof vi.fn>
function mockDbChain(selectResults: unknown[][]) { function mockDbChain(selectResults: unknown[][]) {
let selectCallIndex = 0 let selectCallIndex = 0
mockDbSelect.mockImplementation(() => ({ mockDbSelect.mockImplementation(() => ({

View File

@@ -3,17 +3,14 @@
* *
* @vitest-environment node * @vitest-environment node
*/ */
import { loggerMock } from '@sim/testing' import { databaseMock, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect } = vi.hoisted( const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission } = vi.hoisted(() => ({
() => ({
mockGetSession: vi.fn(), mockGetSession: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockDbSelect: vi.fn(), }))
})
)
vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/auth', () => ({
getSession: mockGetSession, getSession: mockGetSession,
@@ -23,11 +20,7 @@ vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
})) }))
vi.mock('@sim/db', () => ({ vi.mock('@sim/db', () => databaseMock)
db: {
select: mockDbSelect,
},
}))
vi.mock('@sim/db/schema', () => ({ vi.mock('@sim/db/schema', () => ({
workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' },
@@ -62,6 +55,8 @@ function createRequest(url: string): NextRequest {
return new NextRequest(new URL(url), { method: 'GET' }) return new NextRequest(new URL(url), { method: 'GET' })
} }
const mockDbSelect = databaseMock.db.select as ReturnType<typeof vi.fn>
function mockDbChain(results: any[]) { function mockDbChain(results: any[]) {
let callIndex = 0 let callIndex = 0
mockDbSelect.mockImplementation(() => ({ mockDbSelect.mockImplementation(() => ({

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { import {
type RegenerateStateInput, type RegenerateStateInput,
regenerateWorkflowStateIds, regenerateWorkflowStateIds,
@@ -115,7 +115,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Step 3: Save the workflow state using the existing state endpoint (like imports do) // Step 3: Save the workflow state using the existing state endpoint (like imports do)
// Ensure variables in state are remapped for the new workflow as well // Ensure variables in state are remapped for the new workflow as well
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables } const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, { const stateResponse = await fetch(
`${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`,
{
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -123,7 +125,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
cookie: request.headers.get('cookie') || '', cookie: request.headers.get('cookie') || '',
}, },
body: JSON.stringify(workflowStateWithVariables), body: JSON.stringify(workflowStateWithVariables),
}) }
)
if (!stateResponse.ok) { if (!stateResponse.ok) {
logger.error(`[${requestId}] Failed to save workflow state for template use`) logger.error(`[${requestId}] Failed to save workflow state for template use`)

View File

@@ -66,6 +66,12 @@
* Credits: * Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email) * POST /api/v1/admin/credits - Issue credits to user (by userId or email)
* *
* Referral Campaigns:
* GET /api/v1/admin/referral-campaigns - List campaigns (?active=true/false)
* POST /api/v1/admin/referral-campaigns - Create campaign
* GET /api/v1/admin/referral-campaigns/:id - Get campaign details
* PATCH /api/v1/admin/referral-campaigns/:id - Update campaign fields
*
* Access Control (Permission Groups): * Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X) * GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X) * DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)
@@ -97,6 +103,7 @@ export type {
AdminOrganization, AdminOrganization,
AdminOrganizationBillingSummary, AdminOrganizationBillingSummary,
AdminOrganizationDetail, AdminOrganizationDetail,
AdminReferralCampaign,
AdminSeatAnalytics, AdminSeatAnalytics,
AdminSingleResponse, AdminSingleResponse,
AdminSubscription, AdminSubscription,
@@ -111,6 +118,7 @@ export type {
AdminWorkspaceMember, AdminWorkspaceMember,
DbMember, DbMember,
DbOrganization, DbOrganization,
DbReferralCampaign,
DbSubscription, DbSubscription,
DbUser, DbUser,
DbUserStats, DbUserStats,
@@ -139,6 +147,7 @@ export {
parseWorkflowVariables, parseWorkflowVariables,
toAdminFolder, toAdminFolder,
toAdminOrganization, toAdminOrganization,
toAdminReferralCampaign,
toAdminSubscription, toAdminSubscription,
toAdminUser, toAdminUser,
toAdminWorkflow, toAdminWorkflow,

View File

@@ -0,0 +1,142 @@
/**
* GET /api/v1/admin/referral-campaigns/:id
*
* Get a single referral campaign by ID.
*
* PATCH /api/v1/admin/referral-campaigns/:id
*
* Update campaign fields. All fields are optional.
*
* Body:
* - name: string (non-empty) - Campaign name
* - bonusCreditAmount: number (> 0) - Bonus credits in dollars
* - isActive: boolean - Enable/disable the campaign
* - code: string | null (min 6 chars, auto-uppercased, null to remove) - Redeemable code
* - utmSource: string | null - UTM source match (null = wildcard)
* - utmMedium: string | null - UTM medium match (null = wildcard)
* - utmCampaign: string | null - UTM campaign match (null = wildcard)
* - utmContent: string | null - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminReferralCampaign } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignDetailAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
try {
const { id: campaignId } = await context.params
const [campaign] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!campaign) {
return notFoundResponse('Campaign')
}
logger.info(`Admin API: Retrieved referral campaign ${campaignId}`)
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to get referral campaign', { error })
return internalErrorResponse('Failed to get referral campaign')
}
})
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
try {
const { id: campaignId } = await context.params
const body = await request.json()
const [existing] = await db
.select()
.from(referralCampaigns)
.where(eq(referralCampaigns.id, campaignId))
.limit(1)
if (!existing) {
return notFoundResponse('Campaign')
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (body.name !== undefined) {
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
return badRequestResponse('name must be a non-empty string')
}
updateData.name = body.name.trim()
}
if (body.bonusCreditAmount !== undefined) {
if (
typeof body.bonusCreditAmount !== 'number' ||
!Number.isFinite(body.bonusCreditAmount) ||
body.bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
updateData.bonusCreditAmount = body.bonusCreditAmount.toString()
}
if (body.isActive !== undefined) {
if (typeof body.isActive !== 'boolean') {
return badRequestResponse('isActive must be a boolean')
}
updateData.isActive = body.isActive
}
if (body.code !== undefined) {
if (body.code !== null) {
if (typeof body.code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (body.code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
updateData.code = body.code ? body.code.trim().toUpperCase() : null
}
for (const field of ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent'] as const) {
if (body[field] !== undefined) {
if (body[field] !== null && typeof body[field] !== 'string') {
return badRequestResponse(`${field} must be a string or null`)
}
updateData[field] = body[field] || null
}
}
const [updated] = await db
.update(referralCampaigns)
.set(updateData)
.where(eq(referralCampaigns.id, campaignId))
.returning()
logger.info(`Admin API: Updated referral campaign ${campaignId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
return singleResponse(toAdminReferralCampaign(updated, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to update referral campaign', { error })
return internalErrorResponse('Failed to update referral campaign')
}
})

View File

@@ -0,0 +1,140 @@
/**
* GET /api/v1/admin/referral-campaigns
*
* List referral campaigns with optional filtering and pagination.
*
* Query Parameters:
* - active: string (optional) - Filter by active status ('true' or 'false')
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* POST /api/v1/admin/referral-campaigns
*
* Create a new referral campaign.
*
* Body:
* - name: string (required) - Campaign name
* - bonusCreditAmount: number (required, > 0) - Bonus credits in dollars
* - code: string | null (optional, min 6 chars, auto-uppercased) - Redeemable code
* - utmSource: string | null (optional) - UTM source match (null = wildcard)
* - utmMedium: string | null (optional) - UTM medium match (null = wildcard)
* - utmCampaign: string | null (optional) - UTM campaign match (null = wildcard)
* - utmContent: string | null (optional) - UTM content match (null = wildcard)
*/
import { db } from '@sim/db'
import { referralCampaigns } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminReferralCampaign,
createPaginationMeta,
parsePaginationParams,
toAdminReferralCampaign,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminReferralCampaignsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const activeFilter = url.searchParams.get('active')
try {
const conditions: SQL<unknown>[] = []
if (activeFilter === 'true') {
conditions.push(eq(referralCampaigns.isActive, true))
} else if (activeFilter === 'false') {
conditions.push(eq(referralCampaigns.isActive, false))
}
const whereClause = conditions.length > 0 ? conditions[0] : undefined
const baseUrl = getBaseUrl()
const [countResult, campaigns] = await Promise.all([
db.select({ total: count() }).from(referralCampaigns).where(whereClause),
db
.select()
.from(referralCampaigns)
.where(whereClause)
.orderBy(referralCampaigns.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminReferralCampaign[] = campaigns.map((c) => toAdminReferralCampaign(c, baseUrl))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} referral campaigns (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list referral campaigns', { error })
return internalErrorResponse('Failed to list referral campaigns')
}
})
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { name, code, utmSource, utmMedium, utmCampaign, utmContent, bonusCreditAmount } = body
if (!name || typeof name !== 'string') {
return badRequestResponse('name is required and must be a string')
}
if (
typeof bonusCreditAmount !== 'number' ||
!Number.isFinite(bonusCreditAmount) ||
bonusCreditAmount <= 0
) {
return badRequestResponse('bonusCreditAmount must be a positive number')
}
if (code !== undefined && code !== null) {
if (typeof code !== 'string') {
return badRequestResponse('code must be a string or null')
}
if (code.trim().length < 6) {
return badRequestResponse('code must be at least 6 characters')
}
}
const id = nanoid()
const [campaign] = await db
.insert(referralCampaigns)
.values({
id,
name,
code: code ? code.trim().toUpperCase() : null,
utmSource: utmSource || null,
utmMedium: utmMedium || null,
utmCampaign: utmCampaign || null,
utmContent: utmContent || null,
bonusCreditAmount: bonusCreditAmount.toString(),
})
.returning()
logger.info(`Admin API: Created referral campaign ${id}`, {
name,
code: campaign.code,
bonusCreditAmount,
})
return singleResponse(toAdminReferralCampaign(campaign, getBaseUrl()))
} catch (error) {
logger.error('Admin API: Failed to create referral campaign', { error })
return internalErrorResponse('Failed to create referral campaign')
}
})

View File

@@ -8,6 +8,7 @@
import type { import type {
member, member,
organization, organization,
referralCampaigns,
subscription, subscription,
user, user,
userStats, userStats,
@@ -31,6 +32,7 @@ export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription> export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member> export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats> export type DbUserStats = InferSelectModel<typeof userStats>
export type DbReferralCampaign = InferSelectModel<typeof referralCampaigns>
// ============================================================================= // =============================================================================
// Pagination // Pagination
@@ -646,3 +648,49 @@ export interface AdminDeployResult {
export interface AdminUndeployResult { export interface AdminUndeployResult {
isDeployed: boolean isDeployed: boolean
} }
// =============================================================================
// Referral Campaign Types
// =============================================================================
export interface AdminReferralCampaign {
id: string
name: string
code: string | null
utmSource: string | null
utmMedium: string | null
utmCampaign: string | null
utmContent: string | null
bonusCreditAmount: string
isActive: boolean
signupUrl: string | null
createdAt: string
updatedAt: string
}
export function toAdminReferralCampaign(
dbCampaign: DbReferralCampaign,
baseUrl: string
): AdminReferralCampaign {
const utmParams = new URLSearchParams()
if (dbCampaign.utmSource) utmParams.set('utm_source', dbCampaign.utmSource)
if (dbCampaign.utmMedium) utmParams.set('utm_medium', dbCampaign.utmMedium)
if (dbCampaign.utmCampaign) utmParams.set('utm_campaign', dbCampaign.utmCampaign)
if (dbCampaign.utmContent) utmParams.set('utm_content', dbCampaign.utmContent)
const query = utmParams.toString()
return {
id: dbCampaign.id,
name: dbCampaign.name,
code: dbCampaign.code,
utmSource: dbCampaign.utmSource,
utmMedium: dbCampaign.utmMedium,
utmCampaign: dbCampaign.utmCampaign,
utmContent: dbCampaign.utmContent,
bonusCreditAmount: dbCampaign.bonusCreditAmount,
isActive: dbCampaign.isActive,
signupUrl: query ? `${baseUrl}/signup?${query}` : null,
createdAt: dbCampaign.createdAt.toISOString(),
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}

View File

@@ -32,9 +32,10 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema' import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm' import { and, count, eq } from 'drizzle-orm'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { import {
badRequestResponse, badRequestResponse,
@@ -232,6 +233,20 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
permissionId, permissionId,
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: body.userId,
})
}
return singleResponse({ return singleResponse({
id: permissionId, id: permissionId,
workspaceId, workspaceId,

View File

@@ -8,7 +8,7 @@ import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
import { authenticateV1Request } from '@/app/api/v1/auth' import { authenticateV1Request } from '@/app/api/v1/auth'
const logger = createLogger('CopilotHeadlessAPI') const logger = createLogger('CopilotHeadlessAPI')
const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' const DEFAULT_COPILOT_MODEL = 'claude-opus-4-5'
const RequestSchema = z.object({ const RequestSchema = z.object({
message: z.string().min(1, 'message is required'), message: z.string().min(1, 'message is required'),

View File

@@ -536,6 +536,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }
@@ -885,6 +886,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
useDraftState: shouldUseDraftState, useDraftState: shouldUseDraftState,
startTime: new Date().toISOString(), startTime: new Date().toISOString(),
isClientSession, isClientSession,
enforceCredentialAccess: useAuthenticatedUserAsActor,
workflowStateOverride: effectiveWorkflowStateOverride, workflowStateOverride: effectiveWorkflowStateOverride,
} }

View File

@@ -5,7 +5,7 @@
* @vitest-environment node * @vitest-environment node
*/ */
import { loggerMock } from '@sim/testing' import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -284,9 +284,7 @@ describe('Workflow By ID API Route', () => {
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}) })
global.fetch = vi.fn().mockResolvedValue({ setupGlobalFetchMock({ ok: true })
ok: true,
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE', method: 'DELETE',
@@ -331,9 +329,7 @@ describe('Workflow By ID API Route', () => {
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
}) })
global.fetch = vi.fn().mockResolvedValue({ setupGlobalFetchMock({ ok: true })
ok: true,
})
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
method: 'DELETE', method: 'DELETE',

View File

@@ -1,12 +1,14 @@
import { db } from '@sim/db' import { db } from '@sim/db'
import { environment, workspaceEnvironment } from '@sim/db/schema' import { workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceEnvironmentAPI') const logger = createLogger('WorkspaceEnvironmentAPI')
@@ -44,44 +46,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
// Workspace env (encrypted) const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv(
const wsEnvRow = await db userId,
.select() workspaceId
.from(workspaceEnvironment) )
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
// Personal env (encrypted)
const personalRow = await db
.select()
.from(environment)
.where(eq(environment.userId, userId))
.limit(1)
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
// Decrypt both for UI
const decryptAll = async (src: Record<string, string>) => {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(src)) {
try {
const { decrypted } = await decryptSecret(v)
out[k] = decrypted
} catch {
out[k] = ''
}
}
return out
}
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
decryptAll(wsEncrypted),
decryptAll(personalEncrypted),
])
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
return NextResponse.json( return NextResponse.json(
{ {
@@ -156,6 +124,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() }, set: { variables: merged, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(merged),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env PUT error`, error) logger.error(`[${requestId}] Workspace env PUT error`, error)
@@ -222,6 +196,12 @@ export async function DELETE(
set: { variables: current, updatedAt: new Date() }, set: { variables: current, updatedAt: new Date() },
}) })
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: Object.keys(current),
actingUserId: userId,
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (error: any) { } catch (error: any) {
logger.error(`[${requestId}] Workspace env DELETE error`, error) logger.error(`[${requestId}] Workspace env DELETE error`, error)

View File

@@ -1,11 +1,12 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema' import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { import {
getUsersWithPermissions, getUsersWithPermissions,
hasWorkspaceAdminAccess, hasWorkspaceAdminAccess,
@@ -154,6 +155,20 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
const updatedUsers = await getUsersWithPermissions(workspaceId) const updatedUsers = await getUsersWithPermissions(workspaceId)
return NextResponse.json({ return NextResponse.json({

View File

@@ -8,15 +8,27 @@ const mockHasWorkspaceAdminAccess = vi.fn()
let dbSelectResults: any[] = [] let dbSelectResults: any[] = []
let dbSelectCallIndex = 0 let dbSelectCallIndex = 0
const mockDbSelect = vi.fn().mockImplementation(() => ({ const mockDbSelect = vi.fn().mockImplementation(() => {
from: vi.fn().mockReturnThis(), const makeThen = () =>
where: vi.fn().mockReturnThis(), vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
then: vi.fn().mockImplementation((callback: (rows: any[]) => any) => {
const result = dbSelectResults[dbSelectCallIndex] || [] const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++ dbSelectCallIndex++
return Promise.resolve(callback ? callback(result) : result) return Promise.resolve(callback ? callback(result) : result)
}), })
})) const makeLimit = () =>
vi.fn().mockImplementation(() => {
const result = dbSelectResults[dbSelectCallIndex] || []
dbSelectCallIndex++
return Promise.resolve(result)
})
const chain: any = {}
chain.from = vi.fn().mockReturnValue(chain)
chain.where = vi.fn().mockReturnValue(chain)
chain.limit = makeLimit()
chain.then = makeThen()
return chain
})
const mockDbInsert = vi.fn().mockImplementation(() => ({ const mockDbInsert = vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue(undefined), values: vi.fn().mockResolvedValue(undefined),
@@ -53,6 +65,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
mockHasWorkspaceAdminAccess(userId, workspaceId), mockHasWorkspaceAdminAccess(userId, workspaceId),
})) }))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/logger', () => loggerMock) vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/core/utils/urls', () => ({ vi.mock('@/lib/core/utils/urls', () => ({
@@ -95,6 +111,10 @@ vi.mock('@sim/db/schema', () => ({
userId: 'userId', userId: 'userId',
permissionType: 'permissionType', permissionType: 'permissionType',
}, },
workspaceEnvironment: {
workspaceId: 'workspaceId',
variables: 'variables',
},
})) }))
vi.mock('drizzle-orm', () => ({ vi.mock('drizzle-orm', () => ({
@@ -207,6 +227,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request = new NextRequest( const request = new NextRequest(
@@ -460,6 +481,7 @@ describe('Workspace Invitation [invitationId] API Route', () => {
[mockWorkspace], [mockWorkspace],
[{ ...mockUser, email: 'invited@example.com' }], [{ ...mockUser, email: 'invited@example.com' }],
[], [],
[],
] ]
const request2 = new NextRequest( const request2 = new NextRequest(

View File

@@ -6,6 +6,7 @@ import {
user, user,
type WorkspaceInvitationStatus, type WorkspaceInvitationStatus,
workspace, workspace,
workspaceEnvironment,
workspaceInvitation, workspaceInvitation,
} from '@sim/db/schema' } from '@sim/db/schema'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
@@ -14,6 +15,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails' import { WorkspaceInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -162,6 +164,20 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id)) .where(eq(workspaceInvitation.id, invitation.id))
}) })
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: invitation.workspaceId,
envKeys: wsEnvKeys,
actingUserId: session.user.id,
})
}
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())) return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
} }

View File

@@ -1,7 +1,10 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { useNotificationStore } from '@/stores/notifications' import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('useDeployment') const logger = createLogger('useDeployment')
@@ -35,6 +38,24 @@ export function useDeployment({
return { success: true, shouldOpenModal: true } return { success: true, shouldOpenModal: true }
} }
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
const liveBlocks = mergeSubblockState(blocks, workflowId)
const checkResult = runPreDeployChecks({
blocks: liveBlocks,
edges,
loops,
parallels,
workflowId,
})
if (!checkResult.passed) {
addNotification({
level: 'error',
message: checkResult.error || 'Pre-deploy validation failed',
workflowId,
})
return { success: false, shouldOpenModal: false }
}
setIsDeploying(true) setIsDeploying(true)
try { try {
const response = await fetch(`/api/workflows/${workflowId}/deploy`, { const response = await fetch(`/api/workflows/${workflowId}/deploy`, {

View File

@@ -30,6 +30,7 @@ export interface OAuthRequiredModalProps {
requiredScopes?: string[] requiredScopes?: string[]
serviceId: string serviceId: string
newScopes?: string[] newScopes?: string[]
onConnect?: () => Promise<void> | void
} }
const SCOPE_DESCRIPTIONS: Record<string, string> = { const SCOPE_DESCRIPTIONS: Record<string, string> = {
@@ -314,6 +315,7 @@ export function OAuthRequiredModal({
requiredScopes = [], requiredScopes = [],
serviceId, serviceId,
newScopes = [], newScopes = [],
onConnect,
}: OAuthRequiredModalProps) { }: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider) const { baseProvider } = parseProvider(provider)
@@ -359,6 +361,12 @@ export function OAuthRequiredModal({
setError(null) setError(null)
try { try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId) const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', { logger.info('Linking OAuth2:', {

View File

@@ -3,10 +3,12 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react' import { ExternalLink, Users } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { getSubscriptionStatus } from '@/lib/billing/client' import { getSubscriptionStatus } from '@/lib/billing/client'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
@@ -18,9 +20,9 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' import { CREDENTIAL_SET } from '@/executor/constants'
import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useCredentialSets } from '@/hooks/queries/credential-sets'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
@@ -46,6 +48,8 @@ export function CredentialSelector({
previewValue, previewValue,
previewContextValues, previewContextValues,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
@@ -96,64 +100,70 @@ export function CredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) } = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const shouldFetchForeignMeta =
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } =
useOAuthCredentialDetail(
shouldFetchForeignMeta ? selectedId : undefined,
activeWorkflowId || undefined,
shouldFetchForeignMeta
)
const hasForeignMeta = foreignCredentials.length > 0
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta)
const selectedCredentialSet = useMemo( const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId), () => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId] [credentialSets, selectedCredentialSetId]
) )
const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet) const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
useEffect(() => {
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
setInaccessibleCredentialName(null)
return
}
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
)
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
setStoreValue(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
return () => {
cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredentialSet) return selectedCredentialSet.name if (selectedCredentialSet) return selectedCredentialSet.name
if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (isForeign) return CREDENTIAL.FOREIGN_LABEL if (inaccessibleCredentialName) return inaccessibleCredentialName
return '' return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) }, [
selectedCredentialSet,
selectedCredential,
inaccessibleCredentialName,
selectedId,
credentialsLoading,
])
const displayValue = isEditing ? editingValue : resolvedLabel const displayValue = isEditing ? editingValue : resolvedLabel
const invalidSelection = useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
!isPreview &&
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
logger.info('Clearing invalid credential selection - credential was disconnected', {
selectedId,
provider: effectiveProviderId,
})
setStoreValue('')
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -195,8 +205,18 @@ export function CredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
setShowOAuthModal(true) writePendingCredentialCreateRequest({
}, []) workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const getProviderIcon = useCallback((providerName: OAuthProvider) => { const getProviderIcon = useCallback((providerName: OAuthProvider) => {
const { baseProvider } = parseProvider(providerName) const { baseProvider } = parseProvider(providerName)
@@ -251,23 +271,18 @@ export function CredentialSelector({
label: cred.name, label: cred.name,
value: cred.id, value: cred.id,
})) }))
credentialItems.push({
label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
})
if (credentialItems.length > 0) {
groups.push({ groups.push({
section: 'Personal Credential', section: 'Personal Credential',
items: credentialItems, items: credentialItems,
}) })
} else {
groups.push({
section: 'Personal Credential',
items: [
{
label: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
},
],
})
}
return { comboboxOptions: [], comboboxGroups: groups } return { comboboxOptions: [], comboboxGroups: groups }
} }
@@ -277,12 +292,13 @@ export function CredentialSelector({
value: cred.id, value: cred.id,
})) }))
if (credentials.length === 0) {
options.push({ options.push({
label: `Connect ${getProviderName(provider)} account`, label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__', value: '__connect_account__',
}) })
}
return { comboboxOptions: options, comboboxGroups: undefined } return { comboboxOptions: options, comboboxGroups: undefined }
}, [ }, [
@@ -368,7 +384,7 @@ export function CredentialSelector({
} }
disabled={effectiveDisabled} disabled={effectiveDisabled}
editable={true} editable={true}
filterOptions={!isForeign && !isForeignCredentialSet} filterOptions={true}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''} className={selectedId || isCredentialSetSelected ? 'pl-[28px]' : ''}
@@ -380,7 +396,6 @@ export function CredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
{!isForeign && (
<Button <Button
variant='active' variant='active'
onClick={() => setShowOAuthModal(true)} onClick={() => setShowOAuthModal(true)}
@@ -388,7 +403,6 @@ export function CredentialSelector({
> >
Update access Update access
</Button> </Button>
)}
</div> </div>
)} )}
@@ -407,7 +421,11 @@ export function CredentialSelector({
) )
} }
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) { function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -425,12 +443,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [refetchCredentials]) }, [providerId, workspaceId, refetchCredentials])
} }

View File

@@ -9,6 +9,7 @@ import {
PopoverSection, PopoverSection,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
usePersonalEnvironment, usePersonalEnvironment,
useWorkspaceEnvironment, useWorkspaceEnvironment,
@@ -168,7 +169,15 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}, [searchTerm]) }, [searchTerm])
const openEnvironmentSettings = () => { const openEnvironmentSettings = () => {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'environment' } })) if (workspaceId) {
writePendingCredentialCreateRequest({
workspaceId,
type: 'env_personal',
envKey: searchTerm.trim(),
requestedAt: Date.now(),
})
}
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
onClose?.() onClose?.()
} }
@@ -302,7 +311,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}} }}
> >
<Plus className='h-3 w-3' /> <Plus className='h-3 w-3' />
<span>Create environment variable</span> <span>Create Secret</span>
</PopoverItem> </PopoverItem>
</PopoverScrollArea> </PopoverScrollArea>
) : ( ) : (

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -125,8 +124,6 @@ export function FileSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -168,7 +165,6 @@ export function FileSelectorInput({
const disabledReason = const disabledReason =
finalDisabled || finalDisabled ||
isForeignCredential ||
missingCredential || missingCredential ||
missingDomain || missingDomain ||
missingProject || missingProject ||

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -47,10 +46,6 @@ export function FolderSelectorInput({
subBlock.canonicalParamId === 'copyDestinationId' || subBlock.canonicalParamId === 'copyDestinationId' ||
subBlock.id === 'copyDestinationFolder' || subBlock.id === 'copyDestinationFolder' ||
subBlock.id === 'manualCopyDestinationFolder' subBlock.id === 'manualCopyDestinationFolder'
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
// Central dependsOn gating // Central dependsOn gating
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
@@ -119,9 +114,7 @@ export function FolderSelectorInput({
selectorContext={ selectorContext={
selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' } selectorResolution?.context ?? { credentialId, workflowId: activeWorkflowId || '' }
} }
disabled={ disabled={finalDisabled || missingCredential || !selectorResolution?.key}
finalDisabled || isForeignCredential || missingCredential || !selectorResolution?.key
}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select folder'} placeholder={subBlock.placeholder || 'Select folder'}

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
@@ -73,11 +72,6 @@ export function ProjectSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(connectedCredential as string) || ''
)
const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || ''
const { finalDisabled } = useDependsOnGate(blockId, subBlock, { const { finalDisabled } = useDependsOnGate(blockId, subBlock, {
disabled, disabled,
@@ -123,7 +117,7 @@ export function ProjectSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={selectorResolution.key} selectorKey={selectorResolution.key}
selectorContext={selectorResolution.context} selectorContext={selectorResolution.context}
disabled={finalDisabled || isForeignCredential || missingCredential} disabled={finalDisabled || missingCredential}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select project'} placeholder={subBlock.placeholder || 'Select project'}

View File

@@ -7,7 +7,6 @@ import { getProviderIdFromServiceId } from '@/lib/oauth'
import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -87,8 +86,6 @@ export function SheetSelectorInput({
const serviceId = subBlock.serviceId || '' const serviceId = subBlock.serviceId || ''
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
const { isForeignCredential } = useForeignCredential(effectiveProviderId, normalizedCredentialId)
const selectorResolution = useMemo<SelectorResolution | null>(() => { const selectorResolution = useMemo<SelectorResolution | null>(() => {
return resolveSelectorForSubBlock(subBlock, { return resolveSelectorForSubBlock(subBlock, {
workflowId: workflowIdFromUrl, workflowId: workflowIdFromUrl,
@@ -101,11 +98,7 @@ export function SheetSelectorInput({
const missingSpreadsheet = !normalizedSpreadsheetId const missingSpreadsheet = !normalizedSpreadsheetId
const disabledReason = const disabledReason =
finalDisabled || finalDisabled || missingCredential || missingSpreadsheet || !selectorResolution?.key
isForeignCredential ||
missingCredential ||
missingSpreadsheet ||
!selectorResolution?.key
if (!selectorResolution?.key) { if (!selectorResolution?.key) {
return ( return (

View File

@@ -6,7 +6,6 @@ import { Tooltip } from '@/components/emcn'
import { getProviderIdFromServiceId } from '@/lib/oauth' import { getProviderIdFromServiceId } from '@/lib/oauth'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
@@ -85,11 +84,6 @@ export function SlackSelectorInput({
? (effectiveBotToken as string) || '' ? (effectiveBotToken as string) || ''
: (effectiveCredential as string) || '' : (effectiveCredential as string) || ''
const { isForeignCredential } = useForeignCredential(
effectiveProviderId,
(effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || ''
)
useEffect(() => { useEffect(() => {
const val = isPreview && previewValue !== undefined ? previewValue : storeValue const val = isPreview && previewValue !== undefined ? previewValue : storeValue
if (typeof val === 'string') { if (typeof val === 'string') {
@@ -99,7 +93,7 @@ export function SlackSelectorInput({
const requiresCredential = dependsOn.includes('credential') const requiresCredential = dependsOn.includes('credential')
const missingCredential = !credential || credential.trim().length === 0 const missingCredential = !credential || credential.trim().length === 0
const shouldForceDisable = requiresCredential && (missingCredential || isForeignCredential) const shouldForceDisable = requiresCredential && missingCredential
const context: SelectorContext = useMemo( const context: SelectorContext = useMemo(
() => ({ () => ({
@@ -136,7 +130,7 @@ export function SlackSelectorInput({
subBlock={subBlock} subBlock={subBlock}
selectorKey={config.selectorKey} selectorKey={config.selectorKey}
selectorContext={context} selectorContext={context}
disabled={finalDisabled || shouldForceDisable || isForeignCredential} disabled={finalDisabled || shouldForceDisable}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue ?? null} previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || config.placeholder} placeholder={subBlock.placeholder || config.placeholder}

View File

@@ -1,17 +1,19 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react' import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Combobox } from '@/components/emcn/components' import { Button, Combobox } from '@/components/emcn/components'
import { writePendingCredentialCreateRequest } from '@/lib/credentials/client-state'
import { import {
getCanonicalScopesForProvider, getCanonicalScopesForProvider,
getProviderIdFromServiceId, getProviderIdFromServiceId,
getServiceConfigByProviderId,
OAUTH_PROVIDERS, OAUTH_PROVIDERS,
type OAuthProvider, type OAuthProvider,
type OAuthService, type OAuthService,
parseProvider, parseProvider,
} from '@/lib/oauth' } from '@/lib/oauth'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CREDENTIAL } from '@/executor/constants' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -26,6 +28,11 @@ const getProviderIcon = (providerName: OAuthProvider) => {
} }
const getProviderName = (providerName: OAuthProvider) => { const getProviderName = (providerName: OAuthProvider) => {
const serviceConfig = getServiceConfigByProviderId(providerName)
if (serviceConfig) {
return serviceConfig.name
}
const { baseProvider } = parseProvider(providerName) const { baseProvider } = parseProvider(providerName)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
@@ -54,16 +61,19 @@ export function ToolCredentialSelector({
onChange, onChange,
provider, provider,
requiredScopes = [], requiredScopes = [],
label = 'Select account', label,
serviceId, serviceId,
disabled = false, disabled = false,
}: ToolCredentialSelectorProps) { }: ToolCredentialSelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingInputValue, setEditingInputValue] = useState('') const [editingInputValue, setEditingInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const selectedId = value || '' const selectedId = value || ''
const effectiveLabel = label || `Select ${getProviderName(provider)} account`
const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId])
@@ -71,50 +81,58 @@ export function ToolCredentialSelector({
data: credentials = [], data: credentials = [],
isFetching: credentialsLoading, isFetching: credentialsLoading,
refetch: refetchCredentials, refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, Boolean(effectiveProviderId)) } = useOAuthCredentials(effectiveProviderId, {
enabled: Boolean(effectiveProviderId),
workspaceId,
workflowId: activeWorkflowId || undefined,
})
const selectedCredential = useMemo( const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId), () => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId] [credentials, selectedId]
) )
const shouldFetchForeignMeta = const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
Boolean(selectedId) &&
!selectedCredential &&
Boolean(activeWorkflowId) &&
Boolean(effectiveProviderId)
const { data: foreignCredentials = [], isFetching: foreignMetaLoading } = useEffect(() => {
useOAuthCredentialDetail( if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
shouldFetchForeignMeta ? selectedId : undefined, setInaccessibleCredentialName(null)
activeWorkflowId || undefined, return
shouldFetchForeignMeta }
let cancelled = false
;(async () => {
try {
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
) )
if (!response.ok || cancelled) return
const data = await response.json()
if (!cancelled && data.credential?.displayName) {
if (data.credential.id !== selectedId) {
onChange(data.credential.id)
}
setInaccessibleCredentialName(data.credential.displayName)
}
} catch {
// Ignore fetch errors
}
})()
const hasForeignMeta = foreignCredentials.length > 0 return () => {
const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) cancelled = true
}
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
const resolvedLabel = useMemo(() => { const resolvedLabel = useMemo(() => {
if (selectedCredential) return selectedCredential.name if (selectedCredential) return selectedCredential.name
if (isForeign) return CREDENTIAL.FOREIGN_LABEL if (inaccessibleCredentialName) return inaccessibleCredentialName
return '' return ''
}, [selectedCredential, isForeign]) }, [selectedCredential, inaccessibleCredentialName, selectedId, credentialsLoading])
const inputValue = isEditing ? editingInputValue : resolvedLabel const inputValue = isEditing ? editingInputValue : resolvedLabel
const invalidSelection = useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
Boolean(selectedId) &&
!selectedCredential &&
!hasForeignMeta &&
!credentialsLoading &&
!foreignMetaLoading
useEffect(() => {
if (!invalidSelection) return
onChange('')
}, [invalidSelection, onChange])
useCredentialRefreshTriggers(refetchCredentials)
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
@@ -142,8 +160,18 @@ export function ToolCredentialSelector({
) )
const handleAddCredential = useCallback(() => { const handleAddCredential = useCallback(() => {
setShowOAuthModal(true) writePendingCredentialCreateRequest({
}, []) workspaceId,
type: 'oauth',
providerId: effectiveProviderId,
displayName: '',
serviceId,
requiredScopes: getCanonicalScopesForProvider(effectiveProviderId),
requestedAt: Date.now(),
})
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'credentials' } }))
}, [workspaceId, effectiveProviderId, serviceId])
const comboboxOptions = useMemo(() => { const comboboxOptions = useMemo(() => {
const options = credentials.map((cred) => ({ const options = credentials.map((cred) => ({
@@ -151,12 +179,13 @@ export function ToolCredentialSelector({
value: cred.id, value: cred.id,
})) }))
if (credentials.length === 0) {
options.push({ options.push({
label: `Connect ${getProviderName(provider)} account`, label:
credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__', value: '__connect_account__',
}) })
}
return options return options
}, [credentials, provider]) }, [credentials, provider])
@@ -203,10 +232,10 @@ export function ToolCredentialSelector({
selectedValue={selectedId} selectedValue={selectedId}
onChange={handleComboboxChange} onChange={handleComboboxChange}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
placeholder={label} placeholder={effectiveLabel}
disabled={disabled} disabled={disabled}
editable={true} editable={true}
filterOptions={!isForeign} filterOptions={true}
isLoading={credentialsLoading} isLoading={credentialsLoading}
overlayContent={overlayContent} overlayContent={overlayContent}
className={selectedId ? 'pl-[28px]' : ''} className={selectedId ? 'pl-[28px]' : ''}
@@ -218,7 +247,6 @@ export function ToolCredentialSelector({
<span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' /> <span className='mr-[6px] inline-block h-[6px] w-[6px] rounded-[2px] bg-amber-500' />
Additional permissions required Additional permissions required
</div> </div>
{!isForeign && (
<Button <Button
variant='active' variant='active'
onClick={() => setShowOAuthModal(true)} onClick={() => setShowOAuthModal(true)}
@@ -226,7 +254,6 @@ export function ToolCredentialSelector({
> >
Update access Update access
</Button> </Button>
)}
</div> </div>
)} )}
@@ -245,7 +272,11 @@ export function ToolCredentialSelector({
) )
} }
function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>) { function useCredentialRefreshTriggers(
refetchCredentials: () => Promise<unknown>,
providerId: string,
workspaceId: string
) {
useEffect(() => { useEffect(() => {
const refresh = () => { const refresh = () => {
void refetchCredentials() void refetchCredentials()
@@ -263,12 +294,29 @@ function useCredentialRefreshTriggers(refetchCredentials: () => Promise<unknown>
} }
} }
const handleCredentialsUpdated = (
event: CustomEvent<{ providerId?: string; workspaceId?: string }>
) => {
if (event.detail?.providerId && event.detail.providerId !== providerId) {
return
}
if (event.detail?.workspaceId && workspaceId && event.detail.workspaceId !== workspaceId) {
return
}
refresh()
}
document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('pageshow', handlePageShow) window.addEventListener('pageshow', handlePageShow)
window.addEventListener('oauth-credentials-updated', handleCredentialsUpdated as EventListener)
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('pageshow', handlePageShow)
window.removeEventListener(
'oauth-credentials-updated',
handleCredentialsUpdated as EventListener
)
} }
}, [refetchCredentials]) }, [providerId, workspaceId, refetchCredentials])
} }

View File

@@ -0,0 +1,186 @@
'use client'
import type React from 'react'
import { useRef, useState } from 'react'
import { ArrowLeftRight, ArrowUp } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
/**
* Props for a generic parameter with label component
*/
export interface ParameterWithLabelProps {
paramId: string
title: string
isRequired: boolean
visibility: string
wandConfig?: {
enabled: boolean
prompt?: string
placeholder?: string
}
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
disabled: boolean
isPreview: boolean
children: (wandControlRef: React.MutableRefObject<WandControlHandlers | null>) => React.ReactNode
}
/**
* Generic wrapper component for parameters that manages wand state and renders label + input
*/
export function ParameterWithLabel({
paramId,
title,
isRequired,
visibility,
wandConfig,
canonicalToggle,
disabled,
isPreview,
children,
}: ParameterWithLabelProps) {
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(null)
const isWandEnabled = wandConfig?.enabled ?? false
const showWand = isWandEnabled && !isPreview && !disabled
const handleSearchClick = (): void => {
setIsSearchActive(true)
setTimeout(() => {
searchInputRef.current?.focus()
}, 0)
}
const handleSearchBlur = (): void => {
if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) {
setIsSearchActive(false)
}
}
const handleSearchChange = (value: string): void => {
setSearchQuery(value)
}
const handleSearchSubmit = (): void => {
if (searchQuery.trim() && wandControlRef.current) {
wandControlRef.current.onWandTrigger(searchQuery)
setSearchQuery('')
setIsSearchActive(false)
}
}
const handleSearchCancel = (): void => {
setSearchQuery('')
setIsSearchActive(false)
}
const isStreaming = wandControlRef.current?.isWandStreaming ?? false
return (
<div key={paramId} className='relative min-w-0 space-y-[6px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-baseline gap-[6px] whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
{title}
{isRequired && visibility === 'user-only' && <span className='ml-0.5'>*</span>}
</Label>
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
{showWand &&
(!isSearchActive ? (
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleSearchClick}
>
Generate
</Button>
) : (
<div className='-my-1 flex min-w-[120px] max-w-[280px] flex-1 items-center gap-[4px]'>
<Input
ref={searchInputRef}
value={isStreaming ? 'Generating...' : searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleSearchChange(e.target.value)
}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
const relatedTarget = e.relatedTarget as HTMLElement | null
if (relatedTarget?.closest('button')) return
handleSearchBlur()
}}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) {
handleSearchSubmit()
} else if (e.key === 'Escape') {
handleSearchCancel()
}
}}
disabled={isStreaming}
className={cn(
'h-5 min-w-[80px] flex-1 text-[11px]',
isStreaming && 'text-muted-foreground'
)}
placeholder='Generate with AI...'
/>
<Button
variant='tertiary'
disabled={!searchQuery.trim() || isStreaming}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
handleSearchSubmit()
}}
className='h-[20px] w-[20px] flex-shrink-0 p-0'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
</div>
))}
{canonicalToggle && !isPreview && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='flex h-[12px] w-[12px] flex-shrink-0 items-center justify-center bg-transparent p-0 disabled:cursor-not-allowed disabled:opacity-50'
onClick={canonicalToggle.onToggle}
disabled={canonicalToggle.disabled || disabled}
aria-label={
canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'
}
>
<ArrowLeftRight
className={cn(
'!h-[12px] !w-[12px]',
canonicalToggle.mode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{canonicalToggle.mode === 'advanced'
? 'Switch to selector'
: 'Switch to manual ID'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</div>
<div className='relative w-full min-w-0'>{children(wandControlRef)}</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
interface ToolSubBlockRendererProps {
blockId: string
subBlockId: string
toolIndex: number
subBlock: BlockSubBlockConfig
effectiveParamId: string
toolParams: Record<string, string> | undefined
onParamChange: (toolIndex: number, paramId: string, value: string) => void
disabled: boolean
canonicalToggle?: {
mode: 'basic' | 'advanced'
disabled?: boolean
onToggle?: () => void
}
}
/**
* SubBlock types whose store values are objects/arrays/non-strings.
* tool.params stores strings (via JSON.stringify), so when syncing
* back to the store we parse them to restore the native shape.
*/
const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list'])
/**
* Bridges the subblock store with StoredTool.params via a synthetic store key,
* then delegates all rendering to SubBlock for full parity.
*/
export function ToolSubBlockRenderer({
blockId,
subBlockId,
toolIndex,
subBlock,
effectiveParamId,
toolParams,
onParamChange,
disabled,
canonicalToggle,
}: ToolSubBlockRendererProps) {
const syntheticId = `${subBlockId}-tool-${toolIndex}-${effectiveParamId}`
const [storeValue, setStoreValue] = useSubBlockValue(blockId, syntheticId)
const toolParamValue = toolParams?.[effectiveParamId] ?? ''
const isObjectType = OBJECT_SUBBLOCK_TYPES.has(subBlock.type)
const lastPushedToStoreRef = useRef<string | null>(null)
const lastPushedToParamsRef = useRef<string | null>(null)
useEffect(() => {
if (!toolParamValue && lastPushedToStoreRef.current === null) {
lastPushedToStoreRef.current = toolParamValue
lastPushedToParamsRef.current = toolParamValue
return
}
if (toolParamValue !== lastPushedToStoreRef.current) {
lastPushedToStoreRef.current = toolParamValue
lastPushedToParamsRef.current = toolParamValue
if (isObjectType && typeof toolParamValue === 'string' && toolParamValue) {
try {
const parsed = JSON.parse(toolParamValue)
if (typeof parsed === 'object' && parsed !== null) {
setStoreValue(parsed)
return
}
} catch {
// Not valid JSON — fall through to set as string
}
}
setStoreValue(toolParamValue)
}
}, [toolParamValue, setStoreValue, isObjectType])
useEffect(() => {
if (storeValue == null && lastPushedToParamsRef.current === null) return
const stringValue =
storeValue == null
? ''
: typeof storeValue === 'string'
? storeValue
: JSON.stringify(storeValue)
if (stringValue !== lastPushedToParamsRef.current) {
lastPushedToParamsRef.current = stringValue
lastPushedToStoreRef.current = stringValue
onParamChange(toolIndex, effectiveParamId, stringValue)
}
}, [storeValue, toolIndex, effectiveParamId, onParamChange])
const visibility = subBlock.paramVisibility ?? 'user-or-llm'
const isOptionalForUser = visibility !== 'user-only'
const config = {
...subBlock,
id: syntheticId,
...(isOptionalForUser && { required: false }),
}
return (
<SubBlock
blockId={blockId}
config={config}
isPreview={false}
disabled={disabled}
canonicalToggle={canonicalToggle}
dependencyContext={toolParams}
/>
)
}

View File

@@ -2,37 +2,12 @@
* @vitest-environment node * @vitest-environment node
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
interface StoredTool { import {
type: string isCustomToolAlreadySelected,
title?: string isMcpToolAlreadySelected,
toolId?: string isWorkflowAlreadySelected,
params?: Record<string, string> } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils'
customToolId?: string
schema?: any
code?: string
operation?: string
usageControl?: 'auto' | 'force' | 'none'
}
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
}
const isCustomToolAlreadySelected = (
selectedTools: StoredTool[],
customToolId: string
): boolean => {
return selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
)
}
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
return selectedTools.some(
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
)
}
describe('isMcpToolAlreadySelected', () => { describe('isMcpToolAlreadySelected', () => {
describe('basic functionality', () => { describe('basic functionality', () => {

View File

@@ -0,0 +1,31 @@
/**
* Represents a tool selected and configured in the workflow
*
* @remarks
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/
export interface StoredTool {
/** Block type identifier */
type: string
/** Display title for the tool (optional for new custom tool format) */
title?: string
/** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string
/** Parameter values configured by the user (optional for new custom tool format) */
params?: Record<string, string>
/** Whether the tool details are expanded in UI */
isExpanded?: boolean
/** Database ID for custom tools (new format - reference only) */
customToolId?: string
/** Tool schema for custom tools (legacy format - inline JSON schema) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema?: Record<string, any>
/** Implementation code for custom tools (legacy format - inline) */
code?: string
/** Selected operation for multi-operation tools */
operation?: string
/** Tool usage control mode for LLM */
usageControl?: 'auto' | 'force' | 'none'
}

View File

@@ -0,0 +1,32 @@
import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types'
/**
* Checks if an MCP tool is already selected.
*/
export function isMcpToolAlreadySelected(selectedTools: StoredTool[], mcpToolId: string): boolean {
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
}
/**
* Checks if a custom tool is already selected.
*/
export function isCustomToolAlreadySelected(
selectedTools: StoredTool[],
customToolId: string
): boolean {
return selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
)
}
/**
* Checks if a workflow is already selected.
*/
export function isWorkflowAlreadySelected(
selectedTools: StoredTool[],
workflowId: string
): boolean {
return selectedTools.some(
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
)
}

View File

@@ -1,50 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
export function useForeignCredential(
provider: string | undefined,
credentialId: string | undefined
) {
const [isForeign, setIsForeign] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const normalizedProvider = useMemo(() => (provider || '').toString(), [provider])
const normalizedCredentialId = useMemo(() => credentialId || '', [credentialId])
useEffect(() => {
let cancelled = false
async function check() {
setLoading(true)
setError(null)
try {
if (!normalizedProvider || !normalizedCredentialId) {
if (!cancelled) setIsForeign(false)
return
}
const res = await fetch(
`/api/auth/oauth/credentials?provider=${encodeURIComponent(normalizedProvider)}`
)
if (!res.ok) {
if (!cancelled) setIsForeign(true)
return
}
const data = await res.json()
const isOwn = (data.credentials || []).some((c: any) => c.id === normalizedCredentialId)
if (!cancelled) setIsForeign(!isOwn)
} catch (e) {
if (!cancelled) {
setIsForeign(true)
setError((e as Error).message)
}
} finally {
if (!cancelled) setLoading(false)
}
}
void check()
return () => {
cancelled = true
}
}, [normalizedProvider, normalizedCredentialId])
return { isForeignCredential: isForeign, loading, error }
}

View File

@@ -3,7 +3,6 @@ import { isEqual } from 'lodash'
import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react' import { AlertTriangle, ArrowLeftRight, ArrowUp, Check, Clipboard } from 'lucide-react'
import { Button, Input, Label, Tooltip } from '@/components/emcn/components' import { Button, Input, Label, Tooltip } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import { import {
CheckboxList, CheckboxList,
Code, Code,
@@ -69,13 +68,15 @@ interface SubBlockProps {
isPreview?: boolean isPreview?: boolean
subBlockValues?: Record<string, any> subBlockValues?: Record<string, any>
disabled?: boolean disabled?: boolean
fieldDiffStatus?: FieldDiffStatus
allowExpandInPreview?: boolean allowExpandInPreview?: boolean
canonicalToggle?: { canonicalToggle?: {
mode: 'basic' | 'advanced' mode: 'basic' | 'advanced'
disabled?: boolean disabled?: boolean
onToggle?: () => void onToggle?: () => void
} }
labelSuffix?: React.ReactNode
/** Provides sibling values for dependency resolution in non-preview contexts (e.g. tool-input) */
dependencyContext?: Record<string, unknown>
} }
/** /**
@@ -162,16 +163,14 @@ const getPreviewValue = (
/** /**
* Renders the label with optional validation and description tooltips. * Renders the label with optional validation and description tooltips.
* *
* @remarks
* Handles JSON validation indicators for code blocks and required field markers.
* Includes inline AI generate button when wand is enabled.
*
* @param config - The sub-block configuration defining the label content * @param config - The sub-block configuration defining the label content
* @param isValidJson - Whether the JSON content is valid (for code blocks) * @param isValidJson - Whether the JSON content is valid (for code blocks)
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements * @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
* @param wandState - Optional state and handlers for the AI wand feature * @param wandState - State and handlers for the inline AI generate feature
* @param canonicalToggle - Optional canonical toggle metadata and handlers * @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
* @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled * @param canonicalToggleIsDisabled - Whether the canonical toggle is disabled (includes dependsOn gating)
* @param copyState - State and handler for the copy-to-clipboard button
* @param labelSuffix - Additional content rendered after the label text
* @returns The label JSX element, or `null` for switch types or when no title is defined * @returns The label JSX element, or `null` for switch types or when no title is defined
*/ */
const renderLabel = ( const renderLabel = (
@@ -202,7 +201,8 @@ const renderLabel = (
showCopyButton: boolean showCopyButton: boolean
copied: boolean copied: boolean
onCopy: () => void onCopy: () => void
} },
labelSuffix?: React.ReactNode
): JSX.Element | null => { ): JSX.Element | null => {
if (config.type === 'switch') return null if (config.type === 'switch') return null
if (!config.title) return null if (!config.title) return null
@@ -215,9 +215,10 @@ const renderLabel = (
return ( return (
<div className='flex items-center justify-between gap-[6px] pl-[2px]'> <div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px] whitespace-nowrap'> <Label className='flex items-baseline gap-[6px] whitespace-nowrap'>
{config.title} {config.title}
{required && <span className='ml-0.5'>*</span>} {required && <span className='ml-0.5'>*</span>}
{labelSuffix}
{config.type === 'code' && {config.type === 'code' &&
config.language === 'json' && config.language === 'json' &&
!isValidJson && !isValidJson &&
@@ -383,28 +384,25 @@ const arePropsEqual = (prevProps: SubBlockProps, nextProps: SubBlockProps): bool
prevProps.isPreview === nextProps.isPreview && prevProps.isPreview === nextProps.isPreview &&
valueEqual && valueEqual &&
prevProps.disabled === nextProps.disabled && prevProps.disabled === nextProps.disabled &&
prevProps.fieldDiffStatus === nextProps.fieldDiffStatus &&
prevProps.allowExpandInPreview === nextProps.allowExpandInPreview && prevProps.allowExpandInPreview === nextProps.allowExpandInPreview &&
canonicalToggleEqual canonicalToggleEqual &&
prevProps.labelSuffix === nextProps.labelSuffix &&
prevProps.dependencyContext === nextProps.dependencyContext
) )
} }
/** /**
* Renders a single workflow sub-block input based on config.type. * Renders a single workflow sub-block input based on config.type.
* *
* @remarks
* Supports multiple input types including short-input, long-input, dropdown,
* combobox, slider, table, code, switch, tool-input, and many more.
* Handles preview mode, disabled states, and AI wand generation.
*
* @param blockId - The parent block identifier * @param blockId - The parent block identifier
* @param config - Configuration defining the input type and properties * @param config - Configuration defining the input type and properties
* @param isPreview - Whether to render in preview mode * @param isPreview - Whether to render in preview mode
* @param subBlockValues - Current values of all subblocks * @param subBlockValues - Current values of all subblocks
* @param disabled - Whether the input is disabled * @param disabled - Whether the input is disabled
* @param fieldDiffStatus - Optional diff status for visual indicators
* @param allowExpandInPreview - Whether to allow expanding in preview mode * @param allowExpandInPreview - Whether to allow expanding in preview mode
* @returns The rendered sub-block input component * @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
* @param labelSuffix - Additional content rendered after the label text
* @param dependencyContext - Sibling values for dependency resolution in non-preview contexts (e.g. tool-input)
*/ */
function SubBlockComponent({ function SubBlockComponent({
blockId, blockId,
@@ -412,9 +410,10 @@ function SubBlockComponent({
isPreview = false, isPreview = false,
subBlockValues, subBlockValues,
disabled = false, disabled = false,
fieldDiffStatus,
allowExpandInPreview, allowExpandInPreview,
canonicalToggle, canonicalToggle,
labelSuffix,
dependencyContext,
}: SubBlockProps): JSX.Element { }: SubBlockProps): JSX.Element {
const [isValidJson, setIsValidJson] = useState(true) const [isValidJson, setIsValidJson] = useState(true)
const [isSearchActive, setIsSearchActive] = useState(false) const [isSearchActive, setIsSearchActive] = useState(false)
@@ -423,7 +422,6 @@ function SubBlockComponent({
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const wandControlRef = useRef<WandControlHandlers | null>(null) const wandControlRef = useRef<WandControlHandlers | null>(null)
// Use webhook management hook when config has useWebhookUrl enabled
const webhookManagement = useWebhookManagement({ const webhookManagement = useWebhookManagement({
blockId, blockId,
triggerId: undefined, triggerId: undefined,
@@ -510,10 +508,12 @@ function SubBlockComponent({
| null | null
| undefined | undefined
const contextValues = dependencyContext ?? (isPreview ? subBlockValues : undefined)
const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, { const { finalDisabled: gatedDisabled } = useDependsOnGate(blockId, config, {
disabled, disabled,
isPreview, isPreview,
previewContextValues: isPreview ? subBlockValues : undefined, previewContextValues: contextValues,
}) })
const isDisabled = gatedDisabled const isDisabled = gatedDisabled
@@ -797,7 +797,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -809,7 +809,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -821,7 +821,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -833,7 +833,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -845,7 +845,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -868,7 +868,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -880,7 +880,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -892,7 +892,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -917,7 +917,7 @@ function SubBlockComponent({
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
disabled={isDisabled} disabled={isDisabled}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -953,7 +953,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -987,7 +987,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue as any} previewValue={previewValue as any}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -999,7 +999,7 @@ function SubBlockComponent({
disabled={isDisabled} disabled={isDisabled}
isPreview={isPreview} isPreview={isPreview}
previewValue={previewValue} previewValue={previewValue}
previewContextValues={isPreview ? subBlockValues : undefined} previewContextValues={contextValues}
/> />
) )
@@ -1059,7 +1059,8 @@ function SubBlockComponent({
showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl), showCopyButton: Boolean(config.showCopyButton && config.useWebhookUrl),
copied, copied,
onCopy: handleCopy, onCopy: handleCopy,
} },
labelSuffix
)} )}
{renderInput()} {renderInput()}
</div> </div>

View File

@@ -571,7 +571,6 @@ export function Editor() {
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!canEditBlock} disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
canonicalToggle={ canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId isCanonicalSwap && canonicalMode && canonicalId
@@ -635,7 +634,6 @@ export function Editor() {
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!canEditBlock} disabled={!canEditBlock}
fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
/> />
{index < advancedOnlySubBlocks.length - 1 && ( {index < advancedOnlySubBlocks.length - 1 && (

View File

@@ -255,6 +255,69 @@ const WorkflowContent = React.memo(() => {
const addNotification = useNotificationStore((state) => state.addNotification) const addNotification = useNotificationStore((state) => state.addNotification)
useEffect(() => {
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
if (!pending) return
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
;(async () => {
try {
const {
displayName,
providerId,
preCount,
workspaceId: wsId,
reconnect,
} = JSON.parse(pending) as {
displayName: string
providerId: string
preCount: number
workspaceId: string
reconnect?: boolean
}
if (reconnect) {
addNotification({
level: 'info',
message: `"${displayName}" reconnected successfully.`,
})
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId, workspaceId: wsId },
})
)
return
}
const response = await fetch(
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
)
const data = response.ok ? await response.json() : { credentials: [] }
const oauthCredentials = (data.credentials ?? []) as Array<{
displayName: string
providerId: string | null
}>
if (oauthCredentials.length > preCount) {
addNotification({
level: 'info',
message: `"${displayName}" credential connected successfully.`,
})
} else {
const existing = oauthCredentials.find((c) => c.providerId === providerId)
const existingName = existing?.displayName || displayName
addNotification({
level: 'info',
message: `This account is already connected as "${existingName}".`,
})
}
} catch {
// Ignore malformed sessionStorage data
}
})()
}, [])
const { const {
workflows, workflows,
activeWorkflowId, activeWorkflowId,

View File

@@ -473,7 +473,7 @@ function ConnectionsSection({
</div> </div>
)} )}
{/* Environment Variables */} {/* Secrets */}
{envVars.length > 0 && ( {envVars.length > 0 && (
<div className='mb-[2px] last:mb-0'> <div className='mb-[2px] last:mb-0'>
<div <div
@@ -489,7 +489,7 @@ function ConnectionsSection({
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]' 'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
)} )}
> >
Environment Variables Secrets
</span> </span>
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(

View File

@@ -0,0 +1,17 @@
'use client'
import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials-manager'
interface CredentialsProps {
onOpenChange?: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
}
export function Credentials(_props: CredentialsProps) {
return (
<div className='h-full min-h-0'>
<CredentialsManager />
</div>
)
}

View File

@@ -134,7 +134,7 @@ function WorkspaceVariableRow({
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content> <Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
<Trash /> <Trash />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete environment variable</Tooltip.Content> <Tooltip.Content>Delete secret</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</div> </div>
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
filteredWorkspaceEntries.length === 0 && filteredWorkspaceEntries.length === 0 &&
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && ( (envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'> <div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
No environment variables found matching "{searchTerm}" No secrets found matching "{searchTerm}"
</div> </div>
)} )}
</> </>

View File

@@ -2,6 +2,7 @@ export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets' export { CredentialSets } from './credential-sets/credential-sets'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools' export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug' export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment' export { EnvironmentVariables } from './environment/environment'

View File

@@ -1,3 +1,4 @@
export { CancelSubscription } from './cancel-subscription' export { CancelSubscription } from './cancel-subscription'
export { CreditBalance } from './credit-balance' export { CreditBalance } from './credit-balance'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card' export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export { ReferralCode } from './referral-code'

View File

@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Button, Input, Label } from '@/components/emcn'
const logger = createLogger('ReferralCode')
interface ReferralCodeProps {
onRedeemComplete?: () => void
}
/**
* Inline referral/promo code entry field with redeem button.
* One-time use per account — shows success or "already redeemed" state.
*/
export function ReferralCode({ onRedeemComplete }: ReferralCodeProps) {
const [code, setCode] = useState('')
const [isRedeeming, setIsRedeeming] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<{ bonusAmount: number } | null>(null)
const handleRedeem = async () => {
const trimmed = code.trim()
if (!trimmed || isRedeeming) return
setIsRedeeming(true)
setError(null)
try {
const response = await fetch('/api/referral-code/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: trimmed }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to redeem code')
}
if (data.redeemed) {
setSuccess({ bonusAmount: data.bonusAmount })
setCode('')
onRedeemComplete?.()
} else {
setError(data.error || 'Code could not be redeemed')
}
} catch (err) {
logger.error('Referral code redemption failed', { error: err })
setError(err instanceof Error ? err.message : 'Failed to redeem code')
} finally {
setIsRedeeming(false)
}
}
if (success) {
return (
<div className='flex items-center justify-between'>
<Label>Referral Code</Label>
<span className='text-[12px] text-[var(--text-secondary)]'>
+${success.bonusAmount} credits applied
</span>
</div>
)
}
return (
<div className='flex flex-col'>
<div className='flex items-center justify-between gap-[12px]'>
<Label className='shrink-0'>Referral Code</Label>
<div className='flex items-center gap-[8px]'>
<Input
type='text'
value={code}
onChange={(e) => {
setCode(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRedeem()
}}
placeholder='Enter code'
className='h-[32px] w-[140px] text-[12px]'
disabled={isRedeeming}
/>
<Button
variant='active'
className='h-[32px] shrink-0 rounded-[6px] text-[12px]'
onClick={handleRedeem}
disabled={isRedeeming || !code.trim()}
>
{isRedeeming ? 'Redeeming...' : 'Redeem'}
</Button>
</div>
</div>
<div className='mt-[4px] min-h-[18px] text-right'>
{error && <span className='text-[11px] text-[var(--text-error)]'>{error}</span>}
</div>
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
CancelSubscription, CancelSubscription,
CreditBalance, CreditBalance,
PlanCard, PlanCard,
ReferralCode,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components' } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import { import {
ENTERPRISE_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES,
@@ -549,6 +550,10 @@ export function Subscription() {
/> />
)} )}
{!subscription.isEnterprise && (
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{/* Next Billing Date - hidden from team members */} {/* Next Billing Date - hidden from team members */}
{subscription.isPaid && {subscription.isPaid &&
subscriptionData?.data?.periodEnd && subscriptionData?.data?.periodEnd &&

View File

@@ -20,7 +20,6 @@ import {
import { import {
Card, Card,
Connections, Connections,
FolderCode,
HexSimple, HexSimple,
Key, Key,
SModal, SModal,
@@ -45,12 +44,11 @@ import {
BYOK, BYOK,
Copilot, Copilot,
CredentialSets, CredentialSets,
Credentials,
CustomTools, CustomTools,
Debug, Debug,
EnvironmentVariables,
FileUploads, FileUploads,
General, General,
Integrations,
MCP, MCP,
Skills, Skills,
Subscription, Subscription,
@@ -80,6 +78,7 @@ interface SettingsModalProps {
type SettingsSection = type SettingsSection =
| 'general' | 'general'
| 'credentials'
| 'environment' | 'environment'
| 'template-profile' | 'template-profile'
| 'integrations' | 'integrations'
@@ -156,11 +155,10 @@ const allNavigationItems: NavigationItem[] = [
requiresHosted: true, requiresHosted: true,
requiresTeam: true, requiresTeam: true,
}, },
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' }, { id: 'credentials', label: 'Credentials', icon: Connections, section: 'tools' },
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' }, { id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
{ id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' }, { id: 'skills', label: 'Skills', icon: AgentSkillsIcon, section: 'tools' },
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' }, { id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' }, { id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
{ id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' }, { id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{ {
@@ -256,9 +254,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) { if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false return false
} }
if (item.id === 'environment' && permissionConfig.hideEnvironmentTab) {
return false
}
if (item.id === 'files' && permissionConfig.hideFilesTab) { if (item.id === 'files' && permissionConfig.hideFilesTab) {
return false return false
} }
@@ -324,6 +319,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) { if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
return 'general' return 'general'
} }
if (activeSection === 'environment' || activeSection === 'integrations') {
return 'credentials'
}
return activeSection return activeSection
}, [activeSection]) }, [activeSection])
@@ -342,7 +340,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
(sectionId: SettingsSection) => { (sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return if (sectionId === effectiveActiveSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) { if (effectiveActiveSection === 'credentials' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return return
} }
@@ -370,7 +368,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
useEffect(() => { useEffect(() => {
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => { const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
if (event.detail.tab === 'environment' || event.detail.tab === 'integrations') {
setActiveSection('credentials')
} else {
setActiveSection(event.detail.tab) setActiveSection(event.detail.tab)
}
onOpenChange(true) onOpenChange(true)
} }
@@ -479,13 +481,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleDialogOpenChange = (newOpen: boolean) => { const handleDialogOpenChange = (newOpen: boolean) => {
if ( if (
!newOpen && !newOpen &&
effectiveActiveSection === 'environment' && effectiveActiveSection === 'credentials' &&
environmentBeforeLeaveHandler.current environmentBeforeLeaveHandler.current
) { ) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false)) environmentBeforeLeaveHandler.current(() => {
if (integrationsCloseHandler.current) {
integrationsCloseHandler.current(newOpen)
} else {
onOpenChange(false)
}
})
} else if ( } else if (
!newOpen && !newOpen &&
effectiveActiveSection === 'integrations' && effectiveActiveSection === 'credentials' &&
integrationsCloseHandler.current integrationsCloseHandler.current
) { ) {
integrationsCloseHandler.current(newOpen) integrationsCloseHandler.current(newOpen)
@@ -502,7 +510,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</VisuallyHidden.Root> </VisuallyHidden.Root>
<VisuallyHidden.Root> <VisuallyHidden.Root>
<DialogPrimitive.Description> <DialogPrimitive.Description>
Configure your workspace settings, environment variables, integrations, and preferences Configure your workspace settings, credentials, and preferences
</DialogPrimitive.Description> </DialogPrimitive.Description>
</VisuallyHidden.Root> </VisuallyHidden.Root>
@@ -539,18 +547,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
</SModalMainHeader> </SModalMainHeader>
<SModalMainBody> <SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />} {effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'environment' && ( {effectiveActiveSection === 'credentials' && (
<EnvironmentVariables <Credentials
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler} registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/> />
)} )}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />} {effectiveActiveSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && (
<Integrations
onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler}
/>
)}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />} {effectiveActiveSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />} {effectiveActiveSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />} {effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}

View File

@@ -4,12 +4,14 @@ import { useEffect } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client' import { useSession } from '@/lib/auth/auth-client'
import { useReferralAttribution } from '@/hooks/use-referral-attribution'
const logger = createLogger('WorkspacePage') const logger = createLogger('WorkspacePage')
export default function WorkspacePage() { export default function WorkspacePage() {
const router = useRouter() const router = useRouter()
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession()
useReferralAttribution()
useEffect(() => { useEffect(() => {
const redirectToFirstWorkspace = async () => { const redirectToFirstWorkspace = async () => {

View File

@@ -142,6 +142,8 @@ Return ONLY the JSON array.`,
title: 'Google Cloud Account', title: 'Google Cloud Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'vertex-ai', serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account', placeholder: 'Select Google Cloud account',
required: true, required: true,
@@ -150,6 +152,19 @@ Return ONLY the JSON array.`,
value: providers.vertex.models, value: providers.vertex.models,
}, },
}, },
{
id: 'manualCredential',
title: 'Google Cloud Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{ {
id: 'reasoningEffort', id: 'reasoningEffort',
title: 'Reasoning Effort', title: 'Reasoning Effort',
@@ -748,6 +763,7 @@ Example 3 (Array Input):
apiKey: { type: 'string', description: 'Provider API key' }, apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' }, azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' }, azureApiVersion: { type: 'string', description: 'Azure API version' },
oauthCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },

View File

@@ -32,6 +32,8 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
id: 'credential', id: 'credential',
title: 'Airtable Account', title: 'Airtable Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable', serviceId: 'airtable',
requiredScopes: [ requiredScopes: [
'data.records:read', 'data.records:read',
@@ -42,6 +44,15 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Select Airtable account', placeholder: 'Select Airtable account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Airtable Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'baseId', id: 'baseId',
title: 'Base ID', title: 'Base ID',
@@ -219,7 +230,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, records, fields, ...rest } = params const { oauthCredential, records, fields, ...rest } = params
let parsedRecords: any | undefined let parsedRecords: any | undefined
let parsedFields: any | undefined let parsedFields: any | undefined
@@ -237,7 +248,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
// Construct parameters based on operation // Construct parameters based on operation
const baseParams = { const baseParams = {
credential, credential: oauthCredential,
...rest, ...rest,
} }
@@ -255,7 +266,7 @@ Return ONLY the valid JSON object - no explanations, no markdown.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Airtable access token' }, oauthCredential: { type: 'string', description: 'Airtable access token' },
baseId: { type: 'string', description: 'Airtable base identifier' }, baseId: { type: 'string', description: 'Airtable base identifier' },
tableId: { type: 'string', description: 'Airtable table identifier' }, tableId: { type: 'string', description: 'Airtable table identifier' },
// Conditional inputs // Conditional inputs

View File

@@ -32,12 +32,22 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
id: 'credential', id: 'credential',
title: 'Asana Account', title: 'Asana Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'asana', serviceId: 'asana',
requiredScopes: ['default'], requiredScopes: ['default'],
placeholder: 'Select Asana account', placeholder: 'Select Asana account',
}, },
{
id: 'manualCredential',
title: 'Asana Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'workspace', id: 'workspace',
title: 'Workspace GID', title: 'Workspace GID',
@@ -215,7 +225,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation } = params const { oauthCredential, operation } = params
const projectsArray = params.projects const projectsArray = params.projects
? params.projects ? params.projects
@@ -225,7 +235,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
: undefined : undefined
const baseParams = { const baseParams = {
accessToken: credential?.accessToken, accessToken: oauthCredential?.accessToken,
} }
switch (operation) { switch (operation) {
@@ -284,6 +294,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Asana OAuth credential' },
workspace: { type: 'string', description: 'Workspace GID' }, workspace: { type: 'string', description: 'Workspace GID' },
taskGid: { type: 'string', description: 'Task GID' }, taskGid: { type: 'string', description: 'Task GID' },
getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' }, getTasks_workspace: { type: 'string', description: 'Workspace GID for getting tasks' },

View File

@@ -49,9 +49,20 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
title: 'Cal.com Account', title: 'Cal.com Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'calcom', serviceId: 'calcom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
placeholder: 'Select Cal.com account', placeholder: 'Select Cal.com account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Cal.com Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// === Create Booking fields === // === Create Booking fields ===
{ {
@@ -555,7 +566,7 @@ Return ONLY valid JSON - no explanations.`,
params: (params) => { params: (params) => {
const { const {
operation, operation,
credential, oauthCredential,
attendeeName, attendeeName,
attendeeEmail, attendeeEmail,
attendeeTimeZone, attendeeTimeZone,
@@ -745,7 +756,7 @@ Return ONLY valid JSON - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Cal.com OAuth credential' }, oauthCredential: { type: 'string', description: 'Cal.com OAuth credential' },
eventTypeId: { type: 'number', description: 'Event type ID' }, eventTypeId: { type: 'number', description: 'Event type ID' },
start: { type: 'string', description: 'Start time (ISO 8601)' }, start: { type: 'string', description: 'Start time (ISO 8601)' },
end: { type: 'string', description: 'End time (ISO 8601)' }, end: { type: 'string', description: 'End time (ISO 8601)' },

View File

@@ -51,6 +51,8 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -85,6 +87,15 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'pageId', id: 'pageId',
title: 'Select Page', title: 'Select Page',
@@ -287,7 +298,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -300,7 +311,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
if (operation === 'upload_attachment') { if (operation === 'upload_attachment') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: attachmentFile, file: attachmentFile,
@@ -311,7 +322,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
...rest, ...rest,
@@ -322,7 +333,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
credential: { type: 'string', description: 'Confluence access token' }, oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
title: { type: 'string', description: 'Page title' }, title: { type: 'string', description: 'Page title' },
@@ -428,6 +439,8 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
id: 'credential', id: 'credential',
title: 'Confluence Account', title: 'Confluence Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence', serviceId: 'confluence',
requiredScopes: [ requiredScopes: [
'read:confluence-content.all', 'read:confluence-content.all',
@@ -462,6 +475,15 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
placeholder: 'Select Confluence account', placeholder: 'Select Confluence account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Confluence Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'domain', id: 'domain',
title: 'Domain', title: 'Domain',
@@ -943,7 +965,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
pageId, pageId,
operation, operation,
attachmentFile, attachmentFile,
@@ -968,7 +990,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'add_label') { if (operation === 'add_label') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
prefix: labelPrefix || 'global', prefix: labelPrefix || 'global',
@@ -978,7 +1000,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'create_blogpost') { if (operation === 'create_blogpost') {
return { return {
credential, credential: oauthCredential,
operation, operation,
status: blogPostStatus || 'current', status: blogPostStatus || 'current',
...rest, ...rest,
@@ -987,7 +1009,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete') { if (operation === 'delete') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
purge: purge || false, purge: purge || false,
@@ -997,7 +1019,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_comments') { if (operation === 'list_comments') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
bodyFormat: bodyFormat || 'storage', bodyFormat: bodyFormat || 'storage',
@@ -1023,7 +1045,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (supportsCursor.includes(operation) && cursor) { if (supportsCursor.includes(operation) && cursor) {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
operation, operation,
cursor, cursor,
@@ -1036,7 +1058,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('Property key is required for this operation.') throw new Error('Property key is required for this operation.')
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
key: propertyKey, key: propertyKey,
@@ -1047,7 +1069,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'delete_page_property') { if (operation === 'delete_page_property') {
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
propertyId, propertyId,
@@ -1057,7 +1079,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'get_pages_by_label') { if (operation === 'get_pages_by_label') {
return { return {
credential, credential: oauthCredential,
operation, operation,
labelId, labelId,
cursor: cursor || undefined, cursor: cursor || undefined,
@@ -1067,7 +1089,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
if (operation === 'list_space_labels') { if (operation === 'list_space_labels') {
return { return {
credential, credential: oauthCredential,
operation, operation,
cursor: cursor || undefined, cursor: cursor || undefined,
...rest, ...rest,
@@ -1080,7 +1102,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
throw new Error('File is required for upload attachment operation.') throw new Error('File is required for upload attachment operation.')
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId, pageId: effectivePageId,
operation, operation,
file: normalizedFile, file: normalizedFile,
@@ -1091,7 +1113,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
} }
return { return {
credential, credential: oauthCredential,
pageId: effectivePageId || undefined, pageId: effectivePageId || undefined,
blogPostId: blogPostId || undefined, blogPostId: blogPostId || undefined,
versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined, versionNumber: versionNumber ? Number.parseInt(String(versionNumber), 10) : undefined,
@@ -1104,7 +1126,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' }, domain: { type: 'string', description: 'Confluence domain' },
credential: { type: 'string', description: 'Confluence access token' }, oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' }, pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' }, spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' }, blogPostId: { type: 'string', description: 'Blog post identifier' },

View File

@@ -38,6 +38,8 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
id: 'credential', id: 'credential',
title: 'Dropbox Account', title: 'Dropbox Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'dropbox', serviceId: 'dropbox',
requiredScopes: [ requiredScopes: [
'account_info.read', 'account_info.read',
@@ -51,6 +53,15 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
placeholder: 'Select Dropbox account', placeholder: 'Select Dropbox account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Dropbox Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Upload operation inputs // Upload operation inputs
{ {
id: 'path', id: 'path',
@@ -352,7 +363,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Dropbox OAuth credential' }, oauthCredential: { type: 'string', description: 'Dropbox OAuth credential' },
// Common inputs // Common inputs
path: { type: 'string', description: 'Path in Dropbox' }, path: { type: 'string', description: 'Path in Dropbox' },
autorename: { type: 'boolean', description: 'Auto-rename on conflict' }, autorename: { type: 'boolean', description: 'Auto-rename on conflict' },

View File

@@ -76,6 +76,8 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
id: 'credential', id: 'credential',
title: 'Gmail Account', title: 'Gmail Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'gmail', serviceId: 'gmail',
requiredScopes: [ requiredScopes: [
'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.send',
@@ -85,6 +87,15 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
placeholder: 'Select Gmail account', placeholder: 'Select Gmail account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Gmail Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Send Email Fields // Send Email Fields
{ {
id: 'to', id: 'to',
@@ -406,7 +417,7 @@ Return ONLY the search query - no explanations, no extra text.`,
tool: selectGmailToolId, tool: selectGmailToolId,
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
folder, folder,
addLabelIds, addLabelIds,
removeLabelIds, removeLabelIds,
@@ -467,7 +478,7 @@ Return ONLY the search query - no explanations, no extra text.`,
return { return {
...rest, ...rest,
credential, oauthCredential,
...(normalizedAttachments && { attachments: normalizedAttachments }), ...(normalizedAttachments && { attachments: normalizedAttachments }),
} }
}, },
@@ -475,7 +486,7 @@ Return ONLY the search query - no explanations, no extra text.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Gmail access token' }, oauthCredential: { type: 'string', description: 'Gmail access token' },
// Send operation inputs // Send operation inputs
to: { type: 'string', description: 'Recipient email address' }, to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject' }, subject: { type: 'string', description: 'Email subject' },

View File

@@ -0,0 +1,201 @@
import { GoogleBooksIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const GoogleBooksBlock: BlockConfig = {
type: 'google_books',
name: 'Google Books',
description: 'Search and retrieve book information',
authMode: AuthMode.ApiKey,
longDescription:
'Search for books using the Google Books API. Find volumes by title, author, ISBN, or keywords, and retrieve detailed information about specific books including descriptions, ratings, and publication details.',
docsLink: 'https://docs.sim.ai/tools/google_books',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleBooksIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Search Volumes', id: 'volume_search' },
{ label: 'Get Volume Details', id: 'volume_details' },
],
value: () => 'volume_search',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'Enter your Google Books API key',
required: true,
},
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'e.g., intitle:harry potter inauthor:rowling',
condition: { field: 'operation', value: 'volume_search' },
required: { field: 'operation', value: 'volume_search' },
},
{
id: 'filter',
title: 'Filter',
type: 'dropdown',
options: [
{ label: 'None', id: '' },
{ label: 'Partial Preview', id: 'partial' },
{ label: 'Full Preview', id: 'full' },
{ label: 'Free eBooks', id: 'free-ebooks' },
{ label: 'Paid eBooks', id: 'paid-ebooks' },
{ label: 'All eBooks', id: 'ebooks' },
],
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'printType',
title: 'Print Type',
type: 'dropdown',
options: [
{ label: 'All', id: 'all' },
{ label: 'Books', id: 'books' },
{ label: 'Magazines', id: 'magazines' },
],
value: () => 'all',
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'orderBy',
title: 'Order By',
type: 'dropdown',
options: [
{ label: 'Relevance', id: 'relevance' },
{ label: 'Newest', id: 'newest' },
],
value: () => 'relevance',
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'maxResults',
title: 'Max Results',
type: 'short-input',
placeholder: 'Number of results (1-40)',
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'startIndex',
title: 'Start Index',
type: 'short-input',
placeholder: 'Starting index for pagination',
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'langRestrict',
title: 'Language',
type: 'short-input',
placeholder: 'ISO 639-1 code (e.g., en, es, fr)',
condition: { field: 'operation', value: 'volume_search' },
mode: 'advanced',
},
{
id: 'volumeId',
title: 'Volume ID',
type: 'short-input',
placeholder: 'Google Books volume ID',
condition: { field: 'operation', value: 'volume_details' },
required: { field: 'operation', value: 'volume_details' },
},
{
id: 'projection',
title: 'Projection',
type: 'dropdown',
options: [
{ label: 'Full', id: 'full' },
{ label: 'Lite', id: 'lite' },
],
value: () => 'full',
condition: { field: 'operation', value: 'volume_details' },
mode: 'advanced',
},
],
tools: {
access: ['google_books_volume_search', 'google_books_volume_details'],
config: {
tool: (params) => `google_books_${params.operation}`,
params: (params) => {
const { operation, ...rest } = params
let maxResults: number | undefined
if (params.maxResults) {
maxResults = Number.parseInt(params.maxResults, 10)
if (Number.isNaN(maxResults)) {
maxResults = undefined
}
}
let startIndex: number | undefined
if (params.startIndex) {
startIndex = Number.parseInt(params.startIndex, 10)
if (Number.isNaN(startIndex)) {
startIndex = undefined
}
}
return {
...rest,
maxResults,
startIndex,
filter: params.filter || undefined,
printType: params.printType || undefined,
orderBy: params.orderBy || undefined,
projection: params.projection || undefined,
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Google Books API key' },
query: { type: 'string', description: 'Search query' },
filter: { type: 'string', description: 'Filter by availability' },
printType: { type: 'string', description: 'Print type filter' },
orderBy: { type: 'string', description: 'Sort order' },
maxResults: { type: 'string', description: 'Maximum number of results' },
startIndex: { type: 'string', description: 'Starting index for pagination' },
langRestrict: { type: 'string', description: 'Language restriction' },
volumeId: { type: 'string', description: 'Volume ID for details' },
projection: { type: 'string', description: 'Projection level' },
},
outputs: {
totalItems: { type: 'number', description: 'Total number of matching results' },
volumes: { type: 'json', description: 'List of matching volumes' },
id: { type: 'string', description: 'Volume ID' },
title: { type: 'string', description: 'Book title' },
subtitle: { type: 'string', description: 'Book subtitle' },
authors: { type: 'json', description: 'List of authors' },
publisher: { type: 'string', description: 'Publisher name' },
publishedDate: { type: 'string', description: 'Publication date' },
description: { type: 'string', description: 'Book description' },
pageCount: { type: 'number', description: 'Number of pages' },
categories: { type: 'json', description: 'Book categories' },
averageRating: { type: 'number', description: 'Average rating (1-5)' },
ratingsCount: { type: 'number', description: 'Number of ratings' },
language: { type: 'string', description: 'Language code' },
previewLink: { type: 'string', description: 'Link to preview on Google Books' },
infoLink: { type: 'string', description: 'Link to info page' },
thumbnailUrl: { type: 'string', description: 'Book cover thumbnail URL' },
isbn10: { type: 'string', description: 'ISBN-10 identifier' },
isbn13: { type: 'string', description: 'ISBN-13 identifier' },
},
}

View File

@@ -39,11 +39,22 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
id: 'credential', id: 'credential',
title: 'Google Calendar Account', title: 'Google Calendar Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-calendar', serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'], requiredScopes: ['https://www.googleapis.com/auth/calendar'],
placeholder: 'Select Google Calendar account', placeholder: 'Select Google Calendar account',
}, },
{
id: 'manualCredential',
title: 'Google Calendar Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Calendar selector (basic mode) - not needed for list_calendars // Calendar selector (basic mode) - not needed for list_calendars
{ {
id: 'calendarId', id: 'calendarId',
@@ -512,7 +523,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
attendees, attendees,
replaceExisting, replaceExisting,
@@ -576,7 +587,7 @@ Return ONLY the natural language event text - no explanations.`,
} }
return { return {
credential, oauthCredential,
...processedParams, ...processedParams,
} }
}, },
@@ -584,7 +595,7 @@ Return ONLY the natural language event text - no explanations.`,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Calendar access token' }, oauthCredential: { type: 'string', description: 'Google Calendar access token' },
calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' }, calendarId: { type: 'string', description: 'Calendar identifier (canonical param)' },
// Create/Update operation inputs // Create/Update operation inputs

View File

@@ -32,6 +32,8 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-docs', serviceId: 'google-docs',
requiredScopes: [ requiredScopes: [
@@ -40,6 +42,15 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Document selector (basic mode) // Document selector (basic mode)
{ {
id: 'documentId', id: 'documentId',
@@ -157,7 +168,7 @@ Return ONLY the document content - no explanations, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, documentId, folderId, ...rest } = params const { oauthCredential, documentId, folderId, ...rest } = params
const effectiveDocumentId = documentId ? String(documentId).trim() : '' const effectiveDocumentId = documentId ? String(documentId).trim() : ''
const effectiveFolderId = folderId ? String(folderId).trim() : '' const effectiveFolderId = folderId ? String(folderId).trim() : ''
@@ -166,14 +177,14 @@ Return ONLY the document content - no explanations, no extra text.`,
...rest, ...rest,
documentId: effectiveDocumentId || undefined, documentId: effectiveDocumentId || undefined,
folderId: effectiveFolderId || undefined, folderId: effectiveFolderId || undefined,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Docs access token' }, oauthCredential: { type: 'string', description: 'Google Docs access token' },
documentId: { type: 'string', description: 'Document identifier (canonical param)' }, documentId: { type: 'string', description: 'Document identifier (canonical param)' },
title: { type: 'string', description: 'Document title' }, title: { type: 'string', description: 'Document title' },
folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' }, folderId: { type: 'string', description: 'Parent folder identifier (canonical param)' },

View File

@@ -44,6 +44,8 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
id: 'credential', id: 'credential',
title: 'Google Drive Account', title: 'Google Drive Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -52,6 +54,15 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
], ],
placeholder: 'Select Google Drive account', placeholder: 'Select Google Drive account',
}, },
{
id: 'manualCredential',
title: 'Google Drive Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create/Upload File Fields // Create/Upload File Fields
{ {
id: 'fileName', id: 'fileName',
@@ -786,7 +797,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId, uploadFolderId,
createFolderParentId, createFolderParentId,
@@ -873,7 +884,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined sendNotification === 'true' ? true : sendNotification === 'false' ? false : undefined
return { return {
credential, oauthCredential,
folderId: effectiveFolderId, folderId: effectiveFolderId,
fileId: effectiveFileId, fileId: effectiveFileId,
destinationFolderId: effectiveDestinationFolderId, destinationFolderId: effectiveDestinationFolderId,
@@ -891,7 +902,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Drive access token' }, oauthCredential: { type: 'string', description: 'Google Drive access token' },
// Folder canonical params (per-operation) // Folder canonical params (per-operation)
uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' }, uploadFolderId: { type: 'string', description: 'Parent folder for upload/create' },
createFolderParentId: { type: 'string', description: 'Parent folder for create folder' }, createFolderParentId: { type: 'string', description: 'Parent folder for create folder' },

View File

@@ -34,6 +34,8 @@ export const GoogleFormsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-forms', serviceId: 'google-forms',
requiredScopes: [ requiredScopes: [
@@ -45,6 +47,15 @@ export const GoogleFormsBlock: BlockConfig = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Form selector (basic mode) // Form selector (basic mode)
{ {
id: 'formSelector', id: 'formSelector',
@@ -233,7 +244,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
formId, // Canonical param from formSelector (basic) or manualFormId (advanced) formId, // Canonical param from formSelector (basic) or manualFormId (advanced)
responseId, responseId,
@@ -251,7 +262,7 @@ Example for "Add a required multiple choice question about favorite color":
...rest ...rest
} = params } = params
const baseParams = { ...rest, credential } const baseParams = { ...rest, oauthCredential }
const effectiveFormId = formId ? String(formId).trim() : undefined const effectiveFormId = formId ? String(formId).trim() : undefined
switch (operation) { switch (operation) {
@@ -309,7 +320,7 @@ Example for "Add a required multiple choice question about favorite color":
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google OAuth credential' }, oauthCredential: { type: 'string', description: 'Google OAuth credential' },
formId: { type: 'string', description: 'Google Form ID' }, formId: { type: 'string', description: 'Google Form ID' },
responseId: { type: 'string', description: 'Specific response ID' }, responseId: { type: 'string', description: 'Specific response ID' },
pageSize: { type: 'string', description: 'Max responses to retrieve' }, pageSize: { type: 'string', description: 'Max responses to retrieve' },

View File

@@ -42,6 +42,8 @@ export const GoogleGroupsBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Groups Account', title: 'Google Groups Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-groups', serviceId: 'google-groups',
requiredScopes: [ requiredScopes: [
@@ -50,6 +52,15 @@ export const GoogleGroupsBlock: BlockConfig = {
], ],
placeholder: 'Select Google Workspace account', placeholder: 'Select Google Workspace account',
}, },
{
id: 'manualCredential',
title: 'Google Groups Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'customer', id: 'customer',
@@ -311,12 +322,12 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, operation, ...rest } = params const { oauthCredential, operation, ...rest } = params
switch (operation) { switch (operation) {
case 'list_groups': case 'list_groups':
return { return {
credential, oauthCredential,
customer: rest.customer, customer: rest.customer,
domain: rest.domain, domain: rest.domain,
query: rest.query, query: rest.query,
@@ -325,19 +336,19 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_group': case 'get_group':
case 'delete_group': case 'delete_group':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'create_group': case 'create_group':
return { return {
credential, credential: oauthCredential,
email: rest.email, email: rest.email,
name: rest.name, name: rest.name,
description: rest.description, description: rest.description,
} }
case 'update_group': case 'update_group':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
name: rest.newName, name: rest.newName,
email: rest.newEmail, email: rest.newEmail,
@@ -345,7 +356,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
case 'list_members': case 'list_members':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
maxResults: rest.maxResults ? Number(rest.maxResults) : undefined, maxResults: rest.maxResults ? Number(rest.maxResults) : undefined,
roles: rest.roles, roles: rest.roles,
@@ -353,66 +364,66 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
case 'get_member': case 'get_member':
case 'remove_member': case 'remove_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'add_member': case 'add_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
email: rest.memberEmail, email: rest.memberEmail,
role: rest.role, role: rest.role,
} }
case 'update_member': case 'update_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
role: rest.role, role: rest.role,
} }
case 'has_member': case 'has_member':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
memberKey: rest.memberKey, memberKey: rest.memberKey,
} }
case 'list_aliases': case 'list_aliases':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
} }
case 'add_alias': case 'add_alias':
return { return {
credential, credential: oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'remove_alias': case 'remove_alias':
return { return {
credential, oauthCredential,
groupKey: rest.groupKey, groupKey: rest.groupKey,
alias: rest.alias, alias: rest.alias,
} }
case 'get_settings': case 'get_settings':
return { return {
credential, oauthCredential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
case 'update_settings': case 'update_settings':
return { return {
credential, oauthCredential,
groupEmail: rest.groupEmail, groupEmail: rest.groupEmail,
} }
default: default:
return { credential, ...rest } return { oauthCredential, ...rest }
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Workspace OAuth credential' }, oauthCredential: { type: 'string', description: 'Google Workspace OAuth credential' },
customer: { type: 'string', description: 'Customer ID for listing groups' }, customer: { type: 'string', description: 'Customer ID for listing groups' },
domain: { type: 'string', description: 'Domain filter for listing groups' }, domain: { type: 'string', description: 'Domain filter for listing groups' },
query: { type: 'string', description: 'Search query for filtering groups' }, query: { type: 'string', description: 'Search query for filtering groups' },

View File

@@ -36,6 +36,8 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -44,6 +46,15 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector // Spreadsheet Selector
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -246,7 +257,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, values, spreadsheetId, ...rest } = params const { oauthCredential, values, spreadsheetId, ...rest } = params
const parsedValues = values ? JSON.parse(values as string) : undefined const parsedValues = values ? JSON.parse(values as string) : undefined
@@ -260,14 +271,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Sheets access token' }, oauthCredential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
range: { type: 'string', description: 'Cell range' }, range: { type: 'string', description: 'Cell range' },
values: { type: 'string', description: 'Cell values data' }, values: { type: 'string', description: 'Cell values data' },
@@ -323,6 +334,8 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-sheets', serviceId: 'google-sheets',
requiredScopes: [ requiredScopes: [
@@ -331,6 +344,15 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Spreadsheet Selector (basic mode) - not for create operation // Spreadsheet Selector (basic mode) - not for create operation
{ {
id: 'spreadsheetId', id: 'spreadsheetId',
@@ -715,7 +737,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
}), }),
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
values, values,
spreadsheetId, spreadsheetId,
sheetName, sheetName,
@@ -739,7 +761,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
title: (title as string)?.trim(), title: (title as string)?.trim(),
sheetTitles: sheetTitlesArray, sheetTitles: sheetTitlesArray,
credential, oauthCredential,
} }
} }
@@ -753,7 +775,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
if (operation === 'get_info') { if (operation === 'get_info') {
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
credential, oauthCredential,
} }
} }
@@ -763,7 +785,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
credential, oauthCredential,
} }
} }
@@ -774,7 +796,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
...rest, ...rest,
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
data: parsedData, data: parsedData,
credential, oauthCredential,
} }
} }
@@ -784,7 +806,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
return { return {
spreadsheetId: effectiveSpreadsheetId, spreadsheetId: effectiveSpreadsheetId,
ranges: parsedRanges, ranges: parsedRanges,
credential, oauthCredential,
} }
} }
@@ -794,7 +816,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sourceSpreadsheetId: effectiveSpreadsheetId, sourceSpreadsheetId: effectiveSpreadsheetId,
sheetId: Number.parseInt(sheetId as string, 10), sheetId: Number.parseInt(sheetId as string, 10),
destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(), destinationSpreadsheetId: (destinationSpreadsheetId as string)?.trim(),
credential, oauthCredential,
} }
} }
@@ -813,14 +835,14 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
sheetName: effectiveSheetName, sheetName: effectiveSheetName,
cellRange: cellRange ? (cellRange as string).trim() : undefined, cellRange: cellRange ? (cellRange as string).trim() : undefined,
values: parsedValues, values: parsedValues,
credential, oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Sheets access token' }, oauthCredential: { type: 'string', description: 'Google Sheets access token' },
spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' }, spreadsheetId: { type: 'string', description: 'Spreadsheet identifier (canonical param)' },
sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' }, sheetName: { type: 'string', description: 'Name of the sheet/tab (canonical param)' },
cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' }, cellRange: { type: 'string', description: 'Cell range (e.g., A1:D10)' },

View File

@@ -46,6 +46,8 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
id: 'credential', id: 'credential',
title: 'Google Account', title: 'Google Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-drive', serviceId: 'google-drive',
requiredScopes: [ requiredScopes: [
@@ -54,6 +56,15 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
], ],
placeholder: 'Select Google account', placeholder: 'Select Google account',
}, },
{
id: 'manualCredential',
title: 'Google Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Presentation selector (basic mode) - for operations that need an existing presentation // Presentation selector (basic mode) - for operations that need an existing presentation
{ {
id: 'presentationId', id: 'presentationId',
@@ -662,7 +673,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
presentationId, presentationId,
folderId, folderId,
slideIndex, slideIndex,
@@ -679,7 +690,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
const result: Record<string, any> = { const result: Record<string, any> = {
...rest, ...rest,
presentationId: effectivePresentationId || undefined, presentationId: effectivePresentationId || undefined,
credential, oauthCredential,
} }
// Handle operation-specific params // Handle operation-specific params
@@ -799,7 +810,7 @@ Return ONLY the text content - no explanations, no markdown formatting markers,
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Slides access token' }, oauthCredential: { type: 'string', description: 'Google Slides access token' },
presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' }, presentationId: { type: 'string', description: 'Presentation identifier (canonical param)' },
// Write operation // Write operation
slideIndex: { type: 'number', description: 'Slide index to write to' }, slideIndex: { type: 'number', description: 'Slide index to write to' },

View File

@@ -34,6 +34,8 @@ export const GoogleVaultBlock: BlockConfig = {
id: 'credential', id: 'credential',
title: 'Google Vault Account', title: 'Google Vault Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'google-vault', serviceId: 'google-vault',
requiredScopes: [ requiredScopes: [
@@ -42,6 +44,15 @@ export const GoogleVaultBlock: BlockConfig = {
], ],
placeholder: 'Select Google Vault account', placeholder: 'Select Google Vault account',
}, },
{
id: 'manualCredential',
title: 'Google Vault Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Create Hold inputs // Create Hold inputs
{ {
id: 'matterId', id: 'matterId',
@@ -438,10 +449,10 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, holdStartTime, holdEndTime, holdTerms, ...rest } = params const { oauthCredential, holdStartTime, holdEndTime, holdTerms, ...rest } = params
return { return {
...rest, ...rest,
credential, oauthCredential,
// Map hold-specific fields to their tool parameter names // Map hold-specific fields to their tool parameter names
...(holdStartTime && { startTime: holdStartTime }), ...(holdStartTime && { startTime: holdStartTime }),
...(holdEndTime && { endTime: holdEndTime }), ...(holdEndTime && { endTime: holdEndTime }),
@@ -453,7 +464,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
inputs: { inputs: {
// Core inputs // Core inputs
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Google Vault OAuth credential' }, oauthCredential: { type: 'string', description: 'Google Vault OAuth credential' },
matterId: { type: 'string', description: 'Matter ID' }, matterId: { type: 'string', description: 'Matter ID' },
// Create export inputs // Create export inputs

View File

@@ -39,6 +39,8 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
id: 'credential', id: 'credential',
title: 'HubSpot Account', title: 'HubSpot Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'hubspot', serviceId: 'hubspot',
requiredScopes: [ requiredScopes: [
'crm.objects.contacts.read', 'crm.objects.contacts.read',
@@ -68,6 +70,15 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
placeholder: 'Select HubSpot account', placeholder: 'Select HubSpot account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'HubSpot Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'contactId', id: 'contactId',
title: 'Contact ID or Email', title: 'Contact ID or Email',
@@ -823,7 +834,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
params: (params) => { params: (params) => {
const { const {
credential, oauthCredential,
operation, operation,
propertiesToSet, propertiesToSet,
properties, properties,
@@ -835,7 +846,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
} = params } = params
const cleanParams: Record<string, any> = { const cleanParams: Record<string, any> = {
credential, oauthCredential,
} }
const createUpdateOps = [ const createUpdateOps = [
@@ -890,7 +901,7 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'HubSpot access token' }, oauthCredential: { type: 'string', description: 'HubSpot access token' },
contactId: { type: 'string', description: 'Contact ID or email' }, contactId: { type: 'string', description: 'Contact ID or email' },
companyId: { type: 'string', description: 'Company ID or domain' }, companyId: { type: 'string', description: 'Company ID or domain' },
idProperty: { type: 'string', description: 'Property name to use as unique identifier' }, idProperty: { type: 'string', description: 'Property name to use as unique identifier' },

View File

@@ -60,6 +60,8 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -96,6 +98,15 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Project selector (basic mode) // Project selector (basic mode)
{ {
id: 'projectId', id: 'projectId',
@@ -789,14 +800,14 @@ Return ONLY the comment text - no explanations.`,
} }
}, },
params: (params) => { params: (params) => {
const { credential, projectId, issueKey, ...rest } = params const { oauthCredential, projectId, issueKey, ...rest } = params
// Use canonical param IDs (raw subBlock IDs are deleted after serialization) // Use canonical param IDs (raw subBlock IDs are deleted after serialization)
const effectiveProjectId = projectId ? String(projectId).trim() : '' const effectiveProjectId = projectId ? String(projectId).trim() : ''
const effectiveIssueKey = issueKey ? String(issueKey).trim() : '' const effectiveIssueKey = issueKey ? String(issueKey).trim() : ''
const baseParams = { const baseParams = {
credential, oauthCredential,
domain: params.domain, domain: params.domain,
} }
@@ -1049,7 +1060,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
credential: { type: 'string', description: 'Jira access token' }, oauthCredential: { type: 'string', description: 'Jira access token' },
issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' }, issueKey: { type: 'string', description: 'Issue key identifier (canonical param)' },
projectId: { type: 'string', description: 'Project identifier (canonical param)' }, projectId: { type: 'string', description: 'Project identifier (canonical param)' },
// Update/Write operation inputs // Update/Write operation inputs

View File

@@ -55,6 +55,8 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
id: 'credential', id: 'credential',
title: 'Jira Account', title: 'Jira Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true, required: true,
serviceId: 'jira', serviceId: 'jira',
requiredScopes: [ requiredScopes: [
@@ -95,6 +97,15 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
], ],
placeholder: 'Select Jira account', placeholder: 'Select Jira account',
}, },
{
id: 'manualCredential',
title: 'Jira Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{ {
id: 'serviceDeskId', id: 'serviceDeskId',
title: 'Service Desk ID', title: 'Service Desk ID',
@@ -493,7 +504,7 @@ Return ONLY the comment text - no explanations.`,
}, },
params: (params) => { params: (params) => {
const baseParams = { const baseParams = {
credential: params.credential, oauthCredential: params.oauthCredential,
domain: params.domain, domain: params.domain,
} }
@@ -740,7 +751,7 @@ Return ONLY the comment text - no explanations.`,
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Jira domain' }, domain: { type: 'string', description: 'Jira domain' },
credential: { type: 'string', description: 'Jira Service Management access token' }, oauthCredential: { type: 'string', description: 'Jira Service Management access token' },
serviceDeskId: { type: 'string', description: 'Service desk ID' }, serviceDeskId: { type: 'string', description: 'Service desk ID' },
requestTypeId: { type: 'string', description: 'Request type ID' }, requestTypeId: { type: 'string', description: 'Request type ID' },
issueIdOrKey: { type: 'string', description: 'Issue ID or key' }, issueIdOrKey: { type: 'string', description: 'Issue ID or key' },

View File

@@ -129,11 +129,22 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
id: 'credential', id: 'credential',
title: 'Linear Account', title: 'Linear Account',
type: 'oauth-input', type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'linear', serviceId: 'linear',
requiredScopes: ['read', 'write'], requiredScopes: ['read', 'write'],
placeholder: 'Select Linear account', placeholder: 'Select Linear account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'Linear Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Team selector (for most operations) // Team selector (for most operations)
{ {
id: 'teamId', id: 'teamId',
@@ -1504,7 +1515,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
// Base params that most operations need // Base params that most operations need
const baseParams: Record<string, any> = { const baseParams: Record<string, any> = {
credential: params.credential, oauthCredential: params.oauthCredential,
} }
// Operation-specific param mapping // Operation-specific param mapping
@@ -2323,7 +2334,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'Linear access token' }, oauthCredential: { type: 'string', description: 'Linear access token' },
teamId: { type: 'string', description: 'Linear team identifier (canonical param)' }, teamId: { type: 'string', description: 'Linear team identifier (canonical param)' },
projectId: { type: 'string', description: 'Linear project identifier (canonical param)' }, projectId: { type: 'string', description: 'Linear project identifier (canonical param)' },
issueId: { type: 'string', description: 'Issue identifier' }, issueId: { type: 'string', description: 'Issue identifier' },

View File

@@ -33,10 +33,21 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
title: 'LinkedIn Account', title: 'LinkedIn Account',
type: 'oauth-input', type: 'oauth-input',
serviceId: 'linkedin', serviceId: 'linkedin',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'], requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
placeholder: 'Select LinkedIn account', placeholder: 'Select LinkedIn account',
required: true, required: true,
}, },
{
id: 'manualCredential',
title: 'LinkedIn Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
// Share Post specific fields // Share Post specific fields
{ {
@@ -80,25 +91,25 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
}, },
params: (inputs) => { params: (inputs) => {
const operation = inputs.operation || 'share_post' const operation = inputs.operation || 'share_post'
const { credential, ...rest } = inputs const { oauthCredential, ...rest } = inputs
if (operation === 'get_profile') { if (operation === 'get_profile') {
return { return {
accessToken: credential, accessToken: oauthCredential,
} }
} }
return { return {
text: rest.text, text: rest.text,
visibility: rest.visibility || 'PUBLIC', visibility: rest.visibility || 'PUBLIC',
accessToken: credential, accessToken: oauthCredential,
} }
}, },
}, },
}, },
inputs: { inputs: {
operation: { type: 'string', description: 'Operation to perform' }, operation: { type: 'string', description: 'Operation to perform' },
credential: { type: 'string', description: 'LinkedIn access token' }, oauthCredential: { type: 'string', description: 'LinkedIn access token' },
text: { type: 'string', description: 'Post text content' }, text: { type: 'string', description: 'Post text content' },
visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' }, visibility: { type: 'string', description: 'Post visibility (PUBLIC or CONNECTIONS)' },
}, },

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