revert(assistant): drop the ChatGPT relay, open-source apps keep the loopback sign-in

OpenAI offers a fixed web redirect for Sign in with ChatGPT only to selected
commercial partners; open-source apps sign in through the 127.0.0.1 loopback
callback. The relay could never be switched on, so it goes again.
This commit is contained in:
Maurice
2026-10-21 22:48:55 +02:00
parent 7bb5784913
commit d1ee2c05f4
12 changed files with 67 additions and 391 deletions
@@ -5,7 +5,6 @@ import { CHATGPT_USAGE_URL, providerLabel } from '../../Assistant/providerCatalo
import { fs } from '../../shared/DialogShell';
import { INPUT } from '../../shared/dialogParts';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint } from '../settingsKit';
import { rememberChatgptFlow } from './aiConnectModel';
import type { ConnectError, useChatgptConnect } from './useAiConnect';
type ChatgptConnect = ReturnType<typeof useChatgptConnect>;
@@ -98,12 +97,9 @@ export default function ChatGptPasteStep({
<div className="flex">
<a
href={flow?.authorizeUrl}
// A sign-in that comes back by itself leaves this tab; one to paste from opens beside it.
{...(flow?.returns ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
onClick={() => {
if (flow?.returns) rememberChatgptFlow(flow.flowId);
markOpened();
}}
target="_blank"
rel="noopener noreferrer"
onClick={markOpened}
aria-disabled={!flow}
className={`${SETTINGS_BUTTON_PRIMARY} ${flow ? '' : 'pointer-events-none opacity-50'}`}
style={fs(13, 'body')}
@@ -113,58 +109,56 @@ export default function ChatGptPasteStep({
</a>
</div>
</Step>
{!flow?.returns && (
<Step n={2} text={t('assistant.connect.chatgptStep2')}>
<div className="flex gap-2">
<input
id={inputId}
ref={inputRef}
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
dir="ltr"
value={pasted}
onChange={(e) => change(e.target.value)}
onPaste={(e) => {
if (paste(e.clipboardData.getData('text'))) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
}}
placeholder="http://127.0.0.1:1455/auth/callback?code=…"
aria-label={t('assistant.connect.chatgptPaste')}
className={`${INPUT} font-mono`}
/>
{pasted.trim() ? (
<button
type="button"
onClick={() => void submit()}
disabled={busy || !flow}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.save')}
</button>
) : (
<button
type="button"
onClick={pasteFromClipboard}
disabled={!flow}
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
style={fs(13, 'body')}
>
<ClipboardPaste size={14} strokeWidth={2} />
{t('assistant.connect.chatgptPaste')}
</button>
)}
</div>
</Step>
)}
<Step n={2} text={t('assistant.connect.chatgptStep2')}>
<div className="flex gap-2">
<input
id={inputId}
ref={inputRef}
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
dir="ltr"
value={pasted}
onChange={(e) => change(e.target.value)}
onPaste={(e) => {
if (paste(e.clipboardData.getData('text'))) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
}}
placeholder="http://127.0.0.1:1455/auth/callback?code=…"
aria-label={t('assistant.connect.chatgptPaste')}
className={`${INPUT} font-mono`}
/>
{pasted.trim() ? (
<button
type="button"
onClick={() => void submit()}
disabled={busy || !flow}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.save')}
</button>
) : (
<button
type="button"
onClick={pasteFromClipboard}
disabled={!flow}
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
style={fs(13, 'body')}
>
<ClipboardPaste size={14} strokeWidth={2} />
{t('assistant.connect.chatgptPaste')}
</button>
)}
</div>
</Step>
</ol>
{error && (
<ConnectErrorNote error={error} provider={providerLabel('chatgpt', '')} onOtherProvider={onOtherProvider} />
@@ -71,28 +71,6 @@ export function rememberOpenrouterFlow(flowId: string): void {
}
}
const CHATGPT_FLOW_KEY = 'trek.ai.chatgptFlow';
/** Keeps the ChatGPT sign-in that left for OpenAI and comes back by itself, for the page it returns to. */
export function rememberChatgptFlow(flowId: string): void {
try {
sessionStorage.setItem(CHATGPT_FLOW_KEY, flowId);
} catch {
// Storage refused (private mode): the return then reads as an expired sign-in.
}
}
/** The ChatGPT sign-in this tab started, read once. */
export function takeChatgptFlow(): string | null {
try {
const flowId = sessionStorage.getItem(CHATGPT_FLOW_KEY);
sessionStorage.removeItem(CHATGPT_FLOW_KEY);
return flowId;
} catch {
return null;
}
}
/** The flow the OpenRouter sign-in started, read once. */
export function takeOpenrouterFlow(): string | null {
try {
@@ -17,15 +17,9 @@ const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const {
useChatgptConnect,
useChatgptReturn,
useCustomConnect,
useKeyConnect,
useOpenrouterReturn,
useOpenrouterStart,
} = await import('./useAiConnect');
const { rememberChatgptFlow, rememberOpenrouterFlow } = await import('./aiConnectModel');
const { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterReturn, useOpenrouterStart } =
await import('./useAiConnect');
const { rememberOpenrouterFlow } = await import('./aiConnectModel');
function wrapperAt(url = '/settings') {
return ({ children }: { children: ReactNode }) =>
@@ -256,26 +250,3 @@ describe('OpenRouter', () => {
expect(repo.openrouterFinish).not.toHaveBeenCalled();
});
});
describe('useChatgptReturn', () => {
it('FE-HOOK-AI-CONNECT-030: a sign-in back through the relay finishes once, as the address it came back to', async () => {
rememberChatgptFlow(FLOW.flowId);
const onConnected = vi.fn();
const { result } = renderHook(
() => {
useChatgptReturn(onConnected);
return useLocation();
},
{ wrapper: wrapperAt('/settings?tab=integrations&ai=chatgpt&code=c1&state=s1.aA') }
);
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
// The address as the browser holds it; the server checks it as it checks a pasted one.
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: window.location.href });
expect(result.current.search).toBe('?tab=integrations');
vi.clearAllMocks();
renderHook(() => useChatgptReturn(onConnected), { wrapper: wrapperAt('/settings?ai=chatgpt&code=again') });
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(repo.chatgptFinish).not.toHaveBeenCalled();
});
});
@@ -5,14 +5,7 @@ import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { detectKeyKind, isChatgptCallback, providerLabel } from '../../Assistant/providerCatalog';
import { useToast } from '../../shared/Toast';
import {
connectErrorKey,
httpHostOf,
refusalCode,
rememberOpenrouterFlow,
takeChatgptFlow,
takeOpenrouterFlow,
} from './aiConnectModel';
import { connectErrorKey, httpHostOf, refusalCode, rememberOpenrouterFlow, takeOpenrouterFlow } from './aiConnectModel';
/**
* The ways to connect a provider, one hook each, behind `AiConnectPanel`: a
@@ -300,49 +293,6 @@ export function useOpenrouterStart(scope: AiConnectionScope) {
return { start, busy };
}
/**
* Finishes a ChatGPT sign-in the page came back from through the relay
* (`?ai=chatgpt&code=…&state=…`): the server reads the address as it reads a
* pasted one. The parameters leave the address at once, so a reload does not
* try again.
*/
export function useChatgptReturn(onConnected: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [params, setParams] = useSearchParams();
const handled = useRef(false);
const finish = useEffectEvent(async (returned: string) => {
const flowId = takeChatgptFlow();
if (!flowId) {
toast.error(t('assistant.connect.chatgptExpired'));
return;
}
try {
await aiConnectionsRepo.chatgptFinish({ flowId, pastedUrl: returned });
toast.success(t('assistant.connect.ok'));
onConnected();
} catch (err) {
toast.error(t(connectErrorKey(err)));
}
});
useEffect(() => {
if (handled.current || params.get('ai') !== 'chatgpt') return;
handled.current = true;
const returned = window.location.href;
setParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const key of ['ai', 'code', 'state', 'scope', 'client_id', 'error']) next.delete(key);
return next;
},
{ replace: true }
);
void finish(returned);
}, [params, setParams]);
}
/**
* Finishes an OpenRouter sign-in the page came back from (`?ai=openrouter&code=…`)
* once, and takes both parameters out of the address so a reload does not try again.
@@ -9,7 +9,7 @@ import { useAuthStore } from '../../../store/authStore';
import { providerLabel } from '../../Assistant/providerCatalog';
import { useToast } from '../../shared/Toast';
import { type AiConnectMode, connectErrorKey, repairModeOf } from './aiConnectModel';
import { useChatgptReturn, useOpenrouterReturn } from './useAiConnect';
import { useOpenrouterReturn } from './useAiConnect';
/**
* What the AI connection card shows:
@@ -157,7 +157,6 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
void reload();
};
useOpenrouterReturn(onConnected);
useChatgptReturn(onConnected);
const own = status?.own ?? null;
const view = viewOf(status, loaded);