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 9371a11eb0
commit 3a0a125b6d
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
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
* Accepts either agent id (cuid) or uid
*/
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
const { uid, conversationId } = await context.params;
const { id: idOrUid, conversationId } = await context.params;
try {
const body = await request.json();
@ -72,20 +73,39 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
);
}
// Fetch agent with all tool relations
const { fetchAgentByUidWithTools, buildAgentTools } = await import('@/lib/agents/build-tools');
const agent = await fetchAgentByUidWithTools(uid);
// Fetch agent with all tool relations (accepts id or uid)
const { fetchAgentByIdOrUidWithTools, buildAgentTools } = await import(
'@/lib/agents/build-tools'
);
const agent = await fetchAgentByIdOrUidWithTools(idOrUid);
if (!agent) {
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
const userApiKey = await prisma.userApiKey.findUnique({
where: {
userId_provider: {
userId_keyName: {
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]
* Retrieve conversation history
* GET /api/agents/[id]/conversation/[conversationId]
* Retrieve conversation history (accepts id or uid)
*/
export async function GET(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params;
const { id: idOrUid, conversationId } = await context.params;
try {
// Fetch agent
const agent = await prisma.agent.findUnique({
where: { uid },
// Fetch agent by id or uid
const agent = await prisma.agent.findFirst({
where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true },
});
@ -413,16 +435,18 @@ export async function GET(_request: NextRequest, context: RouteContext): Promise
}
/**
* DELETE /api/agents/[uid]/conversation/[conversationId]
* Delete a conversation
* DELETE /api/agents/[id]/conversation/[conversationId]
* Delete a conversation (accepts id or uid)
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
const { uid, conversationId } = await context.params;
const { id: idOrUid, conversationId } = await context.params;
try {
// Fetch agent
const agent = await prisma.agent.findUnique({
where: { uid },
// Fetch agent by id or uid
const agent = await prisma.agent.findFirst({
where: {
OR: [{ id: idOrUid }, { uid: idOrUid }],
},
select: { id: true },
});

View file

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

View file

@ -1,5 +1,3 @@
import type { AIProvider } from '@prisma/client';
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
@ -14,8 +12,8 @@ type RouteContext = {
};
/**
* DELETE /api/user/api-keys/[provider]
* Remove an API key for a provider
* DELETE /api/user/api-keys/[keyName]
* Remove an API key by name
*/
export async function DELETE(_request: NextRequest, context: RouteContext): Promise<NextResponse> {
try {
@ -24,18 +22,12 @@ export async function DELETE(_request: NextRequest, context: RouteContext): Prom
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const { provider } = 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 });
}
const { provider: keyName } = await context.params;
await prisma.userApiKey.deleteMany({
where: {
userId: session.user.id,
provider: provider.toUpperCase() as AIProvider,
keyName,
},
});

View file

@ -1,7 +1,7 @@
import { prisma } from '@tpmjs/db';
import { AddApiKeySchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auth } from '~/lib/auth';
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 dynamic = 'force-dynamic';
const AddKeySchema = z.object({
keyName: z.string().min(1).max(100),
keyValue: z.string().min(1),
});
/**
* GET /api/user/api-keys
* List user's stored API keys (masked)
@ -23,12 +28,13 @@ export async function GET(): Promise<NextResponse> {
const apiKeys = await prisma.userApiKey.findMany({
where: { userId: session.user.id },
select: {
provider: true,
id: true,
keyName: true,
keyHint: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: 'asc' },
orderBy: { keyName: 'asc' },
});
return NextResponse.json({
@ -43,141 +49,72 @@ export async function GET(): Promise<NextResponse> {
/**
* 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> {
const startTime = Date.now();
console.log('[api-keys] POST request received');
try {
// Step 1: Auth check
console.log('[api-keys] Checking session...');
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
console.log('[api-keys] Unauthorized - no session or user id');
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
console.log('[api-keys] Parsing request 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);
const body = await request.json();
const parsed = AddKeySchema.safeParse(body);
if (!parsed.success) {
console.error('[api-keys] Schema validation failed:', parsed.error.flatten());
return NextResponse.json(
{ success: false, error: 'Invalid request', details: parsed.error.flatten() },
{ status: 400 }
);
}
console.log('[api-keys] Schema valid, provider:', parsed.data.provider);
const { provider, apiKey } = parsed.data;
console.log('[api-keys] API key length:', apiKey.length, 'chars');
const { keyName, keyValue } = parsed.data;
// Step 4: Encrypt the key
console.log('[api-keys] Encrypting API key...');
let encrypted: string;
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);
// Check encryption secret
if (!process.env.API_KEY_ENCRYPTION_SECRET) {
console.error('[api-keys] API_KEY_ENCRYPTION_SECRET not set');
return NextResponse.json(
{ success: false, error: 'Failed to encrypt API key' },
{ success: false, error: 'Server configuration error' },
{ status: 500 }
);
}
const keyHint = getKeyHint(apiKey);
console.log('[api-keys] Key hint:', keyHint);
const { encrypted, iv } = encryptApiKey(keyValue);
const keyHint = getKeyHint(keyValue);
// Step 5: Upsert to database
console.log('[api-keys] Upserting to database...');
console.log('[api-keys] Where clause: userId_provider =', { userId: session.user.id, provider });
let result;
try {
result = await prisma.userApiKey.upsert({
where: {
userId_provider: {
userId: session.user.id,
provider,
},
},
create: {
const result = await prisma.userApiKey.upsert({
where: {
userId_keyName: {
userId: session.user.id,
provider,
encryptedKey: encrypted,
keyIv: iv,
keyHint,
keyName,
},
update: {
encryptedKey: encrypted,
keyIv: iv,
keyHint,
},
select: {
provider: true,
keyHint: true,
createdAt: true,
updatedAt: true,
},
});
console.log('[api-keys] Upsert successful:', result.provider, 'updated at', result.updatedAt);
} catch (dbError) {
console.error('[api-keys] Database upsert failed:', dbError);
console.error('[api-keys] Database error details:', {
name: (dbError as Error).name,
message: (dbError as Error).message,
stack: (dbError as Error).stack?.slice(0, 500),
});
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`);
},
create: {
userId: session.user.id,
keyName,
encryptedKey: encrypted,
keyIv: iv,
keyHint,
},
update: {
encryptedKey: encrypted,
keyIv: iv,
keyHint,
},
select: {
id: true,
keyName: true,
keyHint: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
const duration = Date.now() - startTime;
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),
});
console.error('Failed to save API key:', error);
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 { 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 {
id: string;
toolId: string;
@ -334,6 +458,9 @@ export default function CollectionDetailPage(): React.ReactElement {
<span>Updated {new Date(collection.updatedAt).toLocaleDateString()}</span>
</div>
{/* MCP URLs - only for public collections */}
{collection.isPublic && <McpUrlSection collectionId={collection.id} />}
{/* Add Tool Search */}
{collection.isOwner && (
<div className="mb-6">

View file

@ -1,7 +1,5 @@
'use client';
import type { AIProvider } from '@tpmjs/types/agent';
import { SUPPORTED_PROVIDERS } from '@tpmjs/types/agent';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
@ -10,161 +8,117 @@ import { useCallback, useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
interface ApiKeyInfo {
provider: AIProvider;
id: string;
keyName: string;
keyHint: string | null;
createdAt: 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 {
const router = useRouter();
const [apiKeys, setApiKeys] = useState<ApiKeyInfo[]>([]);
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Individual key inputs
const [keyInputs, setKeyInputs] = useState<Record<AIProvider, string>>({
OPENAI: '',
ANTHROPIC: '',
GOOGLE: '',
GROQ: '',
MISTRAL: '',
});
const [savingProvider, setSavingProvider] = useState<AIProvider | null>(null);
const [savedProvider, setSavedProvider] = useState<AIProvider | null>(null);
const [deletingProvider, setDeletingProvider] = useState<AIProvider | null>(null);
// Add new key form
const [newKeyName, setNewKeyName] = useState('');
const [newKeyValue, setNewKeyValue] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// .env import
const [showEnvImport, setShowEnvImport] = useState(false);
const [showImport, setShowImport] = useState(false);
const [envText, setEnvText] = useState('');
const [importing, setImporting] = useState(false);
const fetchApiKeys = useCallback(async () => {
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchKeys = useCallback(async () => {
try {
const response = await fetch('/api/user/api-keys');
const data = await response.json();
if (data.success) {
setApiKeys(data.data);
setKeys(data.data);
} else {
if (response.status === 401) {
router.push('/sign-in');
return;
}
setError(data.error || 'Failed to fetch API keys');
setError(data.error || 'Failed to fetch keys');
}
} catch (err) {
console.error('Failed to fetch API keys:', err);
setError('Failed to fetch API keys');
console.error('Failed to fetch keys:', err);
setError('Failed to fetch keys');
} finally {
setIsLoading(false);
}
}, [router]);
useEffect(() => {
fetchApiKeys();
}, [fetchApiKeys]);
fetchKeys();
}, [fetchKeys]);
const saveKey = useCallback(async (provider: AIProvider, apiKey: string) => {
setSavingProvider(provider);
setSavedProvider(null);
setSaveError(null);
try {
console.log('[saveKey] Saving key for provider:', provider, 'key length:', apiKey.length);
const response = await fetch('/api/user/api-keys', {
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 saveKey = useCallback(async (keyName: string, keyValue: string) => {
const response = await fetch('/api/user/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyName, keyValue }),
});
const result = await response.json();
return result;
}, []);
const handleSave = useCallback((provider: AIProvider) => {
const value = keyInputs[provider]?.trim();
if (value) {
saveKey(provider, value);
const handleAddKey = useCallback(async () => {
if (!newKeyName.trim() || !newKeyValue.trim()) return;
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) => {
if (!confirm(`Delete ${PROVIDER_NAMES[provider]} API key?`)) return;
setSaving(false);
}, [newKeyName, newKeyValue, saveKey, fetchKeys]);
setDeletingProvider(provider);
const handleDelete = useCallback(async (keyName: string, id: string) => {
if (!confirm(`Delete ${keyName}?`)) return;
setDeletingId(id);
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();
if (result.success) {
setApiKeys((prev) => prev.filter((k) => k.provider !== provider));
setKeys((prev) => prev.filter((k) => k.id !== id));
}
} catch (err) {
console.error('Failed to delete:', err);
} finally {
setDeletingProvider(null);
setDeletingId(null);
}
}, []);
const handleEnvImport = useCallback(async () => {
const handleImport = useCallback(async () => {
const lines = envText.split('\n');
const keysToSave: { provider: AIProvider; apiKey: string }[] = [];
const keysToSave: { keyName: string; keyValue: string }[] = [];
for (const line of lines) {
const trimmed = line.trim();
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) {
const [, envVar, value] = match;
if (envVar && value) {
const provider = ENV_VAR_MAP[envVar];
if (provider) {
keysToSave.push({ provider, apiKey: value.trim() });
}
const [, keyName, keyValue] = match;
if (keyName && keyValue) {
keysToSave.push({ keyName, keyValue: keyValue.trim() });
}
}
}
@ -172,16 +126,14 @@ export default function ApiKeysPage(): React.ReactElement {
if (keysToSave.length === 0) return;
setImporting(true);
for (const { provider, apiKey } of keysToSave) {
await saveKey(provider, apiKey);
for (const { keyName, keyValue } of keysToSave) {
await saveKey(keyName, keyValue);
}
setImporting(false);
setEnvText('');
setShowEnvImport(false);
}, [envText, saveKey]);
const hasKey = (provider: AIProvider) => apiKeys.some((k) => k.provider === provider);
const getKeyHint = (provider: AIProvider) => apiKeys.find((k) => k.provider === provider)?.keyHint;
setShowImport(false);
fetchKeys();
}, [envText, saveKey, fetchKeys]);
if (isLoading) {
return (
@ -191,7 +143,6 @@ export default function ApiKeysPage(): React.ReactElement {
<div className="animate-pulse space-y-4">
<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>
</div>
</div>
@ -204,7 +155,7 @@ export default function ApiKeysPage(): React.ReactElement {
<AppHeader />
<div className="max-w-2xl mx-auto py-12 px-4 text-center">
<p className="text-error mb-4">{error}</p>
<Button onClick={fetchApiKeys}>Retry</Button>
<Button onClick={fetchKeys}>Retry</Button>
</div>
</div>
);
@ -219,106 +170,98 @@ export default function ApiKeysPage(): React.ReactElement {
<Link href="/dashboard" className="text-foreground-secondary hover:text-foreground">
<Icon icon="arrowLeft" size="sm" />
</Link>
<h1 className="text-2xl font-bold text-foreground">API Keys</h1>
<h1 className="text-2xl font-bold text-foreground">Environment Variables</h1>
</div>
<button
type="button"
onClick={() => setShowEnvImport(!showEnvImport)}
onClick={() => setShowImport(!showImport)}
className="text-sm text-primary hover:underline"
>
{showEnvImport ? 'Hide' : 'Import from .env'}
{showImport ? 'Hide' : 'Import .env'}
</button>
</div>
{/* .env Import */}
{showEnvImport && (
{/* Import from .env */}
{showImport && (
<div className="mb-6 p-4 bg-surface-secondary border border-border rounded-lg">
<textarea
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder="OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-..."
rows={4}
placeholder="Paste .env content here...
MY_API_KEY=abc123
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"
/>
<Button
size="sm"
onClick={handleEnvImport}
onClick={handleImport}
disabled={importing || !envText.trim()}
className="mt-2"
>
{importing ? 'Importing...' : 'Import Keys'}
{importing ? 'Importing...' : 'Import'}
</Button>
</div>
)}
{/* Error message */}
{saveError && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
{saveError}
{/* Add new key */}
<div className="mb-6 p-4 bg-surface-secondary border border-border rounded-lg">
<h2 className="text-sm font-medium text-foreground mb-3">Add New Key</h2>
{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>
)}
{/* 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>
{/* 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>
);

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
*/

View file

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

View file

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

View file

@ -28,6 +28,14 @@ export const icons = {
viewBox: '0 0 24 24',
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: {
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',