Files
Codewalkers/apps/web/src/components/UpdateCredentialsDialog.tsx
Lukas May 575ad48a55 feat: Add UpdateCredentialsDialog component for re-authenticating accounts
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>
2026-03-06 13:27:13 +01:00

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>
)
}