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
This commit is contained in:
kaitranntt
2025-12-10 21:09:38 -05:00
parent f83051be40
commit 2b1a3b4879
5 changed files with 452 additions and 12 deletions
+187
View File
@@ -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 });
}
});
+8
View File
@@ -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=="],
+2
View File
@@ -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",
+164
View File
@@ -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) => (
<Highlight theme={isDark ? themes.nightOwl : themes.github} code={code} language={language}>
{({ tokens, getLineProps, getTokenProps }) => (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn('table-row', validation.line === i + 1 && 'bg-destructive/20')}
>
<span className="table-cell pr-4 text-right text-muted-foreground select-none opacity-50 text-xs w-8">
{i + 1}
</span>
<span className="table-cell">
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</span>
</div>
))}
</>
)}
</Highlight>
),
[isDark, language, validation.line]
);
return (
<div className={cn('flex flex-col', className)}>
{/* Editor container */}
<div
className={cn(
'relative rounded-md border overflow-hidden',
'bg-muted/30',
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
readonly && 'opacity-70 cursor-not-allowed',
!validation.valid && 'border-destructive'
)}
style={{ minHeight }}
>
<Editor
value={value}
onValueChange={readonly ? () => {} : 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,
}}
/>
</div>
{/* Validation status */}
<div className="flex items-center gap-2 mt-2 text-xs">
{validation.valid ? (
<span className="flex items-center gap-1 text-muted-foreground">
<CheckCircle2 className="w-3 h-3 text-green-500" />
Valid {language.toUpperCase()}
</span>
) : (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
{validation.error}
{validation.line && ` (line ${validation.line})`}
</span>
)}
{readonly && <span className="ml-auto text-muted-foreground">(Read-only)</span>}
</div>
</div>
);
}
+91 -12
View File
@@ -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<string, string>;
}
@@ -55,6 +60,8 @@ function SettingsDialogContent({
}) {
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(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({
</div>
) : (
<div className="flex flex-col h-[60vh]">
<Tabs defaultValue="env" className="flex-1 flex flex-col overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start border-b rounded-none p-0 h-auto bg-transparent">
<TabsTrigger
value="env"
@@ -182,6 +233,13 @@ function SettingsDialogContent({
>
Environment
</TabsTrigger>
<TabsTrigger
value="raw"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
>
<Code2 className="w-4 h-4 mr-1" />
Raw JSON
</TabsTrigger>
<TabsTrigger
value="general"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
@@ -222,6 +280,24 @@ function SettingsDialogContent({
</ScrollArea>
</TabsContent>
<TabsContent value="raw" className="flex-1 overflow-hidden p-4 pt-4 m-0">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
</div>
}
>
<CodeEditor
value={rawJsonContent}
onChange={handleRawJsonChange}
language="json"
minHeight="calc(60vh - 120px)"
/>
</Suspense>
</TabsContent>
<TabsContent value="general" className="p-4 m-0">
<Card>
<CardHeader>
@@ -252,7 +328,10 @@ function SettingsDialogContent({
<Button type="button" variant="outline" onClick={onClose}>
<X className="w-4 h-4 mr-2" /> Cancel
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || (activeTab === 'raw' && !isRawJsonValid)}
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...