import React from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui'; import { Icon } from '@/components/icon/Icon'; import { SETTINGS_SELECT_ROW_TRIGGER_CLASS, SETTINGS_SELECT_SIZE, } from '@/components/sections/shared/SettingsSection'; import { useI18n, type I18nKey } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; import { opencodeClient } from '@/lib/opencode/client'; import { collectPromptInputs, defaultPromptValues, describeOAuthError, firstUnansweredPrompt, parseAuthPrompts, parseAuthorization, shouldOpenAuthorizationUrl, visiblePrompts, type AuthPrompt, type OAuthAuthorization, } from './provider-oauth'; export interface ProviderOAuthMethod { /** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */ index: number; label: string; prompts?: unknown; } interface ProviderOAuthMethodsProps { providerId: string; methods: ProviderOAuthMethod[]; /** Called once a credential has been stored, so the caller can reload providers. */ onConnected: () => void | Promise; /** Layout only — the caller owns separation from whatever sits above. */ className?: string; } type Flow = | { phase: 'idle' } | { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null } | { phase: 'authorizing'; methodIndex: number } /** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */ | { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization } /** `code`: waiting for the user to paste a code out of the browser. */ | { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean } | { phase: 'failed'; methodIndex: number; message: string }; const IDLE: Flow = { phase: 'idle' }; /** * OAuth sign-in for a provider's auth methods. * * The completion method reported by `authorize` drives everything: `auto` * chains straight into `callback` and holds it open until the user finishes in * the browser, `code` collects a pasted code first. See `provider-oauth.ts`. * * Only one method can run at a time, and the in-flight callback is aborted when * this component unmounts. Mount it with `key={providerId}` so switching * providers starts from a clean flow. */ export const ProviderOAuthMethods: React.FC = ({ providerId, methods, onConnected, className, }) => { const { t } = useI18n(); const [flow, setFlow] = React.useState(IDLE); const [promptValues, setPromptValues] = React.useState>({}); const [codeInput, setCodeInput] = React.useState(''); const callbackAbortRef = React.useRef(null); React.useEffect(() => () => callbackAbortRef.current?.abort(), []); const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex; const busy = flow.phase === 'authorizing' || flow.phase === 'waiting' || (flow.phase === 'awaitingCode' && flow.submitting); const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => { const result = await copyTextToClipboard(value); if (result.ok) { toast.success(t(successKey)); return; } console.error('Failed to copy OAuth value:', result.error); toast.error(t(failureKey)); }; /** * Runs the blocking half of the flow. Never throws: the caller has already * handed control to the user, so a failure here is a flow state, not an * exception to unwind. */ const runCallback = async (methodIndex: number, code?: string) => { const controller = new AbortController(); callbackAbortRef.current?.abort(); callbackAbortRef.current = controller; try { const result = await opencodeClient.getSdkClient().provider.oauth.callback( { providerID: providerId, method: methodIndex, ...(code ? { code } : {}), }, { signal: controller.signal }, ); if (controller.signal.aborted) { return; } if (result.error) { throw result.error; } setFlow(IDLE); toast.success(t('settings.providers.page.toast.oauthCompleted')); await onConnected(); } catch (error) { if (controller.signal.aborted) { return; } console.error('Failed to complete OAuth flow:', error); setFlow({ phase: 'failed', methodIndex, message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'), }); } finally { if (callbackAbortRef.current === controller) { callbackAbortRef.current = null; } } }; const runAuthorize = async (methodIndex: number, inputs: Record) => { setFlow({ phase: 'authorizing', methodIndex }); let authorization: OAuthAuthorization; try { const result = await opencodeClient.getSdkClient().provider.oauth.authorize({ providerID: providerId, method: methodIndex, ...(Object.keys(inputs).length > 0 ? { inputs } : {}), }); if (result.error) { throw result.error; } const parsed = parseAuthorization(result.data); if (!parsed) { setFlow({ phase: 'failed', methodIndex, message: t('settings.providers.page.toast.oauthDetailsMissing'), }); return; } authorization = parsed; } catch (error) { console.error('Failed to start OAuth flow:', error); setFlow({ phase: 'failed', methodIndex, message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'), }); return; } // Claude Code CLI owns its OAuth flow and opens the browser itself. Its // plugin URL is informational only; opening it creates a misleading docs // tab alongside the real sign-in page. if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) { void openExternalUrl(authorization.url); } if (authorization.method === 'code') { setCodeInput(''); setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false }); return; } setFlow({ phase: 'waiting', methodIndex, authorization }); await runCallback(methodIndex); }; const beginConnect = (method: ProviderOAuthMethod) => { const prompts = parseAuthPrompts(method.prompts); if (prompts.length === 0) { void runAuthorize(method.index, {}); return; } setPromptValues(defaultPromptValues(prompts)); setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null }); }; const submitPrompts = () => { if (flow.phase !== 'prompting') { return; } const unanswered = firstUnansweredPrompt(flow.prompts, promptValues); if (unanswered) { setFlow({ ...flow, error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }), }); return; } void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues)); }; const submitCode = () => { if (flow.phase !== 'awaitingCode') { return; } const code = codeInput.trim(); if (!code) { return; } setFlow({ ...flow, submitting: true }); void runCallback(flow.methodIndex, code); }; /** * Stops tracking the attempt. Upstream keeps its pending authorization until * a new `authorize` replaces it, so reconnecting is always safe. */ const cancel = () => { callbackAbortRef.current?.abort(); callbackAbortRef.current = null; setFlow(IDLE); }; const renderPrompt = (prompt: AuthPrompt) => { const value = promptValues[prompt.key] ?? ''; const setValue = (next: string) => setPromptValues((prev) => ({ ...prev, [prompt.key]: next })); return (
{prompt.type === 'select' ? ( ) : ( setValue(event.target.value)} placeholder={prompt.placeholder} className="max-w-[24rem] text-xs" /> )}
); }; const renderAuthorizationDetails = (authorization: OAuthAuthorization) => ( <> {authorization.instructions && (

{authorization.instructions}

)} {authorization.userCode && (
)} {authorization.url && (
)} ); return (
{methods.map((method) => { const isActive = activeIndex === method.index; return (
{method.label}
{isActive && flow.phase === 'prompting' && (
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)} {flow.error && (

{flow.error}

)}
)} {isActive && flow.phase === 'authorizing' && (

{t('settings.providers.page.auth.oauth.starting')}

)} {isActive && flow.phase === 'waiting' && (
{renderAuthorizationDetails(flow.authorization)}

{t('settings.providers.page.auth.oauth.waiting')}

{t('settings.providers.page.auth.oauth.waitingHint')}

)} {isActive && flow.phase === 'awaitingCode' && (
{renderAuthorizationDetails(flow.authorization)}

{t('settings.providers.page.auth.oauth.codeHint')}

setCodeInput(event.target.value)} placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} className="font-mono text-xs" disabled={flow.submitting} />
)} {isActive && flow.phase === 'failed' && (

{flow.message}

)}
); })}
); };