mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-31 00:23:38 +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-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@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-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-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=="],
|
"@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-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-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=="],
|
"@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-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-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=="],
|
"@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-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
|
* 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 Editor from 'react-simple-code-editor';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface CodeEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -71,6 +73,19 @@ export function CodeEditor({
|
|||||||
}: CodeEditorProps) {
|
}: CodeEditorProps) {
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
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
|
// Validate on every change for JSON
|
||||||
const validation = useMemo(() => {
|
const validation = useMemo(() => {
|
||||||
@@ -81,32 +96,76 @@ export function CodeEditor({
|
|||||||
}, [value, language]);
|
}, [value, language]);
|
||||||
|
|
||||||
// Highlight function using prism-react-renderer
|
// Highlight function using prism-react-renderer
|
||||||
|
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
|
||||||
const highlightCode = useCallback(
|
const highlightCode = useCallback(
|
||||||
(code: string) => (
|
(code: string) => (
|
||||||
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
|
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
|
||||||
{({ tokens, getLineProps, getTokenProps }) => (
|
{({ tokens, getLineProps, getTokenProps }) => {
|
||||||
<>
|
let nextValueIsSensitive = false;
|
||||||
{tokens.map((line, i) => (
|
|
||||||
<div
|
return (
|
||||||
key={i}
|
<>
|
||||||
{...getLineProps({ line })}
|
{tokens.map((line, i) => (
|
||||||
className={cn('table-row', validation.line === i + 1 && 'bg-destructive/20')}
|
<div
|
||||||
>
|
key={i}
|
||||||
<span className="table-cell pr-4 text-right text-muted-foreground select-none opacity-50 text-xs w-8">
|
{...getLineProps({ line })}
|
||||||
{i + 1}
|
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
|
||||||
</span>
|
>
|
||||||
<span className="table-cell">
|
{line.map((token, key) => {
|
||||||
{line.map((token, key) => (
|
let isSensitive = false;
|
||||||
<span key={key} {...getTokenProps({ token })} />
|
|
||||||
))}
|
// Check for sensitive keys
|
||||||
</span>
|
if (token.types.includes('property')) {
|
||||||
</div>
|
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>
|
</Highlight>
|
||||||
),
|
),
|
||||||
[isDark, language, validation.line]
|
[isDark, language, validation.line, isMasked]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,7 +185,7 @@ export function CodeEditor({
|
|||||||
value={value}
|
value={value}
|
||||||
onValueChange={readonly ? () => {} : onChange}
|
onValueChange={readonly ? () => {} : onChange}
|
||||||
highlight={highlightCode}
|
highlight={highlightCode}
|
||||||
key={isDark ? 'dark' : 'light'}
|
key={editorKey}
|
||||||
padding={12}
|
padding={12}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
@@ -142,6 +201,19 @@ export function CodeEditor({
|
|||||||
minHeight,
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Validation status */}
|
{/* 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,
|
FileJson,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProfileEditor } from '@/components/profile-editor';
|
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 { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import type { Profile } from '@/lib/api-client';
|
import type { Profile } from '@/lib/api-client';
|
||||||
@@ -33,7 +33,7 @@ export function ApiPage() {
|
|||||||
const deleteMutation = useDeleteProfile();
|
const deleteMutation = useDeleteProfile();
|
||||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
// Memoize profiles to maintain stable reference
|
// Memoize profiles to maintain stable reference
|
||||||
@@ -47,12 +47,11 @@ export function ApiPage() {
|
|||||||
|
|
||||||
// Compute effective selected profile (auto-select first if none selected)
|
// Compute effective selected profile (auto-select first if none selected)
|
||||||
const effectiveSelectedProfile = useMemo(() => {
|
const effectiveSelectedProfile = useMemo(() => {
|
||||||
if (showCreateForm) return null;
|
|
||||||
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
|
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
|
||||||
return selectedProfile;
|
return selectedProfile;
|
||||||
}
|
}
|
||||||
return profiles.length > 0 ? profiles[0].name : null;
|
return profiles.length > 0 ? profiles[0].name : null;
|
||||||
}, [selectedProfile, profiles, showCreateForm]);
|
}, [selectedProfile, profiles]);
|
||||||
|
|
||||||
// Handle profile deletion
|
// Handle profile deletion
|
||||||
const handleDelete = (name: string) => {
|
const handleDelete = (name: string) => {
|
||||||
@@ -68,7 +67,7 @@ export function ApiPage() {
|
|||||||
|
|
||||||
// Handle create success
|
// Handle create success
|
||||||
const handleCreateSuccess = (name: string) => {
|
const handleCreateSuccess = (name: string) => {
|
||||||
setShowCreateForm(false);
|
setCreateDialogOpen(false);
|
||||||
setSelectedProfile(name);
|
setSelectedProfile(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,8 +87,7 @@ export function ApiPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(true);
|
setCreateDialogOpen(true);
|
||||||
setSelectedProfile(null);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
@@ -128,8 +126,7 @@ export function ApiPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(true);
|
setCreateDialogOpen(true);
|
||||||
setSelectedProfile(null);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
@@ -151,7 +148,6 @@ export function ApiPage() {
|
|||||||
isSelected={effectiveSelectedProfile === profile.name}
|
isSelected={effectiveSelectedProfile === profile.name}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedProfile(profile.name);
|
setSelectedProfile(profile.name);
|
||||||
setShowCreateForm(false);
|
|
||||||
}}
|
}}
|
||||||
onDelete={() => setDeleteConfirm(profile.name)}
|
onDelete={() => setDeleteConfirm(profile.name)}
|
||||||
/>
|
/>
|
||||||
@@ -178,17 +174,7 @@ export function ApiPage() {
|
|||||||
|
|
||||||
{/* Right Panel - Editor */}
|
{/* Right Panel - Editor */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{showCreateForm ? (
|
{selectedProfileData ? (
|
||||||
<ProfileCreateForm
|
|
||||||
onSuccess={handleCreateSuccess}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowCreateForm(false);
|
|
||||||
if (profiles.length > 0) {
|
|
||||||
setSelectedProfile(profiles[0].name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : selectedProfileData ? (
|
|
||||||
<ProfileEditor
|
<ProfileEditor
|
||||||
profileName={selectedProfileData.name}
|
profileName={selectedProfileData.name}
|
||||||
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
||||||
@@ -196,13 +182,19 @@ export function ApiPage() {
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
onCreateClick={() => {
|
onCreateClick={() => {
|
||||||
setShowCreateForm(true);
|
setCreateDialogOpen(true);
|
||||||
setSelectedProfile(null);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Dialog */}
|
||||||
|
<ProfileCreateDialog
|
||||||
|
open={isCreateDialogOpen}
|
||||||
|
onOpenChange={setCreateDialogOpen}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteConfirm}
|
open={!!deleteConfirm}
|
||||||
|
|||||||
Reference in New Issue
Block a user