mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 06:17:09 +00:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user