feat: add authentication UI helpers for Claude Code and OpenAI providers

- Enhanced ClaudeCode provider component with Get API Key button and authentication status
- Enhanced OpenAI provider component with authentication status indicator
- Added translation strings for authentication UI elements
- Added comprehensive test coverage for both provider components
- Improved UX by showing clear authentication status and easy API key updates

Addresses #9292 - improves authentication experience for Claude Code and OpenAI
This commit is contained in:
Roo Code 2025-11-16 03:30:06 +00:00
parent 744f4bd4c8
commit 335a4e00e8
5 changed files with 554 additions and 14 deletions

View file

@ -1,5 +1,5 @@
import React from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react"
import { VSCodeTextField, VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { type ProviderSettings } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Slider } from "@src/components/ui"
@ -7,29 +7,54 @@ import { Slider } from "@src/components/ui"
interface ClaudeCodeProps {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
vscode?: any
}
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApiConfigurationField }) => {
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApiConfigurationField, vscode }) => {
const { t } = useAppTranslation()
const [isAuthenticating, setIsAuthenticating] = useState(false)
const handleInputChange = (e: Event | React.FormEvent<HTMLElement>) => {
const element = e.target as HTMLInputElement
setApiConfigurationField("claudeCodePath", element.value)
}
const handleGetApiKey = () => {
setIsAuthenticating(true)
// Open Claude's website to get API key
vscode?.postMessage({
type: "openExternal",
url: "https://console.anthropic.com/settings/keys",
})
// Show instructions
vscode?.postMessage({
type: "showInformationMessage",
text: "Please create an API key on the Anthropic Console and paste it in the field above",
})
setIsAuthenticating(false)
}
const maxOutputTokens = apiConfiguration?.claudeCodeMaxOutputTokens || 8000
const hasPath = !!apiConfiguration?.claudeCodePath
return (
<div className="flex flex-col gap-4">
<div>
<VSCodeTextField
value={apiConfiguration?.claudeCodePath || ""}
style={{ width: "100%", marginTop: 3 }}
type="text"
onInput={handleInputChange}
placeholder={t("settings:providers.claudeCode.placeholder")}>
{t("settings:providers.claudeCode.pathLabel")}
</VSCodeTextField>
<div className="flex gap-2 items-end mb-2">
<VSCodeTextField
value={apiConfiguration?.claudeCodePath || ""}
style={{ width: "100%", marginTop: 3 }}
type="password"
onInput={handleInputChange}
placeholder={hasPath ? "••••••••••••••••" : t("settings:providers.claudeCode.placeholder")}>
{t("settings:providers.claudeCode.pathLabel")}
</VSCodeTextField>
<VSCodeButton onClick={handleGetApiKey} disabled={isAuthenticating} appearance="secondary">
{hasPath
? t("settings:providers.claudeCode.updateKey")
: t("settings:providers.claudeCode.getApiKey")}
</VSCodeButton>
</div>
<p
style={{
@ -39,6 +64,17 @@ export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApi
}}>
{t("settings:providers.claudeCode.description")}
</p>
{hasPath && (
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-notificationsInfoIcon-foreground)",
}}>
{t("settings:providers.claudeCode.authenticated")}
</p>
)}
</div>
<div className="flex flex-col gap-1">

View file

@ -69,7 +69,16 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod
<div className="text-sm text-vscode-descriptionForeground -mt-2">
{t("settings:providers.apiKeyStorageNotice")}
</div>
{!apiConfiguration?.openAiNativeApiKey && (
{apiConfiguration?.openAiNativeApiKey ? (
<div className="flex items-center justify-between">
<div className="text-sm text-vscode-notificationsInfoIcon-foreground">
{t("settings:providers.openAi.authenticated")}
</div>
<VSCodeButtonLink href="https://platform.openai.com/api-keys" appearance="secondary">
{t("settings:providers.openAi.updateKey")}
</VSCodeButtonLink>
</div>
) : (
<VSCodeButtonLink href="https://platform.openai.com/api-keys" appearance="secondary">
{t("settings:providers.getOpenAiApiKey")}
</VSCodeButtonLink>

View file

@ -0,0 +1,219 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ClaudeCode } from "../ClaudeCode"
import type { ProviderSettings } from "@roo-code/types"
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => {
const handleInput = (e: any) => {
const event = { target: { value: e.target.value } }
onInput?.(event)
}
return (
<div>
{children}
<input type={type} value={value} onInput={handleInput} placeholder={placeholder} />
</div>
)
},
VSCodeButton: ({ children, onClick, disabled, appearance }: any) => (
<button onClick={onClick} disabled={disabled} data-appearance={appearance}>
{children}
</button>
),
}))
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"settings:providers.claudeCode.pathLabel": "Claude Code API Key",
"settings:providers.claudeCode.placeholder": "Enter your Claude Code API key",
"settings:providers.claudeCode.getApiKey": "Get API Key",
"settings:providers.claudeCode.updateKey": "Update Key",
"settings:providers.claudeCode.authenticated": "Authenticated with Claude Code",
"settings:providers.claudeCode.description":
"Optional path to your Claude Code CLI. Defaults to 'claude' if not set.",
"settings:providers.claudeCode.maxTokensLabel": "Max Output Tokens",
"settings:providers.claudeCode.maxTokensDescription":
"Maximum number of output tokens for Claude Code responses. Default is 8000.",
}
return translations[key] || key
},
}),
}))
vi.mock("@src/components/ui", () => ({
Slider: ({ value, onValueChange }: any) => (
<input
type="range"
value={value[0]}
onChange={(e) => onValueChange([Number(e.target.value)])}
data-testid="slider"
/>
),
}))
describe("ClaudeCode", () => {
const defaultApiConfiguration: ProviderSettings = {
claudeCodePath: "",
claudeCodeMaxOutputTokens: 8000,
}
const mockSetApiConfigurationField = vi.fn()
const mockVscode = {
postMessage: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("Authentication UI", () => {
it("should show 'Get API Key' button when no API key is present", () => {
render(
<ClaudeCode
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const button = screen.getByRole("button")
expect(button).toHaveTextContent("Get API Key")
})
it("should show 'Update Key' button when API key is present", () => {
const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" }
render(
<ClaudeCode
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const button = screen.getByRole("button")
expect(button).toHaveTextContent("Update Key")
})
it("should show authenticated status when API key is present", () => {
const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" }
render(
<ClaudeCode
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
expect(screen.getByText("✓ Authenticated with Claude Code")).toBeInTheDocument()
})
it("should not show authenticated status when no API key", () => {
render(
<ClaudeCode
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
expect(screen.queryByText("✓ Authenticated with Claude Code")).not.toBeInTheDocument()
})
it("should open external link when Get API Key button is clicked", async () => {
const user = userEvent.setup()
render(
<ClaudeCode
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const button = screen.getByRole("button")
await user.click(button)
expect(mockVscode.postMessage).toHaveBeenCalledWith({
type: "openExternal",
url: "https://console.anthropic.com/settings/keys",
})
})
it("should show information message when Get API Key button is clicked", async () => {
const user = userEvent.setup()
render(
<ClaudeCode
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const button = screen.getByRole("button")
await user.click(button)
expect(mockVscode.postMessage).toHaveBeenCalledWith({
type: "showInformationMessage",
text: "Please create an API key on the Anthropic Console and paste it in the field above",
})
})
})
describe("API Key Input", () => {
it("should mask API key input", () => {
render(
<ClaudeCode
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const input = screen.getByPlaceholderText("Enter your Claude Code API key") as HTMLInputElement
expect(input.type).toBe("password")
})
it("should show masked placeholder when API key exists", () => {
const apiConfiguration = { ...defaultApiConfiguration, claudeCodePath: "test-api-key" }
render(
<ClaudeCode
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
const input = screen.getByPlaceholderText("••••••••••••••••") as HTMLInputElement
expect(input).toBeInTheDocument()
})
})
describe("Max Output Tokens", () => {
it("should display current max output tokens value", () => {
const apiConfiguration = { ...defaultApiConfiguration, claudeCodeMaxOutputTokens: 16000 }
render(
<ClaudeCode
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
expect(screen.getByText("16000")).toBeInTheDocument()
})
it("should use default value of 8000 when not specified", () => {
render(
<ClaudeCode
apiConfiguration={{}}
setApiConfigurationField={mockSetApiConfigurationField}
vscode={mockVscode}
/>,
)
expect(screen.getByText("8000")).toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,269 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { OpenAI } from "../OpenAI"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => {
const handleInput = (e: any) => {
const event = { target: { value: e.target.value } }
onInput?.(event)
}
return (
<div>
{children && <label>{children}</label>}
<input type={type} value={value} onInput={handleInput} placeholder={placeholder} />
</div>
)
},
}))
vi.mock("vscrui", () => ({
Checkbox: ({ children, checked, onChange }: any) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{children}
</label>
),
}))
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"settings:providers.openAiApiKey": "OpenAI API Key",
"settings:providers.getOpenAiApiKey": "Get OpenAI API Key",
"settings:providers.openAi.authenticated": "Authenticated with OpenAI",
"settings:providers.openAi.updateKey": "Update API Key",
"settings:providers.apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage",
"settings:providers.useCustomBaseUrl": "Use custom base URL",
"settings:placeholders.apiKey": "Enter API Key...",
"settings:common.select": "Select",
}
return translations[key] || key
},
}),
}))
vi.mock("@src/components/common/VSCodeButtonLink", () => ({
VSCodeButtonLink: ({ children, href, appearance }: any) => (
<a href={href} data-appearance={appearance}>
{children}
</a>
),
}))
vi.mock("@src/components/ui", () => ({
Select: ({ children, value, onValueChange }: any) => (
<select value={value} onChange={(e) => onValueChange(e.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: any) => <div>{children}</div>,
SelectValue: ({ placeholder }: any) => <span>{placeholder}</span>,
SelectContent: ({ children }: any) => <>{children}</>,
SelectItem: ({ children, value }: any) => <option value={value}>{children}</option>,
StandardTooltip: ({ children, content }: any) => <span title={content}>{children}</span>,
}))
describe("OpenAI", () => {
const defaultApiConfiguration: ProviderSettings = {
openAiNativeApiKey: "",
openAiNativeBaseUrl: "",
openAiNativeServiceTier: "default",
}
const mockSetApiConfigurationField = vi.fn()
const mockSelectedModelInfo: ModelInfo = {
contextWindow: 128000,
maxTokens: 8000,
supportsPromptCache: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe("Authentication UI", () => {
it("should show 'Get OpenAI API Key' button when no API key is present", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
const link = screen.getByText("Get OpenAI API Key")
expect(link).toBeInTheDocument()
expect(link.closest("a")).toHaveAttribute("href", "https://platform.openai.com/api-keys")
})
it("should show authenticated status and update button when API key is present", () => {
const apiConfiguration = { ...defaultApiConfiguration, openAiNativeApiKey: "test-api-key" }
render(
<OpenAI
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
expect(screen.getByText("✓ Authenticated with OpenAI")).toBeInTheDocument()
const updateLink = screen.getByText("Update API Key")
expect(updateLink).toBeInTheDocument()
expect(updateLink.closest("a")).toHaveAttribute("href", "https://platform.openai.com/api-keys")
})
it("should not show authenticated status when no API key", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
expect(screen.queryByText("✓ Authenticated with OpenAI")).not.toBeInTheDocument()
})
})
describe("API Key Input", () => {
it("should mask API key input", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
const input = screen.getByPlaceholderText("Enter API Key...") as HTMLInputElement
expect(input.type).toBe("password")
})
it("should show API key storage notice", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
expect(screen.getByText("API keys are stored securely in VSCode's Secret Storage")).toBeInTheDocument()
})
})
describe("Custom Base URL", () => {
it("should not show base URL input by default", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
expect(screen.queryByPlaceholderText("https://api.openai.com/v1")).not.toBeInTheDocument()
})
it("should show base URL input when checkbox is checked", async () => {
const user = userEvent.setup()
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
const checkbox = screen.getByRole("checkbox")
await user.click(checkbox)
expect(screen.getByPlaceholderText("https://api.openai.com/v1")).toBeInTheDocument()
})
it("should clear base URL when checkbox is unchecked", async () => {
const user = userEvent.setup()
const apiConfiguration = { ...defaultApiConfiguration, openAiNativeBaseUrl: "https://custom.url/v1" }
render(
<OpenAI
apiConfiguration={apiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
const checkbox = screen.getByRole("checkbox")
await user.click(checkbox)
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openAiNativeBaseUrl", "")
})
})
describe("Service Tier", () => {
it("should not show service tier selector when model has no tiers", () => {
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={mockSelectedModelInfo}
/>,
)
expect(screen.queryByTestId("openai-service-tier")).not.toBeInTheDocument()
})
it("should show service tier selector when model has flex or priority tiers", () => {
const modelInfo: ModelInfo = {
contextWindow: 128000,
maxTokens: 8000,
supportsPromptCache: false,
tiers: [
{ name: "flex", contextWindow: 128000 },
{ name: "priority", contextWindow: 128000 },
],
}
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={modelInfo}
/>,
)
expect(screen.getByTestId("openai-service-tier")).toBeInTheDocument()
expect(screen.getByText("Standard")).toBeInTheDocument()
expect(screen.getByText("Flex")).toBeInTheDocument()
expect(screen.getByText("Priority")).toBeInTheDocument()
})
it("should update service tier when selection changes", async () => {
const modelInfo: ModelInfo = {
contextWindow: 128000,
maxTokens: 8000,
supportsPromptCache: false,
tiers: [
{ name: "flex", contextWindow: 128000 },
{ name: "priority", contextWindow: 128000 },
],
}
const user = userEvent.setup()
render(
<OpenAI
apiConfiguration={defaultApiConfiguration}
setApiConfigurationField={mockSetApiConfigurationField}
selectedModelInfo={modelInfo}
/>,
)
const select = screen.getByRole("combobox")
await user.selectOptions(select, "priority")
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openAiNativeServiceTier", "priority")
})
})
})

View file

@ -332,6 +332,10 @@
"apiKey": "API Key",
"openAiBaseUrl": "Base URL",
"getOpenAiApiKey": "Get OpenAI API Key",
"openAi": {
"authenticated": "Authenticated with OpenAI",
"updateKey": "Update API Key"
},
"mistralApiKey": "Mistral API Key",
"getMistralApiKey": "Get Mistral / Codestral API Key",
"codestralBaseUrl": "Codestral Base URL (Optional)",
@ -488,9 +492,12 @@
},
"setReasoningLevel": "Enable Reasoning Effort",
"claudeCode": {
"pathLabel": "Claude Code Path",
"pathLabel": "Claude Code API Key",
"description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.",
"placeholder": "Default: claude",
"placeholder": "Enter your Claude Code API key",
"getApiKey": "Get API Key",
"updateKey": "Update Key",
"authenticated": "Authenticated with Claude Code",
"maxTokensLabel": "Max Output Tokens",
"maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000."
}