diff --git a/app/api/ai-gateway/consent/route.ts b/app/api/ai-gateway/consent/route.ts new file mode 100644 index 000000000..700b95a4f --- /dev/null +++ b/app/api/ai-gateway/consent/route.ts @@ -0,0 +1,286 @@ +import { and, eq } from "drizzle-orm"; +import { isAiGatewayManagedKeysEnabled } from "@/lib/ai-gateway/config"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { decrypt, encrypt } from "@/lib/db/integrations"; +import { accounts, integrations } from "@/lib/db/schema"; +import { generateId } from "@/lib/utils/id"; + +const API_KEY_PURPOSE = "ai-gateway"; +const API_KEY_NAME = "Workflow Builder Gateway Key"; + +/** + * Get team ID from Vercel API + * First tries /v2/teams, then falls back to userinfo endpoint + */ +async function getTeamId(accessToken: string): Promise { + // First, try to get teams the user has granted access to + const teamsResponse = await fetch("https://api.vercel.com/v2/teams", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (teamsResponse.ok) { + const teamsData = await teamsResponse.json(); + // biome-ignore lint/suspicious/noExplicitAny: API response type + const accessibleTeam = teamsData.teams?.find((t: any) => !t.limited); + if (accessibleTeam) { + return accessibleTeam.id; + } + } + + // Fallback: get user ID from userinfo endpoint + const userinfoResponse = await fetch( + "https://api.vercel.com/login/oauth/userinfo", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + + if (!userinfoResponse.ok) { + return null; + } + + const userinfo = await userinfoResponse.json(); + return userinfo.sub; +} + +/** + * Create or exchange API key on Vercel + */ +async function createVercelApiKey( + accessToken: string, + teamId: string +): Promise<{ token: string; id: string } | null> { + const response = await fetch( + `https://api.vercel.com/v1/api-keys?teamId=${teamId}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + purpose: API_KEY_PURPOSE, + name: API_KEY_NAME, + exchange: true, + }), + } + ); + + if (!response.ok) { + console.error( + "[ai-gateway] Failed to create API key:", + await response.text() + ); + return null; + } + + const newKey = await response.json(); + if (!newKey.apiKeyString) { + return null; + } + + return { token: newKey.apiKeyString, id: newKey.apiKey?.id }; +} + +type SaveIntegrationParams = { + userId: string; + apiKey: string; + apiKeyId: string; + teamId: string; + teamName: string; +}; + +/** + * Save managed integration in database + * Each team gets its own managed integration - always creates a new one + * The apiKeyId and teamId are stored in config for later deletion + */ +async function saveIntegration(params: SaveIntegrationParams): Promise { + const { userId, apiKey, apiKeyId, teamId, teamName } = params; + + // Config contains the API key plus metadata for managing the key + const configData = { apiKey, managedKeyId: apiKeyId, teamId }; + // Encrypt the entire config for storage (consistent with other integrations) + const encryptedConfig = encrypt(JSON.stringify(configData)); + + // Always create a new integration - users can have multiple managed keys for different teams + const integrationId = generateId(); + await db.insert(integrations).values({ + id: integrationId, + userId, + name: teamName, + type: "ai-gateway", + config: encryptedConfig, + isManaged: true, + }); + return integrationId; +} + +/** + * Delete API key from Vercel + */ +async function deleteVercelApiKey( + accessToken: string, + apiKeyId: string, + teamId: string +): Promise { + await fetch( + `https://api.vercel.com/v1/api-keys/${apiKeyId}?teamId=${teamId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + } + ); +} + +/** + * POST /api/ai-gateway/consent + * Record consent and create API key on user's Vercel account + */ +export async function POST(request: Request) { + if (!isAiGatewayManagedKeysEnabled()) { + return Response.json({ error: "Feature not enabled" }, { status: 403 }); + } + + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.userId, session.user.id), + }); + + if (!account?.accessToken || account.providerId !== "vercel") { + return Response.json( + { error: "No Vercel account linked" }, + { status: 400 } + ); + } + + // Get teamId and teamName from request body + let teamId: string | null = null; + let teamName: string | null = null; + try { + const body = await request.json(); + teamId = body.teamId; + teamName = body.teamName; + } catch { + // If no body, try to auto-detect + } + + // If no teamId provided, try to auto-detect + if (!teamId) { + teamId = await getTeamId(account.accessToken); + } + + if (!teamId) { + return Response.json( + { error: "Could not determine user's team" }, + { status: 500 } + ); + } + + try { + const vercelApiKey = await createVercelApiKey(account.accessToken, teamId); + if (!vercelApiKey) { + return Response.json( + { error: "Failed to create API key" }, + { status: 500 } + ); + } + + const integrationId = await saveIntegration({ + userId: session.user.id, + apiKey: vercelApiKey.token, + apiKeyId: vercelApiKey.id, + teamId, + teamName: teamName || "AI Gateway", + }); + + return Response.json({ + success: true, + hasManagedKey: true, + managedIntegrationId: integrationId, + }); + } catch (e) { + console.error("[ai-gateway] Error creating API key:", e); + return Response.json( + { error: "Failed to create API key" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/ai-gateway/consent?integrationId=xxx + * Revoke consent and delete the API key + * Requires integrationId query parameter to specify which integration to delete + */ +export async function DELETE(request: Request) { + if (!isAiGatewayManagedKeysEnabled()) { + return Response.json({ error: "Feature not enabled" }, { status: 403 }); + } + + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const integrationId = searchParams.get("integrationId"); + + if (!integrationId) { + return Response.json( + { error: "integrationId query parameter is required" }, + { status: 400 } + ); + } + + const managedIntegration = await db.query.integrations.findFirst({ + where: and( + eq(integrations.id, integrationId), + eq(integrations.userId, session.user.id), + eq(integrations.type, "ai-gateway"), + eq(integrations.isManaged, true) + ), + }); + + if (!managedIntegration) { + return Response.json({ error: "Integration not found" }, { status: 404 }); + } + + // Get managedKeyId and teamId from config (decrypt it first since it's stored encrypted) + let config: { managedKeyId?: string; teamId?: string } | null = null; + if (managedIntegration?.config) { + try { + const decrypted = decrypt(managedIntegration.config as string); + config = JSON.parse(decrypted); + } catch (e) { + console.error("[ai-gateway] Failed to decrypt config:", e); + } + } + + if (config?.managedKeyId && config?.teamId) { + const account = await db.query.accounts.findFirst({ + where: eq(accounts.userId, session.user.id), + }); + + if (account?.accessToken) { + try { + await deleteVercelApiKey( + account.accessToken, + config.managedKeyId, + config.teamId + ); + } catch (e) { + console.error("[ai-gateway] Failed to delete API key from Vercel:", e); + } + } + } + + await db + .delete(integrations) + .where(eq(integrations.id, managedIntegration.id)); + + return Response.json({ success: true, hasManagedKey: false }); +} diff --git a/app/api/ai-gateway/status/route.ts b/app/api/ai-gateway/status/route.ts new file mode 100644 index 000000000..1ae0aac30 --- /dev/null +++ b/app/api/ai-gateway/status/route.ts @@ -0,0 +1,60 @@ +import { and, eq } from "drizzle-orm"; +import { isAiGatewayManagedKeysEnabled } from "@/lib/ai-gateway/config"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { accounts, integrations } from "@/lib/db/schema"; + +/** + * GET /api/ai-gateway/status + * Returns user's AI Gateway status including whether they can use managed keys + */ +export async function GET(request: Request) { + const enabled = isAiGatewayManagedKeysEnabled(); + + // If feature is not enabled, return minimal response + if (!enabled) { + return Response.json({ + enabled: false, + signedIn: false, + isVercelUser: false, + hasManagedKey: false, + }); + } + + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user?.id) { + return Response.json({ + enabled: true, + signedIn: false, + isVercelUser: false, + hasManagedKey: false, + }); + } + + // Check if user signed in with Vercel + const account = await db.query.accounts.findFirst({ + where: eq(accounts.userId, session.user.id), + }); + + const isVercelUser = account?.providerId === "vercel"; + + // Check if user has a managed AI Gateway integration + const managedIntegration = await db.query.integrations.findFirst({ + where: and( + eq(integrations.userId, session.user.id), + eq(integrations.type, "ai-gateway"), + eq(integrations.isManaged, true) + ), + }); + + return Response.json({ + enabled: true, + signedIn: true, + isVercelUser, + hasManagedKey: !!managedIntegration, + managedIntegrationId: managedIntegration?.id, + }); +} diff --git a/app/api/ai-gateway/teams/route.ts b/app/api/ai-gateway/teams/route.ts new file mode 100644 index 000000000..3f6ef5f53 --- /dev/null +++ b/app/api/ai-gateway/teams/route.ts @@ -0,0 +1,119 @@ +import { eq } from "drizzle-orm"; +import { isAiGatewayManagedKeysEnabled } from "@/lib/ai-gateway/config"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { accounts } from "@/lib/db/schema"; + +export type VercelTeam = { + id: string; + name: string; + slug: string; + avatar?: string; + isPersonal: boolean; +}; + +type VercelTeamApiResponse = { + id: string; + name: string; + slug: string; + avatar?: string; + limited?: boolean; +}; + +type VercelUserResponse = { + defaultTeamId: string | null; +}; + +/** + * Fetch user's default team ID from Vercel API + */ +async function fetchDefaultTeamId(accessToken: string): Promise { + const response = await fetch("https://api.vercel.com/v2/user", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) return null; + + const data = (await response.json()) as { user?: VercelUserResponse }; + return data.user?.defaultTeamId ?? null; +} + +/** + * Fetch teams from Vercel API and transform to our format + */ +async function fetchTeams(accessToken: string): Promise { + const response = await fetch("https://api.vercel.com/v2/teams", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) return []; + + const data = (await response.json()) as { teams?: VercelTeamApiResponse[] }; + const teams: VercelTeam[] = []; + + for (const team of data.teams || []) { + if (team.limited) continue; + teams.push({ + id: team.id, + name: team.name, + slug: team.slug, + // Team avatar URL uses teamId + avatar: `https://vercel.com/api/www/avatar?teamId=${team.id}&s=64`, + isPersonal: false, + }); + } + + return teams; +} + +/** + * GET /api/ai-gateway/teams + * Fetch Vercel teams for the authenticated user + */ +export async function GET(request: Request) { + if (!isAiGatewayManagedKeysEnabled()) { + return Response.json({ error: "Feature not enabled" }, { status: 403 }); + } + + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.id) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.userId, session.user.id), + }); + + if (!account?.accessToken || account.providerId !== "vercel") { + return Response.json( + { error: "No Vercel account linked" }, + { status: 400 } + ); + } + + try { + // Fetch default team ID and teams in parallel + const [defaultTeamId, teams] = await Promise.all([ + fetchDefaultTeamId(account.accessToken), + fetchTeams(account.accessToken), + ]); + + // Mark the user's default team as personal + const teamsWithPersonal = teams.map((team) => ({ + ...team, + isPersonal: team.id === defaultTeamId, + })); + + // Sort: personal/default team first, then alphabetically by name + const sortedTeams = teamsWithPersonal.sort((a, b) => { + if (a.isPersonal) return -1; + if (b.isPersonal) return 1; + return a.name.localeCompare(b.name); + }); + + return Response.json({ teams: sortedTeams }); + } catch (e) { + console.error("[ai-gateway] Error fetching teams:", e); + return Response.json({ error: "Failed to fetch teams" }, { status: 500 }); + } +} diff --git a/app/api/integrations/route.ts b/app/api/integrations/route.ts index fa50cbb0b..4c591e6ca 100644 --- a/app/api/integrations/route.ts +++ b/app/api/integrations/route.ts @@ -11,13 +11,14 @@ export type GetIntegrationsResponse = { id: string; name: string; type: IntegrationType; + isManaged?: boolean; createdAt: string; updatedAt: string; // Config is intentionally excluded for security }[]; export type CreateIntegrationRequest = { - name: string; + name?: string; type: IntegrationType; config: IntegrationConfig; }; @@ -59,6 +60,7 @@ export async function GET(request: Request) { id: integration.id, name: integration.name, type: integration.type, + isManaged: integration.isManaged ?? false, createdAt: integration.createdAt.toISOString(), updatedAt: integration.updatedAt.toISOString(), }) @@ -86,16 +88,16 @@ export async function POST(request: Request) { const body: CreateIntegrationRequest = await request.json(); - if (!(body.name && body.type && body.config)) { + if (!(body.type && body.config)) { return NextResponse.json( - { error: "Name, type, and config are required" }, + { error: "Type and config are required" }, { status: 400 } ); } const integration = await createIntegration( session.user.id, - body.name, + body.name || "", body.type, body.config ); diff --git a/app/api/integrations/test/route.ts b/app/api/integrations/test/route.ts new file mode 100644 index 000000000..ce85c3141 --- /dev/null +++ b/app/api/integrations/test/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server"; +import postgres from "postgres"; +import { auth } from "@/lib/auth"; +import type { + IntegrationConfig, + IntegrationType, +} from "@/lib/types/integration"; +import { + getCredentialMapping, + getIntegration as getPluginFromRegistry, +} from "@/plugins"; + +export type TestConnectionRequest = { + type: IntegrationType; + config: IntegrationConfig; +}; + +export type TestConnectionResult = { + status: "success" | "error"; + message: string; +}; + +/** + * POST /api/integrations/test + * Test connection credentials without saving + */ +export async function POST(request: Request) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body: TestConnectionRequest = await request.json(); + + if (!(body.type && body.config)) { + return NextResponse.json( + { error: "Type and config are required" }, + { status: 400 } + ); + } + + if (body.type === "database") { + const url = body.config.url; + if (typeof url !== "string") { + return NextResponse.json( + { error: "Database URL must be a string" }, + { status: 400 } + ); + } + const result = await testDatabaseConnection(url); + return NextResponse.json(result); + } + + const plugin = getPluginFromRegistry(body.type); + + if (!plugin) { + return NextResponse.json( + { error: "Invalid integration type" }, + { status: 400 } + ); + } + + if (!plugin.testConfig) { + return NextResponse.json( + { error: "Integration does not support testing" }, + { status: 400 } + ); + } + + const credentials = getCredentialMapping(plugin, body.config); + + const testFn = await plugin.testConfig.getTestFunction(); + const testResult = await testFn(credentials); + + const result: TestConnectionResult = { + status: testResult.success ? "success" : "error", + message: testResult.success + ? "Connection successful" + : testResult.error || "Connection failed", + }; + + return NextResponse.json(result); + } catch (error) { + console.error("Failed to test connection:", error); + return NextResponse.json( + { + status: "error", + message: + error instanceof Error ? error.message : "Failed to test connection", + }, + { status: 500 } + ); + } +} + +async function testDatabaseConnection( + databaseUrl?: string +): Promise { + let connection: postgres.Sql | null = null; + + try { + if (!databaseUrl) { + return { + status: "error", + message: "Connection failed", + }; + } + + connection = postgres(databaseUrl, { + max: 1, + idle_timeout: 5, + connect_timeout: 5, + }); + + await connection`SELECT 1`; + + return { + status: "success", + message: "Connection successful", + }; + } catch { + return { + status: "error", + message: "Connection failed", + }; + } finally { + if (connection) { + await connection.end(); + } + } +} diff --git a/app/layout.tsx b/app/layout.tsx index bcff328bd..75d0e2856 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import { type ReactNode, Suspense } from "react"; import { AuthProvider } from "@/components/auth/provider"; import { GitHubStarsLoader } from "@/components/github-stars-loader"; import { GitHubStarsProvider } from "@/components/github-stars-provider"; +import { GlobalModals } from "@/components/global-modals"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { PersistentCanvas } from "@/components/workflow/persistent-canvas"; @@ -65,6 +66,7 @@ const RootLayout = ({ children }: RootLayoutProps) => ( + diff --git a/app/workflows/[workflowId]/page.tsx b/app/workflows/[workflowId]/page.tsx index c760640dd..1c03c860d 100644 --- a/app/workflows/[workflowId]/page.tsx +++ b/app/workflows/[workflowId]/page.tsx @@ -129,7 +129,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { const setCurrentWorkflowVisibility = useSetAtom( currentWorkflowVisibilityAtom ); - const setIsWorkflowOwner = useSetAtom(isWorkflowOwnerAtom); + const [isOwner, setIsWorkflowOwner] = useAtom(isWorkflowOwnerAtom); const setGlobalIntegrations = useSetAtom(integrationsAtom); const setIntegrationsLoaded = useSetAtom(integrationsLoadedAtom); const integrationsVersion = useAtomValue(integrationsVersionAtom); @@ -427,6 +427,11 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { return; } + // Skip for non-owners (they can't modify the workflow and may not be authenticated) + if (!isOwner) { + return; + } + // Skip if already checked for this workflow+version combination const lastFix = lastAutoFixRef.current; if ( @@ -480,6 +485,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { nodes, currentWorkflowId, integrationsVersion, + isOwner, updateNodeData, setGlobalIntegrations, setIntegrationsLoaded, @@ -677,7 +683,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => { {/* Expand button when panel is collapsed */} {!isMobile && panelCollapsed && ( + )} +
+ + +
+ + + + ); +} diff --git a/components/global-modals.tsx b/components/global-modals.tsx new file mode 100644 index 000000000..592d4ff4f --- /dev/null +++ b/components/global-modals.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { AiGatewayConsentModal } from "@/components/ai-gateway-consent-modal"; + +/** + * Global modals that need to be rendered once at app level + */ +export function GlobalModals() { + return ; +} diff --git a/components/settings/api-keys-dialog.tsx b/components/settings/api-keys-dialog.tsx index d36339631..f40053b5a 100644 --- a/components/settings/api-keys-dialog.tsx +++ b/components/settings/api-keys-dialog.tsx @@ -253,25 +253,38 @@ export function ApiKeysDialog({ open, onOpenChange }: ApiKeysDialogProps) { Create a new API key for webhook authentication -
-
- - setNewKeyName(e.target.value)} - placeholder="e.g., Production, Testing" - value={newKeyName} - /> +
{ + e.preventDefault(); + handleCreate(); + }} + > +
+
+ + setNewKeyName(e.target.value)} + placeholder="e.g., Production, Testing" + value={newKeyName} + /> +
-
+ - diff --git a/components/settings/index.tsx b/components/settings/index.tsx index 5e411465a..df9d9d313 100644 --- a/components/settings/index.tsx +++ b/components/settings/index.tsx @@ -83,21 +83,36 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
) : ( -
+
{ + e.preventDefault(); + saveAccount(); + }} + > -
+ )} - - diff --git a/components/settings/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 9013d886d..523370431 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -1,7 +1,29 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { + ArrowLeft, + Check, + CheckCircle2, + Pencil, + Search, + Trash2, + X, + XCircle, + Zap, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -15,35 +37,28 @@ import { import { Input } from "@/components/ui/input"; import { IntegrationIcon } from "@/components/ui/integration-icon"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { - api, - type Integration, - type IntegrationWithConfig, -} from "@/lib/api-client"; -import type { - IntegrationConfig, - IntegrationType, -} from "@/lib/types/integration"; + aiGatewayStatusAtom, + aiGatewayTeamsAtom, + aiGatewayTeamsLoadingAtom, + openAiGatewayConsentModalAtom, +} from "@/lib/ai-gateway/state"; +import { api, type Integration } from "@/lib/api-client"; +import type { IntegrationType } from "@/lib/types/integration"; import { getIntegration, getIntegrationLabels, getSortedIntegrationTypes, } from "@/plugins"; -import { SendGridIntegrationSection } from "./sendgrid-integration-section"; +import { getIntegrationDescriptions } from "@/plugins/registry"; import { Web3WalletSection } from "./web3-wallet-section"; type IntegrationFormDialogProps = { open: boolean; onClose: () => void; onSuccess?: (integrationId: string) => void; + onDelete?: () => void; integration?: Integration | null; mode: "create" | "edit"; preselectedType?: IntegrationType; @@ -51,8 +66,8 @@ type IntegrationFormDialogProps = { type IntegrationFormData = { name: string; - type: IntegrationType; - config: Record; + type: IntegrationType | null; + config: Record; }; // System integrations that don't have plugins @@ -60,6 +75,9 @@ const SYSTEM_INTEGRATION_TYPES: IntegrationType[] = ["database"]; const SYSTEM_INTEGRATION_LABELS: Record = { database: "Database", }; +const SYSTEM_INTEGRATION_DESCRIPTIONS: Record = { + database: "Connect to PostgreSQL databases", +}; // Get all integration types (plugins + system) const getIntegrationTypes = (): IntegrationType[] => [ @@ -71,100 +89,612 @@ const getIntegrationTypes = (): IntegrationType[] => [ const getLabel = (type: IntegrationType): string => getIntegrationLabels()[type] || SYSTEM_INTEGRATION_LABELS[type] || type; +// Get description for any integration type +const getDescription = (type: IntegrationType): string => + getIntegrationDescriptions()[type] || + SYSTEM_INTEGRATION_DESCRIPTIONS[type] || + ""; + +function SecretField({ + fieldId, + label, + configKey, + placeholder, + helpText, + helpLink, + value, + onChange, + isEditMode, +}: { + fieldId: string; + label: string; + configKey: string; + placeholder?: string; + helpText?: string; + helpLink?: { url: string; text: string }; + value: string; + onChange: (key: string, value: string) => void; + isEditMode: boolean; +}) { + const [isEditing, setIsEditing] = useState(!isEditMode); + const hasNewValue = value.length > 0; + + // In edit mode, start with "configured" state + // User can click to change, or clear after entering a new value + if (isEditMode && !isEditing && !hasNewValue) { + return ( +
+ +
+
+ + Configured +
+ +
+
+ ); + } + + return ( +
+ +
+ onChange(configKey, e.target.value)} + placeholder={placeholder} + type="password" + value={value} + /> + {isEditMode && (isEditing || hasNewValue) && ( + + )} +
+ {(helpText || helpLink) && ( +

+ {helpText} + {helpLink && ( + + {helpLink.text} + + )} +

+ )} +
+ ); +} + +function ConfigFields({ + formData, + updateConfig, + isEditMode, +}: { + formData: IntegrationFormData; + updateConfig: (key: string, value: string) => void; + isEditMode: boolean; +}) { + if (!formData.type) { + return null; + } + + // Handle system integrations with hardcoded fields + if (formData.type === "database") { + return ( + + ); + } + + // Handle Web3 wallet creation + if (formData.type === "web3") { + return ; + } + + // Get plugin form fields from registry + const plugin = getIntegration(formData.type); + if (!plugin?.formFields) { + return null; + } + + return plugin.formFields.map((field) => { + const isSecretField = field.type === "password"; + + if (isSecretField) { + return ( + + ); + } + + return ( +
+ + updateConfig(field.configKey, e.target.value)} + placeholder={field.placeholder} + type={field.type} + value={(formData.config[field.configKey] as string) || ""} + /> + {(field.helpText || field.helpLink) && ( +

+ {field.helpText} + {field.helpLink && ( + + {field.helpLink.text} + + )} +

+ )} +
+ ); + }); +} + +function FormFooterActions({ + step, + mode, + preselectedType, + saving, + deleting, + testing, + testResult, + onBack, + onDelete, + onTestConnection, + onClose, +}: { + step: "select" | "configure"; + mode: "create" | "edit"; + preselectedType?: IntegrationType; + saving: boolean; + deleting: boolean; + testing: boolean; + testResult: { status: "success" | "error"; message: string } | null; + onBack: () => void; + onDelete: () => void; + onTestConnection: () => void; + onClose: () => void; +}) { + if (step === "select") { + return ( + + ); + } + + return ( + <> +
+ {mode === "create" && !preselectedType && ( + + )} + {mode === "edit" && ( + + )} + +
+
+ + +
+ + ); +} + +function TestConnectionIcon({ + testing, + testResult, +}: { + testing: boolean; + testResult: { status: "success" | "error"; message: string } | null; +}) { + if (testing) { + return ; + } + if (testResult?.status === "success") { + return ; + } + if (testResult?.status === "error") { + return ; + } + return ; +} + +function DeleteConfirmDialog({ + open, + onOpenChange, + deleting, + onDelete, + isManaged, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + deleting: boolean; + onDelete: (revokeKey: boolean) => void; + isManaged?: boolean; +}) { + const [revokeKey, setRevokeKey] = useState(true); + + return ( + + + + Delete Connection + + Are you sure you want to delete this connection? Workflows using it + will fail until a new one is configured. + + + {isManaged && ( +
+ setRevokeKey(checked)} + /> + +
+ )} + + Cancel + onDelete(isManaged ? revokeKey : false)} + > + {deleting ? : null} + Delete + + +
+
+ ); +} + +function TestFailedConfirmDialog({ + open, + onOpenChange, + message, + onProceed, + saving, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + message: string; + onProceed: () => void; + saving: boolean; +}) { + return ( + + + + Connection Test Failed + + The connection test failed with the following error: + + {message} + + + Do you want to save the connection anyway? + + + + + Cancel + + {saving ? : null} + Save Anyway + + + + + ); +} + +function TypeSelector({ + searchQuery, + onSearchChange, + filteredTypes, + onSelectType, +}: { + searchQuery: string; + onSearchChange: (value: string) => void; + filteredTypes: IntegrationType[]; + onSelectType: (type: IntegrationType) => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search services..." + value={searchQuery} + /> +
+
+ {filteredTypes.length === 0 ? ( +

+ No services found +

+ ) : ( + filteredTypes.map((type) => { + const description = getDescription(type); + return ( + + ); + }) + )} +
+
+ ); +} + export function IntegrationFormDialog({ open, onClose, onSuccess, + onDelete, integration, mode, preselectedType, }: IntegrationFormDialogProps) { const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + status: "success" | "error"; + message: string; + } | null>(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showTestFailedConfirm, setShowTestFailedConfirm] = useState(false); + const [testFailedMessage, setTestFailedMessage] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const [formData, setFormData] = useState({ name: "", - type: preselectedType || "resend", + type: preselectedType || null, config: {}, }); - const initializeConfigFromPlugin = useCallback( - (pluginType: IntegrationType): Record => { - const plugin = getIntegration(pluginType); - const config: Record = {}; - if (plugin?.formFields) { - for (const field of plugin.formFields) { - if (field.defaultValue !== undefined) { - config[field.configKey] = field.defaultValue as string | boolean; - } - } - } - return config; - }, - [] - ); + // AI Gateway managed keys state + const aiGatewayStatus = useAtomValue(aiGatewayStatusAtom); + const openConsentModal = useSetAtom(openAiGatewayConsentModalAtom); - const initializeConfigFromIntegration = useCallback( - ( - integrationData: Integration | IntegrationWithConfig - ): Record => { - const plugin = getIntegration(integrationData.type); - const config: Record = {}; - - if (plugin?.formFields && "config" in integrationData) { - const integrationConfig = integrationData.config as IntegrationConfig; - for (const field of plugin.formFields) { - if (integrationConfig[field.configKey] !== undefined) { - config[field.configKey] = integrationConfig[field.configKey] as - | string - | boolean; - } else if (field.defaultValue !== undefined) { - config[field.configKey] = field.defaultValue as string | boolean; - } - } - } + // Check if AI Gateway managed keys should be offered + const shouldUseManagedKeys = + aiGatewayStatus?.enabled && aiGatewayStatus?.isVercelUser; - return config; - }, - [] + // Step: "select" for type selection, "configure" for form + const [step, setStep] = useState<"select" | "configure">( + preselectedType || mode === "edit" ? "configure" : "select" ); useEffect(() => { + setTestResult(null); if (integration) { - const initialConfig = initializeConfigFromIntegration(integration); setFormData({ name: integration.name, type: integration.type, - config: initialConfig, + config: {}, }); + setStep("configure"); } else { - const pluginType = preselectedType || "resend"; - const initialConfig = initializeConfigFromPlugin(pluginType); setFormData({ name: "", - type: pluginType, - config: initialConfig, + type: preselectedType || null, + config: {}, + }); + setStep(preselectedType ? "configure" : "select"); + } + }, [integration, preselectedType]); + + // AI Gateway atoms for fetching status and teams + const setAiGatewayStatus = useSetAtom(aiGatewayStatusAtom); + const setTeams = useSetAtom(aiGatewayTeamsAtom); + const setTeamsLoading = useSetAtom(aiGatewayTeamsLoadingAtom); + + // Helper to open consent modal with callbacks + const showConsentModalWithCallbacks = useCallback(() => { + onClose(); + openConsentModal({ + onConsent: (integrationId: string) => { + onSuccess?.(integrationId); + }, + }); + }, [onClose, openConsentModal, onSuccess]); + + // Handle preselected AI Gateway - fetch status/teams and show consent modal if managed keys available + useEffect(() => { + if (!open || preselectedType !== "ai-gateway" || mode !== "create") { + return; + } + + // If we already have status and managed keys are available, show consent modal + if (shouldUseManagedKeys) { + showConsentModalWithCallbacks(); + return; + } + + // If status is null (not fetched yet), fetch it and teams + if (aiGatewayStatus === null) { + api.aiGateway.getStatus().then((status) => { + setAiGatewayStatus(status); + // Check if managed keys should be used after fetching + if (status?.enabled && status?.isVercelUser) { + // Also fetch teams before showing consent modal + setTeamsLoading(true); + api.aiGateway + .getTeams() + .then((response) => { + setTeams(response.teams); + }) + .finally(() => { + setTeamsLoading(false); + showConsentModalWithCallbacks(); + }); + } }); } }, [ - integration, + open, preselectedType, - initializeConfigFromIntegration, - initializeConfigFromPlugin, + mode, + aiGatewayStatus, + shouldUseManagedKeys, + showConsentModalWithCallbacks, + setAiGatewayStatus, + setTeams, + setTeamsLoading, ]); - const handleSave = async () => { + const handleSelectType = (type: IntegrationType) => { + // If selecting AI Gateway and managed keys are available, show consent modal + if (type === "ai-gateway" && shouldUseManagedKeys) { + showConsentModalWithCallbacks(); + return; + } + + setFormData({ + name: "", + type, + config: {}, + }); + setStep("configure"); + }; + + const handleBack = () => { + setStep("select"); + setSearchQuery(""); + setFormData({ + name: "", + type: null, + config: {}, + }); + }; + + const doSave = async () => { + if (!formData.type) { + return; + } + try { setSaving(true); - // Generate a default name if none provided - const integrationName = - formData.name.trim() || `${getLabel(formData.type)} Integration`; + const integrationName = formData.name.trim(); if (mode === "edit" && integration) { + // Only include config if there are actual new values entered + const hasNewConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 + ); await api.integration.update(integration.id, { name: integrationName, - config: formData.config, + ...(hasNewConfig ? { config: formData.config } : {}), }); - toast.success("Integration updated"); + toast.success("Connection updated"); onSuccess?.(integration.id); } else { const newIntegration = await api.integration.create({ @@ -172,7 +702,6 @@ export function IntegrationFormDialog({ type: formData.type, config: formData.config, }); - toast.success("Integration created"); onSuccess?.(newIntegration.id); } onClose(); @@ -184,148 +713,141 @@ export function IntegrationFormDialog({ } }; - const updateConfig = (key: string, value: string | boolean) => { - setFormData({ - ...formData, - config: { ...formData.config, [key]: value }, - }); - }; - - const renderHelpText = ( - helpText?: string, - helpLink?: { text: string; url: string } - ) => { - if (!(helpText || helpLink)) { - return null; + const handleSave = async () => { + if (!formData.type) { + return; } - return ( -

- {helpText} - {helpLink && ( - - {helpLink.text} - - )} -

+ + // Check if we have config values to test + const hasConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 ); - }; - const renderCheckboxField = (field: { - id: string; - type: string; - label: string; - configKey: string; - defaultValue?: string | boolean; - helpText?: string; - helpLink?: { text: string; url: string }; - }) => { - let checkboxValue: string | boolean | undefined = - formData.config[field.configKey]; - if (checkboxValue === undefined) { - checkboxValue = - field.defaultValue !== undefined ? field.defaultValue : true; + // In edit mode without new config, skip testing + if (mode === "edit" && !hasConfig) { + await doSave(); + return; } - const isChecked = - typeof checkboxValue === "boolean" - ? checkboxValue - : checkboxValue === "true"; - return ( -
- - updateConfig(field.configKey, checked === true) - } - /> - - {renderHelpText(field.helpText, field.helpLink)} -
- ); - }; + // Test the connection before saving + try { + setSaving(true); + setTestResult(null); - const renderInputField = (field: { - id: string; - type: string; - label: string; - configKey: string; - placeholder?: string; - helpText?: string; - helpLink?: { text: string; url: string }; - }) => ( -
- - updateConfig(field.configKey, e.target.value)} - placeholder={field.placeholder} - type={field.type} - value={(formData.config[field.configKey] as string) || ""} - /> - {renderHelpText(field.helpText, field.helpLink)} -
- ); + const result = await api.integration.testCredentials({ + type: formData.type, + config: formData.config, + }); - const renderConfigFields = () => { - // Handle system integrations with hardcoded fields - if (formData.type === "database") { - return ( -
- - updateConfig("url", e.target.value)} - placeholder="postgresql://..." - type="password" - value={(formData.config.url as string) || ""} - /> -

- Connection string in the format: - postgresql://user:password@host:port/database -

-
- ); + if (result.status === "error") { + // Test failed - ask user if they want to proceed + setTestFailedMessage(result.message); + setShowTestFailedConfirm(true); + setSaving(false); + return; + } + + // Test passed - proceed with save + setSaving(false); + await doSave(); + } catch (error) { + console.error("Failed to test connection:", error); + const message = + error instanceof Error ? error.message : "Failed to test connection"; + setTestFailedMessage(message); + setShowTestFailedConfirm(true); + setSaving(false); + } + }; + + const handleDelete = async (revokeKey: boolean) => { + if (!integration) { + return; } - // Handle Web3 wallet creation - if (formData.type === "web3") { - return ; + try { + setDeleting(true); + + // If this is a managed connection and user wants to revoke the key + if (integration.isManaged && revokeKey) { + await api.aiGateway.revokeConsent(); + } else { + await api.integration.delete(integration.id); + } + + toast.success("Connection deleted"); + onDelete?.(); + onClose(); + } catch (error) { + console.error("Failed to delete integration:", error); + toast.error("Failed to delete connection"); + } finally { + setDeleting(false); + setShowDeleteConfirm(false); } + }; - // Get plugin form fields from registry - const plugin = getIntegration(formData.type); - if (!plugin?.formFields) { - return null; + const handleTestConnection = async () => { + if (!formData.type) { + return; } - // Handle SendGrid integration with special checkbox logic - if (formData.type === "sendgrid") { - return ( - - ); + // Check if we have any config values to test + const hasConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 + ); + if (!hasConfig && mode === "create") { + toast.error("Please enter credentials first"); + return; } - // Default rendering for other integrations - return plugin.formFields.map((field) => { - if (field.type === "checkbox") { - return renderCheckboxField(field); + try { + setTesting(true); + setTestResult(null); + + let result: { status: "success" | "error"; message: string }; + + if (mode === "edit" && integration && !hasConfig) { + // Test existing integration (no new config entered) + result = await api.integration.testConnection(integration.id); + } else { + // Test with new credentials + result = await api.integration.testCredentials({ + type: formData.type, + config: formData.config, + }); } - return renderInputField(field); + + setTestResult(result); + } catch (error) { + console.error("Failed to test connection:", error); + const message = + error instanceof Error ? error.message : "Failed to test connection"; + setTestResult({ status: "error", message }); + } finally { + setTesting(false); + } + }; + + const updateConfig = (key: string, value: string) => { + setFormData({ + ...formData, + config: { ...formData.config, [key]: value }, }); }; + const integrationTypes = getIntegrationTypes(); + + const filteredIntegrationTypes = useMemo(() => { + if (!searchQuery.trim()) { + return integrationTypes; + } + const query = searchQuery.toLowerCase(); + return integrationTypes.filter((type) => + getLabel(type).toLowerCase().includes(query) + ); + }, [integrationTypes, searchQuery]); + return ( !isOpen && onClose()} open={open}> @@ -354,77 +876,81 @@ export function IntegrationFormDialog({ -
- {mode === "create" && ( + {step === "select" && ( + + )} + + {step === "configure" && ( +
{ + e.preventDefault(); + handleSave(); + }} + > + +
- - + setFormData({ ...formData, name: e.target.value }) } - value={formData.type} - > - - - - - {getIntegrationTypes().map((type) => ( - -
- - {getLabel(type)} -
-
- ))} -
- + placeholder="e.g. Production, Personal, Work" + value={formData.name} + />
- )} - - {renderConfigFields()} - -
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder={`${getLabel(formData.type)} Integration`} - value={formData.name} - /> -
-
+ + )} - - {formData.type === "web3" ? ( - // Web3 wallet creation happens in the component, just show Close - - ) : ( - <> - - - - )} + + setShowDeleteConfirm(true)} + onTestConnection={handleTestConnection} + preselectedType={preselectedType} + saving={saving} + step={step} + testing={testing} + testResult={testResult} + />
+ + + + { + setShowTestFailedConfirm(false); + doSave(); + }} + open={showTestFailedConfirm} + saving={saving} + />
); } diff --git a/components/settings/integrations-dialog.tsx b/components/settings/integrations-dialog.tsx index 443a080a2..280147caf 100644 --- a/components/settings/integrations-dialog.tsx +++ b/components/settings/integrations-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useSetAtom } from "jotai"; -import { Plus } from "lucide-react"; +import { Plus, Search } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { integrationsVersionAtom } from "@/lib/integrations-store"; import { IntegrationsManager } from "./integrations-manager"; @@ -27,6 +28,7 @@ export function IntegrationsDialog({ }: IntegrationsDialogProps) { const [loading, setLoading] = useState(true); const [showCreateDialog, setShowCreateDialog] = useState(false); + const [filter, setFilter] = useState(""); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); // Track if any changes were made during this dialog session const hasChangesRef = useRef(false); @@ -48,6 +50,8 @@ export function IntegrationsDialog({ hasChangesRef.current = false; // Reset create dialog state when opening setShowCreateDialog(false); + // Reset filter when opening + setFilter(""); } }, [open, loadAll]); @@ -68,14 +72,11 @@ export function IntegrationsDialog({ return ( - + - Integrations + Connections - Manage your integrations that can be used across workflows + Manage API keys and credentials used by your workflows @@ -84,18 +85,31 @@ export function IntegrationsDialog({ ) : ( -
- +
+
+ + setFilter(e.target.value)} + placeholder="Filter connections..." + value={filter} + /> +
+
+ setShowCreateDialog(false)} + onIntegrationChange={handleIntegrationChange} + showCreateDialog={showCreateDialog} + /> +
)} - + diff --git a/components/settings/integrations-manager.tsx b/components/settings/integrations-manager.tsx index db7fb9017..ad891d646 100644 --- a/components/settings/integrations-manager.tsx +++ b/components/settings/integrations-manager.tsx @@ -1,7 +1,7 @@ "use client"; import { Pencil, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -31,18 +31,21 @@ const SYSTEM_INTEGRATION_LABELS: Record = { type IntegrationsManagerProps = { showCreateDialog: boolean; + onCreateDialogClose?: () => void; onIntegrationChange?: () => void; + filter?: string; }; export function IntegrationsManager({ showCreateDialog: externalShowCreateDialog, + onCreateDialogClose, onIntegrationChange, + filter = "", }: IntegrationsManagerProps) { const [integrations, setIntegrations] = useState([]); const [loading, setLoading] = useState(true); const [editingIntegration, setEditingIntegration] = useState(null); - const [loadingIntegration, setLoadingIntegration] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false); const [deletingId, setDeletingId] = useState(null); const [testingId, setTestingId] = useState(null); @@ -69,6 +72,38 @@ export function IntegrationsManager({ loadIntegrations(); }, [loadIntegrations]); + // Get integrations with their labels, sorted by label then name + const integrationsWithLabels = useMemo(() => { + const labels = getIntegrationLabels() as Record; + const filterLower = filter.toLowerCase(); + + return integrations + .map((integration) => ({ + ...integration, + label: + labels[integration.type] || + SYSTEM_INTEGRATION_LABELS[integration.type] || + integration.type, + })) + .filter((integration) => { + if (!filter) { + return true; + } + return ( + integration.label.toLowerCase().includes(filterLower) || + integration.name.toLowerCase().includes(filterLower) || + integration.type.toLowerCase().includes(filterLower) + ); + }) + .sort((a, b) => { + const labelCompare = a.label.localeCompare(b.label); + if (labelCompare !== 0) { + return labelCompare; + } + return a.name.localeCompare(b.name); + }); + }, [integrations, filter]); + const handleDelete = async (id: string) => { try { await api.integration.delete(id); @@ -106,6 +141,7 @@ export function IntegrationsManager({ const handleDialogClose = () => { setShowCreateDialog(false); setEditingIntegration(null); + onCreateDialogClose?.(); }; const handleDialogSuccess = async () => { @@ -121,81 +157,93 @@ export function IntegrationsManager({ ); } - return ( -
- {integrations.length === 0 ? ( -
+ const renderIntegrationsList = () => { + if (integrations.length === 0) { + return ( +

- No integrations configured yet + No connections configured yet

- ) : ( -
- {integrations.map((integration) => ( -
-
- -
-

{integration.name}

-

- {getIntegrationLabels()[integration.type] || - SYSTEM_INTEGRATION_LABELS[integration.type] || - integration.type} -

-
-
-
- - - -
-
- ))} + ); + } + + if (integrationsWithLabels.length === 0) { + return ( +
+

+ No connections match your filter +

- )} + ); + } + + return ( +
+ {integrationsWithLabels.map((integration) => ( +
+
+ + {integration.label} + + {integration.name} + +
+
+ + + +
+
+ ))} +
+ ); + }; + + return ( +
+ {renderIntegrationsList()} {(showCreateDialog || editingIntegration) && ( - Delete Integration + Delete Connection - Are you sure you want to delete this integration? Workflows using - this integration will fail until a new one is selected. + Are you sure you want to delete this connection? Workflows using + it will fail until a new one is configured. diff --git a/components/settings/sendgrid-integration-section.tsx b/components/settings/sendgrid-integration-section.tsx deleted file mode 100644 index cb4dab6b1..000000000 --- a/components/settings/sendgrid-integration-section.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import type { IntegrationConfig } from "@/lib/types/integration"; -import type { IntegrationPlugin } from "@/plugins/registry"; - -type SendGridIntegrationSectionProps = { - formFields: IntegrationPlugin["formFields"]; - config: IntegrationConfig; - updateConfig: (key: string, value: string | boolean) => void; -}; - -const renderHelpText = ( - helpText?: string, - helpLink?: { text: string; url: string } -) => { - if (!(helpText || helpLink)) { - return null; - } - return ( -

- {helpText} - {helpLink && ( - - {helpLink.text} - - )} -

- ); -}; - -const renderCheckboxField = ( - field: { - id: string; - type: string; - label: string; - configKey: string; - defaultValue?: string | boolean; - helpText?: string; - helpLink?: { text: string; url: string }; - }, - config: IntegrationConfig, - updateConfig: (key: string, value: string | boolean) => void -) => { - let checkboxValue: string | boolean | undefined = config[field.configKey]; - if (checkboxValue === undefined) { - checkboxValue = - field.defaultValue !== undefined ? field.defaultValue : true; - } - const isChecked = - typeof checkboxValue === "boolean" - ? checkboxValue - : checkboxValue === "true"; - - return ( -
- - updateConfig(field.configKey, checked === true) - } - /> - - {renderHelpText(field.helpText, field.helpLink)} -
- ); -}; - -const renderInputField = ( - field: { - id: string; - type: string; - label: string; - configKey: string; - placeholder?: string; - helpText?: string; - helpLink?: { text: string; url: string }; - }, - config: IntegrationConfig, - updateConfig: (key: string, value: string | boolean) => void -) => ( -
- - updateConfig(field.configKey, e.target.value)} - placeholder={field.placeholder} - type={field.type} - value={(config[field.configKey] as string) || ""} - /> - {renderHelpText(field.helpText, field.helpLink)} -
-); - -export function SendGridIntegrationSection({ - formFields, - config, - updateConfig, -}: SendGridIntegrationSectionProps) { - // Check if useKeeperHubApiKey checkbox is checked - const useKeeperHubApiKey = - config.useKeeperHubApiKey !== undefined - ? config.useKeeperHubApiKey === true || - config.useKeeperHubApiKey === "true" - : true; // Default to true - - return ( - <> - {formFields.map((field) => { - if (field.type === "checkbox") { - return renderCheckboxField(field, config, updateConfig); - } - - // Hide API key field if useKeeperHubApiKey is checked - if (useKeeperHubApiKey && field.configKey === "apiKey") { - return null; - } - - return renderInputField(field, config, updateConfig); - })} - - ); -} diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index 4d5323edf..41af1e69d 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,31 +1,40 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; + AlertTriangle, + Check, + Circle, + Pencil, + Plus, + Settings, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; +import { Button } from "@/components/ui/button"; +import { + aiGatewayStatusAtom, + aiGatewayTeamsAtom, + aiGatewayTeamsFetchedAtom, + aiGatewayTeamsLoadingAtom, + openAiGatewayConsentModalAtom, +} from "@/lib/ai-gateway/state"; import { api, type Integration } from "@/lib/api-client"; import { integrationsAtom, integrationsVersionAtom, } from "@/lib/integrations-store"; import type { IntegrationType } from "@/lib/types/integration"; -import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; +import { cn } from "@/lib/utils"; +import { getIntegration } from "@/plugins"; type IntegrationSelectorProps = { integrationType: IntegrationType; value?: string; onChange: (integrationId: string) => void; onOpenSettings?: () => void; - label?: string; disabled?: boolean; + onAddConnection?: () => void; }; export function IntegrationSelector({ @@ -33,87 +42,241 @@ export function IntegrationSelector({ value, onChange, onOpenSettings, - label, disabled, + onAddConnection, }: IntegrationSelectorProps) { - const [integrations, setIntegrations] = useState([]); - const [loading, setLoading] = useState(true); const [showNewDialog, setShowNewDialog] = useState(false); + const [editingIntegration, setEditingIntegration] = + useState(null); + const [globalIntegrations, setGlobalIntegrations] = useAtom(integrationsAtom); const integrationsVersion = useAtomValue(integrationsVersionAtom); - const setGlobalIntegrations = useSetAtom(integrationsAtom); const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); + const lastVersionRef = useRef(integrationsVersion); + const [hasFetched, setHasFetched] = useState(false); + + // AI Gateway user keys state + const [aiGatewayStatus, setAiGatewayStatus] = useAtom(aiGatewayStatusAtom); + const [aiGatewayStatusFetched, setAiGatewayStatusFetched] = useState(false); + const openConsentModal = useSetAtom(openAiGatewayConsentModalAtom); + + // AI Gateway teams state (pre-loaded for consent modal) + const [teams, setTeams] = useAtom(aiGatewayTeamsAtom); + const [teamsFetched, setTeamsFetched] = useAtom(aiGatewayTeamsFetchedAtom); + const setTeamsLoading = useSetAtom(aiGatewayTeamsLoadingAtom); - const loadIntegrations = async () => { + // Filter integrations from global cache + const integrations = useMemo( + () => globalIntegrations.filter((i) => i.type === integrationType), + [globalIntegrations, integrationType] + ); + + // Check if we have cached data + const hasCachedData = globalIntegrations.length > 0; + + const loadIntegrations = useCallback(async () => { try { - setLoading(true); const all = await api.integration.getAll(); // Update global store so other components can access it setGlobalIntegrations(all); - const filtered = all.filter((i) => i.type === integrationType); - setIntegrations(filtered); - - // Auto-select if only one option and nothing selected yet - if (filtered.length === 1 && !value) { - onChange(filtered[0].id); - } + setHasFetched(true); } catch (error) { console.error("Failed to load integrations:", error); - } finally { - setLoading(false); } - }; + }, [setGlobalIntegrations]); + // Load AI Gateway status for ai-gateway type useEffect(() => { - loadIntegrations(); + if (integrationType === "ai-gateway" && !aiGatewayStatusFetched) { + api.aiGateway + .getStatus() + .then((status) => { + setAiGatewayStatus(status); + setAiGatewayStatusFetched(true); + }) + .catch(() => { + setAiGatewayStatusFetched(true); + }); + } + }, [integrationType, aiGatewayStatusFetched, setAiGatewayStatus]); + + // Load AI Gateway teams when status indicates user can use managed keys + useEffect(() => { + if ( + integrationType === "ai-gateway" && + aiGatewayStatus?.enabled && + aiGatewayStatus?.isVercelUser && + !teamsFetched + ) { + setTeamsLoading(true); + api.aiGateway + .getTeams() + .then((response) => { + setTeams(response.teams); + // Only mark as fetched if we got teams - empty might mean expired token + if (response.teams.length > 0) { + setTeamsFetched(true); + } + }) + .catch(() => { + // Don't mark as fetched on error - allow retry + }) + .finally(() => { + setTeamsLoading(false); + }); + } + }, [ + integrationType, + aiGatewayStatus, + teamsFetched, + setTeams, + setTeamsFetched, + setTeamsLoading, + ]); + + // Refresh teams in background (always try if we should use managed keys) + useEffect(() => { + if ( + integrationType === "ai-gateway" && + aiGatewayStatus?.enabled && + aiGatewayStatus?.isVercelUser + ) { + // Always try to refresh teams - handles token refresh after re-auth + api.aiGateway + .getTeams() + .then((response) => { + if (response.teams.length > 0) { + setTeams(response.teams); + setTeamsFetched(true); + } + }) + .catch(() => { + // Silently fail background refresh + }); + } + // Only run on mount and when status changes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [integrationType, integrationsVersion]); + }, [integrationType, aiGatewayStatus?.enabled, aiGatewayStatus?.isVercelUser]); - const handleValueChange = (newValue: string) => { - if (newValue === "__new__") { - setShowNewDialog(true); - } else if (newValue === "__manage__") { - onOpenSettings?.(); - } else { - onChange(newValue); + useEffect(() => { + loadIntegrations(); + }, [loadIntegrations, integrationType]); + + // Listen for version changes (from other components creating/editing integrations) + useEffect(() => { + // Skip initial render - only react to actual version changes + if (integrationsVersion !== lastVersionRef.current) { + lastVersionRef.current = integrationsVersion; + loadIntegrations(); } - }; + }, [integrationsVersion, loadIntegrations]); + + // Auto-select first integration when none is selected or current selection is invalid + useEffect(() => { + if (integrations.length > 0 && !disabled) { + // Check if current value exists in available integrations + const currentExists = value && integrations.some((i) => i.id === value); + if (!currentExists) { + // Prefer managed integrations, fall back to first available + const managed = integrations.find((i) => i.isManaged); + onChange(managed?.id || integrations[0].id); + } + } + }, [integrations, value, disabled, onChange]); const handleNewIntegrationCreated = async (integrationId: string) => { await loadIntegrations(); onChange(integrationId); setShowNewDialog(false); - // Increment version to trigger auto-fix for other nodes that need this integration type + // Increment version to trigger re-fetch in other selectors setIntegrationsVersion((v) => v + 1); }; - if (loading) { + const handleEditSuccess = async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + }; + + const handleDelete = async () => { + await loadIntegrations(); + setEditingIntegration(null); + setIntegrationsVersion((v) => v + 1); + // Refresh AI Gateway status if this is an AI Gateway integration + if (integrationType === "ai-gateway") { + const status = await api.aiGateway.getStatus(); + setAiGatewayStatus(status); + } + }; + + // Check if AI Gateway managed keys should be used + const shouldUseManagedKeys = + integrationType === "ai-gateway" && + aiGatewayStatus?.enabled && + aiGatewayStatus?.isVercelUser && + !aiGatewayStatus?.hasManagedKey; + + const handleConsentSuccess = useCallback(async (integrationId: string) => { + await loadIntegrations(); + onChange(integrationId); + setIntegrationsVersion((v) => v + 1); + // Refetch AI Gateway status + const status = await api.aiGateway.getStatus(); + setAiGatewayStatus(status); + }, [loadIntegrations, onChange, setIntegrationsVersion, setAiGatewayStatus]); + + const handleAddConnection = useCallback(() => { + if (onAddConnection) { + onAddConnection(); + } else if (shouldUseManagedKeys) { + // For AI Gateway with managed keys enabled, show consent modal + openConsentModal({ + onConsent: handleConsentSuccess, + onManualEntry: () => { + setShowNewDialog(true); + }, + }); + } else { + setShowNewDialog(true); + } + }, [onAddConnection, shouldUseManagedKeys, openConsentModal, handleConsentSuccess]); + + // Only show loading skeleton if we have no cached data and haven't fetched yet + if (!hasCachedData && !hasFetched) { return ( - +
+
+
+
+
+
+
); } + const plugin = getIntegration(integrationType); + const integrationLabel = plugin?.label || integrationType; + + // Separate managed and manual integrations for AI Gateway + const managedIntegrations = integrations.filter((i) => i.isManaged); + const manualIntegrations = integrations.filter((i) => !i.isManaged); + + // No integrations - show add button if (integrations.length === 0) { return ( -
- - + <> + + setShowNewDialog(false)} @@ -121,29 +284,160 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ + ); + } + + // Single integration - show as outlined field (not radio-style) + if (integrations.length === 1) { + const integration = integrations[0]; + const displayName = integration.name || `${integrationLabel} API Key`; + + return ( + <> +
+ + {displayName} + +
+ + setShowNewDialog(false)} + onSuccess={handleNewIntegrationCreated} + open={showNewDialog} + preselectedType={integrationType} + /> + + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={handleDelete} + onSuccess={handleEditSuccess} + open + /> + )} + ); } + // Multiple integrations or AI Gateway with option to add managed key return ( -
- {label && {label}} - - + <> +
+ {/* Show managed integrations first */} + {managedIntegrations.map((integration) => { + const isSelected = value === integration.id; + const displayName = integration.name || `${integrationLabel} API Key`; + return ( +
+ + +
+ ); + })} + + {/* Show manual integrations */} + {manualIntegrations.map((integration) => { + const isSelected = value === integration.id; + const displayName = + integration.name || `${integrationLabel} API Key`; + return ( +
+ + +
+ ); + })} + + {onOpenSettings && ( + + )} +
+ setShowNewDialog(false)} @@ -151,7 +445,17 @@ export function IntegrationSelector({ open={showNewDialog} preselectedType={integrationType} /> -
+ + {editingIntegration && ( + setEditingIntegration(null)} + onDelete={handleDelete} + onSuccess={handleEditSuccess} + open + /> + )} + ); } - diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 000000000..a4e90d4e9 --- /dev/null +++ b/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/components/workflow/config/action-config-renderer.tsx b/components/workflow/config/action-config-renderer.tsx index 702972623..2af79a5c4 100644 --- a/components/workflow/config/action-config-renderer.tsx +++ b/components/workflow/config/action-config-renderer.tsx @@ -457,6 +457,7 @@ function renderField(
; onUpdateConfig: (key: string, value: string) => void; disabled: boolean; + isOwner?: boolean; }; // Database Query fields component @@ -210,6 +229,11 @@ const SYSTEM_ACTIONS: Array<{ id: string; label: string }> = [ const SYSTEM_ACTION_IDS = SYSTEM_ACTIONS.map((a) => a.id); +// System actions that need integrations (not in plugin registry) +const SYSTEM_ACTION_INTEGRATIONS: Record = { + "Database Query": "database", +}; + // Build category mapping dynamically from plugins + System function useCategoryData() { return useMemo(() => { @@ -266,10 +290,12 @@ function normalizeActionType(actionType: string): string { return actionType; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Upstream function - preserving original structure for easier merges export function ActionConfig({ config, onUpdateConfig, disabled, + isOwner = true, }: ActionConfigProps) { const actionType = (config?.actionType as string) || ""; const categories = useCategoryData(); @@ -277,6 +303,12 @@ export function ActionConfig({ const selectedCategory = actionType ? getCategoryForAction(actionType) : null; const [category, setCategory] = useState(selectedCategory || ""); + const [showAddConnectionDialog, setShowAddConnectionDialog] = useState(false); + const setIntegrationsVersion = useSetAtom(integrationsVersionAtom); + + // AI Gateway managed keys state + const aiGatewayStatus = useAtomValue(aiGatewayStatusAtom); + const openConsentModal = useSetAtom(openAiGatewayConsentModalAtom); // Sync category state when actionType changes (e.g., when switching nodes) useEffect(() => { @@ -332,12 +364,52 @@ export function ActionConfig({ // Get dynamic config fields for plugin actions const pluginAction = actionType ? findActionById(actionType) : null; + // Determine the integration type for the current action + const integrationType: IntegrationType | undefined = useMemo(() => { + if (!actionType) { + return; + } + + // Check system actions first + if (SYSTEM_ACTION_INTEGRATIONS[actionType]) { + return SYSTEM_ACTION_INTEGRATIONS[actionType]; + } + + // Check plugin actions + const action = findActionById(actionType); + return action?.integration as IntegrationType | undefined; + }, [actionType]); + + // Check if AI Gateway managed keys should be offered (user can have multiple for different teams) + const shouldUseManagedKeys = + integrationType === "ai-gateway" && + aiGatewayStatus?.enabled && + aiGatewayStatus?.isVercelUser; + + const handleConsentSuccess = (integrationId: string) => { + onUpdateConfig("integrationId", integrationId); + setIntegrationsVersion((v) => v + 1); + }; + + const handleAddSecondaryConnection = () => { + if (shouldUseManagedKeys) { + openConsentModal({ + onConsent: handleConsentSuccess, + onManualEntry: () => { + setShowAddConnectionDialog(true); + }, + }); + } else { + setShowAddConnectionDialog(true); + } + }; + return ( <>
+ + + + + + + {viewMode === "list" ? "Grid view" : "List view"} + + + + {hiddenCount > 0 && ( + + + + + + + {showHidden + ? "Hide hidden groups" + : `Show ${hiddenCount} hidden group${hiddenCount > 1 ? "s" : ""}`} + + + + )}
-
- {filteredActions.map((action) => ( - - ))} -
+ {filteredActions + .filter( + (action) => showHidden || !hiddenGroups.has(action.category) + ) + .map((action) => ( + + ))} +
+ )} - {filteredActions.length === 0 && ( -

- No actions found -

- )} + {/* List View */} + {viewMode === "list" && + visibleGroups.length > 0 && + visibleGroups.map((group, groupIndex) => { + const isCollapsed = collapsedGroups.has(group.category); + const isHidden = hiddenGroups.has(group.category); + return ( +
+ {groupIndex > 0 &&
} +
+ + + + + + + toggleHideGroup(group.category)} + > + {isHidden ? ( + <> + + Show group + + ) : ( + <> + + Hide group + + )} + + + +
+ {!isCollapsed && + group.actions.map((action) => ( + + ))} +
+ ); + })} +
); } diff --git a/components/workflow/node-config-panel.tsx b/components/workflow/node-config-panel.tsx index 35593f839..398e70a73 100644 --- a/components/workflow/node-config-panel.tsx +++ b/components/workflow/node-config-panel.tsx @@ -775,19 +775,11 @@ export const PanelInner = () => { className="flex flex-col overflow-hidden" value="properties" > -
- {selectedNode.data.type === "trigger" && ( - - )} - - {selectedNode.data.type === "action" && - !selectedNode.data.config?.actionType && - isOwner && ( + {/* Action selection - full height flex layout */} + {selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + isOwner && ( +
{ } }} /> +
+ )} + + {/* Other content - scrollable */} + {!( + selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + isOwner + ) && ( +
+ {selectedNode.data.type === "trigger" && ( + )} - {selectedNode.data.type === "action" && - !selectedNode.data.config?.actionType && - !isOwner && ( + {selectedNode.data.type === "action" && + !selectedNode.data.config?.actionType && + !isOwner && ( +
+

+ No action configured for this step. +

+
+ )} + + {selectedNode.data.type === "action" && + selectedNode.data.config?.actionType ? ( + + ) : null} + + {selectedNode.data.type !== "action" || + selectedNode.data.config?.actionType ? ( + <> +
+ + handleUpdateLabel(e.target.value)} + value={selectedNode.data.label} + /> +
+ +
+ + handleUpdateDescription(e.target.value)} + placeholder="Optional description" + value={selectedNode.data.description || ""} + /> +
+ + ) : null} + + {!isOwner && (

- No action configured for this step. + You are viewing a public workflow. Duplicate it to make + changes.

)} - - {selectedNode.data.type === "action" && - selectedNode.data.config?.actionType ? ( - - ) : null} - - {selectedNode.data.type !== "action" || - selectedNode.data.config?.actionType ? ( - <> -
- - handleUpdateLabel(e.target.value)} - value={selectedNode.data.label} - /> -
- -
- - handleUpdateDescription(e.target.value)} - placeholder="Optional description" - value={selectedNode.data.description || ""} - /> -
- - ) : null} - - {!isOwner && ( -
-

- You are viewing a public workflow. Duplicate it to make - changes. -

-
- )} -
+
+ )} {selectedNode.data.type === "action" && isOwner && (
@@ -921,7 +932,6 @@ export const PanelInner = () => { return ( handleUpdateConfig("integrationId", id)} onOpenSettings={() => setShowIntegrationsDialog(true)} value={ diff --git a/components/workflow/workflow-runs.tsx b/components/workflow/workflow-runs.tsx index 4c4624adf..07f7ea497 100644 --- a/components/workflow/workflow-runs.tsx +++ b/components/workflow/workflow-runs.tsx @@ -399,7 +399,7 @@ function ExecutionLogEntry({ return (
{/* Timeline connector */} -
+
{!isFirst && (
)} diff --git a/components/workflow/workflow-toolbar.tsx b/components/workflow/workflow-toolbar.tsx index 84e16998e..40a7794ec 100644 --- a/components/workflow/workflow-toolbar.tsx +++ b/components/workflow/workflow-toolbar.tsx @@ -339,6 +339,7 @@ function getMissingRequiredFields( // Get missing integrations for workflow nodes // Uses the plugin registry to determine which integrations are required // Also handles built-in actions that aren't in the plugin registry +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Upstream function - preserving original structure for easier merges function getMissingIntegrations( nodes: WorkflowNode[], userIntegrations: Array<{ id: string; type: IntegrationType }> @@ -1607,10 +1608,20 @@ function WorkflowIssuesDialog({ const { brokenReferences, missingRequiredFields, missingIntegrations } = actions.workflowIssues; - const handleGoToStep = (nodeId: string) => { + const handleGoToStep = (nodeId: string, fieldKey?: string) => { actions.setShowWorkflowIssuesDialog(false); state.setSelectedNodeId(nodeId); state.setActiveTab("properties"); + // Focus on the specific field after a short delay to allow the panel to render + if (fieldKey) { + setTimeout(() => { + const element = document.getElementById(fieldKey); + if (element) { + element.focus(); + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 100); + } }; const handleAddIntegration = (integrationType: IntegrationType) => { @@ -1642,147 +1653,128 @@ function WorkflowIssuesDialog({ -
- {/* Broken References Section */} - {brokenReferences.length > 0 && ( -
-

- - Broken References ({brokenReferences.length}) +
+ {/* Missing Connections Section */} + {missingIntegrations.length > 0 && ( +
+

+ Missing Connections

-
- {brokenReferences.map((broken) => ( -
( +
+ +

+ + {missing.integrationLabel} + + + {" — "} + {missing.nodeNames.length > 3 + ? `${missing.nodeNames.slice(0, 3).join(", ")} +${missing.nodeNames.length - 3} more` + : missing.nodeNames.join(", ")} + +

+ -
- ))} -
+ Add + +
+ ))}
)} - {/* Missing Required Fields Section */} - {missingRequiredFields.length > 0 && ( + {/* Broken References Section */} + {brokenReferences.length > 0 && (
-

- - Missing Required Fields ({missingRequiredFields.length}) +

+ Broken References

-
- {missingRequiredFields.map((node) => ( -
-
-

- {node.nodeLabel} -

-
- {node.missingFields.map((field) => ( -

- Missing:{" "} - - {field.fieldLabel} - -

- ))} + {brokenReferences.map((broken) => ( +
+

{broken.nodeLabel}

+
+ {broken.brokenReferences.map((ref, idx) => ( +
+

+ {ref.displayText} + {" in "} + {ref.fieldLabel} +

+
-
- + ))}
- ))} -
+
+ ))}
)} - {/* Missing Integrations Section */} - {missingIntegrations.length > 0 && ( + {/* Missing Required Fields Section */} + {missingRequiredFields.length > 0 && (
-

- - Missing Integrations ({missingIntegrations.length}) +

+ Missing Required Fields

-
- {missingIntegrations.map((missing) => ( -
- -
-

- {missing.integrationLabel} -

-

- Used by:{" "} - {missing.nodeNames.length > 3 - ? `${missing.nodeNames.slice(0, 3).join(", ")} and ${missing.nodeNames.length - 3} more` - : missing.nodeNames.join(", ")} -

-
- + {missingRequiredFields.map((node) => ( +
+

{node.nodeLabel}

+
+ {node.missingFields.map((field) => ( +
+

+ {field.fieldLabel} +

+ +
+ ))}
- ))} -
+
+ ))}
)}

- - Cancel + + Cancel @@ -1954,12 +1946,14 @@ function WorkflowDialogsComponent({ the workflow? - - Cancel + - +
+ Cancel + +
diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index ca319988e..3e6ee144d 100644 --- a/components/workflows/user-menu.tsx +++ b/components/workflows/user-menu.tsx @@ -147,7 +147,7 @@ export const UserMenu = () => { )} setIntegrationsOpen(true)}> - Integrations + Connections setApiKeysOpen(true)}> @@ -159,7 +159,7 @@ export const UserMenu = () => { - + Theme diff --git a/drizzle/0004_real_wither.sql b/drizzle/0004_real_wither.sql new file mode 100644 index 000000000..1eb476a85 --- /dev/null +++ b/drizzle/0004_real_wither.sql @@ -0,0 +1 @@ +ALTER TABLE "integrations" ADD COLUMN "is_managed" boolean DEFAULT false; \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..3a4b9ccb0 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,731 @@ +{ + "id": "0832c6e0-e9ef-4979-af70-f53f1f15d10a", + "prevId": "725be3c3-851b-481a-91d1-080feccc324d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_managed": { + "name": "is_managed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_user_id_users_id_fk": { + "name": "integrations_user_id_users_id_fk", + "tableFrom": "integrations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_name": { + "name": "node_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_type": { + "name": "node_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_execution_logs_execution_id_workflow_executions_id_fk": { + "name": "workflow_execution_logs_execution_id_workflow_executions_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_executions": { + "name": "workflow_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_executions_workflow_id_workflows_id_fk": { + "name": "workflow_executions_workflow_id_workflows_id_fk", + "tableFrom": "workflow_executions", + "tableTo": "workflows", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_executions_user_id_users_id_fk": { + "name": "workflow_executions_user_id_users_id_fk", + "tableFrom": "workflow_executions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nodes": { + "name": "nodes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "edges": { + "name": "edges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflows_user_id_users_id_fk": { + "name": "workflows_user_id_users_id_fk", + "tableFrom": "workflows", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 065206e88..de501962f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1765507190572, "tag": "0003_parched_wendell_rand", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1765834764376, + "tag": "0004_real_wither", + "breakpoints": true } ] } diff --git a/lib/ai-gateway/config.ts b/lib/ai-gateway/config.ts new file mode 100644 index 000000000..5cb047165 --- /dev/null +++ b/lib/ai-gateway/config.ts @@ -0,0 +1,27 @@ +/** + * AI Gateway Managed Keys Configuration + * + * This feature allows signed-in users to use their own Vercel AI Gateway + * API keys (and credits) instead of manually entering an API key. + * + * The AI Gateway itself is available to everyone via AI_GATEWAY_API_KEY. + * This feature flag only controls the ability to create API keys on behalf + * of users through OAuth - which is an internal Vercel feature. + * + * Set AI_GATEWAY_MANAGED_KEYS_ENABLED=true to enable. + */ + +export function isAiGatewayManagedKeysEnabled(): boolean { + return process.env.AI_GATEWAY_MANAGED_KEYS_ENABLED === "true"; +} + +/** + * Check if managed keys feature is enabled on the client side + * Uses NEXT_PUBLIC_ prefix for client-side access + */ +export function isAiGatewayManagedKeysEnabledClient(): boolean { + if (typeof window === "undefined") { + return process.env.AI_GATEWAY_MANAGED_KEYS_ENABLED === "true"; + } + return process.env.NEXT_PUBLIC_AI_GATEWAY_MANAGED_KEYS_ENABLED === "true"; +} diff --git a/lib/ai-gateway/state.ts b/lib/ai-gateway/state.ts new file mode 100644 index 000000000..736c45026 --- /dev/null +++ b/lib/ai-gateway/state.ts @@ -0,0 +1,65 @@ +"use client"; + +import { atom } from "jotai"; +import type { VercelTeam } from "@/lib/api-client"; + +/** + * AI Gateway consent modal state + */ +export const showAiGatewayConsentModalAtom = atom(false); + +/** + * Callbacks for the consent modal - stored in atoms so any component can set them + */ +export type AiGatewayConsentCallbacks = { + onConsent?: (integrationId: string) => void; + onManualEntry?: () => void; + onDecline?: () => void; +}; + +export const aiGatewayConsentCallbacksAtom = atom( + {} +); + +/** + * Write-only atom to open the consent modal with specific callbacks. + * Usage: const openModal = useSetAtom(openAiGatewayConsentModalAtom); + * openModal({ onConsent: (id) => ..., onManualEntry: () => ... }); + */ +export const openAiGatewayConsentModalAtom = atom( + null, + (get, set, callbacks: AiGatewayConsentCallbacks) => { + set(aiGatewayConsentCallbacksAtom, callbacks); + set(showAiGatewayConsentModalAtom, true); + } +); + +/** + * AI Gateway status (fetched from API) + */ +export type AiGatewayStatus = { + /** Whether the user keys feature is enabled */ + enabled: boolean; + /** Whether the user is signed in */ + signedIn: boolean; + /** Whether the user signed in with Vercel OAuth */ + isVercelUser: boolean; + /** Whether the user has a managed AI Gateway integration */ + hasManagedKey: boolean; + /** The ID of the managed integration (if exists) */ + managedIntegrationId?: string; +} | null; + +export const aiGatewayStatusAtom = atom(null); + +/** + * Loading state for consent action + */ +export const aiGatewayConsentLoadingAtom = atom(false); + +/** + * Vercel teams for the current user + */ +export const aiGatewayTeamsAtom = atom([]); +export const aiGatewayTeamsLoadingAtom = atom(false); +export const aiGatewayTeamsFetchedAtom = atom(false); diff --git a/lib/api-client.ts b/lib/api-client.ts index 27c9c5ad9..357db48ce 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -320,6 +320,7 @@ export type Integration = { id: string; name: string; type: IntegrationType; + isManaged?: boolean; createdAt: string; updatedAt: string; }; @@ -328,6 +329,34 @@ export type IntegrationWithConfig = Integration & { config: IntegrationConfig; }; +// AI Gateway types +export type AiGatewayStatusResponse = { + enabled: boolean; + signedIn: boolean; + isVercelUser: boolean; + hasManagedKey: boolean; + managedIntegrationId?: string; +}; + +export type AiGatewayConsentResponse = { + success: boolean; + hasManagedKey: boolean; + managedIntegrationId?: string; + error?: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; + avatar?: string; + isPersonal: boolean; +}; + +export type AiGatewayTeamsResponse = { + teams: VercelTeam[]; +}; + // Integration API export const integrationApi = { // List all integrations @@ -362,7 +391,7 @@ export const integrationApi = { method: "DELETE", }), - // Test connection + // Test existing integration connection testConnection: (integrationId: string) => apiCall<{ status: "success" | "error"; message: string }>( `/api/integrations/${integrationId}/test`, @@ -370,6 +399,19 @@ export const integrationApi = { method: "POST", } ), + + // Test credentials without saving + testCredentials: (data: { + type: IntegrationType; + config: IntegrationConfig; + }) => + apiCall<{ status: "success" | "error"; message: string }>( + "/api/integrations/test", + { + method: "POST", + body: JSON.stringify(data), + } + ), }; // User API @@ -608,10 +650,33 @@ export const betaApi = { }), }; +// AI Gateway API (User Keys feature) +export const aiGatewayApi = { + // Get status (whether feature is enabled, user has managed key, etc.) + getStatus: () => apiCall("/api/ai-gateway/status"), + + // Get available Vercel teams + getTeams: () => apiCall("/api/ai-gateway/teams"), + + // Grant consent and create managed API key + consent: (teamId: string, teamName: string) => + apiCall("/api/ai-gateway/consent", { + method: "POST", + body: JSON.stringify({ teamId, teamName }), + }), + + // Revoke consent and delete managed API key + revokeConsent: () => + apiCall("/api/ai-gateway/consent", { + method: "DELETE", + }), +}; + // Export all APIs as a single object export const api = { ai: aiApi, beta: betaApi, + aiGateway: aiGatewayApi, integration: integrationApi, user: userApi, workflow: workflowApi, diff --git a/lib/auth.ts b/lib/auth.ts index 2098ead5c..d30452336 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { anonymous, genericOAuth } from "better-auth/plugins"; import { eq } from "drizzle-orm"; +import { isAiGatewayManagedKeysEnabled } from "./ai-gateway/config"; import { db } from "./db"; import { accounts, @@ -105,7 +106,11 @@ const plugins = [ authorizationUrl: "https://vercel.com/oauth/authorize", tokenUrl: "https://api.vercel.com/login/oauth/token", userInfoUrl: "https://api.vercel.com/login/oauth/userinfo", - scopes: ["openid", "email", "profile"], + // Include read-write:team scope when AI Gateway User Keys is enabled + // This grants APIKey and APIKeyAiGateway permissions for creating user keys + scopes: isAiGatewayManagedKeysEnabled() + ? ["openid", "email", "profile", "read-write:team"] + : ["openid", "email", "profile"], discoveryUrl: undefined, pkce: true, getUserInfo: async (tokens) => { diff --git a/lib/db/index.ts b/lib/db/index.ts index 8f4c186d2..0596f0e15 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,8 +1,10 @@ +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import { accounts, apiKeys, + integrations, sessions, users, verifications, @@ -23,6 +25,7 @@ const schema = { workflowExecutionLogs, workflowExecutionsRelations, apiKeys, + integrations, }; const connectionString = @@ -31,6 +34,18 @@ const connectionString = // For migrations export const migrationClient = postgres(connectionString, { max: 1 }); -// For queries -const queryClient = postgres(connectionString); -export const db = drizzle(queryClient, { schema }); +// Use global singleton to prevent connection exhaustion during HMR +const globalForDb = globalThis as unknown as { + queryClient: ReturnType | undefined; + db: PostgresJsDatabase | undefined; +}; + +// For queries - reuse connection in development +const queryClient = + globalForDb.queryClient ?? postgres(connectionString, { max: 10 }); +export const db = globalForDb.db ?? drizzle(queryClient, { schema }); + +if (process.env.NODE_ENV !== "production") { + globalForDb.queryClient = queryClient; + globalForDb.db = db; +} diff --git a/lib/db/integrations.ts b/lib/db/integrations.ts index d4b46d7bb..4d96768f5 100644 --- a/lib/db/integrations.ts +++ b/lib/db/integrations.ts @@ -105,6 +105,7 @@ export type DecryptedIntegration = { name: string; type: IntegrationType; config: IntegrationConfig; + isManaged: boolean | null; createdAt: Date; updatedAt: Date; }; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 5c50f1c2b..fe2b67275 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -93,6 +93,8 @@ export const integrations = pgTable("integrations", { type: text("type").notNull().$type(), // biome-ignore lint/suspicious/noExplicitAny: JSONB type - encrypted credentials stored as JSON config: jsonb("config").notNull().$type(), + // Whether this integration was created via OAuth (managed by app) vs manual entry + isManaged: boolean("is_managed").default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); diff --git a/lib/next-boilerplate/package.json b/lib/next-boilerplate/package.json index 950962c66..124e01fad 100644 --- a/lib/next-boilerplate/package.json +++ b/lib/next-boilerplate/package.json @@ -9,7 +9,7 @@ "lint": "eslint" }, "dependencies": { - "next": "16.0.7", + "next": "16.0.10", "react": "19.2.0", "react-dom": "19.2.0" }, diff --git a/lib/next-boilerplate/pnpm-lock.yaml b/lib/next-boilerplate/pnpm-lock.yaml index 8d4f62193..72515cd85 100644 --- a/lib/next-boilerplate/pnpm-lock.yaml +++ b/lib/next-boilerplate/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: next: - specifier: 16.0.7 - version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 16.0.10 + version: 16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: 19.2.0 version: 19.2.0 @@ -335,56 +335,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.0.7': - resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} + '@next/env@16.0.10': + resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} '@next/eslint-plugin-next@16.0.3': resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==} - '@next/swc-darwin-arm64@16.0.7': - resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==} + '@next/swc-darwin-arm64@16.0.10': + resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.0.7': - resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} + '@next/swc-darwin-x64@16.0.10': + resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.0.7': - resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} + '@next/swc-linux-arm64-gnu@16.0.10': + resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.7': - resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} + '@next/swc-linux-arm64-musl@16.0.10': + resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.7': - resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} + '@next/swc-linux-x64-gnu@16.0.10': + resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.7': - resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} + '@next/swc-linux-x64-musl@16.0.10': + resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.0.7': - resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} + '@next/swc-win32-arm64-msvc@16.0.10': + resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.7': - resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} + '@next/swc-win32-x64-msvc@16.0.10': + resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1498,8 +1498,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.0.7: - resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} + next@16.0.10: + resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2211,34 +2211,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.0.7': {} + '@next/env@16.0.10': {} '@next/eslint-plugin-next@16.0.3': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.0.7': + '@next/swc-darwin-arm64@16.0.10': optional: true - '@next/swc-darwin-x64@16.0.7': + '@next/swc-darwin-x64@16.0.10': optional: true - '@next/swc-linux-arm64-gnu@16.0.7': + '@next/swc-linux-arm64-gnu@16.0.10': optional: true - '@next/swc-linux-arm64-musl@16.0.7': + '@next/swc-linux-arm64-musl@16.0.10': optional: true - '@next/swc-linux-x64-gnu@16.0.7': + '@next/swc-linux-x64-gnu@16.0.10': optional: true - '@next/swc-linux-x64-musl@16.0.7': + '@next/swc-linux-x64-musl@16.0.10': optional: true - '@next/swc-win32-arm64-msvc@16.0.7': + '@next/swc-win32-arm64-msvc@16.0.10': optional: true - '@next/swc-win32-x64-msvc@16.0.7': + '@next/swc-win32-x64-msvc@16.0.10': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3477,9 +3477,9 @@ snapshots: natural-compare@1.4.0: {} - next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@next/env': 16.0.7 + '@next/env': 16.0.10 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001756 postcss: 8.4.31 @@ -3487,14 +3487,14 @@ snapshots: react-dom: 19.2.0(react@19.2.0) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.7 - '@next/swc-darwin-x64': 16.0.7 - '@next/swc-linux-arm64-gnu': 16.0.7 - '@next/swc-linux-arm64-musl': 16.0.7 - '@next/swc-linux-x64-gnu': 16.0.7 - '@next/swc-linux-x64-musl': 16.0.7 - '@next/swc-win32-arm64-msvc': 16.0.7 - '@next/swc-win32-x64-msvc': 16.0.7 + '@next/swc-darwin-arm64': 16.0.10 + '@next/swc-darwin-x64': 16.0.10 + '@next/swc-linux-arm64-gnu': 16.0.10 + '@next/swc-linux-arm64-musl': 16.0.10 + '@next/swc-linux-x64-gnu': 16.0.10 + '@next/swc-linux-x64-musl': 16.0.10 + '@next/swc-win32-arm64-msvc': 16.0.10 + '@next/swc-win32-x64-msvc': 16.0.10 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' diff --git a/package.json b/package.json index c9b030756..6a8d6e3bf 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@slack/web-api": "^7.12.0", "@vercel/analytics": "^1.5.0", "@vercel/og": "^0.8.5", @@ -47,12 +48,13 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", "ethers": "^6.15.0", + "jose": "^6.1.3", "jotai": "^2.15.1", "jszip": "^3.10.1", "lucide-react": "^0.552.0", "motion": "^12.23.24", "nanoid": "^5.1.6", - "next": "16.0.7", + "next": "16.0.10", "next-themes": "^0.4.6", "openai": "^6.8.1", "postgres": "^3.4.7", diff --git a/plugins/ai-gateway/icon.tsx b/plugins/ai-gateway/icon.tsx new file mode 100644 index 000000000..7b30b3aed --- /dev/null +++ b/plugins/ai-gateway/icon.tsx @@ -0,0 +1,3 @@ +import { Bot } from "lucide-react"; + +export default Bot; diff --git a/plugins/ai-gateway/index.ts b/plugins/ai-gateway/index.ts new file mode 100644 index 000000000..0aaf097ab --- /dev/null +++ b/plugins/ai-gateway/index.ts @@ -0,0 +1,184 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import AiGatewayIcon from "./icon"; + +const aiGatewayPlugin: IntegrationPlugin = { + type: "ai-gateway", + label: "AI Gateway", + description: "Generate text and images using AI models", + + icon: AiGatewayIcon, + + formFields: [ + { + id: "openaiApiKey", + label: "API Key", + type: "password", + placeholder: "Your AI Gateway API key", + configKey: "apiKey", + envVar: "AI_GATEWAY_API_KEY", + helpText: "Get your API key from ", + helpLink: { + text: "vercel.com/ai-gateway", + url: "https://vercel.com/docs/ai-gateway/getting-started", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + return async (_credentials: Record) => { + const { testConnection } = await import("./test"); + const result = await testConnection(); + return { + success: result.status === "success", + error: result.status === "error" ? result.message : undefined, + }; + }; + }, + }, + + dependencies: { + ai: "^5.0.86", + openai: "^6.8.0", + "@google/genai": "^1.28.0", + zod: "^4.1.12", + }, + + actions: [ + { + slug: "generate-text", + label: "Generate Text", + description: "Generate text using AI models", + category: "AI Gateway", + stepFunction: "generateTextStep", + stepImportPath: "generate-text", + configFields: [ + { + key: "aiFormat", + label: "Output Format", + type: "select", + defaultValue: "text", + options: [ + { value: "text", label: "Text" }, + { value: "object", label: "Object" }, + ], + }, + { + key: "aiModel", + label: "Model", + type: "select", + defaultValue: "meta/llama-4-scout", + options: [ + // Current models + { value: "anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, + { value: "anthropic/claude-haiku-4.5", label: "Claude Haiku 4.5" }, + { value: "anthropic/claude-opus-4.5", label: "Claude Opus 4.5" }, + { value: "meta/llama-4-scout", label: "Llama 4 Scout" }, + { value: "meta/llama-4-maverick", label: "Llama 4 Maverick" }, + { value: "openai/gpt-5.2", label: "GPT-5.2" }, + { value: "openai/gpt-5.2-pro", label: "GPT-5.2 Pro" }, + { + value: "google/gemini-3-pro-preview", + label: "Gemini 3 Pro Preview", + }, + { value: "google/gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { value: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { value: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + // Legacy models (kept for backwards compatibility) + { value: "anthropic/claude-sonnet-4.0", label: "Claude Sonnet 4.0" }, + { + value: "anthropic/claude-3.5-sonnet-20241022", + label: "Claude 3.5 Sonnet", + }, + { value: "anthropic/claude-3-7-sonnet", label: "Claude 3.7 Sonnet" }, + { value: "openai/gpt-4o", label: "GPT-4o" }, + { value: "openai/gpt-4o-mini", label: "GPT-4o Mini" }, + { value: "openai/o1", label: "o1" }, + { value: "openai/o1-mini", label: "o1 Mini" }, + { value: "openai/gpt-4-turbo", label: "GPT-4 Turbo" }, + { value: "openai/gpt-3.5-turbo", label: "GPT-3.5 Turbo" }, + { value: "google/gemini-4.0-flash", label: "Gemini 4.0 Flash" }, + { value: "google/gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { + value: "google/gemini-2.0-flash-lite", + label: "Gemini 2.0 Flash Lite", + }, + { value: "meta/llama-4-instruct", label: "Llama 4 Instruct" }, + ], + }, + { + key: "aiPrompt", + label: "Prompt", + type: "template-textarea", + placeholder: + "Enter your prompt here. Use {{NodeName.field}} to reference previous outputs.", + rows: 4, + example: "Summarize the following text: {{Scrape.markdown}}", + required: true, + }, + { + key: "aiSchema", + label: "Schema", + type: "schema-builder", + showWhen: { field: "aiFormat", equals: "object" }, + }, + ], + }, + { + slug: "generate-image", + label: "Generate Image", + description: "Generate images using AI models", + category: "AI Gateway", + stepFunction: "generateImageStep", + stepImportPath: "generate-image", + outputFields: [{ field: "base64", description: "Base64-encoded image data" }], + outputConfig: { type: "image", field: "base64" }, + configFields: [ + { + key: "imageModel", + label: "Model", + type: "select", + defaultValue: "google/imagen-4.0-generate-001", + options: [ + { + value: "google/imagen-4.0-generate-001", + label: "Imagen 4", + }, + { + value: "google/imagen-4.0-fast-generate-001", + label: "Imagen 4 Fast", + }, + { + value: "google/imagen-4.0-ultra-generate-001", + label: "Imagen 4 Ultra", + }, + { + value: "bfl/flux-kontext-pro", + label: "FLUX.1 Kontext Pro", + }, + { + value: "bfl/flux-kontext-max", + label: "FLUX.1 Kontext Max", + }, + ], + }, + { + key: "imagePrompt", + label: "Prompt", + type: "template-textarea", + placeholder: + "Describe the image you want to generate. Use {{NodeName.field}} to reference previous outputs.", + rows: 4, + example: "A serene mountain landscape at sunset", + required: true, + }, + ], + }, + ], +}; + +// Auto-register on import +registerIntegration(aiGatewayPlugin); + +export default aiGatewayPlugin; diff --git a/plugins/ai-gateway/test.ts b/plugins/ai-gateway/test.ts new file mode 100644 index 000000000..fb2972ad0 --- /dev/null +++ b/plugins/ai-gateway/test.ts @@ -0,0 +1,15 @@ +/** + * Test connection for AI Gateway integration + * AI Gateway managed keys are tested through the Vercel API + */ +export async function testConnection(): Promise<{ + status: "success" | "error"; + message: string; +}> { + // AI Gateway connections are validated through the Vercel API consent flow + // No separate test needed as the managed key is created by Vercel + return { + status: "success", + message: "AI Gateway connection is managed by Vercel", + }; +} diff --git a/plugins/registry.ts b/plugins/registry.ts index ec4e87bf8..defe6738c 100644 --- a/plugins/registry.ts +++ b/plugins/registry.ts @@ -380,6 +380,17 @@ export function getIntegrationLabels(): Record { return labels as Record; } +/** + * Get integration descriptions map + */ +export function getIntegrationDescriptions(): Record { + const descriptions: Record = {}; + for (const plugin of integrationRegistry.values()) { + descriptions[plugin.type] = plugin.description; + } + return descriptions as Record; +} + /** * Get sorted integration types for dropdowns */ diff --git a/plugins/sendgrid/steps/send-email.ts b/plugins/sendgrid/steps/send-email.ts index e8aadba34..8e4186337 100644 --- a/plugins/sendgrid/steps/send-email.ts +++ b/plugins/sendgrid/steps/send-email.ts @@ -135,17 +135,8 @@ export async function sendEmailStep( ? await fetchCredentials(input.integrationId) : {}; - // Fetch integration config to check useKeeperHubApiKey - let useKeeperHubApiKey = true; // Default to true - if (input.integrationId) { - const { getIntegrationById } = await import("@/lib/db/integrations"); - const integration = await getIntegrationById(input.integrationId); - if (integration?.config) { - const useKeeperHubValue = integration.config.useKeeperHubApiKey; - useKeeperHubApiKey = - useKeeperHubValue === true || useKeeperHubValue === "true"; - } - } + // Always use KeeperHub API key + const useKeeperHubApiKey = true; const coreInput: SendEmailCoreInput = { ...input, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 174a52bee..dca17aa99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,12 +47,15 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.2)(react@19.2.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@slack/web-api': specifier: ^7.12.0 version: 7.12.0 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 1.5.0(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) '@vercel/og': specifier: ^0.8.5 version: 0.8.5 @@ -61,7 +64,7 @@ importers: version: 1.17.1 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 1.2.0(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) '@xyflow/react': specifier: ^12.9.2 version: 12.9.2(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -70,7 +73,7 @@ importers: version: 5.0.102(zod@4.1.12) better-auth: specifier: ^1.3.34 - version: 1.3.34(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.3.34(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -86,6 +89,9 @@ importers: ethers: specifier: ^6.15.0 version: 6.15.0 + jose: + specifier: ^6.1.3 + version: 6.1.3 jotai: specifier: ^2.15.1 version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.1) @@ -102,8 +108,8 @@ importers: specifier: ^5.1.6 version: 5.1.6 next: - specifier: 16.0.7 - version: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: 16.0.10 + version: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -145,7 +151,7 @@ importers: version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) workflow: specifier: 4.0.1-beta.17 - version: 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3) + version: 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3) zod: specifier: ^4.1.12 version: 4.1.12 @@ -1212,8 +1218,8 @@ packages: '@next/env@15.5.4': resolution: {integrity: sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==} - '@next/env@16.0.7': - resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} + '@next/env@16.0.10': + resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} '@next/swc-darwin-arm64@15.5.4': resolution: {integrity: sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==} @@ -1221,8 +1227,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.0.7': - resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==} + '@next/swc-darwin-arm64@16.0.10': + resolution: {integrity: sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1233,8 +1239,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.0.7': - resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} + '@next/swc-darwin-x64@16.0.10': + resolution: {integrity: sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1245,8 +1251,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@16.0.7': - resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} + '@next/swc-linux-arm64-gnu@16.0.10': + resolution: {integrity: sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1257,8 +1263,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.7': - resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} + '@next/swc-linux-arm64-musl@16.0.10': + resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1269,8 +1275,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.7': - resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} + '@next/swc-linux-x64-gnu@16.0.10': + resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1281,8 +1287,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.7': - resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} + '@next/swc-linux-x64-musl@16.0.10': + resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1293,8 +1299,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.0.7': - resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} + '@next/swc-win32-arm64-msvc@16.0.10': + resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1305,8 +1311,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.7': - resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} + '@next/swc-win32-x64-msvc@16.0.10': + resolution: {integrity: sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3820,8 +3826,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.0: - resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} jotai@2.15.1: resolution: {integrity: sha512-yHT1HAZ3ba2Q8wgaUQ+xfBzEtcS8ie687I8XVCBinfg4bNniyqLIN+utPXWKQE93LMF5fPbQSVRZqgpcN5yd6Q==} @@ -4140,8 +4146,8 @@ packages: sass: optional: true - next@16.0.7: - resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} + next@16.0.10: + resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5550,19 +5556,19 @@ snapshots: '@babel/helper-validator-identifier': 7.28.5 optional: true - '@better-auth/core@1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)': + '@better-auth/core@1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.3)(kysely@0.28.8)(nanostores@1.0.1)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 better-call: 1.0.19 - jose: 6.1.0 + jose: 6.1.3 kysely: 0.28.8 nanostores: 1.0.1 zod: 4.1.12 - '@better-auth/telemetry@1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)': + '@better-auth/telemetry@1.3.34(better-call@1.0.19)(jose@6.1.3)(kysely@0.28.8)(nanostores@1.0.1)': dependencies: - '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.3)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 transitivePeerDependencies: @@ -6287,54 +6293,54 @@ snapshots: '@next/env@15.5.4': {} - '@next/env@16.0.7': {} + '@next/env@16.0.10': {} '@next/swc-darwin-arm64@15.5.4': optional: true - '@next/swc-darwin-arm64@16.0.7': + '@next/swc-darwin-arm64@16.0.10': optional: true '@next/swc-darwin-x64@15.5.4': optional: true - '@next/swc-darwin-x64@16.0.7': + '@next/swc-darwin-x64@16.0.10': optional: true '@next/swc-linux-arm64-gnu@15.5.4': optional: true - '@next/swc-linux-arm64-gnu@16.0.7': + '@next/swc-linux-arm64-gnu@16.0.10': optional: true '@next/swc-linux-arm64-musl@15.5.4': optional: true - '@next/swc-linux-arm64-musl@16.0.7': + '@next/swc-linux-arm64-musl@16.0.10': optional: true '@next/swc-linux-x64-gnu@15.5.4': optional: true - '@next/swc-linux-x64-gnu@16.0.7': + '@next/swc-linux-x64-gnu@16.0.10': optional: true '@next/swc-linux-x64-musl@15.5.4': optional: true - '@next/swc-linux-x64-musl@16.0.7': + '@next/swc-linux-x64-musl@16.0.10': optional: true '@next/swc-win32-arm64-msvc@15.5.4': optional: true - '@next/swc-win32-arm64-msvc@16.0.7': + '@next/swc-win32-arm64-msvc@16.0.10': optional: true '@next/swc-win32-x64-msvc@15.5.4': optional: true - '@next/swc-win32-x64-msvc@16.0.7': + '@next/swc-win32-x64-msvc@16.0.10': optional: true '@noble/ciphers@1.1.3': {} @@ -7894,9 +7900,9 @@ snapshots: '@types/retry@0.12.0': {} - '@vercel/analytics@1.5.0(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@vercel/analytics@1.5.0(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': optionalDependencies: - next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 '@vercel/functions@3.3.3(@aws-sdk/credential-provider-web-identity@3.609.0(@aws-sdk/client-sts@3.936.0))': @@ -7921,9 +7927,9 @@ snapshots: dependencies: zod: 4.1.12 - '@vercel/speed-insights@1.2.0(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@vercel/speed-insights@1.2.0(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': optionalDependencies: - next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 '@workflow/builders@4.0.1-beta.13(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0)': @@ -8019,7 +8025,7 @@ snapshots: '@workflow/utils': 4.0.1-beta.3 ms: 2.1.3 - '@workflow/next@4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': + '@workflow/next@4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0)(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': dependencies: '@swc/core': 1.11.24 '@workflow/builders': 4.0.1-beta.13(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0) @@ -8028,7 +8034,7 @@ snapshots: semver: 7.7.3 watchpack: 2.4.4 optionalDependencies: - next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) transitivePeerDependencies: - '@aws-sdk/client-sts' - '@opentelemetry/api' @@ -8223,10 +8229,10 @@ snapshots: bech32@1.1.4: {} - better-auth@1.3.34(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + better-auth@1.3.34(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) - '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.3)(kysely@0.28.8)(nanostores@1.0.1) + '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.3)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.18 '@noble/ciphers': 2.0.1 @@ -8235,12 +8241,12 @@ snapshots: '@simplewebauthn/server': 13.2.2 better-call: 1.0.19 defu: 6.1.4 - jose: 6.1.0 + jose: 6.1.3 kysely: 0.28.8 nanostores: 1.0.1 zod: 4.1.12 optionalDependencies: - next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) @@ -9023,7 +9029,7 @@ snapshots: jiti@2.6.1: {} - jose@6.1.0: {} + jose@6.1.3: {} jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.1): optionalDependencies: @@ -9285,9 +9291,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@next/env': 16.0.7 + '@next/env': 16.0.10 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001753 postcss: 8.4.31 @@ -9295,14 +9301,14 @@ snapshots: react-dom: 19.2.1(react@19.2.1) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.7 - '@next/swc-darwin-x64': 16.0.7 - '@next/swc-linux-arm64-gnu': 16.0.7 - '@next/swc-linux-arm64-musl': 16.0.7 - '@next/swc-linux-x64-gnu': 16.0.7 - '@next/swc-linux-x64-musl': 16.0.7 - '@next/swc-win32-arm64-msvc': 16.0.7 - '@next/swc-win32-x64-msvc': 16.0.7 + '@next/swc-darwin-arm64': 16.0.10 + '@next/swc-darwin-x64': 16.0.10 + '@next/swc-linux-arm64-gnu': 16.0.10 + '@next/swc-linux-arm64-musl': 16.0.10 + '@next/swc-linux-x64-gnu': 16.0.10 + '@next/swc-linux-x64-musl': 16.0.10 + '@next/swc-win32-arm64-msvc': 16.0.10 + '@next/swc-win32-x64-msvc': 16.0.10 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.57.0 sharp: 0.34.4 @@ -10080,12 +10086,12 @@ snapshots: wordwrap@1.0.0: {} - workflow@4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3): + workflow@4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3): dependencies: '@workflow/cli': 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3) '@workflow/core': 4.0.1-beta.15(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0) '@workflow/errors': 4.0.1-beta.5 - '@workflow/next': 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) + '@workflow/next': 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0)(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) '@workflow/nitro': 4.0.1-beta.17(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0) '@workflow/nuxt': 4.0.1-beta.6(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0) '@workflow/sveltekit': 4.0.0-beta.11(@aws-sdk/client-sts@3.936.0)(@opentelemetry/api@1.9.0) diff --git a/scripts/discover-plugins.ts b/scripts/discover-plugins.ts index 446bdfb01..e686e55b9 100644 --- a/scripts/discover-plugins.ts +++ b/scripts/discover-plugins.ts @@ -901,7 +901,9 @@ async function main(): Promise { const disabledPlugins = allPlugins.filter( (p) => !enabledPlugins.includes(p) ); - console.log(`Disabled ${disabledPlugins.length} plugin(s): ${disabledPlugins.join(", ")}`); + console.log( + `Disabled ${disabledPlugins.length} plugin(s): ${disabledPlugins.join(", ")}` + ); } }