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