Files
openchamber/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx
T

460 lines
16 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import {
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { opencodeClient } from '@/lib/opencode/client';
import {
collectPromptInputs,
defaultPromptValues,
describeOAuthError,
firstUnansweredPrompt,
parseAuthPrompts,
parseAuthorization,
shouldOpenAuthorizationUrl,
visiblePrompts,
type AuthPrompt,
type OAuthAuthorization,
} from './provider-oauth';
export interface ProviderOAuthMethod {
/** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */
index: number;
label: string;
prompts?: unknown;
}
interface ProviderOAuthMethodsProps {
providerId: string;
methods: ProviderOAuthMethod[];
/** Called once a credential has been stored, so the caller can reload providers. */
onConnected: () => void | Promise<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;
}
// Claude Code CLI owns its OAuth flow and opens the browser itself. Its
// plugin URL is informational only; opening it creates a misleading docs
// tab alongside the real sign-in page.
if (authorization.url && shouldOpenAuthorizationUrl(providerId, authorization.url)) {
void openExternalUrl(authorization.url);
}
if (authorization.method === 'code') {
setCodeInput('');
setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false });
return;
}
setFlow({ phase: 'waiting', methodIndex, authorization });
await runCallback(methodIndex);
};
const beginConnect = (method: ProviderOAuthMethod) => {
const prompts = parseAuthPrompts(method.prompts);
if (prompts.length === 0) {
void runAuthorize(method.index, {});
return;
}
setPromptValues(defaultPromptValues(prompts));
setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null });
};
const submitPrompts = () => {
if (flow.phase !== 'prompting') {
return;
}
const unanswered = firstUnansweredPrompt(flow.prompts, promptValues);
if (unanswered) {
setFlow({
...flow,
error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }),
});
return;
}
void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues));
};
const submitCode = () => {
if (flow.phase !== 'awaitingCode') {
return;
}
const code = codeInput.trim();
if (!code) {
return;
}
setFlow({ ...flow, submitting: true });
void runCallback(flow.methodIndex, code);
};
/**
* Stops tracking the attempt. Upstream keeps its pending authorization until
* a new `authorize` replaces it, so reconnecting is always safe.
*/
const cancel = () => {
callbackAbortRef.current?.abort();
callbackAbortRef.current = null;
setFlow(IDLE);
};
const renderPrompt = (prompt: AuthPrompt) => {
const value = promptValues[prompt.key] ?? '';
const setValue = (next: string) =>
setPromptValues((prev) => ({ ...prev, [prompt.key]: next }));
return (
<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>
);
};