diff --git a/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx new file mode 100644 index 00000000..a293fdbe --- /dev/null +++ b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx @@ -0,0 +1,455 @@ +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, + 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; + } + + if (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}

+ +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 008b04e2..a7594c1c 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -19,8 +19,6 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; -import { copyTextToClipboard } from '@/lib/clipboard'; -import { openExternalUrl } from '@/lib/url'; import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -31,8 +29,10 @@ import { parseAuthPayload, shouldShowApiKeyAuth, type AuthMethod, + type OAuthAuthMethodEntry, } from './providerAuth'; import { CustomProviderForm } from './CustomProviderForm'; +import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods'; import { buildAuthSetRequest, buildProviderUpsertRequest, @@ -85,6 +85,16 @@ interface ProviderSources { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +const toOAuthMethods = ( + entries: OAuthAuthMethodEntry[], + fallbackLabel: (index: number) => string, +): ProviderOAuthMethod[] => + entries.map(({ method, methodIndex }) => ({ + index: methodIndex, + label: method.label || method.name || fallbackLabel(methodIndex), + prompts: method.prompts, + })); + const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { if (typeof entry === 'string') { return { id: entry }; @@ -147,9 +157,6 @@ export const ProvidersPage: React.FC = () => { const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); - const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); - const [oauthCodes, setOauthCodes] = React.useState>({}); - const [oauthDetails, setOauthDetails] = React.useState>({}); const [availableProviders, setAvailableProviders] = React.useState([]); const [availableLoading, setAvailableLoading] = React.useState(false); const [availableError, setAvailableError] = React.useState(null); @@ -181,7 +188,8 @@ export const ProvidersPage: React.FC = () => { React.useEffect(() => { // Auth methods drive which credential UI to show (API key vs OAuth). Keep // them loaded for the active provider view so OAuth-only plugins never fall - // back to an API key form merely because methods were never fetched. + // back to an API key form merely because methods were never fetched, and so + // an already-listed provider can still offer re-authentication. if (!selectedProviderId) { return; } @@ -458,117 +466,13 @@ export const ProvidersPage: React.FC = () => { } }; - const handleOAuthStart = async (providerId: string, methodIndex: number) => { - const busyKey = `oauth:${providerId}:${methodIndex}`; - setAuthBusyKey(busyKey); + const oauthMethodFallbackLabel = (index: number) => + t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); - try { - const result = await opencodeClient.getSdkClient().provider.oauth.authorize({ - providerID: providerId, - method: methodIndex, - }); - if (result.error) { - throw new Error(t('settings.providers.page.toast.oauthStartFailed')); - } - - const payloadRecord: Record = isRecord(result.data) ? result.data : {}; - const nestedData = payloadRecord.data; - const dataRecord: Record = isRecord(nestedData) ? nestedData : payloadRecord; - const urlCandidate = - (typeof dataRecord.url === 'string' && dataRecord.url) || - (typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) || - (typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) || - undefined; - const instructions = - (typeof dataRecord.instructions === 'string' && dataRecord.instructions) || - (typeof dataRecord.message === 'string' && dataRecord.message) || - undefined; - const userCode = - (typeof dataRecord.user_code === 'string' && dataRecord.user_code) || - (typeof dataRecord.code === 'string' && dataRecord.code) || - (typeof dataRecord.userCode === 'string' && dataRecord.userCode) || - undefined; - - if (!urlCandidate && !instructions && !userCode) { - throw new Error(t('settings.providers.page.toast.oauthDetailsMissing')); - } - - const detailsKey = `${providerId}:${methodIndex}`; - setOauthDetails((prev) => ({ - ...prev, - [detailsKey]: { - url: urlCandidate, - instructions, - userCode, - }, - })); - - if (urlCandidate) { - void openExternalUrl(urlCandidate); - } - setPendingOAuth({ providerId, methodIndex }); - toast.message(t('settings.providers.page.toast.completeOAuthInBrowser')); - } catch (error) { - console.error('Failed to start OAuth flow:', error); - toast.error(t('settings.providers.page.toast.oauthStartFailed')); - } finally { - setAuthBusyKey(null); - } - }; - - const handleOAuthComplete = async (providerId: string, methodIndex: number) => { - const codeKey = `${providerId}:${methodIndex}`; - const code = oauthCodes[codeKey]?.trim(); - - const busyKey = `oauth-complete:${providerId}:${methodIndex}`; - setAuthBusyKey(busyKey); - - try { - const requestBody: { method: number; code?: string } = { method: methodIndex }; - if (code) { - requestBody.code = code; - } - - const result = await opencodeClient.getSdkClient().provider.oauth.callback({ - providerID: providerId, - method: requestBody.method, - code: requestBody.code, - }); - if (result.error) { - throw new Error(t('settings.providers.page.toast.oauthCompleteFailed')); - } - - toast.success(t('settings.providers.page.toast.oauthCompleted')); - setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); - setPendingOAuth(null); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); - setSelectedProvider(providerId); - } catch (error) { - console.error('Failed to complete OAuth flow:', error); - toast.error(t('settings.providers.page.toast.oauthCompleteFailed')); - } finally { - setAuthBusyKey(null); - } - }; - - const handleCopyOAuthLink = async (url: string) => { - const result = await copyTextToClipboard(url); - if (result.ok) { - toast.success(t('settings.providers.page.toast.oauthLinkCopied')); - return; - } - console.error('Failed to copy OAuth link:', result.error); - toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed')); - }; - - const handleCopyOAuthCode = async (code: string) => { - const result = await copyTextToClipboard(code); - if (result.ok) { - toast.success(t('settings.providers.page.toast.deviceCodeCopied')); - return; - } - console.error('Failed to copy device code:', result.error); - toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); + const handleOAuthConnected = async (providerId: string) => { + setShowAuthPanel(false); + await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' }); + setSelectedProvider(providerId); }; const handleDisconnectProvider = async (providerId: string) => { @@ -777,7 +681,10 @@ export const ProvidersPage: React.FC = () => { <> {(() => { const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; - const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods); + const candidateOAuthMethods = toOAuthMethods( + getOAuthAuthMethods(candidateAuthMethods), + oauthMethodFallbackLabel, + ); const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods); return ( @@ -814,85 +721,13 @@ export const ProvidersPage: React.FC = () => { ) : null} {candidateOAuthMethods.length > 0 ? ( -
- {candidateOAuthMethods.map(({ method, methodIndex }) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); - const codeKey = `${candidateProviderId}:${methodIndex}`; - const isPending = - pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; - - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} -
- )} -
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ handleOAuthConnected(candidateProviderId)} + className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')} + /> ) : null} ); @@ -919,7 +754,10 @@ export const ProvidersPage: React.FC = () => { const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; - const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods); + const oauthAuthMethods = toOAuthMethods( + getOAuthAuthMethods(providerAuthMethods), + oauthMethodFallbackLabel, + ); const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods); const sourcesLoaded = Boolean(selectedSources); const isEditableCustomProvider = sourcesLoaded @@ -1062,85 +900,13 @@ export const ProvidersPage: React.FC = () => { ) : null} {oauthAuthMethods.length > 0 && ( -
- {oauthAuthMethods.map(({ method, methodIndex }) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); - const codeKey = `${selectedProvider.id}:${methodIndex}`; - const isPending = - pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex; - - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} -
- )} -
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ handleOAuthConnected(selectedProvider.id)} + className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')} + /> )} )} diff --git a/packages/ui/src/components/sections/providers/provider-oauth.test.ts b/packages/ui/src/components/sections/providers/provider-oauth.test.ts new file mode 100644 index 00000000..93f8e762 --- /dev/null +++ b/packages/ui/src/components/sections/providers/provider-oauth.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test'; +import { + collectPromptInputs, + defaultPromptValues, + describeOAuthError, + firstUnansweredPrompt, + isPromptVisible, + parseAuthPrompts, + parseAuthorization, + visiblePrompts, + type AuthPrompt, + type ProviderOAuthTranslator, +} from './provider-oauth'; + +/** Mirrors the github-copilot auth method shipped by OpenCode. */ +const copilotPrompts = [ + { + type: 'select', + key: 'deploymentType', + message: 'Select GitHub deployment type', + options: [ + { label: 'GitHub.com', value: 'github.com', hint: 'Public' }, + { label: 'GitHub Enterprise', value: 'enterprise' }, + ], + }, + { + type: 'text', + key: 'enterpriseUrl', + message: 'Enter your GitHub Enterprise URL or domain', + placeholder: 'company.ghe.com', + when: { key: 'deploymentType', op: 'eq', value: 'enterprise' }, + }, +]; + +describe('parseAuthPrompts', () => { + test('parses select and conditional text prompts', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + expect(prompts).toHaveLength(2); + expect(prompts[0]).toEqual({ + type: 'select', + key: 'deploymentType', + message: 'Select GitHub deployment type', + options: [ + { value: 'github.com', label: 'GitHub.com', hint: 'Public' }, + { value: 'enterprise', label: 'GitHub Enterprise' }, + ], + }); + expect(prompts[1]).toEqual({ + type: 'text', + key: 'enterpriseUrl', + message: 'Enter your GitHub Enterprise URL or domain', + options: [], + placeholder: 'company.ghe.com', + when: { key: 'deploymentType', op: 'eq', value: 'enterprise' }, + }); + }); + + test('returns an empty list for a method without prompts', () => { + expect(parseAuthPrompts(undefined)).toEqual([]); + expect(parseAuthPrompts(null)).toEqual([]); + expect(parseAuthPrompts({})).toEqual([]); + }); + + test('drops entries that could never be answered', () => { + const prompts = parseAuthPrompts([ + { type: 'text', message: 'no key' }, + { type: 'select', key: 'empty', message: 'no options', options: [] }, + { type: 'text', key: 'keep', message: 'keep me' }, + ]); + + expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']); + }); + + test('falls back to the key when a message is missing', () => { + expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token'); + }); + + test('ignores a malformed when condition instead of hiding the prompt', () => { + const [prompt] = parseAuthPrompts([ + { type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } }, + ]); + + expect(prompt.when).toBe(undefined); + expect(isPromptVisible(prompt, {})).toBe(true); + }); +}); + +describe('prompt visibility', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + test('hides a conditional prompt until its branch is selected', () => { + expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key)) + .toEqual(['deploymentType']); + expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key)) + .toEqual(['deploymentType', 'enterpriseUrl']); + }); + + test('supports neq conditions', () => { + const prompt: AuthPrompt = { + type: 'text', + key: 'custom', + message: 'Custom', + options: [], + when: { key: 'mode', op: 'neq', value: 'default' }, + }; + + expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false); + expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true); + expect(isPromptVisible(prompt, {})).toBe(true); + }); +}); + +describe('prompt answers', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + test('preselects the first select option so the form starts answerable', () => { + expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' }); + expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull(); + }); + + test('reports the hidden-then-revealed field as unanswered', () => { + const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' }; + + expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl'); + }); + + test('omits answers whose prompt is no longer visible', () => { + const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' }; + + expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' }); + }); + + test('trims submitted answers', () => { + const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' }; + + expect(collectPromptInputs(prompts, values)).toEqual({ + deploymentType: 'enterprise', + enterpriseUrl: 'company.ghe.com', + }); + }); +}); + +describe('parseAuthorization', () => { + test('reads a device-code authorization and recovers the code from instructions', () => { + const authorization = parseAuthorization({ + url: 'https://github.com/login/device', + instructions: 'Enter code: 1A2B-3C4D', + method: 'auto', + }); + + expect(authorization).toEqual({ + method: 'auto', + url: 'https://github.com/login/device', + instructions: 'Enter code: 1A2B-3C4D', + userCode: '1A2B-3C4D', + }); + }); + + test('keeps an explicitly reported code over the instructions match', () => { + expect(parseAuthorization({ + url: 'https://example.com', + instructions: 'Enter code: AAAA-BBBB', + user_code: 'ZZZZ-9999', + method: 'auto', + })?.userCode).toBe('ZZZZ-9999'); + }); + + test('preserves the code method', () => { + expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code'); + }); + + test('treats a missing or unknown method as auto', () => { + expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto'); + expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto'); + }); + + test('unwraps a nested data envelope', () => { + expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({ + method: 'code', + url: 'https://example.com', + }); + }); + + test('accepts device-authorization field names', () => { + expect(parseAuthorization({ + verification_uri_complete: 'https://example.com/activate?code=1', + message: 'Open the link', + })).toEqual({ + method: 'auto', + url: 'https://example.com/activate?code=1', + instructions: 'Open the link', + }); + }); + + test('returns null when nothing is actionable', () => { + expect(parseAuthorization(null)).toBeNull(); + expect(parseAuthorization({})).toBeNull(); + expect(parseAuthorization({ method: 'auto' })).toBeNull(); + }); +}); + +describe('describeOAuthError', () => { + const t: ProviderOAuthTranslator = (key) => key; + const fallback = 'settings.providers.page.toast.oauthCompleteFailed'; + + /** Names come from OpenCode's ProviderAuthApiError schema. */ + test('maps each provider auth error name to its own message', () => { + expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.sessionExpired'); + expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.codeRequired'); + expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.declined'); + }); + + test('surfaces the plugin-authored validation message verbatim', () => { + const error = { + name: 'ProviderAuthValidationFailed', + data: { field: 'enterpriseUrl', message: 'URL or domain is required' }, + }; + + expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required'); + }); + + test('falls back when a validation failure carries no message', () => { + expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.invalidInput'); + }); + + test('falls back for unknown, empty, and non-object errors', () => { + expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback); + expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback); + expect(describeOAuthError(undefined, t, fallback)).toBe(fallback); + }); +}); diff --git a/packages/ui/src/components/sections/providers/provider-oauth.ts b/packages/ui/src/components/sections/providers/provider-oauth.ts new file mode 100644 index 00000000..f85a8ef6 --- /dev/null +++ b/packages/ui/src/components/sections/providers/provider-oauth.ts @@ -0,0 +1,244 @@ +/** + * Provider OAuth flow helpers. + * + * `POST /provider/{id}/oauth/authorize` answers with the completion method that + * decides what the client has to do next: + * + * - `auto` — the client must call `oauth/callback` right away and hold that + * request open. Upstream blocks inside it (device-code polling, or waiting on + * a loopback redirect) until the user finishes signing in, and only then + * persists the credential. Nothing is stored if the client never calls it. + * - `code` — the user copies a code out of the browser and hands it to + * `oauth/callback`. + * + * Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported + * for third-party auth plugins that still return it. + */ + +import type { I18nKey, I18nParams } from '@/lib/i18n'; + +export type OAuthCompletionMethod = 'auto' | 'code'; + +export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string; + +export interface OAuthAuthorization { + method: OAuthCompletionMethod; + url?: string; + instructions?: string; + /** Device code surfaced separately so it can be copied on its own. */ + userCode?: string; +} + +export interface AuthPromptOption { + label: string; + value: string; + hint?: string; +} + +export interface AuthPromptCondition { + key: string; + op: 'eq' | 'neq'; + value: string; +} + +export interface AuthPrompt { + type: 'text' | 'select'; + key: string; + message: string; + placeholder?: string; + options: AuthPromptOption[]; + when?: AuthPromptCondition; +} + +/** + * Device codes are only carried inside the human-readable instructions + * (`Enter code: ABCD-1234`), so they are recovered by shape. + */ +const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asText = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +const parsePromptOptions = (value: unknown): AuthPromptOption[] => { + if (!Array.isArray(value)) { + return []; + } + const options: AuthPromptOption[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const optionValue = asText(entry.value); + if (optionValue === undefined) { + continue; + } + options.push({ + value: optionValue, + label: asText(entry.label) ?? optionValue, + ...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}), + }); + } + return options; +}; + +const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => { + if (!isRecord(value)) { + return undefined; + } + const key = asText(value.key); + const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined; + if (!key || !op || typeof value.value !== 'string') { + return undefined; + } + return { key, op, value: value.value }; +}; + +/** Parses the `prompts` an auth method wants answered before `authorize`. */ +export const parseAuthPrompts = (value: unknown): AuthPrompt[] => { + if (!Array.isArray(value)) { + return []; + } + const prompts: AuthPrompt[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const key = asText(entry.key); + if (!key) { + continue; + } + const type = entry.type === 'select' ? 'select' : 'text'; + const options = type === 'select' ? parsePromptOptions(entry.options) : []; + // A select with no usable option can never be answered; skipping it would + // silently drop a required input, so treat the whole method as unusable. + if (type === 'select' && options.length === 0) { + continue; + } + const when = parsePromptCondition(entry.when); + prompts.push({ + type, + key, + message: asText(entry.message) ?? key, + options, + ...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}), + ...(when ? { when } : {}), + }); + } + return prompts; +}; + +/** True when a prompt's `when` condition is satisfied by the answers so far. */ +export const isPromptVisible = (prompt: AuthPrompt, values: Record): boolean => { + if (!prompt.when) { + return true; + } + const current = values[prompt.when.key] ?? ''; + return prompt.when.op === 'eq' + ? current === prompt.when.value + : current !== prompt.when.value; +}; + +export const visiblePrompts = ( + prompts: AuthPrompt[], + values: Record, +): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values)); + +/** Selects preselect their first option so the form always starts answerable. */ +export const defaultPromptValues = (prompts: AuthPrompt[]): Record => { + const values: Record = {}; + for (const prompt of prompts) { + values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : ''; + } + return values; +}; + +/** First visible prompt still left blank, or `null` when the form is complete. */ +export const firstUnansweredPrompt = ( + prompts: AuthPrompt[], + values: Record, +): AuthPrompt | null => + visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null; + +/** + * Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a + * stale answer from a since-changed branch is never sent upstream. + */ +export const collectPromptInputs = ( + prompts: AuthPrompt[], + values: Record, +): Record => { + const inputs: Record = {}; + for (const prompt of visiblePrompts(prompts, values)) { + inputs[prompt.key] = (values[prompt.key] ?? '').trim(); + } + return inputs; +}; + +/** + * Normalizes an `authorize` response. + * + * Anything that is not explicitly `code` is treated as `auto`: `auto` only + * means "call back and wait", which is also the safe reading of an unknown + * method, whereas guessing `code` would strand the user at a paste field no + * provider can fill. + * + * Returns `null` when the response carries nothing the user can act on. + */ +export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => { + const outer: Record = isRecord(payload) ? payload : {}; + const record: Record = isRecord(outer.data) ? outer.data : outer; + + const url = + asText(record.url) + ?? asText(record.verification_uri_complete) + ?? asText(record.verification_uri); + const instructions = asText(record.instructions) ?? asText(record.message); + + if (!url && !instructions) { + return null; + } + + const userCode = + asText(record.user_code) + ?? asText(record.userCode) + ?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined); + + return { + method: record.method === 'code' ? 'code' : 'auto', + ...(url ? { url } : {}), + ...(instructions ? { instructions } : {}), + ...(userCode ? { userCode } : {}), + }; +}; + +/** + * Renders a `ProviderAuthApiError` as user-facing copy. + * + * Validation failures carry a message authored by the auth plugin (a field + * rule such as "URL or domain is required"); it is shown verbatim because only + * the plugin knows which input was rejected. + */ +export const describeOAuthError = ( + error: unknown, + t: ProviderOAuthTranslator, + fallbackKey: I18nKey, +): string => { + const record: Record = isRecord(error) ? error : {}; + const data: Record = isRecord(record.data) ? record.data : {}; + + switch (record.name) { + case 'ProviderAuthOauthMissing': + return t('settings.providers.page.auth.oauth.error.sessionExpired'); + case 'ProviderAuthOauthCodeMissing': + return t('settings.providers.page.auth.oauth.error.codeRequired'); + case 'ProviderAuthOauthCallbackFailed': + return t('settings.providers.page.auth.oauth.error.declined'); + case 'ProviderAuthValidationFailed': + return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput'); + default: + return t(fallbackKey); + } +}; diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index 5d253e0b..18ca4a9c 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -5,6 +5,8 @@ export interface AuthMethod { description?: string; help?: string; method?: number; + /** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */ + prompts?: unknown; [key: string]: unknown; } diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index fc3cfa86..94cc8694 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1321,6 +1321,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen', + 'settings.providers.page.auth.oauth.starting': 'Autorisierung wird gestartet …', + 'settings.providers.page.auth.oauth.waiting': 'Warten auf Autorisierung …', + 'settings.providers.page.auth.oauth.waitingHint': 'Schließen Sie die Anmeldung im Browser ab. Lassen Sie diese Seite geöffnet – die Verbindung wird von selbst hergestellt.', + 'settings.providers.page.auth.oauth.codeHint': 'Kopieren Sie den Autorisierungscode aus dem Browser und fügen Sie ihn hier ein.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Gerätecode', + 'settings.providers.page.auth.oauth.linkLabel': 'Autorisierungslink', + 'settings.providers.page.auth.oauth.promptRequired': 'Füllen Sie „{field}“ aus, um fortzufahren', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'Die Autorisierungsanfrage ist abgelaufen. Verbinden Sie erneut, um sie neu zu starten.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Dieser Anbieter benötigt den Autorisierungscode aus Ihrem Browser.', + 'settings.providers.page.auth.oauth.error.declined': 'Die Autorisierung wurde abgelehnt oder nicht abgeschlossen.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Die eingegebenen Angaben wurden abgelehnt.', 'settings.providers.page.auth.connected': 'Verbunden', 'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen', 'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden', @@ -1351,6 +1362,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Öffnen', 'settings.providers.page.actions.copy': 'Kopieren', 'settings.providers.page.actions.complete': 'Vervollständigen', + 'settings.providers.page.actions.continue': 'Weiter', + 'settings.providers.page.actions.cancel': 'Abbrechen', + 'settings.providers.page.actions.tryAgain': 'Wiederholen', 'settings.providers.page.actions.hide': 'Ausblenden', 'settings.providers.page.actions.reconnect': 'Erneut verbinden', 'settings.providers.page.actions.edit': 'Bearbeiten', @@ -1365,7 +1379,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert', 'settings.providers.page.toast.oauthStartFailed': 'Fehler beim Starten des OAuth-Flows', 'settings.providers.page.toast.oauthDetailsMissing': 'Keine OAuth-Details zurückgegeben', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Schließen Sie den OAuth-Flow in Ihrem Browser ab', 'settings.providers.page.toast.oauthCompleteFailed': 'Fehler beim Abschließen des OAuth-Flows', 'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 4a1deb81..82ef3778 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1386,6 +1386,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code', + 'settings.providers.page.auth.oauth.starting': 'Starting authorization…', + 'settings.providers.page.auth.oauth.waiting': 'Waiting for authorization…', + 'settings.providers.page.auth.oauth.waitingHint': 'Finish signing in in your browser. Keep this page open — the connection completes on its own.', + 'settings.providers.page.auth.oauth.codeHint': 'Copy the authorization code from your browser and paste it here.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Device code', + 'settings.providers.page.auth.oauth.linkLabel': 'Authorization link', + 'settings.providers.page.auth.oauth.promptRequired': 'Fill in “{field}” to continue', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'The authorization request expired. Connect again to restart it.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'This provider needs the authorization code from your browser.', + 'settings.providers.page.auth.oauth.error.declined': 'Authorization was declined or did not complete.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'The details you entered were rejected.', 'settings.providers.page.auth.connected': 'Connected', 'settings.providers.page.auth.incomplete': 'Credentials missing', 'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat', @@ -1416,6 +1427,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Open', 'settings.providers.page.actions.copy': 'Copy', 'settings.providers.page.actions.complete': 'Complete', + 'settings.providers.page.actions.continue': 'Continue', + 'settings.providers.page.actions.cancel': 'Cancel', + 'settings.providers.page.actions.tryAgain': 'Try again', 'settings.providers.page.actions.hide': 'Hide', 'settings.providers.page.actions.reconnect': 'Reconnect', 'settings.providers.page.actions.edit': 'Edit', @@ -1430,7 +1444,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API key saved', 'settings.providers.page.toast.oauthStartFailed': 'Failed to start OAuth flow', 'settings.providers.page.toast.oauthDetailsMissing': 'No OAuth details returned', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Complete the OAuth flow in your browser', 'settings.providers.page.toast.oauthCompleteFailed': 'Failed to complete OAuth flow', 'settings.providers.page.toast.oauthCompleted': 'OAuth connection completed', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth link copied', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 31b07208..b4613162 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización", + "settings.providers.page.auth.oauth.starting": "Iniciando la autorización…", + "settings.providers.page.auth.oauth.waiting": "Esperando la autorización…", + "settings.providers.page.auth.oauth.waitingHint": "Termina de iniciar sesión en el navegador. Mantén esta página abierta: la conexión se completará sola.", + "settings.providers.page.auth.oauth.codeHint": "Copia el código de autorización del navegador y pégalo aquí.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Código del dispositivo", + "settings.providers.page.auth.oauth.linkLabel": "Enlace de autorización", + "settings.providers.page.auth.oauth.promptRequired": "Completa «{field}» para continuar", + "settings.providers.page.auth.oauth.error.sessionExpired": "La solicitud de autorización caducó. Vuelve a conectar para reiniciarla.", + "settings.providers.page.auth.oauth.error.codeRequired": "Este proveedor necesita el código de autorización de tu navegador.", + "settings.providers.page.auth.oauth.error.declined": "La autorización se rechazó o no se completó.", + "settings.providers.page.auth.oauth.error.invalidInput": "Se rechazaron los datos introducidos.", "settings.providers.page.auth.connected": "Conectado", "settings.providers.page.auth.incomplete": "Faltan credenciales", "settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Abrir", "settings.providers.page.actions.copy": "Copiar", "settings.providers.page.actions.complete": "Completar", + "settings.providers.page.actions.continue": "Continuar", + "settings.providers.page.actions.cancel": "Cancelar", + "settings.providers.page.actions.tryAgain": "Reintentar", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.edit": "Editar", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Clave API guardada", "settings.providers.page.toast.oauthStartFailed": "No se pudo iniciar el flujo OAuth", "settings.providers.page.toast.oauthDetailsMissing": "No se devolvieron detalles de OAuth", - "settings.providers.page.toast.completeOAuthInBrowser": "Completa el flujo OAuth en tu navegador", "settings.providers.page.toast.oauthCompleteFailed": "No se pudo completar el flujo OAuth", "settings.providers.page.toast.oauthCompleted": "Conexión OAuth completada", "settings.providers.page.toast.oauthLinkCopied": "Enlace de OAuth copiado", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f5dc26d4..249dfad8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1280,6 +1280,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation', + 'settings.providers.page.auth.oauth.starting': 'Démarrage de l’autorisation…', + 'settings.providers.page.auth.oauth.waiting': 'En attente de l’autorisation…', + 'settings.providers.page.auth.oauth.waitingHint': 'Terminez la connexion dans votre navigateur. Laissez cette page ouverte : la connexion se finalisera d’elle-même.', + 'settings.providers.page.auth.oauth.codeHint': 'Copiez le code d’autorisation depuis votre navigateur et collez-le ici.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Code de l’appareil', + 'settings.providers.page.auth.oauth.linkLabel': 'Lien d’autorisation', + 'settings.providers.page.auth.oauth.promptRequired': 'Renseignez « {field} » pour continuer', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'La demande d’autorisation a expiré. Reconnectez-vous pour la relancer.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Ce fournisseur a besoin du code d’autorisation de votre navigateur.', + 'settings.providers.page.auth.oauth.error.declined': 'L’autorisation a été refusée ou n’a pas abouti.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Les informations saisies ont été refusées.', 'settings.providers.page.auth.connected': 'Connecté', 'settings.providers.page.auth.incomplete': 'Identifiants manquants', 'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant d’utiliser ce fournisseur dans le chat', @@ -1312,6 +1323,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Ouvrir', 'settings.providers.page.actions.copy': 'Copie', 'settings.providers.page.actions.complete': 'Complet', + 'settings.providers.page.actions.continue': 'Continuer', + 'settings.providers.page.actions.cancel': 'Annuler', + 'settings.providers.page.actions.tryAgain': 'Réessayer', 'settings.providers.page.actions.hide': 'Cacher', 'settings.providers.page.actions.reconnect': 'Reconnecter', 'settings.providers.page.actions.edit': 'Modifier', @@ -1327,7 +1341,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'Clé API enregistrée', 'settings.providers.page.toast.oauthStartFailed': 'Échec du démarrage du flux OAuth', 'settings.providers.page.toast.oauthDetailsMissing': 'Aucun détail OAuth renvoyé', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Complétez le flux OAuth dans votre navigateur', 'settings.providers.page.toast.oauthCompleteFailed': 'Échec de la réalisation du flux OAuth', 'settings.providers.page.toast.oauthCompleted': 'Connexion OAuth terminée', 'settings.providers.page.toast.oauthLinkCopied': 'Lien OAuth copié', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 3d7565a9..f2c58bdd 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1392,6 +1392,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け', + 'settings.providers.page.auth.oauth.starting': '認証を開始しています…', + 'settings.providers.page.auth.oauth.waiting': '認証を待っています…', + 'settings.providers.page.auth.oauth.waitingHint': 'ブラウザーでサインインを完了してください。このページは開いたままにしてください。接続は自動的に完了します。', + 'settings.providers.page.auth.oauth.codeHint': 'ブラウザーから認証コードをコピーして、ここに貼り付けてください。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'デバイスコード', + 'settings.providers.page.auth.oauth.linkLabel': '認証リンク', + 'settings.providers.page.auth.oauth.promptRequired': '続行するには「{field}」を入力してください', + 'settings.providers.page.auth.oauth.error.sessionExpired': '認証リクエストの有効期限が切れました。もう一度接続してやり直してください。', + 'settings.providers.page.auth.oauth.error.codeRequired': 'このプロバイダーにはブラウザーの認証コードが必要です。', + 'settings.providers.page.auth.oauth.error.declined': '認証が拒否されたか、完了しませんでした。', + 'settings.providers.page.auth.oauth.error.invalidInput': '入力された内容は拒否されました。', 'settings.providers.page.auth.connected': '接続済み', 'settings.providers.page.auth.incomplete': '認証情報が不足しています', 'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください', @@ -1424,6 +1435,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '開く', 'settings.providers.page.actions.copy': 'コピー', 'settings.providers.page.actions.complete': '完了', + 'settings.providers.page.actions.continue': '続行', + 'settings.providers.page.actions.cancel': 'キャンセル', + 'settings.providers.page.actions.tryAgain': '再試行', 'settings.providers.page.actions.hide': '非表示', 'settings.providers.page.actions.reconnect': '再接続', 'settings.providers.page.actions.edit': '編集', @@ -1439,7 +1453,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました', 'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました', 'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした', - 'settings.providers.page.toast.completeOAuthInBrowser': 'ブラウザで OAuth フローを完了してください', 'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました', 'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index df181c60..0f9b0aba 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기', + 'settings.providers.page.auth.oauth.starting': '인증을 시작하는 중…', + 'settings.providers.page.auth.oauth.waiting': '인증을 기다리는 중…', + 'settings.providers.page.auth.oauth.waitingHint': '브라우저에서 로그인을 완료하세요. 이 페이지를 열어 두면 연결이 자동으로 완료됩니다.', + 'settings.providers.page.auth.oauth.codeHint': '브라우저에서 인증 코드를 복사해 여기에 붙여넣으세요.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '기기 코드', + 'settings.providers.page.auth.oauth.linkLabel': '인증 링크', + 'settings.providers.page.auth.oauth.promptRequired': '계속하려면 “{field}”을(를) 입력하세요', + 'settings.providers.page.auth.oauth.error.sessionExpired': '인증 요청이 만료되었습니다. 다시 연결해 처음부터 시작하세요.', + 'settings.providers.page.auth.oauth.error.codeRequired': '이 제공자에는 브라우저의 인증 코드가 필요합니다.', + 'settings.providers.page.auth.oauth.error.declined': '인증이 거부되었거나 완료되지 않았습니다.', + 'settings.providers.page.auth.oauth.error.invalidInput': '입력한 정보가 거부되었습니다.', 'settings.providers.page.auth.connected': '연결됨', 'settings.providers.page.auth.incomplete': '자격 증명 없음', 'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요', @@ -1391,6 +1402,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '열기', 'settings.providers.page.actions.copy': '복사', 'settings.providers.page.actions.complete': '완료', + 'settings.providers.page.actions.continue': '계속', + 'settings.providers.page.actions.cancel': '취소', + 'settings.providers.page.actions.tryAgain': '다시 시도', 'settings.providers.page.actions.hide': '숨기기', 'settings.providers.page.actions.reconnect': '재연결', 'settings.providers.page.actions.edit': '편집', @@ -1406,7 +1420,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API key가 저장되었습니다', 'settings.providers.page.toast.oauthStartFailed': 'OAuth flow를 시작하지 못했습니다', 'settings.providers.page.toast.oauthDetailsMissing': '반환된 OAuth 세부 정보가 없습니다', - 'settings.providers.page.toast.completeOAuthInBrowser': '브라우저에서 OAuth flow를 완료하세요', 'settings.providers.page.toast.oauthCompleteFailed': 'OAuth flow를 완료하지 못했습니다', 'settings.providers.page.toast.oauthCompleted': 'OAuth 연결이 완료되었습니다', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 링크가 복사되었습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index dcc3d774..89bafd5b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1360,6 +1360,9 @@ export const settingsDict = { 'settings.projects.sidebar.actions.addProject': 'Dodaj projekt', 'settings.projects.sidebar.total': 'Suma: {count}', 'settings.providers.page.actions.complete': 'Zakończ', + 'settings.providers.page.actions.continue': 'Kontynuuj', + 'settings.providers.page.actions.cancel': 'Anuluj', + 'settings.providers.page.actions.tryAgain': 'Spróbuj ponownie', 'settings.providers.page.actions.connect': 'Połącz', 'settings.providers.page.actions.copy': 'Kopiuj', 'settings.providers.page.actions.copyCode': 'Kopiuj kod', @@ -1385,6 +1388,17 @@ export const settingsDict = { 'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...', 'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny', + 'settings.providers.page.auth.oauth.starting': 'Rozpoczynanie autoryzacji…', + 'settings.providers.page.auth.oauth.waiting': 'Oczekiwanie na autoryzację…', + 'settings.providers.page.auth.oauth.waitingHint': 'Dokończ logowanie w przeglądarce. Zostaw tę stronę otwartą — połączenie zakończy się samo.', + 'settings.providers.page.auth.oauth.codeHint': 'Skopiuj kod autoryzacji z przeglądarki i wklej go tutaj.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Kod urządzenia', + 'settings.providers.page.auth.oauth.linkLabel': 'Link autoryzacyjny', + 'settings.providers.page.auth.oauth.promptRequired': 'Wypełnij pole „{field}”, aby kontynuować', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'Żądanie autoryzacji wygasło. Połącz ponownie, aby zacząć od nowa.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Ten dostawca wymaga kodu autoryzacji z przeglądarki.', + 'settings.providers.page.auth.oauth.error.declined': 'Autoryzacja została odrzucona lub nie została ukończona.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Wprowadzone dane zostały odrzucone.', 'settings.providers.page.auth.title': 'Uwierzytelnianie', 'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania', 'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy', @@ -1474,7 +1488,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaveFailed': 'Nie udało się zapisać klucza API', 'settings.providers.page.toast.apiKeySaved': 'Klucz API został zapisany', 'settings.providers.page.toast.authMethodsLoadFailed': 'Nie udało się załadować metod uwierzytelniania dostawcy', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Dokończ proces OAuth w przeglądarce', 'settings.providers.page.toast.deviceCodeCopied': 'Kod urządzenia został skopiowany', 'settings.providers.page.toast.deviceCodeCopyFailed': 'Nie udało się skopiować kodu urządzenia', 'settings.providers.page.toast.oauthCompleteFailed': 'Nie udało się dokończyć procesu OAuth', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 5950ec10..73386454 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização", + "settings.providers.page.auth.oauth.starting": "Iniciando a autorização…", + "settings.providers.page.auth.oauth.waiting": "Aguardando a autorização…", + "settings.providers.page.auth.oauth.waitingHint": "Conclua o login no navegador. Mantenha esta página aberta — a conexão será concluída sozinha.", + "settings.providers.page.auth.oauth.codeHint": "Copie o código de autorização do navegador e cole aqui.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Código do dispositivo", + "settings.providers.page.auth.oauth.linkLabel": "Link de autorização", + "settings.providers.page.auth.oauth.promptRequired": "Preencha “{field}” para continuar", + "settings.providers.page.auth.oauth.error.sessionExpired": "A solicitação de autorização expirou. Conecte novamente para reiniciá-la.", + "settings.providers.page.auth.oauth.error.codeRequired": "Este provedor precisa do código de autorização do seu navegador.", + "settings.providers.page.auth.oauth.error.declined": "A autorização foi recusada ou não foi concluída.", + "settings.providers.page.auth.oauth.error.invalidInput": "Os dados informados foram recusados.", "settings.providers.page.auth.connected": "Conectado", "settings.providers.page.auth.incomplete": "Credenciais ausentes", "settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Abrir", "settings.providers.page.actions.copy": "Copiar", "settings.providers.page.actions.complete": "Completar", + "settings.providers.page.actions.continue": "Continuar", + "settings.providers.page.actions.cancel": "Cancelar", + "settings.providers.page.actions.tryAgain": "Tentar novamente", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.edit": "Editar", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Chave API salva", "settings.providers.page.toast.oauthStartFailed": "Não foi possível iniciar o fluxo OAuth", "settings.providers.page.toast.oauthDetailsMissing": "Não se devolvieron detalhes de OAuth", - "settings.providers.page.toast.completeOAuthInBrowser": "Complete o fluxo OAuth no navegador", "settings.providers.page.toast.oauthCompleteFailed": "Não foi possível concluir o fluxo OAuth", "settings.providers.page.toast.oauthCompleted": "Conexão OAuth concluída", "settings.providers.page.toast.oauthLinkCopied": "Link de OAuth copiado", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 306608d2..42d29d22 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації", + "settings.providers.page.auth.oauth.starting": "Запускаємо авторизацію…", + "settings.providers.page.auth.oauth.waiting": "Очікуємо на авторизацію…", + "settings.providers.page.auth.oauth.waitingHint": "Завершіть вхід у браузері. Не закривайте цю сторінку — підключення завершиться саме.", + "settings.providers.page.auth.oauth.codeHint": "Скопіюйте код авторизації з браузера і вставте його сюди.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Код пристрою", + "settings.providers.page.auth.oauth.linkLabel": "Посилання для авторизації", + "settings.providers.page.auth.oauth.promptRequired": "Заповніть «{field}», щоб продовжити", + "settings.providers.page.auth.oauth.error.sessionExpired": "Термін дії запиту на авторизацію минув. Підключіться ще раз, щоб почати заново.", + "settings.providers.page.auth.oauth.error.codeRequired": "Цьому провайдеру потрібен код авторизації з браузера.", + "settings.providers.page.auth.oauth.error.declined": "Авторизацію відхилено або не завершено.", + "settings.providers.page.auth.oauth.error.invalidInput": "Введені дані відхилено.", "settings.providers.page.auth.connected": "Підключено", "settings.providers.page.auth.incomplete": "Облікові дані відсутні", "settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Відкрити", "settings.providers.page.actions.copy": "Копіювати", "settings.providers.page.actions.complete": "Завершити", + "settings.providers.page.actions.continue": "Продовжити", + "settings.providers.page.actions.cancel": "Скасувати", + "settings.providers.page.actions.tryAgain": "Повторити спробу", "settings.providers.page.actions.hide": "Сховати", "settings.providers.page.actions.reconnect": "Перепідключити", "settings.providers.page.actions.edit": "Редагувати", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Ключ API збережено", "settings.providers.page.toast.oauthStartFailed": "Не вдалося запустити потік OAuth", "settings.providers.page.toast.oauthDetailsMissing": "Деталі OAuth не повернуто", - "settings.providers.page.toast.completeOAuthInBrowser": "Завершіть процес OAuth у вашому браузері", "settings.providers.page.toast.oauthCompleteFailed": "Не вдалося завершити потік OAuth", "settings.providers.page.toast.oauthCompleted": "Підключення OAuth завершено", "settings.providers.page.toast.oauthLinkCopied": "Посилання OAuth скопійовано", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 42aa7d0a..36f85a43 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码', + 'settings.providers.page.auth.oauth.starting': '正在启动授权…', + 'settings.providers.page.auth.oauth.waiting': '正在等待授权…', + 'settings.providers.page.auth.oauth.waitingHint': '请在浏览器中完成登录。保持此页面打开,连接会自动完成。', + 'settings.providers.page.auth.oauth.codeHint': '从浏览器复制授权码并粘贴到此处。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '设备码', + 'settings.providers.page.auth.oauth.linkLabel': '授权链接', + 'settings.providers.page.auth.oauth.promptRequired': '请填写“{field}”后继续', + 'settings.providers.page.auth.oauth.error.sessionExpired': '授权请求已过期。请重新连接以重新开始。', + 'settings.providers.page.auth.oauth.error.codeRequired': '此提供方需要浏览器中的授权码。', + 'settings.providers.page.auth.oauth.error.declined': '授权被拒绝或未完成。', + 'settings.providers.page.auth.oauth.error.invalidInput': '输入的信息被拒绝。', 'settings.providers.page.auth.connected': '已连接', 'settings.providers.page.auth.incomplete': '缺少凭据', 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}', @@ -1391,6 +1402,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '打开', 'settings.providers.page.actions.copy': '复制', 'settings.providers.page.actions.complete': '完成', + 'settings.providers.page.actions.continue': '继续', + 'settings.providers.page.actions.cancel': '取消', + 'settings.providers.page.actions.tryAgain': '重试', 'settings.providers.page.actions.hide': '隐藏', 'settings.providers.page.actions.reconnect': '重新连接', 'settings.providers.page.actions.edit': '编辑', @@ -1406,7 +1420,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API Key 已保存', 'settings.providers.page.toast.oauthStartFailed': '启动 OAuth 流程失败', 'settings.providers.page.toast.oauthDetailsMissing': '未返回 OAuth 详情', - 'settings.providers.page.toast.completeOAuthInBrowser': '请在浏览器中完成 OAuth 流程', 'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失败', 'settings.providers.page.toast.oauthCompleted': 'OAuth 连接已完成', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 链接已复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a1d814b0..892dab37 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1265,6 +1265,17 @@ 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼', + 'settings.providers.page.auth.oauth.starting': '正在啟動授權…', + 'settings.providers.page.auth.oauth.waiting': '正在等待授權…', + 'settings.providers.page.auth.oauth.waitingHint': '請在瀏覽器中完成登入。保持此頁面開啟,連線會自動完成。', + 'settings.providers.page.auth.oauth.codeHint': '從瀏覽器複製授權碼並貼上到這裡。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '裝置碼', + 'settings.providers.page.auth.oauth.linkLabel': '授權連結', + 'settings.providers.page.auth.oauth.promptRequired': '請填寫「{field}」後繼續', + 'settings.providers.page.auth.oauth.error.sessionExpired': '授權請求已過期。請重新連線以重新開始。', + 'settings.providers.page.auth.oauth.error.codeRequired': '此提供者需要瀏覽器中的授權碼。', + 'settings.providers.page.auth.oauth.error.declined': '授權遭拒或未完成。', + 'settings.providers.page.auth.oauth.error.invalidInput': '輸入的資訊遭拒。', 'settings.providers.page.auth.connected': '已連線', 'settings.providers.page.auth.incomplete': '缺少憑證', 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}', @@ -1297,6 +1308,9 @@ 'settings.providers.page.actions.open': '開啟', 'settings.providers.page.actions.copy': '複製', 'settings.providers.page.actions.complete': '完成', + 'settings.providers.page.actions.continue': '繼續', + 'settings.providers.page.actions.cancel': '取消', + 'settings.providers.page.actions.tryAgain': '重試', 'settings.providers.page.actions.hide': '隱藏', 'settings.providers.page.actions.reconnect': '重新連線', 'settings.providers.page.actions.edit': '編輯', @@ -1312,7 +1326,6 @@ 'settings.providers.page.toast.apiKeySaved': 'API Key 已儲存', 'settings.providers.page.toast.oauthStartFailed': '啟動 OAuth 流程失敗', 'settings.providers.page.toast.oauthDetailsMissing': '未回傳 OAuth 詳情', - 'settings.providers.page.toast.completeOAuthInBrowser': '請在瀏覽器中完成 OAuth 流程', 'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失敗', 'settings.providers.page.toast.oauthCompleted': 'OAuth 連線已完成', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 連結已複製', diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 1c625beb..dfd520c0 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -375,6 +375,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - SSE forwarders: `GET /api/global/event`, `GET /api/event` - Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport. - Session message forwarder: `POST /api/session/:sessionId/message` + - Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback` + - Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline. - Generic `/api/*` forwarding with hop-by-hop header filtering - Windows `/session` merge fallback path behavior - OpenCode readiness gate for proxied `/api` requests diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 04fecf54..9d1be85b 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => { const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS); const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut'); + // A provider OAuth callback blocks upstream for as long as the user takes to + // sign in in their browser (device-code polling, or a loopback redirect), so + // it cannot share the ordinary request deadline. Bounded by the shortest + // upstream expiry we know of — GitHub device codes last ~15 minutes. + const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000; + const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/; + + const isInteractiveOAuthCallback = (req) => + req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path); + const isProxyTimeoutError = (error) => { const code = typeof error?.code === 'string' ? error.code : ''; const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; @@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => { }; const applyProxyResponseDeadline = (req, res, next) => { + if (isInteractiveOAuthCallback(req)) { + return next(); + } + const timeout = setTimeout(() => { req[PROXY_TIMEOUT_MARKER] = true; if (sendProxyErrorResponse(res, 504)) { @@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => { }); // Generic proxy for non-SSE OpenCode API routes. - const apiProxy = createProxyMiddleware({ + const createApiProxy = (timeoutMs) => createProxyMiddleware({ target: resolveProxyTarget(), changeOrigin: true, pathRewrite: { '^/api': '' }, - timeout: PROXY_REQUEST_TIMEOUT_MS, - proxyTimeout: PROXY_REQUEST_TIMEOUT_MS, + timeout: timeoutMs, + proxyTimeout: timeoutMs, // Dynamic target — port can change after restart router: () => resolveProxyTarget(), on: { @@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => { }, }); + const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS); + const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS); + // Best-effort fallback for stale clients still sending symlink paths. // Settings and project selection normalize at source; this cached async path // avoids blocking the proxy hot path on every directory-scoped request. @@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => { }); app.use('/api', applyProxyResponseDeadline); + app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy); app.use('/api', apiProxy); }; diff --git a/packages/web/server/opencode-proxy.test.js b/packages/web/server/opencode-proxy.test.js index da8c7f72..2eb1f0cd 100644 --- a/packages/web/server/opencode-proxy.test.js +++ b/packages/web/server/opencode-proxy.test.js @@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => { expect(response.status).toBe(504); await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' }); }); + + it('exempts interactive provider OAuth callbacks from the request deadline', async () => { + const upstream = express(); + // Stands in for upstream blocking until the user finishes signing in. + upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + res.json(true); + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 50, + getRuntime: () => ({ + openCodePort: upstreamPort, + openCodeBaseUrl: externalBaseUrl, + isOpenCodeReady: true, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ method: 0 }), + signal: AbortSignal.timeout(5000), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toBe(true); + }); + + it('still applies the request deadline to the OAuth authorize call', async () => { + const upstream = express(); + upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => { + // Leave the response open so the proxy timeout path is exercised. + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 50, + getRuntime: () => ({ + openCodePort: upstreamPort, + openCodeBaseUrl: externalBaseUrl, + isOpenCodeReady: true, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ method: 0 }), + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(504); + }); });