fix(providers): complete OAuth logins that finish in the browser
OpenCode's authorize response reports how the client must finish: `code` expects a pasted code, while `auto` requires the client to call oauth/callback immediately and hold it open — upstream blocks in there polling for the device code or waiting on its loopback redirect, and only that call persists the credential. Every auth plugin OpenCode ships uses `auto`; none use `code`. The page implemented only `code`. It opened the browser, showed a paste field no provider can fill, and never called back, so a successful sign-in stored nothing and the app sat unchanged. Authorization now drives the UI: `auto` chains straight into the callback behind a waiting state with a cancel, and the paste field appears only when a provider actually asks for a code. Two smaller failures shared that surface. Prompts were never collected, which put GitHub Copilot Enterprise out of reach entirely, so a method that declares them now asks first and passes the answers to authorize. Device codes are also recovered from the instructions text, where they actually live — the old code read fields the API does not return, so the copy button never appeared. The callback is exempt from the ordinary proxy deadline and gets a 15-minute budget, bounded by the shortest upstream expiry we know of. A human sign-in with 2FA does not fit in four minutes, and expiring it turned a completed login into a 504.
This commit is contained in:
@@ -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<void>;
|
||||
/** 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<ProviderOAuthMethodsProps> = ({
|
||||
providerId,
|
||||
methods,
|
||||
onConnected,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [flow, setFlow] = React.useState<Flow>(IDLE);
|
||||
const [promptValues, setPromptValues] = React.useState<Record<string, string>>({});
|
||||
const [codeInput, setCodeInput] = React.useState('');
|
||||
const callbackAbortRef = React.useRef<AbortController | null>(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<string, string>) => {
|
||||
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 (
|
||||
<div key={prompt.key} className="space-y-1.5">
|
||||
<label className="typography-ui-label text-foreground">{prompt.message}</label>
|
||||
{prompt.type === 'select' ? (
|
||||
<Select value={value} onValueChange={setValue}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||
<SelectValue>
|
||||
{(current) => prompt.options.find((option) => option.value === current)?.label ?? null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{prompt.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.hint ? `${option.label} · ${option.hint}` : option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder={prompt.placeholder}
|
||||
className="max-w-[24rem] text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAuthorizationDetails = (authorization: OAuthAuthorization) => (
|
||||
<>
|
||||
{authorization.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{authorization.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{authorization.userCode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={authorization.userCode}
|
||||
readOnly
|
||||
aria-label={t('settings.providers.page.auth.oauth.deviceCodeLabel')}
|
||||
className="font-mono text-center tracking-widest"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => void copy(
|
||||
authorization.userCode ?? '',
|
||||
'settings.providers.page.toast.deviceCodeCopied',
|
||||
'settings.providers.page.toast.deviceCodeCopyFailed',
|
||||
)}
|
||||
>
|
||||
{t('settings.providers.page.actions.copyCode')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authorization.url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={authorization.url}
|
||||
readOnly
|
||||
aria-label={t('settings.providers.page.auth.oauth.linkLabel')}
|
||||
className="text-xs text-muted-foreground"
|
||||
/>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void openExternalUrl(authorization.url ?? '')}
|
||||
>
|
||||
{t('settings.providers.page.actions.open')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void copy(
|
||||
authorization.url ?? '',
|
||||
'settings.providers.page.toast.oauthLinkCopied',
|
||||
'settings.providers.page.toast.oauthLinkCopyFailed',
|
||||
)}
|
||||
>
|
||||
{t('settings.providers.page.actions.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{methods.map((method) => {
|
||||
const isActive = activeIndex === method.index;
|
||||
|
||||
return (
|
||||
<div key={method.index} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-ui-label text-foreground">{method.label}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => beginConnect(method)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isActive && flow.phase === 'prompting' && (
|
||||
<div className="space-y-3">
|
||||
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)}
|
||||
{flow.error && (
|
||||
<p className="typography-meta text-[var(--status-error)]">{flow.error}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="xs" className="!font-normal" onClick={submitPrompts}>
|
||||
{t('settings.providers.page.actions.continue')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" className="!font-normal" onClick={cancel}>
|
||||
{t('settings.providers.page.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && flow.phase === 'authorizing' && (
|
||||
<p className="typography-meta text-muted-foreground flex items-center gap-2">
|
||||
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('settings.providers.page.auth.oauth.starting')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isActive && flow.phase === 'waiting' && (
|
||||
<div className="space-y-3">
|
||||
{renderAuthorizationDetails(flow.authorization)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="typography-meta text-muted-foreground flex items-center gap-2">
|
||||
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('settings.providers.page.auth.oauth.waiting')}
|
||||
</p>
|
||||
<Button variant="ghost" size="xs" className="!font-normal shrink-0" onClick={cancel}>
|
||||
{t('settings.providers.page.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.providers.page.auth.oauth.waitingHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && flow.phase === 'awaitingCode' && (
|
||||
<div className="space-y-3">
|
||||
{renderAuthorizationDetails(flow.authorization)}
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.providers.page.auth.oauth.codeHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={codeInput}
|
||||
onChange={(event) => setCodeInput(event.target.value)}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
disabled={flow.submitting}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={submitCode}
|
||||
disabled={flow.submitting || codeInput.trim().length === 0}
|
||||
>
|
||||
{flow.submitting
|
||||
? t('settings.providers.page.actions.saving')
|
||||
: t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={cancel}
|
||||
disabled={flow.submitting}
|
||||
>
|
||||
{t('settings.providers.page.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && flow.phase === 'failed' && (
|
||||
<div className="space-y-2">
|
||||
<p className="typography-meta text-[var(--status-error)]">{flow.message}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => beginConnect(method)}
|
||||
>
|
||||
{t('settings.providers.page.actions.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string, unknown> =>
|
||||
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<Record<string, string>>({});
|
||||
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
|
||||
const [modelQuery, setModelQuery] = React.useState('');
|
||||
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
|
||||
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
|
||||
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
|
||||
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
|
||||
const [availableLoading, setAvailableLoading] = React.useState(false);
|
||||
const [availableError, setAvailableError] = React.useState<string | null>(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<string, unknown> = isRecord(result.data) ? result.data : {};
|
||||
const nestedData = payloadRecord.data;
|
||||
const dataRecord: Record<string, unknown> = 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 ? (
|
||||
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{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 (
|
||||
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProviderOAuthMethods
|
||||
key={candidateProviderId}
|
||||
providerId={candidateProviderId}
|
||||
methods={candidateOAuthMethods}
|
||||
onConnected={() => 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 && (
|
||||
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||
{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 (
|
||||
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||
{(method.description || method.help) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
{String(method.description || method.help)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)}
|
||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`}
|
||||
>
|
||||
{t('settings.providers.page.actions.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||
{oauthDetails[codeKey]?.instructions}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauthDetails[codeKey]?.url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={oauthCodes[codeKey] ?? ''}
|
||||
onChange={(event) =>
|
||||
setOauthCodes((prev) => ({
|
||||
...prev,
|
||||
[codeKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(selectedProvider.id, methodIndex)}
|
||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProviderOAuthMethods
|
||||
key={selectedProvider.id}
|
||||
providerId={selectedProvider.id}
|
||||
methods={oauthAuthMethods}
|
||||
onConnected={() => handleOAuthConnected(selectedProvider.id)}
|
||||
className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> =>
|
||||
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<string, string>): 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<string, string>,
|
||||
): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values));
|
||||
|
||||
/** Selects preselect their first option so the form always starts answerable. */
|
||||
export const defaultPromptValues = (prompts: AuthPrompt[]): Record<string, string> => {
|
||||
const values: Record<string, string> = {};
|
||||
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<string, string>,
|
||||
): 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<string, string>,
|
||||
): Record<string, string> => {
|
||||
const inputs: Record<string, string> = {};
|
||||
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<string, unknown> = isRecord(payload) ? payload : {};
|
||||
const record: Record<string, unknown> = 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<string, unknown> = isRecord(error) ? error : {};
|
||||
const data: Record<string, unknown> = 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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 リンクをコピーしました',
|
||||
|
||||
@@ -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 링크가 복사되었습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 скопійовано",
|
||||
|
||||
@@ -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 链接已复制',
|
||||
|
||||
@@ -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 連結已複製',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user