feat(assistant): the ChatGPT sign-in in a small window, the copied address taken on return

- The sign-in opens in a small window over TREK instead of a new tab.
- Step 2 shows the address the window ends on, so the error page there is
  expected rather than alarming.
- Back in TREK the copied address is read off the clipboard where the browser
  allows it and the sign-in finishes; else the field has the cursor.
- The token and OAuth client factories hand their columns over apart from
  the row, as the others do (TS2589).
This commit is contained in:
Maurice
2026-10-21 22:50:41 +02:00
parent 00b50e280f
commit e3d40ddcf6
4 changed files with 94 additions and 24 deletions
@@ -2,7 +2,7 @@
import userEvent from '@testing-library/user-event';
import type { AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
@@ -124,6 +124,26 @@ describe('AiConnectPanel', () => {
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: CALLBACK });
});
it('FE-COMP-AI-CONNECT-PANEL-005b: the sign-in opens in a small window, and coming back takes the copied address', async () => {
const user = userEvent.setup();
const onConnected = vi.fn();
const opened = vi.spyOn(window, 'open').mockReturnValue(null);
const readText = vi.fn(async () => CALLBACK);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } });
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
render(<AiConnectPanel scope="user" onConnected={onConnected} />);
await user.click(screen.getByRole('button', { name: /Sign in with ChatGPT/ }));
const open = await screen.findByRole('link', { name: /Open ChatGPT sign-in/ });
await waitFor(() => expect(open).toHaveAttribute('href', FLOW.authorizeUrl));
await user.click(open);
expect(opened).toHaveBeenCalledWith(FLOW.authorizeUrl, 'trek-chatgpt', expect.stringContaining('popup'));
fireEvent.focus(window);
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: CALLBACK });
opened.mockRestore();
});
it('FE-COMP-AI-CONNECT-PANEL-006: an account ChatGPT will not share offers another provider, which opens the key field', async () => {
repo.chatgptFinish.mockRejectedValueOnce({
response: { status: 403, data: { error: 'no', code: 'not_eligible' } },
@@ -1,4 +1,4 @@
import { ClipboardPaste, ExternalLink, Loader2 } from 'lucide-react';
import { ClipboardPaste, ExternalLink, Globe, Loader2 } from 'lucide-react';
import React, { useId } from 'react';
import { useTranslation } from '../../../i18n';
import { CHATGPT_USAGE_URL, providerLabel } from '../../Assistant/providerCatalog';
@@ -9,6 +9,9 @@ import type { ConnectError, useChatgptConnect } from './useAiConnect';
type ChatgptConnect = ReturnType<typeof useChatgptConnect>;
/** The sign-in window: small, beside TREK, without access back to this page. */
const POPUP_FEATURES = 'popup,width=520,height=760,noopener,noreferrer';
/** A numbered step: the number, what to do, and the control that does it. */
function Step({ n, text, children }: { n: number; text: string; children: React.ReactNode }) {
return (
@@ -99,7 +102,15 @@ export default function ChatGptPasteStep({
href={flow?.authorizeUrl}
target="_blank"
rel="noopener noreferrer"
onClick={markOpened}
onClick={(e) => {
// A small window over TREK: its last page is the one to copy from, and TREK stays in view.
// With noopener the browser hands back no window, so a blocked one cannot be told apart.
if (flow) {
e.preventDefault();
window.open(flow.authorizeUrl, 'trek-chatgpt', POPUP_FEATURES);
}
markOpened();
}}
aria-disabled={!flow}
className={`${SETTINGS_BUTTON_PRIMARY} ${flow ? '' : 'pointer-events-none opacity-50'}`}
style={fs(13, 'body')}
@@ -110,6 +121,18 @@ export default function ChatGptPasteStep({
</div>
</Step>
<Step n={2} text={t('assistant.connect.chatgptStep2')}>
{/* What the window ends on, so the error page is expected rather than alarming. */}
<div
aria-hidden="true"
dir="ltr"
className="flex min-w-0 items-center gap-2 rounded-full bg-surface-secondary px-3 py-1.5 font-mono text-content-muted"
style={fs(11.5)}
>
<Globe size={12} className="flex-none text-content-faint" />
<span className="truncate">
<span className="rounded bg-accent-subtle px-1 text-content">127.0.0.1:1455/auth/callback?code=…</span>
</span>
</div>
<div className="flex gap-2">
<input
id={inputId}
@@ -184,13 +184,32 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on
};
}, [active, attempt]);
// Back from the sign-in window: the address the user just copied is taken
// off the clipboard where the browser allows it, else the field has the cursor.
const takeCopied = useEffectEvent(() => {
if (pasted.trim() || busy) return;
if (!navigator.clipboard?.readText || !window.isSecureContext) {
inputRef.current?.focus();
return;
}
navigator.clipboard.readText().then(
(text) => {
if (!paste(text)) inputRef.current?.focus();
},
() => inputRef.current?.focus()
);
});
useEffect(() => {
if (!opened) return;
const onVisible = () => {
if (document.visibilityState === 'visible') inputRef.current?.focus();
const onBack = () => {
if (document.visibilityState === 'visible') takeCopied();
};
document.addEventListener('visibilitychange', onBack);
window.addEventListener('focus', onBack);
return () => {
document.removeEventListener('visibilitychange', onBack);
window.removeEventListener('focus', onBack);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [opened]);
/** A fresh sign-in: the old one ran out or was used up. */
+25 -17
View File
@@ -20,13 +20,17 @@ export async function makeMcpToken(
): Promise<{ token: EntityDTO<McpTokens>; rawToken: string }> {
const { rawToken: given, ...columns } = overrides;
const rawToken = given ?? `trek_test_${nextSeq('mcp-token')}_${randomBytes(8).toString('hex')}`;
const token = await createRow(orm, McpTokens, {
user: userId,
name: 'Test Token',
token_hash: sha256(rawToken),
token_prefix: rawToken.slice(0, 12),
...columns,
});
const token = await createRow(
orm,
McpTokens,
{
user: userId,
name: 'Test Token',
token_hash: sha256(rawToken),
token_prefix: rawToken.slice(0, 12),
},
columns,
);
return { token, rawToken };
}
@@ -44,16 +48,20 @@ export async function makeOauthClient(
const n = nextSeq('oauth-client');
const secret = given ?? `test-secret-${n}-${randomBytes(8).toString('hex')}`;
const id = givenId ?? `test-oauth-client-${n}`;
await insertRow(orm, OauthClients, {
id,
user: userId,
name: `Test Client ${n}`,
client_id: `client-${n}`,
client_secret_hash: sha256(secret),
redirect_uris: JSON.stringify(['https://client.example.test/callback']),
allowed_scopes: JSON.stringify(['trips:read']),
...columns,
});
await insertRow(
orm,
OauthClients,
{
id,
user: userId,
name: `Test Client ${n}`,
client_id: `client-${n}`,
client_secret_hash: sha256(secret),
redirect_uris: JSON.stringify(['https://client.example.test/callback']),
allowed_scopes: JSON.stringify(['trips:read']),
},
columns,
);
const client = await findRow(orm, OauthClients, { id });
if (!client) throw new Error(`makeOauthClient: no client ${id} after its insert`);
return { client, secret };