mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
fix: validate required config fields before save (#225)
* fix(ui): validate BASE_URL and AUTH_TOKEN before save - Add validation in use-provider-editor to block save when required env vars (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) are missing - Show warning banner in raw editor when required fields are absent - Always apply default preset on account add (not just first account) This implements defense-in-depth: validation UI prevents confusion, save-time validation prevents broken configs, auto-preset ensures new providers start with working configuration. Fixes #224 * fix: add validation to remaining settings routes and UI hooks Backend: - settings-routes.ts: Validate ANTHROPIC_BASE_URL and AUTH_TOKEN on PUT - profile-routes.ts: Validate baseUrl/apiKey not empty on update - variant-routes.ts: Require model field for variant creation Frontend: - use-profile-editor.ts: Add required field validation + error handling - use-copilot-config-form.ts: Add required field validation + error handling Both hooks now expose missingRequiredFields for UI warnings. Ref #224 * feat(ux): auto-fill missing BASE_URL/AUTH_TOKEN from defaults at runtime Instead of blocking saves when required env vars are missing, the system now: - Runtime fills missing values from getClaudeEnvVars() defaults - Backend returns warning in response (not 400 error) - UI shows informational toast instead of blocking error - Yellow warning banner still shown for user awareness This provides better UX for CLIProxy users - saves always work, and missing fields automatically use sensible defaults (local proxy URL and global API key). * fix(api): validate settings object before write to prevent undefined file content Add validation to PUT /api/settings/:profile to return 400 if settings object is undefined/null. Previously JSON.stringify(undefined) would write literal "undefined" string to settings file. * fix: add port validation and pre-save warning UI - Add validatePort() helper (1-65535 range) to config-generator.ts - Add missing field warning banner to copilot/config-form UI - Add missing field warning banner to profiles/editor UI - Wire missingRequiredFields prop through components
This commit is contained in:
1 parent
68835aee8e
commit
388428bba9
15 files changed
+266
-25
No files matched your search
@@ -23,6 +23,42 @@ interface ProviderSettings {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate port is a valid positive integer (1-65535).
|
||||||
|
* Returns default port if invalid.
|
||||||
|
*/
|
||||||
|
function validatePort(port: number): number {
|
||||||
|
if (!Number.isFinite(port) || port < 1 || port > 65535 || !Number.isInteger(port)) {
|
||||||
|
return CLIPROXY_DEFAULT_PORT;
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure required CLIProxy env vars are present.
|
||||||
|
* Falls back to bundled defaults if missing from user settings.
|
||||||
|
* This prevents 404 errors when users forget to set BASE_URL/AUTH_TOKEN.
|
||||||
|
*/
|
||||||
|
function ensureRequiredEnvVars(
|
||||||
|
envVars: NodeJS.ProcessEnv,
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
port: number
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
const validPort = validatePort(port);
|
||||||
|
const result = { ...envVars };
|
||||||
|
const defaults = getClaudeEnvVars(provider, validPort);
|
||||||
|
|
||||||
|
// Fill in missing required vars from defaults
|
||||||
|
if (!result.ANTHROPIC_BASE_URL?.trim()) {
|
||||||
|
result.ANTHROPIC_BASE_URL = defaults.ANTHROPIC_BASE_URL;
|
||||||
|
}
|
||||||
|
if (!result.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
||||||
|
result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Default CLIProxy port */
|
/** Default CLIProxy port */
|
||||||
export const CLIPROXY_DEFAULT_PORT = 8317;
|
export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||||
|
|
||||||
@@ -563,6 +599,8 @@ export function getEffectiveEnvVars(
|
|||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
// Custom variant settings found - merge with global env
|
// Custom variant settings found - merge with global env
|
||||||
envVars = { ...globalEnv, ...settings.env };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
|
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
||||||
// Apply remote rewrite if configured
|
// Apply remote rewrite if configured
|
||||||
if (remoteRewriteConfig) {
|
if (remoteRewriteConfig) {
|
||||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||||
@@ -590,6 +628,8 @@ export function getEffectiveEnvVars(
|
|||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
// User override found - merge with global env
|
// User override found - merge with global env
|
||||||
envVars = { ...globalEnv, ...settings.env };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
|
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
||||||
// Apply remote rewrite if configured
|
// Apply remote rewrite if configured
|
||||||
if (remoteRewriteConfig) {
|
if (remoteRewriteConfig) {
|
||||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ router.put('/:name', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields if provided (prevent setting to empty)
|
||||||
|
if (baseUrl !== undefined && !baseUrl.trim()) {
|
||||||
|
res.status(400).json({ error: 'baseUrl cannot be empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (apiKey !== undefined && !apiKey.trim()) {
|
||||||
|
res.status(400).json({ error: 'apiKey cannot be empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
|
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
|
||||||
res.json({ name, updated: true });
|
res.json({ name, updated: true });
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Required env vars for CLIProxy providers to function */
|
||||||
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
|
||||||
|
/** Check if settings have required fields (returns missing list for warnings) */
|
||||||
|
function checkRequiredEnvVars(settings: Settings): string[] {
|
||||||
|
const env = settings?.env || {};
|
||||||
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/settings/:profile - Update settings with conflict detection and backup
|
* PUT /api/settings/:profile - Update settings with conflict detection and backup
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +129,17 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
|||||||
try {
|
try {
|
||||||
const { profile } = req.params;
|
const { profile } = req.params;
|
||||||
const { settings, expectedMtime } = req.body;
|
const { settings, expectedMtime } = req.body;
|
||||||
|
|
||||||
|
// Validate settings object exists
|
||||||
|
if (!settings || typeof settings !== 'object') {
|
||||||
|
res.status(400).json({ error: 'settings object is required in request body' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ccsDir = getCcsDir();
|
const ccsDir = getCcsDir();
|
||||||
|
|
||||||
|
// Check for missing required fields (warning, not blocking - runtime fills defaults)
|
||||||
|
const missingFields = checkRequiredEnvVars(settings);
|
||||||
const settingsPath = resolveSettingsPath(profile);
|
const settingsPath = resolveSettingsPath(profile);
|
||||||
|
|
||||||
const fileExists = fs.existsSync(settingsPath);
|
const fileExists = fs.existsSync(settingsPath);
|
||||||
@@ -165,6 +184,11 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
|||||||
mtime: newStat.mtime.getTime(),
|
mtime: newStat.mtime.getTime(),
|
||||||
backupPath,
|
backupPath,
|
||||||
created: !fileExists,
|
created: !fileExists,
|
||||||
|
// Include warning if fields missing (runtime will use defaults)
|
||||||
|
...(missingFields.length > 0 && {
|
||||||
|
warning: `Missing fields will use defaults: ${missingFields.join(', ')}`,
|
||||||
|
missingFields,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ router.post('/', (req: Request, res: Response): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require model for variant creation (prevents empty model causing issues)
|
||||||
|
if (!model || !model.trim()) {
|
||||||
|
res.status(400).json({ error: 'Missing required field: model' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use variant-service for proper port allocation
|
// Use variant-service for proper port allocation
|
||||||
const result = createVariant(name, provider as CLIProxyProvider, model || '', account);
|
const result = createVariant(name, provider as CLIProxyProvider, model, account);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
res.status(409).json({ error: result.error });
|
res.status(409).json({ error: result.error });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Add Account Dialog Component
|
* Add Account Dialog Component
|
||||||
* Triggers OAuth flow server-side to add another account to a provider
|
* Triggers OAuth flow server-side to add another account to a provider
|
||||||
* Applies default preset when adding first account
|
* Always applies default preset to ensure required env vars are set
|
||||||
* For Kiro: Also shows "Import from IDE" option as fallback
|
* For Kiro: Also shows "Import from IDE" option as fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ interface AddAccountDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** Whether this is the first account being added (triggers preset application) */
|
/** Whether this is the first account being added (shows different toast message) */
|
||||||
isFirstAccount?: boolean;
|
isFirstAccount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +49,17 @@ export function AddAccountDialog({
|
|||||||
{ provider, nickname: nickname.trim() || undefined },
|
{ provider, nickname: nickname.trim() || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
// Apply default preset if this is the first account
|
// Always apply default preset to ensure BASE_URL and AUTH_TOKEN are set
|
||||||
if (isFirstAccount) {
|
const result = await applyDefaultPreset(provider);
|
||||||
const result = await applyDefaultPreset(provider);
|
if (result.success && result.presetName) {
|
||||||
if (result.success && result.presetName) {
|
if (isFirstAccount) {
|
||||||
toast.success(`Applied "${result.presetName}" preset`);
|
toast.success(`Applied "${result.presetName}" preset`);
|
||||||
} else if (!result.success) {
|
|
||||||
toast.warning('Account added, but failed to apply default preset');
|
|
||||||
}
|
}
|
||||||
|
// Silent success for non-first accounts - preset ensures required vars exist
|
||||||
|
} else if (!result.success) {
|
||||||
|
toast.warning(
|
||||||
|
'Account added, but failed to apply default preset. You may need to configure settings manually.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setNickname('');
|
setNickname('');
|
||||||
onClose();
|
onClose();
|
||||||
@@ -68,12 +71,10 @@ export function AddAccountDialog({
|
|||||||
const handleKiroImport = () => {
|
const handleKiroImport = () => {
|
||||||
kiroImportMutation.mutate(undefined, {
|
kiroImportMutation.mutate(undefined, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
// Apply default preset if this is the first account
|
// Always apply default preset for Kiro as well
|
||||||
if (isFirstAccount) {
|
const result = await applyDefaultPreset('kiro');
|
||||||
const result = await applyDefaultPreset('kiro');
|
if (result.success && result.presetName && isFirstAccount) {
|
||||||
if (result.success && result.presetName) {
|
toast.success(`Applied "${result.presetName}" preset`);
|
||||||
toast.success(`Applied "${result.presetName}" preset`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setNickname('');
|
setNickname('');
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function ProviderEditor({
|
|||||||
saveMutation,
|
saveMutation,
|
||||||
conflictDialog,
|
conflictDialog,
|
||||||
handleConflictResolve,
|
handleConflictResolve,
|
||||||
|
missingRequiredFields,
|
||||||
} = useProviderEditor(provider);
|
} = useProviderEditor(provider);
|
||||||
|
|
||||||
const accounts = authStatus.accounts || [];
|
const accounts = authStatus.accounts || [];
|
||||||
@@ -216,6 +217,7 @@ export function ProviderEditor({
|
|||||||
rawJsonEdits={rawJsonEdits}
|
rawJsonEdits={rawJsonEdits}
|
||||||
onRawJsonChange={handleRawJsonChange}
|
onRawJsonChange={handleRawJsonChange}
|
||||||
profileEnv={data?.settings?.env}
|
profileEnv={data?.settings?.env}
|
||||||
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { Loader2, X } from 'lucide-react';
|
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||||
import type { RawEditorSectionProps } from './types';
|
import type { RawEditorSectionProps } from './types';
|
||||||
|
|
||||||
@@ -19,7 +19,10 @@ export function RawEditorSection({
|
|||||||
rawJsonEdits,
|
rawJsonEdits,
|
||||||
onRawJsonChange,
|
onRawJsonChange,
|
||||||
profileEnv,
|
profileEnv,
|
||||||
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
|
const hasMissingFields = missingRequiredFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -36,6 +39,23 @@ export function RawEditorSection({
|
|||||||
Invalid JSON syntax
|
Invalid JSON syntax
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isRawJsonValid && hasMissingFields && (
|
||||||
|
<div className="mb-2 px-3 py-2 bg-warning/10 text-warning-foreground text-sm rounded-md flex items-start gap-2 mx-6 mt-4 shrink-0 border border-warning/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
Missing required fields:
|
||||||
|
</span>{' '}
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||||
|
{missingRequiredFields.join(', ')}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Apply a preset from Model Config tab or add these fields manually. Without them,
|
||||||
|
Claude Code will fail with 404 errors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface RawEditorSectionProps {
|
|||||||
rawJsonEdits: string | null;
|
rawJsonEdits: string | null;
|
||||||
onRawJsonChange: (value: string) => void;
|
onRawJsonChange: (value: string) => void;
|
||||||
profileEnv?: Record<string, string>;
|
profileEnv?: Record<string, string>;
|
||||||
|
/** List of required env vars that are missing (empty if all present) */
|
||||||
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelConfigSectionProps {
|
export interface ModelConfigSectionProps {
|
||||||
@@ -113,4 +115,6 @@ export interface UseProviderEditorReturn {
|
|||||||
conflictDialog: boolean;
|
conflictDialog: boolean;
|
||||||
setConflictDialog: (open: boolean) => void;
|
setConflictDialog: (open: boolean) => void;
|
||||||
handleConflictResolve: (overwrite: boolean) => Promise<void>;
|
handleConflictResolve: (overwrite: boolean) => Promise<void>;
|
||||||
|
/** List of required env vars that are missing (empty if all present) */
|
||||||
|
missingRequiredFields: string[];
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
||||||
|
|
||||||
|
/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */
|
||||||
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
|
||||||
|
/** Check settings for missing fields (for UI warnings) */
|
||||||
|
function checkMissingFields(settings: { env?: Record<string, string> }): string[] {
|
||||||
|
const env = settings?.env || {};
|
||||||
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
||||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||||
const [conflictDialog, setConflictDialog] = useState(false);
|
const [conflictDialog, setConflictDialog] = useState(false);
|
||||||
@@ -95,10 +104,14 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
|||||||
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
||||||
}, [rawJsonEdits, settings]);
|
}, [rawJsonEdits, settings]);
|
||||||
|
|
||||||
// Save mutation
|
// Validation state for missing required fields (informational warning)
|
||||||
|
const missingFields = useMemo(() => checkMissingFields(currentSettings), [currentSettings]);
|
||||||
|
|
||||||
|
// Save mutation (no blocking validation - runtime uses defaults)
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const settingsToSave = JSON.parse(rawJsonContent);
|
const settingsToSave = JSON.parse(rawJsonContent);
|
||||||
|
|
||||||
const res = await fetch(`/api/settings/${provider}`, {
|
const res = await fetch(`/api/settings/${provider}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -112,10 +125,17 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
|||||||
if (!res.ok) throw new Error('Failed to save');
|
if (!res.ok) throw new Error('Failed to save');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (responseData) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
|
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
|
||||||
setRawJsonEdits(null);
|
setRawJsonEdits(null);
|
||||||
toast.success('Settings saved');
|
// Show warning if fields missing (runtime uses defaults)
|
||||||
|
if (responseData?.warning) {
|
||||||
|
toast.success('Settings saved', {
|
||||||
|
description: responseData.warning,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Settings saved');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
if (error.message === 'CONFLICT') {
|
if (error.message === 'CONFLICT') {
|
||||||
@@ -159,5 +179,7 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
|||||||
conflictDialog,
|
conflictDialog,
|
||||||
setConflictDialog,
|
setConflictDialog,
|
||||||
handleConflictResolve,
|
handleConflictResolve,
|
||||||
|
// Validation (informational)
|
||||||
|
missingRequiredFields: missingFields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,7 @@ export function CopilotConfigForm() {
|
|||||||
handleSave,
|
handleSave,
|
||||||
handleConflictResolve,
|
handleConflictResolve,
|
||||||
refetchRawSettings,
|
refetchRawSettings,
|
||||||
|
missingRequiredFields,
|
||||||
} = useCopilotConfigForm();
|
} = useCopilotConfigForm();
|
||||||
|
|
||||||
if (configLoading || rawSettingsLoading) {
|
if (configLoading || rawSettingsLoading) {
|
||||||
@@ -146,6 +147,7 @@ export function CopilotConfigForm() {
|
|||||||
rawJsonEdits={rawJsonEdits}
|
rawJsonEdits={rawJsonEdits}
|
||||||
rawSettingsEnv={rawSettings?.settings?.env as Record<string, string> | undefined}
|
rawSettingsEnv={rawSettings?.settings?.env as Record<string, string> | undefined}
|
||||||
onChange={handleRawJsonChange}
|
onChange={handleRawJsonChange}
|
||||||
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { Loader2, X } from 'lucide-react';
|
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||||
|
|
||||||
// Lazy load CodeEditor
|
// Lazy load CodeEditor
|
||||||
@@ -18,6 +18,7 @@ interface RawEditorSectionProps {
|
|||||||
rawJsonEdits: string | null;
|
rawJsonEdits: string | null;
|
||||||
rawSettingsEnv: Record<string, string> | undefined;
|
rawSettingsEnv: Record<string, string> | undefined;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawEditorSection({
|
export function RawEditorSection({
|
||||||
@@ -26,7 +27,10 @@ export function RawEditorSection({
|
|||||||
rawJsonEdits,
|
rawJsonEdits,
|
||||||
rawSettingsEnv,
|
rawSettingsEnv,
|
||||||
onChange,
|
onChange,
|
||||||
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
|
const hasMissingFields = missingRequiredFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -43,6 +47,22 @@ export function RawEditorSection({
|
|||||||
Invalid JSON syntax
|
Invalid JSON syntax
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isRawJsonValid && hasMissingFields && (
|
||||||
|
<div className="mb-2 px-3 py-2 bg-warning/10 text-warning-foreground text-sm rounded-md flex items-start gap-2 mx-6 mt-4 shrink-0 border border-warning/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
Missing required fields:
|
||||||
|
</span>{' '}
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||||
|
{missingRequiredFields.join(', ')}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
These fields will use default values at runtime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { useCopilot } from '@/hooks/use-copilot';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { ModelPreset } from './types';
|
import type { ModelPreset } from './types';
|
||||||
|
|
||||||
|
/** Required env vars for Copilot settings (informational only - runtime fills defaults) */
|
||||||
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
|
||||||
|
/** Check settings for missing fields (for UI warnings) */
|
||||||
|
function checkMissingFields(settings: { env?: Record<string, string> } | undefined): string[] {
|
||||||
|
const env = settings?.env || {};
|
||||||
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
export function useCopilotConfigForm() {
|
export function useCopilotConfigForm() {
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
@@ -101,6 +110,23 @@ export function useCopilotConfigForm() {
|
|||||||
return hasLocalChanges || hasJsonChanges;
|
return hasLocalChanges || hasJsonChanges;
|
||||||
}, [localOverrides, rawJsonEdits, rawSettings]);
|
}, [localOverrides, rawJsonEdits, rawSettings]);
|
||||||
|
|
||||||
|
// Validation state for missing required fields (informational warning)
|
||||||
|
const currentSettingsForValidation = useMemo(() => {
|
||||||
|
if (rawJsonEdits !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawJsonEdits);
|
||||||
|
} catch {
|
||||||
|
return rawSettings?.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawSettings?.settings;
|
||||||
|
}, [rawJsonEdits, rawSettings?.settings]);
|
||||||
|
|
||||||
|
const missingFields = useMemo(
|
||||||
|
() => checkMissingFields(currentSettingsForValidation),
|
||||||
|
[currentSettingsForValidation]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
// Save config changes
|
// Save config changes
|
||||||
@@ -119,19 +145,31 @@ export function useCopilotConfigForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save raw JSON changes
|
// Save raw JSON changes (no blocking validation - runtime uses defaults)
|
||||||
if (rawJsonEdits !== null && isRawJsonValid) {
|
if (rawJsonEdits !== null && isRawJsonValid) {
|
||||||
const settingsToSave = JSON.parse(rawJsonContent);
|
const settingsToSave = JSON.parse(rawJsonContent);
|
||||||
|
const missing = checkMissingFields(settingsToSave);
|
||||||
|
|
||||||
await saveRawSettingsAsync({
|
await saveRawSettingsAsync({
|
||||||
settings: settingsToSave,
|
settings: settingsToSave,
|
||||||
expectedMtime: rawSettings?.mtime,
|
expectedMtime: rawSettings?.mtime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show warning if fields missing
|
||||||
|
if (missing.length > 0) {
|
||||||
|
toast.success('Copilot configuration saved', {
|
||||||
|
description: `Missing fields will use defaults: ${missing.join(', ')}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Copilot configuration saved');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success('Copilot configuration saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear local state
|
// Clear local state
|
||||||
setLocalOverrides({});
|
setLocalOverrides({});
|
||||||
setRawJsonEdits(null);
|
setRawJsonEdits(null);
|
||||||
toast.success('Copilot configuration saved');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as Error).message === 'CONFLICT') {
|
if ((error as Error).message === 'CONFLICT') {
|
||||||
setConflictDialog(true);
|
setConflictDialog(true);
|
||||||
@@ -189,5 +227,8 @@ export function useCopilotConfigForm() {
|
|||||||
handleSave,
|
handleSave,
|
||||||
handleConflictResolve,
|
handleConflictResolve,
|
||||||
refetchRawSettings,
|
refetchRawSettings,
|
||||||
|
|
||||||
|
/** List of required env vars that are missing (empty if all present) - informational */
|
||||||
|
missingRequiredFields: missingFields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,13 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
|
|||||||
return Object.keys(localEdits).length > 0;
|
return Object.keys(localEdits).length > 0;
|
||||||
}, [rawJsonEdits, localEdits, settings]);
|
}, [rawJsonEdits, localEdits, settings]);
|
||||||
|
|
||||||
|
// Check for missing required fields (informational warning)
|
||||||
|
const missingRequiredFields = useMemo(() => {
|
||||||
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
const env = currentSettings?.env || {};
|
||||||
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||||
|
}, [currentSettings]);
|
||||||
|
|
||||||
// Notify parent of hasChanges state
|
// Notify parent of hasChanges state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onHasChangesUpdate?.(computedHasChanges);
|
onHasChangesUpdate?.(computedHasChanges);
|
||||||
@@ -208,6 +215,7 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro
|
|||||||
rawJsonEdits={rawJsonEdits}
|
rawJsonEdits={rawJsonEdits}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
onChange={handleRawJsonChange}
|
onChange={handleRawJsonChange}
|
||||||
|
missingRequiredFields={missingRequiredFields}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { Loader2, X } from 'lucide-react';
|
import { Loader2, X, AlertTriangle } from 'lucide-react';
|
||||||
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||||
import type { Settings } from './types';
|
import type { Settings } from './types';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ interface RawEditorSectionProps {
|
|||||||
rawJsonEdits: string | null;
|
rawJsonEdits: string | null;
|
||||||
settings: Settings | undefined;
|
settings: Settings | undefined;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
missingRequiredFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawEditorSection({
|
export function RawEditorSection({
|
||||||
@@ -27,7 +28,10 @@ export function RawEditorSection({
|
|||||||
rawJsonEdits,
|
rawJsonEdits,
|
||||||
settings,
|
settings,
|
||||||
onChange,
|
onChange,
|
||||||
|
missingRequiredFields = [],
|
||||||
}: RawEditorSectionProps) {
|
}: RawEditorSectionProps) {
|
||||||
|
const hasMissingFields = missingRequiredFields.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -44,6 +48,22 @@ export function RawEditorSection({
|
|||||||
Invalid JSON syntax
|
Invalid JSON syntax
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isRawJsonValid && hasMissingFields && (
|
||||||
|
<div className="mb-2 px-3 py-2 bg-warning/10 text-warning-foreground text-sm rounded-md flex items-start gap-2 mx-6 mt-4 shrink-0 border border-warning/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
Missing required fields:
|
||||||
|
</span>{' '}
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||||
|
{missingRequiredFields.join(', ')}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
These fields will use default values at runtime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Settings, SettingsResponse } from './types';
|
import type { Settings, SettingsResponse } from './types';
|
||||||
|
|
||||||
|
/** Required env vars for profiles to function (informational only - runtime fills defaults) */
|
||||||
|
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
|
||||||
|
|
||||||
|
/** Check settings for missing fields (for UI warnings) */
|
||||||
|
function checkMissingFields(settings: Settings | undefined): string[] {
|
||||||
|
const env = settings?.env || {};
|
||||||
|
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
interface UseProfileEditorOptions {
|
interface UseProfileEditorOptions {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
localEdits: Record<string, string>;
|
localEdits: Record<string, string>;
|
||||||
@@ -78,6 +87,9 @@ export function useProfileEditor({
|
|||||||
return Object.keys(localEdits).length > 0;
|
return Object.keys(localEdits).length > 0;
|
||||||
}, [rawJsonEdits, localEdits, query.data?.settings]);
|
}, [rawJsonEdits, localEdits, query.data?.settings]);
|
||||||
|
|
||||||
|
// Validation state for missing required fields (informational warning)
|
||||||
|
const missingFields = useMemo(() => checkMissingFields(currentSettings), [currentSettings]);
|
||||||
|
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -114,11 +126,18 @@ export function useProfileEditor({
|
|||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
onSuccess();
|
onSuccess();
|
||||||
toast.success('Settings saved');
|
// Show warning if fields missing (runtime uses defaults)
|
||||||
|
if (data?.warning) {
|
||||||
|
toast.success('Settings saved', {
|
||||||
|
description: data.warning,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Settings saved');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
if (error.message === 'CONFLICT') {
|
if (error.message === 'CONFLICT') {
|
||||||
@@ -135,5 +154,7 @@ export function useProfileEditor({
|
|||||||
isRawJsonValid,
|
isRawJsonValid,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
saveMutation,
|
saveMutation,
|
||||||
|
/** List of required env vars that are missing (empty if all present) - informational */
|
||||||
|
missingRequiredFields: missingFields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user