mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user