Skip to content

Commit 2b6463a

Browse files
filopedrazclaude
andauthored
feat(org): add bring your own Anthropic API key feature (#375)
* feat(org): add bring your own Anthropic API key feature Organizations can now configure their own Anthropic API key for code generation in sandboxes instead of using the platform default. Changes: - Database: Added organizationApiKeys table with encrypted API key storage - Encryption: Implemented AES-256-GCM encryption utilities in lib/crypto.ts - API Routes: Created organization API key management endpoints (GET/PUT/DELETE) - Frontend: Added Usage tab in organization settings with API key management UI - Sandbox: Modified manager to fetch and use organization's custom API key - Build Jobs: Extended job data to include orgId for API key lookup - Environment: Added ENCRYPTION_KEY configuration for all environments - Migration: Generated database migration for new table The feature allows organizations to use their own Anthropic API keys, providing better cost control and usage tracking per organization. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix(security): improve encryption key derivation and API key handling Security improvements: - Use per-encryption random salt in crypto module (prevents vulnerability from static salt) - Add ENCRYPTION_KEY to required env vars in instrumentation.ts - Remove unsafe || '' fallback in getAnthropicApiKey (validated at startup) Refactoring: - Create useOrganizationApiKeys hook with Zod validation for API key management - Update usage page to use new hook (cleaner, type-safe implementation) - Make orgId optional in BuildJobData and SandboxCreateOptions - Fix null handling for orgId in route files (use ?? instead of ||) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 770e9a6 commit 2b6463a

File tree

20 files changed

+2037
-5
lines changed

20 files changed

+2037
-5
lines changed

.env.local

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ GOOGLE_API_KEY=replace-me
7171
GOOGLE_MODEL=gemini-2.0-flash
7272
AGENT_MAX_TURNS=25
7373

74+
# Encryption
75+
# ------------------------------------------------------------------------------------
76+
ENCRYPTION_KEY=your-32-char-secret-key-here-1234
77+
7478
# Docker Related
7579
# ------------------------------------------------------------------------------------
7680
DOCKER_HOST=unix:///var/run/docker.sock

.env.prod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ GOOGLE_API_KEY=${GOOGLE_API_KEY}
1919
GOOGLE_MODEL=gemini-2.0-flash
2020
AGENT_MAX_TURNS=25
2121

22+
# Encryption
23+
# ------------------------------------------------------------------------------------
24+
ENCRYPTION_KEY=${ENCRYPTION_KEY}
25+
2226
# Docker Related
2327
# ------------------------------------------------------------------------------------
2428
DOCKER_HOST=unix:///var/run/docker.sock

.env.stage

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ GOOGLE_API_KEY=${GOOGLE_API_KEY}
1919
GOOGLE_MODEL=gemini-2.0-flash
2020
AGENT_MAX_TURNS=25
2121

22+
# Encryption
23+
# ------------------------------------------------------------------------------------
24+
ENCRYPTION_KEY=${ENCRYPTION_KEY}
25+
2226
# Docker Related
2327
# ------------------------------------------------------------------------------------
2428
DOCKER_HOST=unix:///var/run/docker.sock

src/app/(logged-in)/organizations/[orgSlug]/layout.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
'use client';
22

3-
import { Building2, Users } from 'lucide-react';
3+
import { Building2, Key, Users } from 'lucide-react';
44
import { useParams, usePathname, useRouter } from 'next/navigation';
55
import { Suspense } from 'react';
66

77
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
88

9+
function getCurrentTab(pathname: string): string {
10+
if (pathname.endsWith('/members')) return 'members';
11+
if (pathname.endsWith('/usage')) return 'usage';
12+
return 'general';
13+
}
14+
915
export default function OrganizationSettingsLayout({ children }: { children: React.ReactNode }) {
1016
const pathname = usePathname();
1117
const router = useRouter();
1218
const params = useParams();
1319
const orgSlug = params.orgSlug as string;
1420

15-
const currentTab = pathname.endsWith('/members') ? 'members' : 'general';
21+
const currentTab = getCurrentTab(pathname);
1622

1723
const handleTabChange = (value: string) => {
1824
router.push(`/organizations/${orgSlug}/${value === 'general' ? '' : value}`);
@@ -36,6 +42,10 @@ export default function OrganizationSettingsLayout({ children }: { children: Rea
3642
<Users className="h-4 w-4" />
3743
<span>Members</span>
3844
</TabsTrigger>
45+
<TabsTrigger value="usage" className="flex items-center gap-2">
46+
<Key className="h-4 w-4" />
47+
<span>Usage</span>
48+
</TabsTrigger>
3949
</TabsList>
4050
</Tabs>
4151

src/app/(logged-in)/organizations/[orgSlug]/members/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export default function OrganizationMembersPage() {
192192
<Card>
193193
<CardHeader>
194194
<div className="flex items-center justify-between">
195-
<div>
195+
<div className="space-y-1.5">
196196
<CardTitle>Members</CardTitle>
197197
<CardDescription>
198198
{isPersonal
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
'use client';
2+
3+
import { useOrganization } from '@clerk/nextjs';
4+
import { AlertCircle, Check, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react';
5+
import { useState } from 'react';
6+
7+
import { Alert, AlertDescription } from '@/components/ui/alert';
8+
import { Badge } from '@/components/ui/badge';
9+
import { Button } from '@/components/ui/button';
10+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
11+
import { Input } from '@/components/ui/input';
12+
import { Label } from '@/components/ui/label';
13+
import { Skeleton } from '@/components/ui/skeleton';
14+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
15+
import { useOrganizationApiKeys } from '@/hooks/use-organization-api-keys';
16+
17+
export default function OrganizationUsagePage() {
18+
const { organization, isLoaded, membership } = useOrganization();
19+
const {
20+
status: apiKeyStatus,
21+
isLoading: isLoadingStatus,
22+
saveApiKey,
23+
isSaving,
24+
deleteApiKey,
25+
isDeleting,
26+
} = useOrganizationApiKeys(organization?.id);
27+
28+
const [apiKeyInput, setApiKeyInput] = useState('');
29+
const [showApiKey, setShowApiKey] = useState(false);
30+
31+
const isAdmin = membership?.role === 'org:admin';
32+
33+
const handleSaveApiKey = () => {
34+
if (!apiKeyInput.trim()) return;
35+
saveApiKey(apiKeyInput, {
36+
onSuccess: () => setApiKeyInput(''),
37+
});
38+
};
39+
40+
const handleDeleteApiKey = () => {
41+
deleteApiKey();
42+
};
43+
44+
if (!isLoaded) {
45+
return <UsagePageSkeleton />;
46+
}
47+
48+
if (!organization) {
49+
return (
50+
<Card>
51+
<CardContent className="py-8">
52+
<p className="text-center text-muted-foreground">Organization not found</p>
53+
</CardContent>
54+
</Card>
55+
);
56+
}
57+
58+
return (
59+
<div className="space-y-6">
60+
<Card>
61+
<CardHeader>
62+
<div className="flex items-center justify-between">
63+
<div className="space-y-1.5">
64+
<CardTitle>Anthropic API Key</CardTitle>
65+
<CardDescription>
66+
Use your own Anthropic API key for code generation in sandboxes.
67+
</CardDescription>
68+
</div>
69+
{!isLoadingStatus && (
70+
<div className="flex items-center gap-2">
71+
<TooltipProvider>
72+
{apiKeyStatus?.hasCustomKey && isAdmin && (
73+
<Tooltip>
74+
<TooltipTrigger asChild>
75+
<Button
76+
variant="ghost"
77+
size="sm"
78+
onClick={handleDeleteApiKey}
79+
disabled={isDeleting}
80+
className="text-destructive hover:text-destructive"
81+
>
82+
{isDeleting ? (
83+
<Loader2 className="h-4 w-4 animate-spin" />
84+
) : (
85+
<Trash2 className="h-4 w-4" />
86+
)}
87+
</Button>
88+
</TooltipTrigger>
89+
<TooltipContent>
90+
<p>This will switch back to using the system default API key.</p>
91+
</TooltipContent>
92+
</Tooltip>
93+
)}
94+
<Tooltip>
95+
<TooltipTrigger asChild>
96+
<Badge variant={apiKeyStatus?.hasCustomKey ? 'default' : 'secondary'}>
97+
{apiKeyStatus?.hasCustomKey ? 'Custom Key' : 'System Default'}
98+
</Badge>
99+
</TooltipTrigger>
100+
<TooltipContent>
101+
<p>
102+
{apiKeyStatus?.hasCustomKey
103+
? `Using: ${apiKeyStatus.maskedKey}`
104+
: 'All code generation will use the platform API key.'}
105+
</p>
106+
</TooltipContent>
107+
</Tooltip>
108+
</TooltipProvider>
109+
</div>
110+
)}
111+
</div>
112+
</CardHeader>
113+
<CardContent className="space-y-6">
114+
{/* Current Status */}
115+
{isLoadingStatus ? (
116+
<div className="space-y-2">
117+
<Skeleton className="h-4 w-48" />
118+
<Skeleton className="h-10 w-full" />
119+
</div>
120+
) : (
121+
<>
122+
{/* Admin-only API Key Management */}
123+
{isAdmin ? (
124+
<div className="space-y-4">
125+
<div className="space-y-2">
126+
<div className="flex items-center justify-between">
127+
<Label htmlFor="apiKey">
128+
{apiKeyStatus?.hasCustomKey ? 'Update API Key' : 'Set API Key'}
129+
</Label>
130+
{apiKeyStatus?.hasCustomKey && apiKeyStatus.maskedKey && (
131+
<span className="text-xs text-muted-foreground">
132+
Current: {apiKeyStatus.maskedKey}
133+
</span>
134+
)}
135+
</div>
136+
<div className="flex gap-2">
137+
<div className="relative flex-1">
138+
<Input
139+
id="apiKey"
140+
type={showApiKey ? 'text' : 'password'}
141+
placeholder="sk-ant-api03-..."
142+
value={apiKeyInput}
143+
onChange={e => setApiKeyInput(e.target.value)}
144+
className="pr-10"
145+
/>
146+
<Button
147+
type="button"
148+
variant="ghost"
149+
size="icon"
150+
className="absolute right-0 top-0 h-full px-3"
151+
onClick={() => setShowApiKey(!showApiKey)}
152+
>
153+
{showApiKey ? (
154+
<EyeOff className="h-4 w-4 text-muted-foreground" />
155+
) : (
156+
<Eye className="h-4 w-4 text-muted-foreground" />
157+
)}
158+
</Button>
159+
</div>
160+
<Button onClick={handleSaveApiKey} disabled={isSaving || !apiKeyInput.trim()}>
161+
{isSaving ? (
162+
<>
163+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
164+
Validating...
165+
</>
166+
) : (
167+
<>
168+
<Check className="h-4 w-4 mr-2" />
169+
Save
170+
</>
171+
)}
172+
</Button>
173+
</div>
174+
<p className="text-xs text-muted-foreground">
175+
Your API key will be encrypted and validated before saving.
176+
</p>
177+
</div>
178+
</div>
179+
) : (
180+
<Alert>
181+
<AlertCircle className="h-4 w-4" />
182+
<AlertDescription>Only organization admins can manage API keys.</AlertDescription>
183+
</Alert>
184+
)}
185+
</>
186+
)}
187+
</CardContent>
188+
</Card>
189+
</div>
190+
);
191+
}
192+
193+
function UsagePageSkeleton() {
194+
return (
195+
<div className="space-y-6">
196+
<Card>
197+
<CardHeader>
198+
<div className="flex items-center justify-between">
199+
<div className="space-y-2">
200+
<Skeleton className="h-6 w-48" />
201+
<Skeleton className="h-4 w-96" />
202+
</div>
203+
<Skeleton className="h-6 w-24" />
204+
</div>
205+
</CardHeader>
206+
<CardContent>
207+
<div className="space-y-4">
208+
<Skeleton className="h-20 w-full" />
209+
<Skeleton className="h-10 w-full" />
210+
</div>
211+
</CardContent>
212+
</Card>
213+
</div>
214+
);
215+
}

0 commit comments

Comments
 (0)