diff --git a/apps/web/src/components/UpdateCredentialsDialog.test.tsx b/apps/web/src/components/UpdateCredentialsDialog.test.tsx new file mode 100644 index 0000000..c29a550 --- /dev/null +++ b/apps/web/src/components/UpdateCredentialsDialog.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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( + + ) + + // 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() + // Re-open dialog + rerender() + + // 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() + }) +}) diff --git a/apps/web/src/components/UpdateCredentialsDialog.tsx b/apps/web/src/components/UpdateCredentialsDialog.tsx new file mode 100644 index 0000000..9df9d8c --- /dev/null +++ b/apps/web/src/components/UpdateCredentialsDialog.tsx @@ -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 ( + + + + Update Credentials + + +
+ {/* Read-only account identity */} +
+

{account.email}

+ {account.provider} +
+ + {/* Tab navigation */} +
+ + +
+ + {/* Tab A — By token */} + {tab === 'token' && ( +
+ + setToken(e.target.value)} + /> + {tokenError && ( +

{tokenError}

+ )} +
+ )} + + {/* Tab B — By credentials JSON */} + {tab === 'credentials' && ( +
+
+ +