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:
289
apps/web/src/components/AddAccountDialog.tsx
Normal file
289
apps/web/src/components/AddAccountDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user