feat: generic env variable CRUD - any key name, not locked to providers

- Changed schema from AIProvider enum to arbitrary keyName string
- Updated API routes for generic key storage
- Simple CRUD UI with optional .env import
This commit is contained in:
Ajax Davis 2026-01-03 14:35:19 +10:00
parent dca55f1faf
commit ef95c37984
10 changed files with 423 additions and 347 deletions

View file

@ -18,7 +18,7 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 300; // 5 minutes for long agentic runs export const maxDuration = 300; // 5 minutes for long agentic runs
type RouteContext = { type RouteContext = {
params: Promise<{ uid: string; conversationId: string }>; params: Promise<{ id: string; conversationId: string }>;
}; };
/** /**
@ -56,11 +56,12 @@ async function getProviderModel(
} }
/** /**
* POST /api/agents/[uid]/conversation/[conversationId] * POST /api/agents/[id]/conversation/[conversationId]
* Send a message and stream the AI response via SSE * Send a message and stream the AI response via SSE
* Accepts either agent id (cuid) or uid
*/ */
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> { export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
const { uid, conversationId } = await context.params; const { id: idOrUid, conversationId } = await context.params;
try { try {
const body = await request.json(); const body = await request.json();
@ -72,20 +73,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
); );
} }
// Fetch agent with all tool relations // Fetch agent with all tool relations (accepts id or uid)
const { fetchAgentByUidWithTools, buildAgentTools } = await import('@/lib/agents/build-tools'); const { fetchAgentByIdOrUidWithTools, buildAgentTools } = await import(
const agent = await fetchAgentByUidWithTools(uid); '@/lib/agents/build-tools'
);
const agent = await fetchAgentByIdOrUidWithTools(idOrUid);
if (!agent) { if (!agent) {
return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 });
} }
// Map provider to expected key name format
const providerKeyNames: Record<string, string> = {
OPENAI: 'OPENAI_API_KEY',
ANTHROPIC: 'ANTHROPIC_API_KEY',
GOOGLE: 'GOOGLE_API_KEY',
GROQ: 'GROQ_API_KEY',
MISTRAL: 'MISTRAL_API_KEY',
};
const keyName = providerKeyNames[agent.provider];
if (!keyName) {
return NextResponse.json(
{ success: false, error: `Unsupported provider: ${agent.provider}` },
{ status: 400 }
);
}
// Get user's API key for this provider // Get user's API key for this provider
const userApiKey = await prisma.userApiKey.findUnique({ const userApiKey = await prisma.userApiKey.findUnique({
where: { where: {
userId_provider: { userId_keyName: {
userId: agent.userId, userId: agent.userId,
provider: agent.provider, keyName,
}, },
}, },
}); });
@ -342,16 +362,18 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
} }
/** /**
* GET /api/agents/[uid]/conversation/[conversationId] * GET /api/agents/[id]/conversation/[conversationId]
* Retrieve conversation history * Retrieve conversation history (accepts id or uid)
*/ */
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> { export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params; const { id: idOrUid, conversationId } = await context.params;
try { try {
// Fetch agent // Fetch agent by id or uid
const agent = await prisma.agent.findUnique({ const agent = await prisma.agent.findFirst({
where: { uid }, where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true }, select: { id: true },
}); });
@ -413,16 +435,18 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise
} }
/** /**
* DELETE /api/agents/[uid]/conversation/[conversationId] * DELETE /api/agents/[id]/conversation/[conversationId]
* Delete a conversation * Delete a conversation (accepts id or uid)
*/ */
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> { export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params; const { id: idOrUid, conversationId } = await context.params;
try { try {
// Fetch agent // Fetch agent by id or uid
const agent = await prisma.agent.findUnique({ const agent = await prisma.agent.findFirst({
where: { uid }, where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true }, select: { id: true },
}); });

View file

@ -12,24 +12,26 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 30; export const maxDuration = 30;
type RouteContext = { type RouteContext = {
params: Promise<{ uid: string }>; params: Promise<{ id: string }>;
}; };
/** /**
* GET /api/agents/[uid]/conversations * GET /api/agents/[id]/conversations
* List all conversations for an agent * List all conversations for an agent (accepts id or uid)
*/ */
export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> { export async function GET(request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid } = await context.params; const { id: idOrUid } = await context.params;
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 100); const limit = Math.min(Number.parseInt(searchParams.get('limit') || '20', 10), 100);
const offset = Number.parseInt(searchParams.get('offset') || '0', 10); const offset = Number.parseInt(searchParams.get('offset') || '0', 10);
try { try {
// Fetch agent // Fetch agent by id or uid
const agent = await prisma.agent.findUnique({ const agent = await prisma.agent.findFirst({
where: { uid }, where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true }, select: { id: true },
}); });

View file

@ -1,5 +1,3 @@
import type { AIProvider } from '@prisma/client';
import { prisma } from '@tpmjs/db'; import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
@ -14,8 +12,8 @@ type RouteContext = {
}; };
/** /**
* DELETE /api/user/api-keys/[provider] * DELETE /api/user/api-keys/[keyName]
* Remove an API key for a provider * Remove an API key by name
*/ */
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> { export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try { try {
@ -24,18 +22,12 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
} }
const { provider } = await context.params; const { provider: keyName } = await context.params;
// Validate provider is a valid enum value
const validProviders = ['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL'];
if (!validProviders.includes(provider.toUpperCase())) {
return NextResponse.json({ success: false, error: 'Invalid provider' }, { status: 400 });
}
await prisma.userApiKey.deleteMany({ await prisma.userApiKey.deleteMany({
where: { where: {
userId: session.user.id, userId: session.user.id,
provider: provider.toUpperCase() as AIProvider, keyName,
}, },
}); });

View file

@ -1,7 +1,7 @@
import { prisma } from '@tpmjs/db'; import { prisma } from '@tpmjs/db';
import { AddApiKeySchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server'; import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auth } from '~/lib/auth'; import { auth } from '~/lib/auth';
import { encryptApiKey, getKeyHint } from '~/lib/crypto/api-keys'; import { encryptApiKey, getKeyHint } from '~/lib/crypto/api-keys';
@ -9,6 +9,11 @@ import { encryptApiKey, getKeyHint } from '~/lib/crypto/api-keys';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const AddKeySchema = z.object({
keyName: z.string().min(1).max(100),
keyValue: z.string().min(1),
});
/** /**
* GET /api/user/api-keys * GET /api/user/api-keys
* List user's stored API keys (masked) * List user's stored API keys (masked)
@ -23,12 +28,13 @@ export async function GET(): Promise<NextResponse> {
const apiKeys = await prisma.userApiKey.findMany({ const apiKeys = await prisma.userApiKey.findMany({
where: { userId: session.user.id }, where: { userId: session.user.id },
select: { select: {
provider: true, id: true,
keyName: true,
keyHint: true, keyHint: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
orderBy: { createdAt: 'asc' }, orderBy: { keyName: 'asc' },
}); });
return NextResponse.json({ return NextResponse.json({
@ -43,141 +49,72 @@ export async function GET(): Promise<NextResponse> {
/** /**
* POST /api/user/api-keys * POST /api/user/api-keys
* Add or update an API key for a provider * Add or update an API key
*/ */
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(request: NextRequest): Promise<NextResponse> {
const startTime = Date.now();
console.log('[api-keys] POST request received');
try { try {
// Step 1: Auth check
console.log('[api-keys] Checking session...');
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) { if (!session?.user?.id) {
console.log('[api-keys] Unauthorized - no session or user id');
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
} }
console.log('[api-keys] Session valid for user:', session.user.id);
// Step 2: Parse request body const body = await request.json();
console.log('[api-keys] Parsing request body...'); const parsed = AddKeySchema.safeParse(body);
let body: unknown;
try {
body = await request.json();
console.log('[api-keys] Body parsed, provider:', (body as { provider?: string })?.provider);
} catch (parseError) {
console.error('[api-keys] Failed to parse JSON body:', parseError);
return NextResponse.json(
{ success: false, error: 'Invalid JSON body' },
{ status: 400 }
);
}
// Step 3: Validate schema
console.log('[api-keys] Validating schema...');
const parsed = AddApiKeySchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
console.error('[api-keys] Schema validation failed:', parsed.error.flatten());
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() }, { success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 } { status: 400 }
); );
} }
console.log('[api-keys] Schema valid, provider:', parsed.data.provider);
const { provider, apiKey } = parsed.data; const { keyName, keyValue } = parsed.data;
console.log('[api-keys] API key length:', apiKey.length, 'chars');
// Step 4: Encrypt the key // Check encryption secret
console.log('[api-keys] Encrypting API key...'); if (!process.env.API_KEY_ENCRYPTION_SECRET) {
let encrypted: string; console.error('[api-keys] API_KEY_ENCRYPTION_SECRET not set');
let iv: string;
try {
const hasEncryptionSecret = !!process.env.API_KEY_ENCRYPTION_SECRET;
console.log('[api-keys] API_KEY_ENCRYPTION_SECRET present:', hasEncryptionSecret);
if (!hasEncryptionSecret) {
console.error('[api-keys] CRITICAL: API_KEY_ENCRYPTION_SECRET is not set!');
return NextResponse.json(
{ success: false, error: 'Server configuration error: encryption not configured' },
{ status: 500 }
);
}
const result = encryptApiKey(apiKey);
encrypted = result.encrypted;
iv = result.iv;
console.log('[api-keys] Encryption successful, encrypted length:', encrypted.length);
} catch (encryptError) {
console.error('[api-keys] Encryption failed:', encryptError);
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Failed to encrypt API key' }, { success: false, error: 'Server configuration error' },
{ status: 500 } { status: 500 }
); );
} }
const keyHint = getKeyHint(apiKey); const { encrypted, iv } = encryptApiKey(keyValue);
console.log('[api-keys] Key hint:', keyHint); const keyHint = getKeyHint(keyValue);
// Step 5: Upsert to database const result = await prisma.userApiKey.upsert({
console.log('[api-keys] Upserting to database...'); where: {
console.log('[api-keys] Where clause: userId_provider =', { userId: session.user.id, provider }); userId_keyName: {
let result;
try {
result = await prisma.userApiKey.upsert({
where: {
userId_provider: {
userId: session.user.id,
provider,
},
},
create: {
userId: session.user.id, userId: session.user.id,
provider, keyName,
encryptedKey: encrypted,
keyIv: iv,
keyHint,
}, },
update: { },
encryptedKey: encrypted, create: {
keyIv: iv, userId: session.user.id,
keyHint, keyName,
}, encryptedKey: encrypted,
select: { keyIv: iv,
provider: true, keyHint,
keyHint: true, },
createdAt: true, update: {
updatedAt: true, encryptedKey: encrypted,
}, keyIv: iv,
}); keyHint,
console.log('[api-keys] Upsert successful:', result.provider, 'updated at', result.updatedAt); },
} catch (dbError) { select: {
console.error('[api-keys] Database upsert failed:', dbError); id: true,
console.error('[api-keys] Database error details:', { keyName: true,
name: (dbError as Error).name, keyHint: true,
message: (dbError as Error).message, createdAt: true,
stack: (dbError as Error).stack?.slice(0, 500), updatedAt: true,
}); },
return NextResponse.json( });
{ success: false, error: 'Failed to save API key to database' },
{ status: 500 }
);
}
const duration = Date.now() - startTime;
console.log(`[api-keys] POST completed successfully in ${duration}ms`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: result, data: result,
}); });
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; console.error('Failed to save API key:', error);
console.error(`[api-keys] Unexpected error after ${duration}ms:`, error);
console.error('[api-keys] Error details:', {
name: (error as Error).name,
message: (error as Error).message,
stack: (error as Error).stack?.slice(0, 500),
});
return NextResponse.json({ success: false, error: 'Failed to save API key' }, { status: 500 }); return NextResponse.json({ success: false, error: 'Failed to save API key' }, { status: 500 });
} }
} }

View file

@ -11,6 +11,130 @@ import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm'; import { CollectionForm } from '~/components/collections/CollectionForm';
import { CollectionToolList } from '~/components/collections/CollectionToolList'; import { CollectionToolList } from '~/components/collections/CollectionToolList';
function McpUrlSection({ collectionId }: { collectionId: string }) {
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
const [showConfig, setShowConfig] = useState(false);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const httpUrl = `${baseUrl}/api/collections/${collectionId}/mcp/http`;
const sseUrl = `${baseUrl}/api/collections/${collectionId}/mcp/sse`;
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
await navigator.clipboard.writeText(url);
setCopiedUrl(type);
setTimeout(() => setCopiedUrl(null), 2000);
};
const configSnippet = `{
"mcpServers": {
"tpmjs-collection": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}"
]
}
}
}`;
return (
<div className="mb-8 p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon icon="link" size="sm" className="text-primary" />
</div>
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
<Badge variant="secondary" size="sm">Public</Badge>
</div>
<div className="space-y-3">
{/* HTTP Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">HTTP Transport</span>
<span className="text-xs text-foreground-tertiary">(recommended)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-background border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{httpUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(httpUrl, 'http')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{/* SSE Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">SSE Transport</span>
<span className="text-xs text-foreground-tertiary">(streaming)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-background border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{sseUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(sseUrl, 'sse')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</div>
{/* Config snippet toggle */}
<div className="mt-4 pt-4 border-t border-border/50">
<button
type="button"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Show Claude Desktop config</span>
</button>
{showConfig && (
<div className="mt-3 relative">
<pre className="p-4 bg-background border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
{configSnippet}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(configSnippet);
setCopiedUrl('http');
setTimeout(() => setCopiedUrl(null), 2000);
}}
className="absolute top-2 right-2"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
)}
</div>
<p className="mt-3 text-xs text-foreground-tertiary">
Use these URLs with{' '}
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
Claude Desktop, Cursor, or any MCP client
</Link>
</p>
</div>
);
}
interface CollectionTool { interface CollectionTool {
id: string; id: string;
toolId: string; toolId: string;
@ -334,6 +458,9 @@ export default function CollectionDetailPage(): React.ReactElement {
<span>Updated {new Date(collection.updatedAt).toLocaleDateString()}</span> <span>Updated {new Date(collection.updatedAt).toLocaleDateString()}</span>
</div> </div>
{/* MCP URLs - only for public collections */}
{collection.isPublic && <McpUrlSection collectionId={collection.id} />}
{/* Add Tool Search */} {/* Add Tool Search */}
{collection.isOwner && ( {collection.isOwner && (
<div className="mb-6"> <div className="mb-6">

View file

@ -1,7 +1,5 @@
'use client'; 'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
import { Button } from '@tpmjs/ui/Button/Button'; import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link'; import Link from 'next/link';
@ -10,161 +8,117 @@ import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader'; import { AppHeader } from '~/components/AppHeader';
interface ApiKeyInfo { interface ApiKeyInfo {
provider: AIProvider; id: string;
keyName: string;
keyHint: string | null; keyHint: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
const PROVIDER_NAMES: Record<AIProvider, string> = {
OPENAI: 'OpenAI',
ANTHROPIC: 'Anthropic',
GOOGLE: 'Google',
GROQ: 'Groq',
MISTRAL: 'Mistral',
};
const ENV_VAR_MAP: Record<string, AIProvider> = {
OPENAI_API_KEY: 'OPENAI',
ANTHROPIC_API_KEY: 'ANTHROPIC',
GOOGLE_API_KEY: 'GOOGLE',
GOOGLE_GENERATIVE_AI_API_KEY: 'GOOGLE',
GROQ_API_KEY: 'GROQ',
MISTRAL_API_KEY: 'MISTRAL',
};
export default function ApiKeysPage(): React.ReactElement { export default function ApiKeysPage(): React.ReactElement {
const router = useRouter(); const router = useRouter();
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]); const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Individual key inputs // Add new key form
const [keyInputs, setKeyInputs] = useState<Record<AIProvider, string>>({ const [newKeyName, setNewKeyName] = useState('');
OPENAI: '', const [newKeyValue, setNewKeyValue] = useState('');
ANTHROPIC: '', const [saving, setSaving] = useState(false);
GOOGLE: '',
GROQ: '',
MISTRAL: '',
});
const [savingProvider, setSavingProvider] = useState<AIProvider | null>(null);
const [savedProvider, setSavedProvider] = useState<AIProvider | null>(null);
const [deletingProvider, setDeletingProvider] = useState<AIProvider | null>(null);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
// .env import // .env import
const [showEnvImport, setShowEnvImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [envText, setEnvText] = useState(''); const [envText, setEnvText] = useState('');
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const fetchApiKeys = useCallback(async () => { const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchKeys = useCallback(async () => {
try { try {
const response = await fetch('/api/user/api-keys'); const response = await fetch('/api/user/api-keys');
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setApiKeys(data.data); setKeys(data.data);
} else { } else {
if (response.status === 401) { if (response.status === 401) {
router.push('/sign-in'); router.push('/sign-in');
return; return;
} }
setError(data.error || 'Failed to fetch API keys'); setError(data.error || 'Failed to fetch keys');
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch API keys:', err); console.error('Failed to fetch keys:', err);
setError('Failed to fetch API keys'); setError('Failed to fetch keys');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [router]); }, [router]);
useEffect(() => { useEffect(() => {
fetchApiKeys(); fetchKeys();
}, [fetchApiKeys]); }, [fetchKeys]);
const saveKey = useCallback(async (provider: AIProvider, apiKey: string) => { const saveKey = useCallback(async (keyName: string, keyValue: string) => {
setSavingProvider(provider); const response = await fetch('/api/user/api-keys', {
setSavedProvider(null); method: 'POST',
setSaveError(null); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyName, keyValue }),
try { });
console.log('[saveKey] Saving key for provider:', provider, 'key length:', apiKey.length); const result = await response.json();
const response = await fetch('/api/user/api-keys', { return result;
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, apiKey }),
});
const result = await response.json();
console.log('[saveKey] Response:', result);
if (result.success) {
setApiKeys((prev) => {
const idx = prev.findIndex((k) => k.provider === provider);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = result.data;
return updated;
}
return [...prev, result.data];
});
setKeyInputs((prev) => ({ ...prev, [provider]: '' }));
setSavedProvider(provider);
setTimeout(() => setSavedProvider(null), 2000);
return true;
} else {
console.error('[saveKey] Error:', result.error);
setSaveError(result.error || 'Failed to save');
return false;
}
} catch (err) {
console.error('[saveKey] Network error:', err);
setSaveError('Network error');
return false;
} finally {
setSavingProvider(null);
}
}, []); }, []);
const handleSave = useCallback((provider: AIProvider) => { const handleAddKey = useCallback(async () => {
const value = keyInputs[provider]?.trim(); if (!newKeyName.trim() || !newKeyValue.trim()) return;
if (value) {
saveKey(provider, value); setSaving(true);
setSaveError(null);
const result = await saveKey(newKeyName.trim(), newKeyValue.trim());
if (result.success) {
setNewKeyName('');
setNewKeyValue('');
fetchKeys();
} else {
setSaveError(result.error || 'Failed to save');
} }
}, [keyInputs, saveKey]);
const handleDelete = useCallback(async (provider: AIProvider) => { setSaving(false);
if (!confirm(`Delete ${PROVIDER_NAMES[provider]} API key?`)) return; }, [newKeyName, newKeyValue, saveKey, fetchKeys]);
setDeletingProvider(provider); const handleDelete = useCallback(async (keyName: string, id: string) => {
if (!confirm(`Delete ${keyName}?`)) return;
setDeletingId(id);
try { try {
const response = await fetch(`/api/user/api-keys/${provider}`, { method: 'DELETE' }); const response = await fetch(`/api/user/api-keys/${encodeURIComponent(keyName)}`, {
method: 'DELETE',
});
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setApiKeys((prev) => prev.filter((k) => k.provider !== provider)); setKeys((prev) => prev.filter((k) => k.id !== id));
} }
} catch (err) { } catch (err) {
console.error('Failed to delete:', err); console.error('Failed to delete:', err);
} finally { } finally {
setDeletingProvider(null); setDeletingId(null);
} }
}, []); }, []);
const handleEnvImport = useCallback(async () => { const handleImport = useCallback(async () => {
const lines = envText.split('\n'); const lines = envText.split('\n');
const keysToSave: { provider: AIProvider; apiKey: string }[] = []; const keysToSave: { keyName: string; keyValue: string }[] = [];
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue; if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Z_]+)\s*=\s*["']?([^"'\n]+)["']?$/); const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["']?(.+?)["']?$/);
if (match) { if (match) {
const [, envVar, value] = match; const [, keyName, keyValue] = match;
if (envVar && value) { if (keyName && keyValue) {
const provider = ENV_VAR_MAP[envVar]; keysToSave.push({ keyName, keyValue: keyValue.trim() });
if (provider) {
keysToSave.push({ provider, apiKey: value.trim() });
}
} }
} }
} }
@ -172,16 +126,14 @@ export default function ApiKeysPage(): React.ReactElement {
if (keysToSave.length === 0) return; if (keysToSave.length === 0) return;
setImporting(true); setImporting(true);
for (const { provider, apiKey } of keysToSave) { for (const { keyName, keyValue } of keysToSave) {
await saveKey(provider, apiKey); await saveKey(keyName, keyValue);
} }
setImporting(false); setImporting(false);
setEnvText(''); setEnvText('');
setShowEnvImport(false); setShowImport(false);
}, [envText, saveKey]); fetchKeys();
}, [envText, saveKey, fetchKeys]);
const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider);
const getKeyHint = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider)?.keyHint;
if (isLoading) { if (isLoading) {
return ( return (
@ -191,7 +143,6 @@ export default function ApiKeysPage(): React.ReactElement {
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
<div className="h-8 bg-surface-secondary rounded w-32" /> <div className="h-8 bg-surface-secondary rounded w-32" />
<div className="h-16 bg-surface-secondary rounded" /> <div className="h-16 bg-surface-secondary rounded" />
<div className="h-16 bg-surface-secondary rounded" />
</div> </div>
</div> </div>
</div> </div>
@ -204,7 +155,7 @@ export default function ApiKeysPage(): React.ReactElement {
<AppHeader /> <AppHeader />
<div className="max-w-2xl mx-auto py-12 px-4 text-center"> <div className="max-w-2xl mx-auto py-12 px-4 text-center">
<p className="text-error mb-4">{error}</p> <p className="text-error mb-4">{error}</p>
<Button onClick={fetchApiKeys}>Retry</Button> <Button onClick={fetchKeys}>Retry</Button>
</div> </div>
</div> </div>
); );
@ -219,106 +170,98 @@ export default function ApiKeysPage(): React.ReactElement {
<Link href="/dashboard" className="text-foreground-secondary hover:text-foreground"> <Link href="/dashboard" className="text-foreground-secondary hover:text-foreground">
<Icon icon="arrowLeft" size="sm" /> <Icon icon="arrowLeft" size="sm" />
</Link> </Link>
<h1 className="text-2xl font-bold text-foreground">API Keys</h1> <h1 className="text-2xl font-bold text-foreground">Environment Variables</h1>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setShowEnvImport(!showEnvImport)} onClick={() => setShowImport(!showImport)}
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
{showEnvImport ? 'Hide' : 'Import from .env'} {showImport ? 'Hide' : 'Import .env'}
</button> </button>
</div> </div>
{/* .env Import */} {/* Import from .env */}
{showEnvImport && ( {showImport && (
<div className="mb-6 p-4 bg-surface-secondary border border-border rounded-lg"> <div className="mb-6 p-4 bg-surface-secondary border border-border rounded-lg">
<textarea <textarea
value={envText} value={envText}
onChange={(e) => setEnvText(e.target.value)} onChange={(e) => setEnvText(e.target.value)}
placeholder="OPENAI_API_KEY=sk-... placeholder="Paste .env content here...
ANTHROPIC_API_KEY=sk-ant-..." MY_API_KEY=abc123
rows={4} ANOTHER_SECRET=xyz789"
rows={5}
className="w-full px-3 py-2 bg-background border border-border rounded text-foreground font-mono text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none" className="w-full px-3 py-2 bg-background border border-border rounded text-foreground font-mono text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
/> />
<Button <Button
size="sm" size="sm"
onClick={handleEnvImport} onClick={handleImport}
disabled={importing || !envText.trim()} disabled={importing || !envText.trim()}
className="mt-2" className="mt-2"
> >
{importing ? 'Importing...' : 'Import Keys'} {importing ? 'Importing...' : 'Import'}
</Button> </Button>
</div> </div>
)} )}
{/* Error message */} {/* Add new key */}
{saveError && ( <div className="mb-6 p-4 bg-surface-secondary border border-border rounded-lg">
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm"> <h2 className="text-sm font-medium text-foreground mb-3">Add New Key</h2>
{saveError} {saveError && (
<p className="text-red-500 text-sm mb-2">{saveError}</p>
)}
<div className="flex gap-2 mb-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
placeholder="KEY_NAME"
className="flex-1 px-3 py-2 bg-background border border-border rounded text-foreground text-sm font-mono placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<div className="flex gap-2">
<input
type="password"
value={newKeyValue}
onChange={(e) => setNewKeyValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKey()}
placeholder="Value"
className="flex-1 px-3 py-2 bg-background border border-border rounded text-foreground text-sm font-mono placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<Button onClick={handleAddKey} disabled={saving || !newKeyName.trim() || !newKeyValue.trim()}>
{saving ? 'Saving...' : 'Add'}
</Button>
</div> </div>
)}
{/* Provider list */}
<div className="space-y-3">
{SUPPORTED_PROVIDERS.map((provider) => {
const configured = hasKey(provider);
const hint = getKeyHint(provider);
const isSaving = savingProvider === provider;
const isSaved = savedProvider === provider;
const isDeleting = deletingProvider === provider;
const inputValue = keyInputs[provider] || '';
return (
<div
key={provider}
className="p-4 bg-surface-secondary border border-border rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{PROVIDER_NAMES[provider]}</span>
{configured && (
<span className="text-xs text-foreground-tertiary">...{hint}</span>
)}
</div>
{configured && (
<button
type="button"
onClick={() => handleDelete(provider)}
disabled={isDeleting}
className="text-foreground-tertiary hover:text-red-500 disabled:opacity-50"
>
<Icon icon="trash" size="xs" />
</button>
)}
</div>
<div className="flex gap-2">
<input
type="password"
value={inputValue}
onChange={(e) => setKeyInputs((prev) => ({ ...prev, [provider]: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleSave(provider)}
placeholder={configured ? 'Enter new key to update...' : 'Paste API key...'}
className="flex-1 px-3 py-2 bg-background border border-border rounded text-foreground text-sm font-mono placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<Button
size="sm"
onClick={() => handleSave(provider)}
disabled={isSaving || !inputValue.trim()}
>
{isSaving ? (
<Icon icon="loader" size="xs" className="animate-spin" />
) : isSaved ? (
<Icon icon="check" size="xs" />
) : (
'Save'
)}
</Button>
</div>
</div>
);
})}
</div> </div>
{/* Keys list */}
{keys.length > 0 ? (
<div className="space-y-2">
{keys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between px-4 py-3 bg-surface-secondary border border-border rounded-lg"
>
<div className="flex items-center gap-3">
<span className="font-mono text-sm text-foreground">{key.keyName}</span>
<span className="text-xs text-foreground-tertiary">...{key.keyHint}</span>
</div>
<button
type="button"
onClick={() => handleDelete(key.keyName, key.id)}
disabled={deletingId === key.id}
className="text-foreground-tertiary hover:text-red-500 disabled:opacity-50"
>
<Icon icon="trash" size="sm" />
</button>
</div>
))}
</div>
) : (
<p className="text-center text-foreground-tertiary py-8">
No environment variables stored yet.
</p>
)}
</div> </div>
</div> </div>
); );

View file

@ -92,6 +92,47 @@ export async function fetchAgentByUidWithTools(uid: string): Promise<AgentWithRe
}); });
} }
/**
* Fetch an agent by ID or UID with all tool relations
* Accepts either the cuid or the user-friendly uid
*/
export async function fetchAgentByIdOrUidWithTools(
idOrUid: string
): Promise<AgentWithRelations | null> {
return prisma.agent.findFirst({
where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
include: {
collections: {
include: {
collection: {
include: {
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
},
},
orderBy: { position: 'asc' },
},
tools: {
include: {
tool: {
include: { package: true },
},
},
orderBy: { position: 'asc' },
},
},
});
}
/** /**
* Sanitize npm package name to valid tool name * Sanitize npm package name to valid tool name
*/ */

View file

@ -8,6 +8,8 @@ import { sendVerificationEmail } from './email';
// So we prioritize BETTER_AUTH_URL or fall back to the production domain // So we prioritize BETTER_AUTH_URL or fall back to the production domain
const getBaseURL = () => { const getBaseURL = () => {
if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL; if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL;
// Local development
if (process.env.NODE_ENV === 'development') return 'http://localhost:3000';
// In production, always use the custom domain, not VERCEL_URL // In production, always use the custom domain, not VERCEL_URL
if (process.env.VERCEL_ENV === 'production') return 'https://tpmjs.com'; if (process.env.VERCEL_ENV === 'production') return 'https://tpmjs.com';
// For preview deployments, use the Vercel URL // For preview deployments, use the Vercel URL
@ -40,11 +42,11 @@ export const auth = betterAuth({
maxAge: 60 * 5, // 5 minutes maxAge: 60 * 5, // 5 minutes
}, },
}, },
trustedOrigins: ['https://tpmjs.com'], trustedOrigins: ['https://tpmjs.com', 'http://localhost:3000'],
advanced: { advanced: {
defaultCookieAttributes: { defaultCookieAttributes: {
sameSite: 'lax', sameSite: 'lax',
secure: true, secure: process.env.NODE_ENV !== 'development',
httpOnly: true, httpOnly: true,
}, },
}, },

View file

@ -557,27 +557,27 @@ model AgentTool {
@@map("agent_tools") @@map("agent_tools")
} }
/// UserApiKey - encrypted API keys per user per provider /// UserApiKey - encrypted API keys/env vars per user
model UserApiKey { model UserApiKey {
id String @id @default(cuid()) id String @id @default(cuid())
// Owner relationship // Owner relationship
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Provider identification // Key identification (e.g. OPENAI_API_KEY, MY_SECRET, etc.)
provider AIProvider keyName String @map("key_name") @db.VarChar(100)
// Encrypted key storage // Encrypted key storage
encryptedKey String @map("encrypted_key") @db.Text encryptedKey String @map("encrypted_key") @db.Text
keyIv String @map("key_iv") @db.VarChar(32) keyIv String @map("key_iv") @db.VarChar(32)
keyHint String? @map("key_hint") @db.VarChar(10) // Last 4 chars keyHint String? @map("key_hint") @db.VarChar(10) // Last 4 chars
// Timestamps // Timestamps
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, provider]) @@unique([userId, keyName])
@@index([userId]) @@index([userId])
@@map("user_api_keys") @@map("user_api_keys")
} }

View file

@ -28,6 +28,14 @@ export const icons = {
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
path: 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z', path: 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z',
}, },
chevronRight: {
viewBox: '0 0 24 24',
path: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z',
},
link: {
viewBox: '0 0 24 24',
path: 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z',
},
sun: { sun: {
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
path: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z', path: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z',