feat(profile): refactor create UX with dialog-based interface

- Replace ProfileCreateForm with ProfileCreateDialog for better UX
- Add Select component for dropdown functionality
- Implement sensitive keys detection and masking
- Enhance code editor with improved validation
- Update dependencies and lock files
This commit is contained in:
kaitranntt
2025-12-10 23:38:43 -05:00
parent 46ee1df083
commit 720ff9d7d6
8 changed files with 648 additions and 366 deletions
+7
View File
@@ -12,6 +12,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
@@ -245,6 +246,8 @@
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
@@ -263,6 +266,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
@@ -807,6 +812,8 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
+96 -24
View File
@@ -4,12 +4,14 @@
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
import { AlertCircle, CheckCircle2 } from 'lucide-react';
import { isSensitiveKey } from '@/lib/sensitive-keys';
import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface CodeEditorProps {
value: string;
@@ -71,6 +73,19 @@ export function CodeEditor({
}: CodeEditorProps) {
const { isDark } = useTheme();
const [isFocused, setIsFocused] = useState(false);
const [isMasked, setIsMasked] = useState(true);
// Force Editor remount when theme changes (works around react-simple-code-editor caching)
const [editorKey, setEditorKey] = useState(0);
const isFirstRender = useRef(true);
useEffect(() => {
// Skip first render, only trigger on theme changes
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setEditorKey((k) => k + 1);
}, [isDark]);
// Validate on every change for JSON
const validation = useMemo(() => {
@@ -81,32 +96,76 @@ export function CodeEditor({
}, [value, language]);
// Highlight function using prism-react-renderer
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
const highlightCode = useCallback(
(code: string) => (
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
{({ tokens, getLineProps, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn('table-row', validation.line === i + 1 && 'bg-destructive/20')}
>
<span className="table-cell pr-4 text-right text-muted-foreground select-none opacity-50 text-xs w-8">
{i + 1}
</span>
<span className="table-cell">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
))}
</>
)}
{({ tokens, getLineProps, getTokenProps }) => {
let nextValueIsSensitive = false;
return (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
>
{line.map((token, key) => {
let isSensitive = false;
// Check for sensitive keys
if (token.types.includes('property')) {
const content = token.content.replace(/['"]/g, '');
// Use shared sensitive key detection utility
if (isSensitiveKey(content)) {
nextValueIsSensitive = true;
} else {
nextValueIsSensitive = false;
}
}
// Apply masking to values following sensitive keys
else if (
(token.types.includes('string') ||
token.types.includes('number') ||
token.types.includes('boolean')) &&
nextValueIsSensitive
) {
isSensitive = true;
// Consumes the flag for this value
nextValueIsSensitive = false;
}
// Reset flag on commas or new keys (handled by property check),
// but persist through colons and whitespace
else if (token.types.includes('punctuation')) {
if (
token.content !== ':' &&
token.content !== '[' &&
token.content !== '{'
) {
nextValueIsSensitive = false;
}
}
const tokenProps = getTokenProps({ token });
if (isSensitive && isMasked) {
tokenProps.className = cn(
tokenProps.className,
'blur-[3px] select-none opacity-70 transition-all duration-200'
);
}
return <span key={key} {...tokenProps} />;
})}
</div>
))}
</>
);
}}
</Highlight>
),
[isDark, language, validation.line]
[isDark, language, validation.line, isMasked]
);
return (
@@ -126,7 +185,7 @@ export function CodeEditor({
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={isDark ? 'dark' : 'light'}
key={editorKey}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
@@ -142,6 +201,19 @@ export function CodeEditor({
minHeight,
}}
/>
{/* Secrets Toggle Overlay */}
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 bg-background/50 hover:bg-background border shadow-sm rounded-full"
onClick={() => setIsMasked(!isMasked)}
title={isMasked ? 'Reveal sensitive values' : 'Mask sensitive values'}
>
{isMasked ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
</Button>
</div>
</div>
{/* Validation status */}
+344
View File
@@ -0,0 +1,344 @@
/**
* Profile Create Dialog Component
* Modal dialog with tabbed interface for creating new API profiles
* Includes Quick Start templates and advanced model configuration
*/
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
const schema = z.object({
name: z
.string()
.min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
baseUrl: z.string().url('Invalid URL format'),
apiKey: z.string().min(1, 'API key is required'),
model: z.string().optional(),
opusModel: z.string().optional(),
sonnetModel: z.string().optional(),
haikuModel: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface ProfileCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (name: string) => void;
}
// Common URL mistakes to warn about
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) {
const createMutation = useCreateProfile();
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
watch,
reset,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
baseUrl: '',
apiKey: '',
model: '',
opusModel: '',
sonnetModel: '',
haikuModel: '',
},
});
const baseUrlValue = watch('baseUrl');
// Reset form when dialog opens
useEffect(() => {
if (open) {
reset();
setActiveTab('basic');
setUrlWarning(null);
setShowApiKey(false);
}
}, [open, reset]);
// Check for common URL mistakes
useEffect(() => {
if (baseUrlValue) {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
if (lowerUrl.endsWith(path)) {
const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), '');
setUrlWarning(
`URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}`
);
return;
}
}
}
setUrlWarning(null);
}, [baseUrlValue]);
const onSubmit = async (data: FormData) => {
try {
await createMutation.mutateAsync(data);
toast.success(`Profile "${data.name}" created`);
onSuccess(data.name);
onOpenChange(false);
} catch (error) {
toast.error((error as Error).message || 'Failed to create profile');
}
};
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0 overflow-hidden">
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
Create API Profile
</DialogTitle>
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
Basic Information
{hasBasicErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="models" className="relative">
Model Configuration
{hasModelErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-y-auto max-h-[60vh]">
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">Toggle API key visibility</span>
</Button>
</div>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
</div>
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-6">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
to the specific models supported by your API provider.
</p>
</div>
</div>
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
</Label>
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Fallback model if no specific tier is requested
</p>
</div>
<div className="grid gap-4 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping (Primary)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping (Complex Tasks)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1-preview, claude-3-opus"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping (Fast Tasks)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3-haiku"
className="font-mono text-sm h-9"
/>
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter className="p-6 pt-2 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className={cn(createMutation.isPending && 'opacity-80')}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</DialogFooter>
</Tabs>
</form>
</DialogContent>
</Dialog>
);
}
-319
View File
@@ -1,319 +0,0 @@
/**
* Profile Create Form Component
* Inline form for creating new API profiles with model mapping
*/
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useCreateProfile } from '@/hooks/use-profiles';
import {
ArrowLeft,
Loader2,
Plus,
ChevronDown,
ChevronRight,
AlertTriangle,
HelpCircle,
} from 'lucide-react';
import { toast } from 'sonner';
const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
const schema = z.object({
name: z
.string()
.min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
baseUrl: z.string().url('Invalid URL format'),
apiKey: z.string().min(10, 'API key must be at least 10 characters'),
model: z.string().optional(),
opusModel: z.string().optional(),
sonnetModel: z.string().optional(),
haikuModel: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface ProfileCreateFormProps {
onSuccess: (name: string) => void;
onCancel: () => void;
}
// Common URL mistakes to warn about
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
export function ProfileCreateForm({ onSuccess, onCancel }: ProfileCreateFormProps) {
const createMutation = useCreateProfile();
const [showModelMapping, setShowModelMapping] = useState(false);
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
model: '',
opusModel: '',
sonnetModel: '',
haikuModel: '',
},
});
const modelValue = watch('model');
const baseUrlValue = watch('baseUrl');
// Auto-expand model mapping when custom model is entered
useEffect(() => {
if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') {
setShowModelMapping(true);
}
}, [modelValue]);
// Check for common URL mistakes
useEffect(() => {
if (baseUrlValue) {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
if (lowerUrl.endsWith(path)) {
const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), '');
setUrlWarning(
`URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}`
);
return;
}
}
}
setUrlWarning(null);
}, [baseUrlValue]);
const onSubmit = async (data: FormData) => {
try {
await createMutation.mutateAsync(data);
toast.success(`Profile "${data.name}" created`);
onSuccess(data.name);
} catch (error) {
toast.error((error as Error).message || 'Failed to create profile');
}
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onCancel}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">Create API Profile</h2>
<p className="text-xs text-muted-foreground">Configure a new custom API endpoint</p>
</div>
</div>
<ScrollArea className="flex-1">
<form onSubmit={handleSubmit(onSubmit)} className="p-6 max-w-2xl">
<div className="space-y-6">
{/* Basic Info Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Basic Information</CardTitle>
<CardDescription>Profile name and API connection details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">Profile Name</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used as: <code className="bg-muted px-1 rounded">ccs my-api "prompt"</code>
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">API Base URL</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
Base URL without /chat/completions (Claude adds this)
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
{...register('apiKey')}
placeholder="••••••••••••••••"
/>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
</div>
</CardContent>
</Card>
{/* Model Configuration Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Configuration</CardTitle>
<CardDescription>Configure which models to use with this API</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Default Model */}
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_MODEL
</Badge>
</Label>
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Leave blank to use: {DEFAULT_MODEL}
</p>
</div>
{/* Model Mapping Expander */}
<div className="border rounded-md">
<button
type="button"
className="w-full flex items-center justify-between p-3 text-sm hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<div className="flex items-center gap-2">
<span className="font-medium">Model Mapping</span>
<Badge variant="secondary" className="text-[10px]">
Opus / Sonnet / Haiku
</Badge>
</div>
{showModelMapping ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{showModelMapping && (
<div className="p-4 pt-0 space-y-4 border-t bg-muted/30">
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-background p-2 rounded">
<HelpCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
Configure different model IDs for each tier. Useful for API proxies that
route Opus/Sonnet/Haiku to different backends.
</span>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_OPUS_MODEL
</Badge>
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_SONNET_MODEL
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_HAIKU_MODEL
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</div>
</div>
</form>
</ScrollArea>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Sensitive Key Detection Utilities (UI)
*
* Re-exports from main package for use in UI components.
* Patterns detect API keys, tokens, passwords, etc.
*/
/**
* Patterns that match sensitive keys (API keys, tokens, passwords).
* More specific than substring matching to avoid false positives.
*/
export const SENSITIVE_KEY_PATTERNS = [
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
/_API_KEY$/, // Keys ending with _API_KEY
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
/_SECRET$/, // Keys ending with _SECRET
/_SECRET_KEY$/, // Keys ending with _SECRET_KEY
/^API_KEY$/, // Exact match for API_KEY
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
/^SECRET$/, // Exact match for SECRET
/_PASSWORD$/, // Keys ending with _PASSWORD
/^PASSWORD$/, // Exact match for PASSWORD
/_CREDENTIAL$/, // Keys ending with _CREDENTIAL
/_PRIVATE_KEY$/, // Keys ending with _PRIVATE_KEY
];
/**
* Check if a key name contains a secret/sensitive value.
*
* @param key - Environment variable key name
* @returns true if the key likely contains sensitive data
*/
export function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
}
+15 -23
View File
@@ -21,7 +21,7 @@ import {
FileJson,
} from 'lucide-react';
import { ProfileEditor } from '@/components/profile-editor';
import { ProfileCreateForm } from '@/components/profile-create-form';
import { ProfileCreateDialog } from '@/components/profile-create-dialog';
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
import { ConfirmDialog } from '@/components/confirm-dialog';
import type { Profile } from '@/lib/api-client';
@@ -33,7 +33,7 @@ export function ApiPage() {
const deleteMutation = useDeleteProfile();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
// Memoize profiles to maintain stable reference
@@ -47,12 +47,11 @@ export function ApiPage() {
// Compute effective selected profile (auto-select first if none selected)
const effectiveSelectedProfile = useMemo(() => {
if (showCreateForm) return null;
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
return selectedProfile;
}
return profiles.length > 0 ? profiles[0].name : null;
}, [selectedProfile, profiles, showCreateForm]);
}, [selectedProfile, profiles]);
// Handle profile deletion
const handleDelete = (name: string) => {
@@ -68,7 +67,7 @@ export function ApiPage() {
// Handle create success
const handleCreateSuccess = (name: string) => {
setShowCreateForm(false);
setCreateDialogOpen(false);
setSelectedProfile(name);
};
@@ -88,8 +87,7 @@ export function ApiPage() {
<Button
size="sm"
onClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
@@ -128,8 +126,7 @@ export function ApiPage() {
size="sm"
variant="outline"
onClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
setCreateDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-1" />
@@ -151,7 +148,6 @@ export function ApiPage() {
isSelected={effectiveSelectedProfile === profile.name}
onSelect={() => {
setSelectedProfile(profile.name);
setShowCreateForm(false);
}}
onDelete={() => setDeleteConfirm(profile.name)}
/>
@@ -178,17 +174,7 @@ export function ApiPage() {
{/* Right Panel - Editor */}
<div className="flex-1 flex flex-col min-w-0">
{showCreateForm ? (
<ProfileCreateForm
onSuccess={handleCreateSuccess}
onCancel={() => {
setShowCreateForm(false);
if (profiles.length > 0) {
setSelectedProfile(profiles[0].name);
}
}}
/>
) : selectedProfileData ? (
{selectedProfileData ? (
<ProfileEditor
profileName={selectedProfileData.name}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
@@ -196,13 +182,19 @@ export function ApiPage() {
) : (
<EmptyState
onCreateClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
setCreateDialogOpen(true);
}}
/>
)}
</div>
{/* Create Dialog */}
<ProfileCreateDialog
open={isCreateDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deleteConfirm}