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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 13:21:55 +01:00
parent 9c4131c814
commit 89282d33b3

View File

@@ -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 <Input value={value} onChange={(e) => onChange(e.target.value)} />
}
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{(providersQuery.data ?? ['claude']).map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Account</DialogTitle>
</DialogHeader>
<div className="flex border-b">
<button
type="button"
onClick={() => {
setTab('token')
setCredEmail('')
setCredProvider('claude')
setConfigJsonText('')
setCredentialsText('')
setCredEmailError('')
setConfigJsonError('')
setCredentialsError('')
setServerError('')
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'token'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
By token
</button>
<button
type="button"
onClick={() => {
setTab('credentials')
setEmail('')
setToken('')
setEmailError('')
setTokenError('')
setServerError('')
}}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'credentials'
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
By credentials JSON
</button>
</div>
{tab === 'token' ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Email</Label>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
{emailError && <p className="text-sm text-destructive">{emailError}</p>}
</div>
<div className="space-y-2">
<Label>Setup token (from `claude setup-token`)</Label>
<Input
value={token}
onChange={(e) => setToken(e.target.value)}
/>
{tokenError && <p className="text-sm text-destructive">{tokenError}</p>}
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
</div>
<div className="space-y-2">
<Label>Provider</Label>
{renderProviderSelect(provider, setProvider)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Email</Label>
<Input
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
placeholder="user@example.com"
/>
{credEmailError && <p className="text-sm text-destructive">{credEmailError}</p>}
</div>
<div className="space-y-2">
<Label>Provider</Label>
{renderProviderSelect(credProvider, setCredProvider)}
</div>
<div className="space-y-2">
<Label>Config JSON (`.claude.json` content)</Label>
<Textarea
value={configJsonText}
onChange={(e) => setConfigJsonText(e.target.value)}
placeholder={`{ "oauthAccount": { ... } }`}
/>
{configJsonError && <p className="text-sm text-destructive">{configJsonError}</p>}
</div>
<div className="space-y-2">
<Label>Credentials JSON (`.credentials.json` content)</Label>
<Textarea
value={credentialsText}
onChange={(e) => setCredentialsText(e.target.value)}
placeholder={`{ "claudeAiOauth": { ... } }`}
/>
{credentialsError && <p className="text-sm text-destructive">{credentialsError}</p>}
</div>
<p className="text-xs text-muted-foreground">
Run `cw account extract` in your terminal to obtain these values.
</p>
{serverError && <p className="text-sm text-destructive">{serverError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
onClick={handleSubmit}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Account
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}