From 89282d33b35679afd59b19b03bdf2e3e63efbb59 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:21:55 +0100 Subject: [PATCH] feat: Add AddAccountDialog component for account management UI Two-tab dialog (setup token + credentials JSON) with full validation, error handling, provider select with fallback, and state reset on open. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/AddAccountDialog.tsx | 289 +++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 apps/web/src/components/AddAccountDialog.tsx diff --git a/apps/web/src/components/AddAccountDialog.tsx b/apps/web/src/components/AddAccountDialog.tsx new file mode 100644 index 0000000..b9928f4 --- /dev/null +++ b/apps/web/src/components/AddAccountDialog.tsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react' +import { Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { trpc } from '@/lib/trpc' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' + +interface AddAccountDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +export function AddAccountDialog({ open, onOpenChange }: AddAccountDialogProps) { + const [tab, setTab] = useState<'token' | 'credentials'>('token') + + // Tab A — token + const [email, setEmail] = useState('') + const [token, setToken] = useState('') + const [provider, setProvider] = useState('claude') + + // Tab B — credentials JSON + const [credEmail, setCredEmail] = useState('') + const [credProvider, setCredProvider] = useState('claude') + const [configJsonText, setConfigJsonText] = useState('') + const [credentialsText, setCredentialsText] = useState('') + + // Validation errors + const [emailError, setEmailError] = useState('') + const [tokenError, setTokenError] = useState('') + const [credEmailError, setCredEmailError] = useState('') + const [configJsonError, setConfigJsonError] = useState('') + const [credentialsError, setCredentialsError] = useState('') + const [serverError, setServerError] = useState('') + + const utils = trpc.useUtils() + const providersQuery = trpc.listProviderNames.useQuery() + + const addByToken = trpc.addAccountByToken.useMutation({ + onSuccess: (data) => { + const msg = data.upserted + ? `Account updated — credentials refreshed for ${email}.` + : `Account added: ${email}.` + toast.success(msg) + void utils.systemHealthCheck.invalidate() + onOpenChange(false) + }, + onError: (err) => { + setServerError(err.message) + }, + }) + + const addAccount = trpc.addAccount.useMutation({ + onSuccess: () => { + toast.success(`Account added: ${credEmail}.`) + void utils.systemHealthCheck.invalidate() + onOpenChange(false) + }, + onError: (err) => { + if (err.message.toLowerCase().includes('already exists')) { + setCredEmailError( + "An account with this email already exists. Use 'Update credentials' on the existing account instead." + ) + } else { + setServerError(err.message) + } + }, + }) + + useEffect(() => { + if (open) { + setTab('token') + setEmail(''); setToken(''); setProvider('claude') + setCredEmail(''); setCredProvider('claude') + setConfigJsonText(''); setCredentialsText('') + setEmailError(''); setTokenError('') + setCredEmailError(''); setConfigJsonError(''); setCredentialsError('') + setServerError('') + } + }, [open]) + + function handleSubmit() { + setEmailError(''); setTokenError(''); setCredEmailError('') + setConfigJsonError(''); setCredentialsError(''); setServerError('') + + if (tab === 'token') { + let hasError = false + if (email.trim() === '') { + setEmailError('Required') + hasError = true + } else if (!EMAIL_REGEX.test(email)) { + setEmailError('Enter a valid email address') + hasError = true + } + if (token.trim() === '') { + setTokenError('Required') + hasError = true + } + if (hasError) return + addByToken.mutate({ email, token, provider }) + } else { + let hasError = false + if (credEmail.trim() === '') { + setCredEmailError('Required') + hasError = true + } else if (!EMAIL_REGEX.test(credEmail)) { + setCredEmailError('Enter a valid email address') + hasError = true + } + if (configJsonText.trim() !== '') { + try { + JSON.parse(configJsonText) + } catch { + setConfigJsonError('Invalid JSON') + hasError = true + } + } + if (credentialsText.trim() !== '') { + try { + JSON.parse(credentialsText) + } catch { + setCredentialsError('Invalid JSON') + hasError = true + } + } + if (hasError) return + addAccount.mutate({ + email: credEmail, + provider: credProvider, + configJson: configJsonText.trim() || undefined, + credentials: credentialsText.trim() || undefined, + }) + } + } + + const isPending = tab === 'token' ? addByToken.isPending : addAccount.isPending + + function renderProviderSelect(value: string, onChange: (v: string) => void) { + if (providersQuery.isError) { + return onChange(e.target.value)} /> + } + return ( + + ) + } + + return ( + + + + Add Account + + +
+ + +
+ + {tab === 'token' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + /> + {emailError &&

{emailError}

} +
+
+ + setToken(e.target.value)} + /> + {tokenError &&

{tokenError}

} + {serverError &&

{serverError}

} +
+
+ + {renderProviderSelect(provider, setProvider)} +
+
+ ) : ( +
+
+ + setCredEmail(e.target.value)} + placeholder="user@example.com" + /> + {credEmailError &&

{credEmailError}

} +
+
+ + {renderProviderSelect(credProvider, setCredProvider)} +
+
+ +