From 2b1a3b48799eae30b5d0493e5af65edab204f4d8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 21:09:38 -0500 Subject: [PATCH] feat(dashboard): add code editor for raw JSON settings editing - Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73 --- src/web-server/routes.ts | 187 ++++++++++++++++++++++++++ ui/bun.lock | 8 ++ ui/package.json | 2 + ui/src/components/code-editor.tsx | 164 ++++++++++++++++++++++ ui/src/components/settings-dialog.tsx | 103 ++++++++++++-- 5 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/code-editor.tsx diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 25007940..956cae88 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => { keys: Object.keys(secrets), // Only key names, not values }); }); + +// ==================== Generic File API (Issue #73) ==================== + +/** + * Security: Validate file path is within allowed directories + * - ~/.ccs/ directory: read/write allowed + * - ~/.claude/settings.json: read-only + */ +function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } { + const expandedPath = expandPath(filePath); + const normalizedPath = path.normalize(expandedPath); + const ccsDir = getCcsDir(); + const claudeSettingsPath = expandPath('~/.claude/settings.json'); + + // Check if path is within ~/.ccs/ + if (normalizedPath.startsWith(ccsDir)) { + // Block access to sensitive subdirectories + const relativePath = normalizedPath.slice(ccsDir.length); + if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + return { valid: true, readonly: false }; + } + + // Allow read-only access to ~/.claude/settings.json + if (normalizedPath === claudeSettingsPath) { + return { valid: true, readonly: true }; + } + + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; +} + +/** + * GET /api/file - Read a file with path validation + * Query params: path (required) + * Returns: { content: string, mtime: number, readonly: boolean, path: string } + */ +apiRoutes.get('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + const expandedPath = expandPath(filePath); + + if (!fs.existsSync(expandedPath)) { + res.status(404).json({ error: 'File not found' }); + return; + } + + try { + const stat = fs.statSync(expandedPath); + const content = fs.readFileSync(expandedPath, 'utf8'); + + res.json({ + content, + mtime: stat.mtime.getTime(), + readonly: validation.readonly, + path: expandedPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/file - Write a file with conflict detection and backup + * Query params: path (required) + * Body: { content: string, expectedMtime?: number } + * Returns: { success: true, mtime: number, backupPath?: string } + */ +apiRoutes.put('/file', (req: Request, res: Response): void => { + const filePath = req.query.path as string; + const { content, expectedMtime } = req.body; + + if (!filePath) { + res.status(400).json({ error: 'Missing required query parameter: path' }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const validation = validateFilePath(filePath); + if (!validation.valid) { + res.status(403).json({ error: validation.error }); + return; + } + + if (validation.readonly) { + res.status(403).json({ error: 'File is read-only' }); + return; + } + + const expandedPath = expandPath(filePath); + const ccsDir = getCcsDir(); + + // Conflict detection (if file exists and expectedMtime provided) + if (fs.existsSync(expandedPath) && expectedMtime !== undefined) { + const stat = fs.statSync(expandedPath); + if (stat.mtime.getTime() !== expectedMtime) { + res.status(409).json({ + error: 'File modified externally', + currentMtime: stat.mtime.getTime(), + }); + return; + } + } + + try { + // Create backup if file exists + let backupPath: string | undefined; + if (fs.existsSync(expandedPath)) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const filename = path.basename(expandedPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`); + fs.copyFileSync(expandedPath, backupPath); + } + + // Ensure parent directory exists + const parentDir = path.dirname(expandedPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + + // Write atomically + const tempPath = expandedPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, expandedPath); + + const newStat = fs.statSync(expandedPath); + res.json({ + success: true, + mtime: newStat.mtime.getTime(), + backupPath, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/files - List editable files in ~/.ccs/ + * Returns: { files: Array<{ name: string, path: string, mtime: number }> } + */ +apiRoutes.get('/files', (_req: Request, res: Response): void => { + const ccsDir = getCcsDir(); + + if (!fs.existsSync(ccsDir)) { + res.json({ files: [] }); + return; + } + + try { + const entries = fs.readdirSync(ccsDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => { + const filePath = path.join(ccsDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + path: `~/.ccs/${entry.name}`, + mtime: stat.mtime.getTime(), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ files }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/ui/bun.lock b/ui/bun.lock index 14e0041b..f2f7ca2e 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -23,11 +23,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -387,6 +389,8 @@ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -677,6 +681,8 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -701,6 +707,8 @@ "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + "react-simple-code-editor": ["react-simple-code-editor@0.14.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], diff --git a/ui/package.json b/ui/package.json index acd632e1..7e02cbdb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,11 +34,13 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.556.0", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "react-simple-code-editor": "^0.14.1", "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/ui/src/components/code-editor.tsx b/ui/src/components/code-editor.tsx new file mode 100644 index 00000000..68a8bc8c --- /dev/null +++ b/ui/src/components/code-editor.tsx @@ -0,0 +1,164 @@ +/** + * Code Editor Component + * Lightweight JSON editor with syntax highlighting, line numbers, and validation + * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB) + */ + +import { useState, useCallback, useMemo } 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'; + +interface CodeEditorProps { + value: string; + onChange: (value: string) => void; + language?: 'json' | 'yaml'; + readonly?: boolean; + className?: string; + minHeight?: string; +} + +interface ValidationResult { + valid: boolean; + error?: string; + line?: number; +} + +/** + * Validate JSON and extract error location + */ +function validateJson(code: string): ValidationResult { + if (!code.trim()) { + return { valid: true }; + } + + try { + JSON.parse(code); + return { valid: true }; + } catch (e) { + const error = e as SyntaxError; + const message = error.message; + + // Try to extract line number from error message + // Format: "... at position X" or "... at line Y column Z" + const posMatch = message.match(/position (\d+)/); + if (posMatch) { + const pos = parseInt(posMatch[1], 10); + const lines = code.substring(0, pos).split('\n'); + return { + valid: false, + error: message, + line: lines.length, + }; + } + + return { + valid: false, + error: message, + }; + } +} + +export function CodeEditor({ + value, + onChange, + language = 'json', + readonly = false, + className, + minHeight = '300px', +}: CodeEditorProps) { + const { isDark } = useTheme(); + const [isFocused, setIsFocused] = useState(false); + + // Validate on every change for JSON + const validation = useMemo(() => { + if (language === 'json') { + return validateJson(value); + } + return { valid: true }; + }, [value, language]); + + // Highlight function using prism-react-renderer + const highlightCode = useCallback( + (code: string) => ( + + {({ tokens, getLineProps, getTokenProps }) => ( + <> + {tokens.map((line, i) => ( +
+ + {i + 1} + + + {line.map((token, key) => ( + + ))} + +
+ ))} + + )} +
+ ), + [isDark, language, validation.line] + ); + + return ( +
+ {/* Editor container */} +
+ {} : onChange} + highlight={highlightCode} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> +
+ + {/* Validation status */} +
+ {validation.valid ? ( + + + Valid {language.toUpperCase()} + + ) : ( + + + {validation.error} + {validation.line && ` (line ${validation.line})`} + + )} + {readonly && (Read-only)} +
+
+ ); +} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx index d4b0f3b9..624ce907 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/settings-dialog.tsx @@ -1,10 +1,10 @@ /** * Settings Dialog Component * Reusable dialog for editing profile environment variables - * Features: masked inputs for sensitive keys, conflict detection, save/cancel + * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor */ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Dialog, @@ -18,9 +18,14 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { MaskedInput } from '@/components/ui/masked-input'; import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, X, Loader2 } from 'lucide-react'; +import { Save, X, Loader2, Code2 } from 'lucide-react'; import { toast } from 'sonner'; +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + interface Settings { env?: Record; } @@ -55,6 +60,8 @@ function SettingsDialogContent({ }) { const [localEdits, setLocalEdits] = useState>({}); const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); const queryClient = useQueryClient(); // Fetch settings for selected profile @@ -63,6 +70,23 @@ function SettingsDialogContent({ queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), }); + // Derive raw JSON content: use edits if available, otherwise serialize from data + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + // Update raw JSON when user edits + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + // Derive current settings by merging original data with local edits const currentSettings = useMemo((): Settings | undefined => { const settings = data?.settings; @@ -76,16 +100,39 @@ function SettingsDialogContent({ }; }, [data?.settings, localEdits]); + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + // Save mutation const saveMutation = useMutation({ mutationFn: async () => { - const settingsToSave: Settings = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; + let settingsToSave: Settings; + + // Determine what to save based on active tab + if (activeTab === 'raw') { + // Parse raw JSON content + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + // Use form-based edits + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } const res = await fetch(`/api/settings/${profileName}`, { method: 'PUT', @@ -174,7 +221,11 @@ function SettingsDialogContent({ ) : (
- + Environment + + + Raw JSON + + + + + Loading editor... +
+ } + > + + + + @@ -252,7 +328,10 @@ function SettingsDialogContent({ -