diff --git a/ui/src/components/accounts-table.tsx b/ui/src/components/accounts-table.tsx index b62ab449..434c6cbe 100644 --- a/ui/src/components/accounts-table.tsx +++ b/ui/src/components/accounts-table.tsx @@ -104,13 +104,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {headerGroup.headers.map((header) => { + const widthClass = + { + name: 'w-[200px]', + type: 'w-[100px]', + created: 'w-[150px]', + last_used: 'w-[150px]', + actions: 'w-[120px]', + }[header.id] || 'w-auto'; + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} ))} diff --git a/ui/src/components/profiles-table.tsx b/ui/src/components/profiles-table.tsx index b69b702a..91f50048 100644 --- a/ui/src/components/profiles-table.tsx +++ b/ui/src/components/profiles-table.tsx @@ -17,24 +17,27 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Trash2, Edit } from 'lucide-react'; +import { MoreHorizontal, Trash2, Edit, Settings } from 'lucide-react'; import { useDeleteProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; interface ProfilesTableProps { data: Profile[]; onEdit?: (profile: Profile) => void; + onEditSettings?: (profile: Profile) => void; } -export function ProfilesTable({ data, onEdit }: ProfilesTableProps) { +export function ProfilesTable({ data, onEdit, onEditSettings }: ProfilesTableProps) { const deleteMutation = useDeleteProfile(); const columns: ColumnDef[] = [ { accessorKey: 'name', header: 'Name', + size: 200, }, { accessorKey: 'settingsPath', @@ -43,6 +46,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) { { accessorKey: 'configured', header: 'Status', + size: 100, cell: ({ row }) => ( {row.original.configured ? '[OK]' : '[!]'} @@ -52,6 +56,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) { { id: 'actions', header: 'Actions', + size: 100, cell: ({ row }) => ( @@ -63,9 +68,16 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) { {onEdit && ( onEdit(row.original)}> - Edit + Edit Profile )} + {onEditSettings && ( + onEditSettings(row.original)}> + + Edit Settings + + )} + deleteMutation.mutate(row.original.name)} @@ -100,13 +112,30 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) { {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {headerGroup.headers.map((header) => { + const isAction = header.id === 'actions'; + const isStatus = header.id === 'configured'; + const isName = header.id === 'name'; + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} ))} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx new file mode 100644 index 00000000..4142850c --- /dev/null +++ b/ui/src/components/settings-dialog.tsx @@ -0,0 +1,246 @@ +/** + * Settings Dialog Component + * Reusable dialog for editing profile environment variables + * Features: masked inputs for sensitive keys, conflict detection, save/cancel + */ + +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +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 { toast } from 'sonner'; + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface SettingsDialogProps { + open: boolean; + onClose: () => void; + profileName: string | null; +} + +/** + * Inner component that manages local edits state + * Gets unmounted/remounted via key prop when dialog closes/opens + */ +function SettingsDialogContent({ + profileName, + onClose, +}: { + profileName: string; + onClose: () => void; +}) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + const settings = data?.settings; + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [data?.settings, localEdits]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + const settingsToSave: Settings = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + toast.success('Settings saved'); + onClose(); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + // Refetch to get new mtime, then save + await refetch(); + saveMutation.mutate(); + } else { + // Discard local changes and close + onClose(); + } + }; + + const updateEnvValue = (key: string, value: string) => { + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const isSensitiveKey = (key: string): boolean => { + return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'); + }; + + return ( + <> + + Environment Settings: {profileName} + + Edit environment variables for this profile. Sensitive keys are masked. + + + + {isLoading ? ( +
+ + Loading settings... +
+ ) : currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( +
+ {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono" + /> + )} +
+ ))} + + {data && ( +
+

Path: {data.path}

+

Last modified: {new Date(data.mtime).toLocaleString()}

+
+ )} + +
+ + +
+
+ ) : ( +
+ No environment variables configured for this profile. +
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> + + ); +} + +export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) { + // Handle dialog open/close state changes + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + onClose(); + } + }, + [onClose] + ); + + return ( + + + {/* Key prop ensures fresh state on each open */} + {open && profileName && ( + + )} + + + ); +} diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 8b80fea2..497a1412 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -30,7 +30,7 @@ export function AccountsPage() {

Accounts are isolated Claude instances with separate sessions.
- Use ccs login <name> to add new + Use ccs auth create <name> to add new accounts via CLI.

diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 63517483..a68df835 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -8,12 +8,15 @@ import { Button } from '@/components/ui/button'; import { Plus } from 'lucide-react'; import { ProfilesTable } from '@/components/profiles-table'; import { ProfileDialog } from '@/components/profile-dialog'; +import { SettingsDialog } from '@/components/settings-dialog'; import { useProfiles } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; export function ApiPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingProfile, setEditingProfile] = useState(null); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [settingsProfileName, setSettingsProfileName] = useState(null); const { data, isLoading } = useProfiles(); const handleEdit = (profile: Profile) => { @@ -21,11 +24,21 @@ export function ApiPage() { setDialogOpen(true); }; + const handleEditSettings = (profile: Profile) => { + setSettingsProfileName(profile.name); + setSettingsDialogOpen(true); + }; + const handleCloseDialog = () => { setDialogOpen(false); setEditingProfile(null); }; + const handleCloseSettingsDialog = () => { + setSettingsDialogOpen(false); + setSettingsProfileName(null); + }; + return (
@@ -44,10 +57,19 @@ export function ApiPage() { {isLoading ? (
Loading profiles...
) : ( - + )} +
); }