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:
parent
9371a11eb0
commit
3a0a125b6d
10 changed files with 423 additions and 347 deletions
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue