diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx index 1a306b3c..3699e6cb 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -329,8 +329,6 @@ describe('the plain usage portal link', () => { expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', 'noopener noreferrer'); expect(service.portalSession).not.toHaveBeenCalled(); - // The session-bound link is a different thing and stays behind `connect`. - expect(screen.queryByText('productUsage.openPortal')).toBeNull(); }); it('is not offered when the collector URL is unusable', async () => { @@ -344,3 +342,63 @@ describe('the plain usage portal link', () => { expect(screen.queryByRole('link', { name: 'productUsage.openUsagePortal' })).toBeNull(); }); }); + +// While participating, the same button signs the operator in. The credential +// must never sit in a URL a server sees: the backend mints a short-lived +// collector session and the portal receives it in the fragment only. +describe('the signed-in usage portal button', () => { + const tab = { document: { open: vi.fn(), write: vi.fn(), close: vi.fn() }, opener: {} as unknown, close: vi.fn() }; + const written = () => tab.document.write.mock.calls.map((c) => String(c[0])).join(''); + beforeEach(() => { + tab.opener = {}; tab.close.mockClear(); tab.document.write.mockClear(); tab.document.open.mockClear(); tab.document.close.mockClear(); + vi.stubGlobal('open', vi.fn(() => tab)); + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active' }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('opens a tab synchronously, mints a session and navigates it without a referrer', async () => { + vi.mocked(service.portalSession).mockResolvedValue({ + delivered: true, url: 'https://usage.picpeak.app/#connect=session-token' + }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + expect(window.open).toHaveBeenCalledWith('about:blank', '_blank'); + await waitFor(() => expect(written()).toContain('url=https://usage.picpeak.app/#connect=session-token')); + // The written page, not the admin page, initiates the navigation — and + // it says no-referrer, so the collector never sees this origin. + expect(written()).toContain(''); + expect(tab.opener).toBeNull(); + expect(screen.queryByRole('link', { name: 'productUsage.openUsagePortal' })).toBeNull(); + expect(screen.queryByRole('link', { name: 'productUsage.portalReady' })).toBeNull(); + }); + + it('offers the session URL as a link when the browser refused the tab', async () => { + vi.mocked(window.open).mockReturnValue(null); + vi.mocked(service.portalSession).mockResolvedValue({ + delivered: true, url: 'https://usage.picpeak.app/#connect=session-token' + }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + const link = await screen.findByRole('link', { name: 'productUsage.portalReady' }); + expect(link).toHaveAttribute('href', 'https://usage.picpeak.app/#connect=session-token'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(window.open).toHaveBeenCalledTimes(1); + }); + + it('falls back to the public portal and says so when the session is only queued', async () => { + vi.mocked(service.portalSession).mockResolvedValue({ delivered: false, url: null }); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + await waitFor(() => expect(written()).toContain('url=https://usage.picpeak.app"')); + await screen.findByText('productUsage.queued'); + }); + + it('closes the tab again when the request fails', async () => { + vi.mocked(service.portalSession).mockRejectedValue(new Error('down')); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'productUsage.openUsagePortal' })); + await screen.findByText('productUsage.failed'); + expect(tab.close).toHaveBeenCalled(); + expect(tab.document.write).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index fb571e73..54647ab3 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -196,8 +196,10 @@ export default function ProductUsageTab() { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(''); const [preview, setPreview] = useState(null); - const [portalUrl, setPortalUrl] = useState(null); const [named, setNamed] = useState(false); + // Set only when the browser refused the tab (popup blocked): the session + // URL is then offered as a plain link the operator can click instead. + const [portalUrl, setPortalUrl] = useState(null); const [form, setForm] = useState({ kind: 'feedback', title: '', @@ -231,6 +233,58 @@ export default function ProductUsageTab() { if (isPending) return

{t('productUsage.loading')}

; if (isError || !data) return

{t('productUsage.failed')}

; const active = data.status === 'active'; + // Signed-in portal access without the credential ever touching a URL that + // a server sees. The backend asks the collector for a short-lived session + // (a signed `session` command, so the collector knows which installation + // this is), and the portal is opened with that token in the URL *fragment*: + // fragments are never sent over the wire, and the portal drops it from the + // address bar on load and keeps the session in memory only. The lookup + // hash itself never leaves the settings page. + // + // The tab is opened synchronously in the click handler and navigated once + // the session exists — opening it after the await trips popup blockers. + // Navigation goes through a document written into the blank tab rather + // than `tab.location`: a script-initiated navigation carries the admin + // page as referrer, and the collector must not learn this installation's + // origin. The written page declares no-referrer and refreshes itself. + // + // If the browser refused the tab, the URL is kept as a plain link instead + // of a second window.open after the await, which would be refused too. If + // the collector cannot be reached the session command is queued for retry + // and the tab falls back to the public portal, so the click still lands + // somewhere. + const openPortal = () => { + if (!data.collector_url) return; + setPortalUrl(null); + const tab = window.open('about:blank', '_blank'); + if (tab) tab.opener = null; + const go = (url: string) => { + if (!tab) { + setPortalUrl(url); + return; + } + const escaped = url.replace(/"/g, '"'); + tab.document.open(); + tab.document.write( + `` + ); + tab.document.close(); + }; + return run(async () => { + try { + const result = await service.portalSession(); + if (result.url) { + go(result.url); + } else { + go(data.collector_url as string); + if (!result.delivered) setMessage(t('productUsage.queued')); + } + } catch (error) { + tab?.close(); + throw error; + } + }); + }; return (

{t('productUsage.purpose')}

@@ -326,7 +380,6 @@ export default function ProductUsageTab() { await run(async () => { await service.abandon(); setPreview(null); - setPortalUrl(null); }); } }} @@ -368,7 +421,6 @@ export default function ProductUsageTab() { await run(async () => { await service.disable(); setPreview(null); - setPortalUrl(null); }); } }} @@ -379,19 +431,43 @@ export default function ProductUsageTab() { )} {data.collector_url && ( <> - {/* The public portal itself, not the session-bound link below: - it needs neither participation nor a voting session, so an - operator can look at the portal before deciding to join. - Styled as a button so it reads as an action, not a footnote. */} - - {t('productUsage.openUsagePortal')} - + {/* One button, two behaviours. Before participation it is a plain + link: the portal is public and an operator deciding whether + to join should be able to look at it first. While + participating it opens the portal signed in — see openPortal + above — so nobody has to copy the lookup hash around. */} + {active ? ( + <> + + {portalUrl && ( + + {t('productUsage.portalReady')} + + )} + + ) : ( + + {t('productUsage.openUsagePortal')} + + )} {t('productUsage.export')} -
- {portalUrl && ( - - {t('productUsage.openPortal')} - - )} {preview !== null && (