diff --git a/app/api/ai-gateway/consent/route.ts b/app/api/ai-gateway/consent/route.ts new file mode 100644 index 00000000..700b95a4 --- /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 00000000..1ae0aac3 --- /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 00000000..3f6ef5f5 --- /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 6d81d56c..ea9643e0 100644 --- a/app/api/integrations/route.ts +++ b/app/api/integrations/route.ts @@ -10,6 +10,7 @@ export type GetIntegrationsResponse = { id: string; name: string; type: IntegrationType; + isManaged?: boolean; createdAt: string; updatedAt: string; // Config is intentionally excluded for security @@ -58,6 +59,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(), }) diff --git a/app/layout.tsx b/app/layout.tsx index d46652c8..3f3f11df 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 c760640d..1c03c860 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 00000000..592d4ff4 --- /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/integration-form-dialog.tsx b/components/settings/integration-form-dialog.tsx index 8757af09..ad892655 100644 --- a/components/settings/integration-form-dialog.tsx +++ b/components/settings/integration-form-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAtomValue, useSetAtom } from "jotai"; import { ArrowLeft, Check, @@ -11,7 +12,7 @@ import { XCircle, Zap, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -24,6 +25,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -36,6 +38,12 @@ import { Input } from "@/components/ui/input"; import { IntegrationIcon } from "@/components/ui/integration-icon"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import { + 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 { @@ -372,12 +380,16 @@ function DeleteConfirmDialog({ onOpenChange, deleting, onDelete, + isManaged, }: { open: boolean; onOpenChange: (open: boolean) => void; deleting: boolean; - onDelete: () => void; + onDelete: (revokeKey: boolean) => void; + isManaged?: boolean; }) { + const [revokeKey, setRevokeKey] = useState(true); + return ( @@ -388,9 +400,24 @@ function DeleteConfirmDialog({ will fail until a new one is configured. + {isManaged && ( +
+ setRevokeKey(checked)} + /> + +
+ )} Cancel - + onDelete(isManaged ? revokeKey : false)} + > {deleting ? : null} Delete @@ -400,6 +427,46 @@ function DeleteConfirmDialog({ ); } +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, @@ -414,7 +481,7 @@ function TypeSelector({ return (
- + (null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showTestFailedConfirm, setShowTestFailedConfirm] = useState(false); + const [testFailedMessage, setTestFailedMessage] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [formData, setFormData] = useState({ name: "", @@ -484,7 +553,15 @@ export function IntegrationFormDialog({ config: {}, }); - // Step: "select" for type selection list, "configure" for form + // AI Gateway managed keys state + const aiGatewayStatus = useAtomValue(aiGatewayStatusAtom); + const openConsentModal = useSetAtom(openAiGatewayConsentModalAtom); + + // Check if AI Gateway managed keys should be offered + const shouldUseManagedKeys = + aiGatewayStatus?.enabled && aiGatewayStatus?.isVercelUser; + + // Step: "select" for type selection, "configure" for form const [step, setStep] = useState<"select" | "configure">( preselectedType || mode === "edit" ? "configure" : "select" ); @@ -508,7 +585,72 @@ export function IntegrationFormDialog({ } }, [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(); + }); + } + }); + } + }, [ + open, + preselectedType, + mode, + aiGatewayStatus, + shouldUseManagedKeys, + showConsentModalWithCallbacks, + setAiGatewayStatus, + setTeams, + setTeamsLoading, + ]); + 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, @@ -527,7 +669,7 @@ export function IntegrationFormDialog({ }); }; - const handleSave = async () => { + const doSave = async () => { if (!formData.type) { return; } @@ -565,14 +707,68 @@ export function IntegrationFormDialog({ } }; - const handleDelete = async () => { + const handleSave = async () => { + if (!formData.type) { + return; + } + + // Check if we have config values to test + const hasConfig = Object.values(formData.config).some( + (v) => v && v.length > 0 + ); + + // In edit mode without new config, skip testing + if (mode === "edit" && !hasConfig) { + await doSave(); + return; + } + + // Test the connection before saving + try { + setSaving(true); + setTestResult(null); + + const result = await api.integration.testCredentials({ + type: formData.type, + config: formData.config, + }); + + 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; } try { setDeleting(true); - await api.integration.delete(integration.id); + + // 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(); @@ -674,14 +870,16 @@ export function IntegrationFormDialog({ {getDialogDescription()} - {step === "select" ? ( + {step === "select" && ( - ) : ( + )} + + {step === "configure" && (
+ + { + setShowTestFailedConfirm(false); + doSave(); + }} + open={showTestFailedConfirm} + saving={saving} + /> ); } diff --git a/components/settings/integrations-dialog.tsx b/components/settings/integrations-dialog.tsx index 1104b4d7..280147ca 100644 --- a/components/settings/integrations-dialog.tsx +++ b/components/settings/integrations-dialog.tsx @@ -87,7 +87,7 @@ export function IntegrationsDialog({ ) : (
- + setFilter(e.target.value)} diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 00000000..cb0b07b4 --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/components/ui/integration-selector.tsx b/components/ui/integration-selector.tsx index eb5c954e..41af1e69 100644 --- a/components/ui/integration-selector.tsx +++ b/components/ui/integration-selector.tsx @@ -1,9 +1,24 @@ "use client"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { AlertTriangle, Check, Circle, Pencil, Plus, Settings } from "lucide-react"; +import { + 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, @@ -12,7 +27,6 @@ import { import type { IntegrationType } from "@/lib/types/integration"; import { cn } from "@/lib/utils"; import { getIntegration } from "@/plugins"; -import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog"; type IntegrationSelectorProps = { integrationType: IntegrationType; @@ -40,6 +54,16 @@ export function IntegrationSelector({ 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); + // Filter integrations from global cache const integrations = useMemo( () => globalIntegrations.filter((i) => i.type === integrationType), @@ -60,6 +84,79 @@ export function IntegrationSelector({ } }, [setGlobalIntegrations]); + // Load AI Gateway status for ai-gateway type + useEffect(() => { + 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, aiGatewayStatus?.enabled, aiGatewayStatus?.isVercelUser]); + useEffect(() => { loadIntegrations(); }, [loadIntegrations, integrationType]); @@ -73,10 +170,16 @@ export function IntegrationSelector({ } }, [integrationsVersion, loadIntegrations]); - // Auto-select single integration from cached data + // Auto-select first integration when none is selected or current selection is invalid useEffect(() => { - if (integrations.length === 1 && !value && !disabled) { - onChange(integrations[0].id); + 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]); @@ -94,13 +197,48 @@ export function IntegrationSelector({ setIntegrationsVersion((v) => v + 1); }; - const handleAddConnection = () => { + 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) { @@ -118,7 +256,11 @@ export function IntegrationSelector({ const plugin = getIntegration(integrationType); const integrationLabel = plugin?.label || integrationType; - // No integrations - show error button to add one + // 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 ( <> @@ -172,16 +314,20 @@ export function IntegrationSelector({
+ setShowNewDialog(false)} + onSuccess={handleNewIntegrationCreated} + open={showNewDialog} + preselectedType={integrationType} + /> + {editingIntegration && ( setEditingIntegration(null)} - onDelete={async () => { - await loadIntegrations(); - setEditingIntegration(null); - setIntegrationsVersion((v) => v + 1); - }} + onDelete={handleDelete} onSuccess={handleEditSuccess} open /> @@ -190,11 +336,54 @@ export function IntegrationSelector({ ); } - // Multiple integrations - show radio-style selection list + // Multiple integrations or AI Gateway with option to add managed key return ( <>
- {integrations.map((integration) => { + {/* 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`; @@ -202,9 +391,7 @@ export function IntegrationSelector({
); })} + {onOpenSettings && (
- {integrationType && ( + {integrationType && isOwner && (
@@ -418,24 +451,15 @@ export function ActionConfig({
- - - - - - - - Add Secondary Connection(s) - - - +
onUpdateConfig("integrationId", id)} value={(config?.integrationId as string) || ""} /> + setShowAddConnectionDialog(false)} + onSuccess={(integrationId) => { + setShowAddConnectionDialog(false); + setIntegrationsVersion((v) => v + 1); + onUpdateConfig("integrationId", integrationId); + }} + open={showAddConnectionDialog} + preselectedType={integrationType} + />
)} diff --git a/components/workflow/config/action-grid.tsx b/components/workflow/config/action-grid.tsx index 69decbb2..20f986d6 100644 --- a/components/workflow/config/action-grid.tsx +++ b/components/workflow/config/action-grid.tsx @@ -251,7 +251,7 @@ export function ActionGrid({
- + { ) : null} diff --git a/components/workflow/workflow-runs.tsx b/components/workflow/workflow-runs.tsx index ec93e167..e8f95ffa 100644 --- a/components/workflow/workflow-runs.tsx +++ b/components/workflow/workflow-runs.tsx @@ -426,7 +426,7 @@ function ExecutionLogEntry({ return (
{/* Timeline connector */} -
+
{!isFirst && (
)} diff --git a/components/workflows/user-menu.tsx b/components/workflows/user-menu.tsx index 43013b13..772872c3 100644 --- a/components/workflows/user-menu.tsx +++ b/components/workflows/user-menu.tsx @@ -149,7 +149,7 @@ export const UserMenu = () => { - + Theme diff --git a/drizzle/0004_real_wither.sql b/drizzle/0004_real_wither.sql new file mode 100644 index 00000000..1eb476a8 --- /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 00000000..3a4b9ccb --- /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 c760c27f..c3cd4f1a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1764694021611, "tag": "0003_clammy_tusk", "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 00000000..5cb04716 --- /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 00000000..736c4502 --- /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 583e372c..73224e40 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 @@ -603,9 +632,32 @@ export const workflowApi = { })(), }; +// 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, + aiGateway: aiGatewayApi, integration: integrationApi, user: userApi, workflow: workflowApi, diff --git a/lib/auth.ts b/lib/auth.ts index 9be288e8..3149038e 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 8f4c186d..0596f0e1 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 42a04c1f..c9454b00 100644 --- a/lib/db/integrations.ts +++ b/lib/db/integrations.ts @@ -101,6 +101,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 8f1dad64..abcf48cf 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/package.json b/package.json index e700a06d..3e86c840 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@linear/sdk": "^63.2.0", "@mendable/firecrawl-js": "^4.6.2", "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -41,6 +42,7 @@ "clsx": "^2.1.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "jose": "^6.1.3", "jotai": "^2.15.1", "jszip": "^3.10.1", "lucide-react": "^0.552.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6163a35e..f5c01ca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.54.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@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) '@radix-ui/react-collapsible': specifier: ^1.1.12 version: 1.1.12(@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) @@ -68,6 +71,9 @@ importers: drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@opentelemetry/api@1.9.0)(kysely@0.28.8)(postgres@3.4.7) + 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) @@ -3532,8 +3538,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==} @@ -5172,19 +5178,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: @@ -7548,8 +7554,8 @@ snapshots: 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 @@ -7558,7 +7564,7 @@ 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 @@ -8269,7 +8275,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: