From 03059dbdccaca9736ae45c0754543e59c2a3e0f6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 7 Dec 2025 18:35:58 -0500 Subject: [PATCH] feat(ui): add accounts and cliproxy management dashboard --- ui/src/components/accounts-table.tsx | 131 ++++++++++++++++++++++++++ ui/src/components/cliproxy-dialog.tsx | 111 ++++++++++++++++++++++ ui/src/components/cliproxy-table.tsx | 123 ++++++++++++++++++++++++ ui/src/hooks/use-accounts.ts | 30 ++++++ ui/src/hooks/use-cliproxy.ts | 45 +++++++++ ui/src/pages/accounts.tsx | 39 ++++++++ ui/src/pages/cliproxy.tsx | 41 ++++++++ ui/src/pages/index.tsx | 18 +--- 8 files changed, 522 insertions(+), 16 deletions(-) create mode 100644 ui/src/components/accounts-table.tsx create mode 100644 ui/src/components/cliproxy-dialog.tsx create mode 100644 ui/src/components/cliproxy-table.tsx create mode 100644 ui/src/hooks/use-accounts.ts create mode 100644 ui/src/hooks/use-cliproxy.ts create mode 100644 ui/src/pages/accounts.tsx create mode 100644 ui/src/pages/cliproxy.tsx diff --git a/ui/src/components/accounts-table.tsx b/ui/src/components/accounts-table.tsx new file mode 100644 index 00000000..b62ab449 --- /dev/null +++ b/ui/src/components/accounts-table.tsx @@ -0,0 +1,131 @@ +/** + * Accounts Table Component + * Phase 03: REST API Routes & CRUD + */ + +import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Check } from 'lucide-react'; +import { useSetDefaultAccount } from '@/hooks/use-accounts'; +import type { Account } from '@/lib/api-client'; + +interface AccountsTableProps { + data: Account[]; + defaultAccount: string | null; +} + +export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { + const setDefaultMutation = useSetDefaultAccount(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.name === defaultAccount && ( + + default + + )} +
+ ), + }, + { + accessorKey: 'type', + header: 'Type', + cell: ({ row }) => row.original.type || 'oauth', + }, + { + accessorKey: 'created', + header: 'Created', + cell: ({ row }) => { + const date = new Date(row.original.created); + return date.toLocaleDateString(); + }, + }, + { + accessorKey: 'last_used', + header: 'Last Used', + cell: ({ row }) => { + if (!row.original.last_used) return '-'; + const date = new Date(row.original.last_used); + return date.toLocaleDateString(); + }, + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => { + const isDefault = row.original.name === defaultAccount; + return ( + + ); + }, + }, + ]; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) { + return ( +
+ No accounts found. Use ccs login to + add accounts. +
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} diff --git a/ui/src/components/cliproxy-dialog.tsx b/ui/src/components/cliproxy-dialog.tsx new file mode 100644 index 00000000..0b629370 --- /dev/null +++ b/ui/src/components/cliproxy-dialog.tsx @@ -0,0 +1,111 @@ +/** + * CLIProxy Variant Dialog Component + * Phase 03: REST API Routes & CRUD + */ + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useCreateVariant } from '@/hooks/use-cliproxy'; + +const providers = ['gemini', 'codex', 'agy', 'qwen'] as const; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), + provider: z.enum(providers, { message: 'Provider is required' }), + model: z.string().optional(), +}); + +type FormData = z.infer; + +interface CliproxyDialogProps { + open: boolean; + onClose: () => void; +} + +const providerOptions = [ + { value: 'gemini', label: 'Google Gemini' }, + { value: 'codex', label: 'OpenAI Codex' }, + { value: 'agy', label: 'Antigravity' }, + { value: 'qwen', label: 'Alibaba Qwen' }, +]; + +export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { + const createMutation = useCreateVariant(); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormData) => { + try { + await createMutation.mutateAsync(data); + reset(); + onClose(); + } catch (error) { + console.error('Failed to create variant:', error); + } + }; + + return ( + + + + Create CLIProxy Variant + +
+
+ + + {errors.name && {errors.name.message}} +
+ +
+ + + {errors.provider && ( + {errors.provider.message} + )} +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy-table.tsx b/ui/src/components/cliproxy-table.tsx new file mode 100644 index 00000000..75e4c9ae --- /dev/null +++ b/ui/src/components/cliproxy-table.tsx @@ -0,0 +1,123 @@ +/** + * CLIProxy Variants Table Component + * Phase 03: REST API Routes & CRUD + */ + +import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { MoreHorizontal, Trash2 } from 'lucide-react'; +import { useDeleteVariant } from '@/hooks/use-cliproxy'; +import type { Variant } from '@/lib/api-client'; + +interface CliproxyTableProps { + data: Variant[]; +} + +const providerLabels: Record = { + gemini: 'Google Gemini', + codex: 'OpenAI Codex', + agy: 'Antigravity', + qwen: 'Alibaba Qwen', +}; + +export function CliproxyTable({ data }: CliproxyTableProps) { + const deleteMutation = useDeleteVariant(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + }, + { + accessorKey: 'provider', + header: 'Provider', + cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider, + }, + { + accessorKey: 'settings', + header: 'Settings Path', + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( + + + + + + deleteMutation.mutate(row.original.name)} + > + + Delete + + + + ), + }, + ]; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) { + return ( +
+ No CLIProxy variants found. Create one to use OAuth-based providers. +
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts new file mode 100644 index 00000000..a74422f2 --- /dev/null +++ b/ui/src/hooks/use-accounts.ts @@ -0,0 +1,30 @@ +/** + * React Query hooks for accounts (profiles.json) + * Phase 03: REST API Routes & CRUD + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; +import { toast } from 'sonner'; + +export function useAccounts() { + return useQuery({ + queryKey: ['accounts'], + queryFn: () => api.accounts.list(), + }); +} + +export function useSetDefaultAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => api.accounts.setDefault(name), + onSuccess: (_data, name) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + toast.success(`Default account set to "${name}"`); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts new file mode 100644 index 00000000..c984fdb1 --- /dev/null +++ b/ui/src/hooks/use-cliproxy.ts @@ -0,0 +1,45 @@ +/** + * React Query hooks for CLIProxy variants + * Phase 03: REST API Routes & CRUD + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api, type CreateVariant } from '@/lib/api-client'; +import { toast } from 'sonner'; + +export function useCliproxy() { + return useQuery({ + queryKey: ['cliproxy'], + queryFn: () => api.cliproxy.list(), + }); +} + +export function useCreateVariant() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateVariant) => api.cliproxy.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); + toast.success('Variant created successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useDeleteVariant() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (name: string) => api.cliproxy.delete(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); + toast.success('Variant deleted successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx new file mode 100644 index 00000000..8b80fea2 --- /dev/null +++ b/ui/src/pages/accounts.tsx @@ -0,0 +1,39 @@ +/** + * Accounts Page + * Phase 03: REST API Routes & CRUD + */ + +import { AccountsTable } from '@/components/accounts-table'; +import { useAccounts } from '@/hooks/use-accounts'; + +export function AccountsPage() { + const { data, isLoading } = useAccounts(); + + return ( +
+
+
+

Accounts

+

+ Manage multi-account Claude sessions (profiles.json) +

+
+
+ + {isLoading ? ( +
Loading accounts...
+ ) : ( + + )} + +
+

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

+
+
+ ); +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx new file mode 100644 index 00000000..8c878efb --- /dev/null +++ b/ui/src/pages/cliproxy.tsx @@ -0,0 +1,41 @@ +/** + * CLIProxy Page + * Phase 03: REST API Routes & CRUD + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Plus } from 'lucide-react'; +import { CliproxyTable } from '@/components/cliproxy-table'; +import { CliproxyDialog } from '@/components/cliproxy-dialog'; +import { useCliproxy } from '@/hooks/use-cliproxy'; + +export function CliproxyPage() { + const [dialogOpen, setDialogOpen] = useState(false); + const { data, isLoading } = useCliproxy(); + + return ( +
+
+
+

CLIProxy

+

+ Manage OAuth-based provider variants (Gemini, Codex, Antigravity, Qwen) +

+
+ +
+ + {isLoading ? ( +
Loading variants...
+ ) : ( + + )} + + setDialogOpen(false)} /> +
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 822919f1..52297c6b 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -2,23 +2,9 @@ export { HomePage } from './home'; export { ApiPage } from './api'; -export function CliproxyPage() { - return ( -
-

CLIProxy

-

OAuth provider management (Phase 03)

-
- ); -} +export { CliproxyPage } from './cliproxy'; -export function AccountsPage() { - return ( -
-

Accounts

-

Multi-account management (Phase 03)

-
- ); -} +export { AccountsPage } from './accounts'; export { SettingsPage } from './settings';