From 720ff9d7d6eb881a73547daab262030fb619e5ee Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 23:38:43 -0500 Subject: [PATCH] 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 --- ui/bun.lock | 7 + ui/package.json | 1 + ui/src/components/code-editor.tsx | 120 +++++-- ui/src/components/profile-create-dialog.tsx | 344 ++++++++++++++++++++ ui/src/components/profile-create-form.tsx | 319 ------------------ ui/src/components/ui/select.tsx | 150 +++++++++ ui/src/lib/sensitive-keys.ts | 35 ++ ui/src/pages/api.tsx | 38 +-- 8 files changed, 648 insertions(+), 366 deletions(-) create mode 100644 ui/src/components/profile-create-dialog.tsx delete mode 100644 ui/src/components/profile-create-form.tsx create mode 100644 ui/src/components/ui/select.tsx create mode 100644 ui/src/lib/sensitive-keys.ts diff --git a/ui/bun.lock b/ui/bun.lock index f2f7ca2e..c677c243 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -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=="], diff --git a/ui/package.json b/ui/package.json index 7e02cbdb..9b90a795 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx index 16243dc6..d379a7c9 100644 --- a/ui/src/components/code-editor.tsx +++ b/ui/src/components/code-editor.tsx @@ -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) => ( - {({ tokens, getLineProps, getTokenProps }) => ( - <> - {tokens.map((line, i) => ( -
- - {i + 1} - - - {line.map((token, key) => ( - - ))} - -
- ))} - - )} + {({ tokens, getLineProps, getTokenProps }) => { + let nextValueIsSensitive = false; + + return ( + <> + {tokens.map((line, i) => ( +
+ {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 ; + })} +
+ ))} + + ); + }}
), - [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 */} +
+ +
{/* Validation status */} diff --git a/ui/src/components/profile-create-dialog.tsx b/ui/src/components/profile-create-dialog.tsx new file mode 100644 index 00000000..5506bd45 --- /dev/null +++ b/ui/src/components/profile-create-dialog.tsx @@ -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; + +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(null); + const [showApiKey, setShowApiKey] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + reset, + } = useForm({ + 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 ( + + + + + + Create API Profile + + Configure a custom API endpoint for Claude Code. + + +
+ +
+ + + Basic Information + {hasBasicErrors && ( + + )} + + + Model Configuration + {hasModelErrors && ( + + )} + + +
+ +
+ +
+ {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + + ccs my-api "prompt" + +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ The endpoint that accepts OpenAI-compatible and Anthropic requests +

+ )} +
+ + {/* API Key */} +
+ +
+ + +
+ {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + +
+ +
+

Model Mapping

+

+ Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers + to the specific models supported by your API provider. +

+
+
+ +
+
+ + +

+ Fallback model if no specific tier is requested +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/profile-create-form.tsx b/ui/src/components/profile-create-form.tsx deleted file mode 100644 index 8674ecf0..00000000 --- a/ui/src/components/profile-create-form.tsx +++ /dev/null @@ -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; - -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(null); - - const { - register, - handleSubmit, - formState: { errors }, - watch, - } = useForm({ - 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 ( -
- {/* Header */} -
- -
-

Create API Profile

-

Configure a new custom API endpoint

-
-
- - -
-
- {/* Basic Info Card */} - - - Basic Information - Profile name and API connection details - - - {/* Name */} -
- - - {errors.name ? ( -

{errors.name.message}

- ) : ( -

- Used as: ccs my-api "prompt" -

- )} -
- - {/* Base URL */} -
- - - {errors.baseUrl ? ( -

{errors.baseUrl.message}

- ) : urlWarning ? ( -
- - {urlWarning} -
- ) : ( -

- Base URL without /chat/completions (Claude adds this) -

- )} -
- - {/* API Key */} -
- - - {errors.apiKey && ( -

{errors.apiKey.message}

- )} -
-
-
- - {/* Model Configuration Card */} - - - Model Configuration - Configure which models to use with this API - - - {/* Default Model */} -
- - -

- Leave blank to use: {DEFAULT_MODEL} -

-
- - {/* Model Mapping Expander */} -
- - - {showModelMapping && ( -
-
- - - Configure different model IDs for each tier. Useful for API proxies that - route Opus/Sonnet/Haiku to different backends. - -
- -
- - -
- -
- - -
- -
- - -
-
- )} -
-
-
- - {/* Actions */} -
- - -
-
-
-
-
- ); -} diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx new file mode 100644 index 00000000..fad1c32d --- /dev/null +++ b/ui/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/ui/src/lib/sensitive-keys.ts b/ui/src/lib/sensitive-keys.ts new file mode 100644 index 00000000..c4f1cc98 --- /dev/null +++ b/ui/src/lib/sensitive-keys.ts @@ -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)); +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index aa21b1d2..cde4a4d6 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -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(null); const [searchQuery, setSearchQuery] = useState(''); - const [showCreateForm, setShowCreateForm] = useState(false); + const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(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() {