diff --git a/client/src/components/Settings/ai/AiConnectPanel.test.tsx b/client/src/components/Settings/ai/AiConnectPanel.test.tsx index 1af1f808e..3664c4ef8 100644 --- a/client/src/components/Settings/ai/AiConnectPanel.test.tsx +++ b/client/src/components/Settings/ai/AiConnectPanel.test.tsx @@ -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(); + 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' } }, diff --git a/client/src/components/Settings/ai/ChatGptPasteStep.tsx b/client/src/components/Settings/ai/ChatGptPasteStep.tsx index daef222c4..320aa0ec8 100644 --- a/client/src/components/Settings/ai/ChatGptPasteStep.tsx +++ b/client/src/components/Settings/ai/ChatGptPasteStep.tsx @@ -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; +/** 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({ + {/* What the window ends on, so the error page is expected rather than alarming. */} +
{ + 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. */ diff --git a/server/tests/helpers/factories/tokens.ts b/server/tests/helpers/factories/tokens.ts index 670905571..b48f3c228 100644 --- a/server/tests/helpers/factories/tokens.ts +++ b/server/tests/helpers/factories/tokens.ts @@ -20,13 +20,17 @@ export async function makeMcpToken( ): Promise<{ token: EntityDTO; 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 };