Merge pull request #2621 from openchamber/feat/oauth-only-provider-auth-a542
fix(providers): hide API key form for OAuth-only providers
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||||
|
import {
|
||||||
|
getOAuthAuthMethods,
|
||||||
|
normalizeAuthType,
|
||||||
|
parseAuthPayload,
|
||||||
|
shouldShowApiKeyAuth,
|
||||||
|
} from './providerAuth';
|
||||||
|
|
||||||
describe('ProvidersPage available provider loading', () => {
|
describe('ProvidersPage available provider loading', () => {
|
||||||
test('loads available providers only in add-provider mode', () => {
|
test('loads available providers only in add-provider mode', () => {
|
||||||
@@ -7,3 +13,48 @@ describe('ProvidersPage available provider loading', () => {
|
|||||||
expect(shouldLoadAvailableProviders(true)).toBe(true);
|
expect(shouldLoadAvailableProviders(true)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('provider auth method helpers', () => {
|
||||||
|
test('normalizeAuthType recognizes oauth and api labels', () => {
|
||||||
|
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
|
||||||
|
expect(normalizeAuthType({ type: 'api', label: 'API Key' })).toBe('api');
|
||||||
|
expect(normalizeAuthType({ label: 'OAuth browser login' })).toBe('oauth');
|
||||||
|
expect(normalizeAuthType({ name: 'API key' })).toBe('api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAuthPayload keeps only object auth method entries', () => {
|
||||||
|
expect(parseAuthPayload({
|
||||||
|
cursor: [{ type: 'oauth', label: 'Cursor' }, 'skip'],
|
||||||
|
openai: null,
|
||||||
|
})).toEqual({
|
||||||
|
cursor: [{ type: 'oauth', label: 'Cursor' }],
|
||||||
|
});
|
||||||
|
expect(parseAuthPayload(null)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldShowApiKeyAuth hides API key for oauth-only providers', () => {
|
||||||
|
expect(shouldShowApiKeyAuth([{ type: 'oauth', label: 'Cursor OAuth' }])).toBe(false);
|
||||||
|
expect(shouldShowApiKeyAuth([
|
||||||
|
{ type: 'api', label: 'API Key' },
|
||||||
|
{ type: 'oauth', label: 'ChatGPT' },
|
||||||
|
])).toBe(true);
|
||||||
|
expect(shouldShowApiKeyAuth([{ type: 'api', label: 'API Key' }])).toBe(true);
|
||||||
|
// Unknown / unloaded methods keep the legacy API key fallback.
|
||||||
|
expect(shouldShowApiKeyAuth([])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOAuthAuthMethods preserves original method indexes', () => {
|
||||||
|
const methods = [
|
||||||
|
{ type: 'api', label: 'API Key' },
|
||||||
|
{ type: 'oauth', label: 'OAuth' },
|
||||||
|
{ type: 'oauth', label: 'Device' },
|
||||||
|
];
|
||||||
|
expect(getOAuthAuthMethods(methods)).toEqual([
|
||||||
|
{ method: methods[1], methodIndex: 1 },
|
||||||
|
{ method: methods[2], methodIndex: 2 },
|
||||||
|
]);
|
||||||
|
expect(getOAuthAuthMethods([{ type: 'oauth', label: 'Cursor' }])).toEqual([
|
||||||
|
{ method: { type: 'oauth', label: 'Cursor' }, methodIndex: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||||
|
import {
|
||||||
|
getOAuthAuthMethods,
|
||||||
|
parseAuthPayload,
|
||||||
|
shouldShowApiKeyAuth,
|
||||||
|
type AuthMethod,
|
||||||
|
} from './providerAuth';
|
||||||
import { CustomProviderForm } from './CustomProviderForm';
|
import { CustomProviderForm } from './CustomProviderForm';
|
||||||
import {
|
import {
|
||||||
buildAuthSetRequest,
|
buildAuthSetRequest,
|
||||||
@@ -59,16 +65,6 @@ const formatTokens = (value?: number | null) => {
|
|||||||
|
|
||||||
const ADD_PROVIDER_ID = '__add_provider__';
|
const ADD_PROVIDER_ID = '__add_provider__';
|
||||||
|
|
||||||
interface AuthMethod {
|
|
||||||
type?: string;
|
|
||||||
name?: string;
|
|
||||||
label?: string;
|
|
||||||
description?: string;
|
|
||||||
help?: string;
|
|
||||||
method?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderOption {
|
interface ProviderOption {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -89,28 +85,6 @@ interface ProviderSources {
|
|||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === 'object' && value !== null;
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
const normalizeAuthType = (method: AuthMethod) => {
|
|
||||||
const raw = typeof method.type === 'string' ? method.type : '';
|
|
||||||
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
|
||||||
const merged = `${raw} ${label}`.toLowerCase();
|
|
||||||
if (merged.includes('oauth')) return 'oauth';
|
|
||||||
if (merged.includes('api')) return 'api';
|
|
||||||
return raw.toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
|
||||||
if (!isRecord(payload)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const result: Record<string, AuthMethod[]> = {};
|
|
||||||
for (const [providerId, value] of Object.entries(payload)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return { id: entry };
|
return { id: entry };
|
||||||
@@ -205,7 +179,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}, [providers, selectedProviderId, setSelectedProvider]);
|
}, [providers, selectedProviderId, setSelectedProvider]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAddMode) {
|
// 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.
|
||||||
|
if (!selectedProviderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +213,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [isAddMode, t]);
|
}, [selectedProviderId, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!shouldLoadAvailableProviders(isAddMode)) {
|
if (!shouldLoadAvailableProviders(isAddMode)) {
|
||||||
@@ -323,6 +300,26 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedProviderId, editingCustomProviderId]);
|
}, [selectedProviderId, editingCustomProviderId]);
|
||||||
|
|
||||||
|
// Unauthenticated providers (OAuth-only plugins before login) should open the
|
||||||
|
// auth panel instead of a false "Connected" summary.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sources = providerSources[selectedProviderId];
|
||||||
|
if (!sources) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = providers.find((entry) => entry.id === selectedProviderId);
|
||||||
|
const envEntries = Array.isArray(provider?.env)
|
||||||
|
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
|
||||||
|
if (!hasCreds) {
|
||||||
|
setShowAuthPanel(true);
|
||||||
|
}
|
||||||
|
}, [selectedProviderId, providerSources, providers]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
if (!selectedProviderId || selectedProviderId === ADD_PROVIDER_ID) {
|
||||||
return;
|
return;
|
||||||
@@ -778,125 +775,126 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
|
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="py-1.5">
|
|
||||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
|
||||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
|
||||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={apiKeyInputs[candidateProviderId] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setApiKeyInputs((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[candidateProviderId]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
|
||||||
className="flex-1 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
className="!font-normal shrink-0"
|
|
||||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
|
||||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
|
||||||
>
|
|
||||||
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
||||||
const candidateOAuthMethods = candidateAuthMethods.filter(
|
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
|
||||||
(method) => normalizeAuthType(method) === 'oauth'
|
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
|
||||||
);
|
|
||||||
|
|
||||||
if (candidateOAuthMethods.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
<>
|
||||||
{candidateOAuthMethods.map((method, index) => {
|
{showApiKey ? (
|
||||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
|
<div className="py-1.5">
|
||||||
const codeKey = `${candidateProviderId}:${index}`;
|
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||||
const isPending =
|
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index;
|
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={apiKeyInputs[candidateProviderId] ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
setApiKeyInputs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[candidateProviderId]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||||
|
className="flex-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal shrink-0"
|
||||||
|
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||||
|
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||||
|
>
|
||||||
|
{authBusyKey === `api:${candidateProviderId}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
return (
|
{candidateOAuthMethods.length > 0 ? (
|
||||||
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-3">
|
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
{candidateOAuthMethods.map(({ method, methodIndex }) => {
|
||||||
<div>
|
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
||||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
const codeKey = `${candidateProviderId}:${methodIndex}`;
|
||||||
{(method.description || method.help) && (
|
const isPending =
|
||||||
<div className="typography-meta text-muted-foreground">
|
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
|
||||||
{String(method.description || method.help)}
|
|
||||||
|
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>
|
</div>
|
||||||
<Button
|
);
|
||||||
variant="outline"
|
})}
|
||||||
size="xs"
|
</div>
|
||||||
className="!font-normal"
|
) : null}
|
||||||
onClick={() => handleOAuthStart(candidateProviderId, index)}
|
</>
|
||||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
|
|
||||||
>
|
|
||||||
{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, index)}
|
|
||||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
|
|
||||||
>
|
|
||||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
@@ -921,7 +919,8 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
|
|
||||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||||
const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth');
|
const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
|
||||||
|
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
|
||||||
const sourcesLoaded = Boolean(selectedSources);
|
const sourcesLoaded = Boolean(selectedSources);
|
||||||
const isEditableCustomProvider = sourcesLoaded
|
const isEditableCustomProvider = sourcesLoaded
|
||||||
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
&& isConfigDefinedCustomProvider(selectedProvider, selectedSources);
|
||||||
@@ -931,7 +930,11 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||||
const hasEnvCredentials = providerEnv.length > 0;
|
const hasEnvCredentials = providerEnv.length > 0;
|
||||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||||
const authStatusIncomplete = isEditableCustomProvider && !hasCredentials;
|
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
|
||||||
|
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
|
||||||
|
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
||||||
|
? t('settings.providers.page.auth.useReconnectHint')
|
||||||
|
: t('settings.providers.page.auth.incompleteHint');
|
||||||
|
|
||||||
const filteredModels = providerModels.filter((model) => {
|
const filteredModels = providerModels.filter((model) => {
|
||||||
const name = typeof model?.name === 'string' ? model.name : '';
|
const name = typeof model?.name === 'string' ? model.name : '';
|
||||||
@@ -1014,7 +1017,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-1.5 py-1.5">
|
<div className="flex items-center gap-1.5 py-1.5">
|
||||||
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
<Icon name="alert" className="w-4 h-4 text-[var(--status-warning)] shrink-0" />
|
||||||
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
<span className="typography-ui-label text-foreground">{t('settings.providers.page.auth.incomplete')}</span>
|
||||||
<SettingsInfoHint>{t('settings.providers.page.auth.incompleteHint')}</SettingsInfoHint>
|
<SettingsInfoHint>{incompleteAuthHint}</SettingsInfoHint>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 py-1.5">
|
<div className="flex items-center gap-1.5 py-1.5">
|
||||||
@@ -1027,45 +1030,47 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
<div className="py-1.5 typography-meta text-muted-foreground">{t('settings.providers.page.auth.loadingMethods')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="py-1.5">
|
{showApiKeyAuth ? (
|
||||||
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
<div className="py-1.5">
|
||||||
{t('settings.providers.page.auth.apiKeyLabel')}
|
<label className="typography-ui-label text-foreground flex items-center gap-1.5">
|
||||||
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
{t('settings.providers.page.auth.apiKeyLabel')}
|
||||||
</label>
|
<SettingsInfoHint>{t('settings.providers.page.auth.apiKeyTooltip')}</SettingsInfoHint>
|
||||||
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
</label>
|
||||||
<Input
|
<div className="flex flex-col @xl:flex-row @xl:items-center gap-2 mt-1.5">
|
||||||
type="password"
|
<Input
|
||||||
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
type="password"
|
||||||
onChange={(event) =>
|
value={apiKeyInputs[selectedProvider.id] ?? ''}
|
||||||
setApiKeyInputs((prev) => ({
|
onChange={(event) =>
|
||||||
...prev,
|
setApiKeyInputs((prev) => ({
|
||||||
[selectedProvider.id]: event.target.value,
|
...prev,
|
||||||
}))
|
[selectedProvider.id]: event.target.value,
|
||||||
}
|
}))
|
||||||
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
}
|
||||||
className="flex-1 font-mono text-xs"
|
placeholder={t('settings.providers.page.auth.apiKeyPlaceholder')}
|
||||||
/>
|
className="flex-1 font-mono text-xs"
|
||||||
<Button
|
/>
|
||||||
size="xs"
|
<Button
|
||||||
className="!font-normal shrink-0"
|
size="xs"
|
||||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
className="!font-normal shrink-0"
|
||||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||||
>
|
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||||
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
>
|
||||||
</Button>
|
{authBusyKey === `api:${selectedProvider.id}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.saveKey')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{oauthAuthMethods.length > 0 && (
|
{oauthAuthMethods.length > 0 && (
|
||||||
<div className="space-y-4 border-t border-[var(--surface-subtle)] pt-2">
|
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
||||||
{oauthAuthMethods.map((method, index) => {
|
{oauthAuthMethods.map(({ method, methodIndex }) => {
|
||||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
|
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
||||||
const codeKey = `${selectedProvider.id}:${index}`;
|
const codeKey = `${selectedProvider.id}:${methodIndex}`;
|
||||||
const isPending =
|
const isPending =
|
||||||
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index;
|
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-3">
|
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
||||||
@@ -1079,8 +1084,8 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
onClick={() => handleOAuthStart(selectedProvider.id, index)}
|
onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)}
|
||||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
|
disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`}
|
||||||
>
|
>
|
||||||
{t('settings.providers.page.actions.connect')}
|
{t('settings.providers.page.actions.connect')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1125,10 +1130,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
|
onClick={() => handleOAuthComplete(selectedProvider.id, methodIndex)}
|
||||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
|
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}`}
|
||||||
>
|
>
|
||||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
{authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1175,14 +1180,13 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{showModelsSection ? (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.providers.page.models.title')}
|
title={t('settings.providers.page.models.title')}
|
||||||
titleAccessory={
|
titleAccessory={
|
||||||
providerModels.length > 0 ? (
|
<span className="typography-micro text-muted-foreground font-normal">
|
||||||
<span className="typography-micro text-muted-foreground font-normal">
|
({providerModels.length})
|
||||||
({providerModels.length})
|
</span>
|
||||||
</span>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
headerAction={(
|
headerAction={(
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -1291,6 +1295,7 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
) : null}
|
||||||
</SettingsPageLayout>
|
</SettingsPageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface AuthMethod {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
help?: string;
|
||||||
|
method?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthAuthMethodEntry {
|
||||||
|
method: AuthMethod;
|
||||||
|
/** Index in the full provider auth-methods array (passed to oauth authorize/callback). */
|
||||||
|
methodIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
export const normalizeAuthType = (method: AuthMethod): string => {
|
||||||
|
const raw = typeof method.type === 'string' ? method.type : '';
|
||||||
|
const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase();
|
||||||
|
const merged = `${raw} ${label}`.toLowerCase();
|
||||||
|
if (merged.includes('oauth')) return 'oauth';
|
||||||
|
if (merged.includes('api')) return 'api';
|
||||||
|
return raw.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseAuthPayload = (payload: unknown): Record<string, AuthMethod[]> => {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const result: Record<string, AuthMethod[]> = {};
|
||||||
|
for (const [providerId, value] of Object.entries(payload)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the API key form when the provider declares API auth, or when auth
|
||||||
|
* methods are still unknown (empty). OAuth-only providers must not get an
|
||||||
|
* API key prompt.
|
||||||
|
*/
|
||||||
|
export const shouldShowApiKeyAuth = (methods: AuthMethod[]): boolean => {
|
||||||
|
if (methods.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return methods.some((method) => normalizeAuthType(method) === 'api');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOAuthAuthMethods = (methods: AuthMethod[]): OAuthAuthMethodEntry[] =>
|
||||||
|
methods
|
||||||
|
.map((method, methodIndex) => ({ method, methodIndex }))
|
||||||
|
.filter(({ method }) => normalizeAuthType(method) === 'oauth');
|
||||||
Reference in New Issue
Block a user