Implements a self-contained dialog that allows users to update credentials
for an existing account without deleting and re-adding it. Supports two
modes: token-based (Tab A) and credentials JSON (Tab B).
Also sets up web component test infrastructure: vitest now includes
apps/web/**/*.test.tsx files with happy-dom environment, @vitejs/plugin-react
for JSX, and @testing-library/{react,jest-dom,user-event} packages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
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 { Badge } from '@/components/ui/badge'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface UpdateCredentialsDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
account: { id: string; email: string; provider: string }
|
|
}
|
|
|
|
export function UpdateCredentialsDialog({
|
|
open,
|
|
onOpenChange,
|
|
account,
|
|
}: UpdateCredentialsDialogProps) {
|
|
const [tab, setTab] = useState<'token' | 'credentials'>('token')
|
|
|
|
// Tab A — token
|
|
const [token, setToken] = useState('')
|
|
const [tokenError, setTokenError] = useState('')
|
|
|
|
// Tab B — credentials JSON
|
|
const [configJsonText, setConfigJsonText] = useState('')
|
|
const [credentialsText, setCredentialsText] = useState('')
|
|
const [configJsonError, setConfigJsonError] = useState('')
|
|
const [credentialsError, setCredentialsError] = useState('')
|
|
|
|
// Shared
|
|
const [serverError, setServerError] = useState('')
|
|
|
|
const utils = trpc.useUtils()
|
|
|
|
const mutation = trpc.updateAccountAuth.useMutation({
|
|
onSuccess: () => {
|
|
toast.success(`Credentials updated for ${account.email}.`)
|
|
void utils.systemHealthCheck.invalidate()
|
|
onOpenChange(false)
|
|
},
|
|
onError: (err) => {
|
|
setServerError(err.message)
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setTab('token')
|
|
setToken('')
|
|
setTokenError('')
|
|
setConfigJsonText('')
|
|
setCredentialsText('')
|
|
setConfigJsonError('')
|
|
setCredentialsError('')
|
|
setServerError('')
|
|
}
|
|
}, [open])
|
|
|
|
function handleSubmit() {
|
|
if (tab === 'token') {
|
|
if (!token.trim()) {
|
|
setTokenError('Required')
|
|
return
|
|
}
|
|
setTokenError('')
|
|
mutation.mutate({
|
|
id: account.id,
|
|
configJson: JSON.stringify({ hasCompletedOnboarding: true }),
|
|
credentials: JSON.stringify({ claudeAiOauth: { accessToken: token } }),
|
|
})
|
|
} else {
|
|
let hasError = false
|
|
|
|
if (configJsonText.trim()) {
|
|
try {
|
|
JSON.parse(configJsonText)
|
|
setConfigJsonError('')
|
|
} catch {
|
|
setConfigJsonError('Invalid JSON')
|
|
hasError = true
|
|
}
|
|
} else {
|
|
setConfigJsonError('')
|
|
}
|
|
|
|
if (credentialsText.trim()) {
|
|
try {
|
|
JSON.parse(credentialsText)
|
|
setCredentialsError('')
|
|
} catch {
|
|
setCredentialsError('Invalid JSON')
|
|
hasError = true
|
|
}
|
|
} else {
|
|
setCredentialsError('')
|
|
}
|
|
|
|
if (hasError) return
|
|
|
|
mutation.mutate({
|
|
id: account.id,
|
|
configJson: configJsonText.trim() || '{}',
|
|
credentials: credentialsText.trim() || '{}',
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Update Credentials</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Read-only account identity */}
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-medium">{account.email}</p>
|
|
<Badge variant="outline">{account.provider}</Badge>
|
|
</div>
|
|
|
|
{/* Tab navigation */}
|
|
<div className="flex border-b">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTab('token')}
|
|
className={cn(
|
|
'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')}
|
|
className={cn(
|
|
'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 A — By token */}
|
|
{tab === 'token' && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="setup-token">
|
|
Setup token (from `claude setup-token`)
|
|
</Label>
|
|
<Input
|
|
id="setup-token"
|
|
type="text"
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
/>
|
|
{tokenError && (
|
|
<p className="text-sm text-destructive">{tokenError}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab B — By credentials JSON */}
|
|
{tab === 'credentials' && (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="config-json">
|
|
Config JSON (`.claude.json` content)
|
|
</Label>
|
|
<Textarea
|
|
id="config-json"
|
|
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 htmlFor="credentials-json">
|
|
Credentials JSON (`.credentials.json` content)
|
|
</Label>
|
|
<Textarea
|
|
id="credentials-json"
|
|
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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Server error */}
|
|
{serverError && (
|
|
<p className="text-sm text-destructive">{serverError}</p>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={mutation.isPending}>
|
|
{mutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Update Credentials
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|