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>
This commit is contained in:
175
apps/web/src/components/UpdateCredentialsDialog.test.tsx
Normal file
175
apps/web/src/components/UpdateCredentialsDialog.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import { UpdateCredentialsDialog } from './UpdateCredentialsDialog'
|
||||
|
||||
// Captured mutation options from the latest useMutation call
|
||||
let capturedOnSuccess: (() => void) | undefined
|
||||
let capturedOnError: ((err: { message: string }) => void) | undefined
|
||||
|
||||
const mockMutate = vi.fn()
|
||||
const mockInvalidate = vi.fn()
|
||||
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
updateAccountAuth: {
|
||||
useMutation: vi.fn((opts?: {
|
||||
onSuccess?: () => void
|
||||
onError?: (err: { message: string }) => void
|
||||
}) => {
|
||||
capturedOnSuccess = opts?.onSuccess
|
||||
capturedOnError = opts?.onError
|
||||
return { mutate: mockMutate, isPending: false }
|
||||
}),
|
||||
},
|
||||
useUtils: vi.fn(() => ({
|
||||
systemHealthCheck: { invalidate: mockInvalidate },
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn() },
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const account = {
|
||||
id: 'acc-1',
|
||||
email: 'alice@example.com',
|
||||
provider: 'claude',
|
||||
}
|
||||
|
||||
describe('UpdateCredentialsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedOnSuccess = undefined
|
||||
capturedOnError = undefined
|
||||
})
|
||||
|
||||
// Test 1: Renders with read-only identity
|
||||
it('renders account email and provider as non-editable', () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('claude')).toBeInTheDocument()
|
||||
|
||||
// They must not be editable inputs
|
||||
const emailEl = screen.getByText('alice@example.com')
|
||||
expect(emailEl.tagName).not.toBe('INPUT')
|
||||
const providerEl = screen.getByText('claude')
|
||||
expect(providerEl.tagName).not.toBe('INPUT')
|
||||
})
|
||||
|
||||
// Test 2: Tab A submit — empty token — shows Required, no mutation
|
||||
it('shows Required error when token is empty and does not call mutation', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Required')).toBeInTheDocument())
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 3: Tab A submit — valid token — calls mutation with correct input
|
||||
it('calls mutation with correct token input', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'test-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
||||
id: 'acc-1',
|
||||
configJson: '{"hasCompletedOnboarding":true}',
|
||||
credentials: '{"claudeAiOauth":{"accessToken":"test-token"}}',
|
||||
}))
|
||||
})
|
||||
|
||||
// Test 4: Tab B submit — invalid config JSON — shows "Invalid JSON", no mutation
|
||||
it('shows Invalid JSON error for bad config JSON and does not call mutation', async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByText('By credentials JSON'))
|
||||
fireEvent.change(screen.getByLabelText(/config json/i), { target: { value: 'bad json' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Invalid JSON')).toBeInTheDocument())
|
||||
expect(mockMutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 5: Tab B submit — empty textareas — calls mutation with '{}' defaults
|
||||
it("calls mutation with '{}' defaults when credentials textareas are empty", async () => {
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
fireEvent.click(screen.getByText('By credentials JSON'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
||||
id: 'acc-1',
|
||||
configJson: '{}',
|
||||
credentials: '{}',
|
||||
}))
|
||||
})
|
||||
|
||||
// Test 6: Server error — shows inline error, dialog stays open
|
||||
it('shows server error inline and does not close dialog on error', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnError?.({ message: 'Token expired' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Token expired')).toBeInTheDocument())
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Test 7: Success — toasts and closes dialog
|
||||
it('shows success toast and closes dialog on success', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnSuccess?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Credentials updated for alice@example.com.')
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Test 8: State reset on re-open
|
||||
it('resets state when dialog is re-opened', async () => {
|
||||
const { rerender } = render(
|
||||
<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />
|
||||
)
|
||||
|
||||
// Type a token and trigger server error
|
||||
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'old-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
||||
|
||||
act(() => {
|
||||
capturedOnError?.({ message: 'Server error' })
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument())
|
||||
|
||||
// Close dialog
|
||||
rerender(<UpdateCredentialsDialog open={false} onOpenChange={vi.fn()} account={account} />)
|
||||
// Re-open dialog
|
||||
rerender(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
||||
|
||||
// Token should be cleared
|
||||
const tokenInput = screen.getByLabelText(/setup token/i) as HTMLInputElement
|
||||
expect(tokenInput.value).toBe('')
|
||||
// Server error should be gone
|
||||
expect(screen.queryByText('Server error')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
239
apps/web/src/components/UpdateCredentialsDialog.tsx
Normal file
239
apps/web/src/components/UpdateCredentialsDialog.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user