feat(usage): open the portal signed in, with the credential never in a served URL
The "open usage portal" button was a plain link, so an operator who wanted to see their own data had to copy the lookup hash out of the settings page and paste it into the portal. Next to it sat a second control, "connect to requests & voting", which minted a collector session and then showed a third thing, a link to open it. One button now. Before participation it stays the plain link: the portal is public and someone deciding whether to join should be able to look at it first. While participating, a click asks the backend for a collector session (a signed `session` command, so the collector knows which installation this is) and opens the portal with that token in the URL fragment. Fragments are never sent over the wire; 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, and no URL a server or an access log sees ever carries a credential. The tab is opened synchronously in the click handler and navigated once the session exists, because opening it after the await trips popup blockers. 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; a failed request closes the tab again. The separate connect button and its session link are gone, and so are their strings.
This commit is contained in:
@@ -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('<meta name="referrer" content="no-referrer">');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,8 +196,10 @@ export default function ProductUsageTab() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [preview, setPreview] = useState<unknown>(null);
|
||||
const [portalUrl, setPortalUrl] = useState<string | null>(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<string | null>(null);
|
||||
const [form, setForm] = useState<ProductFeedback>({
|
||||
kind: 'feedback',
|
||||
title: '',
|
||||
@@ -231,6 +233,58 @@ export default function ProductUsageTab() {
|
||||
if (isPending) return <p>{t('productUsage.loading')}</p>;
|
||||
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
|
||||
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(
|
||||
`<!doctype html><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${escaped}">`
|
||||
);
|
||||
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 (
|
||||
<div className="space-y-6 text-theme">
|
||||
<p>{t('productUsage.purpose')}</p>
|
||||
@@ -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. */}
|
||||
<a
|
||||
className="btn btn-outline btn-md"
|
||||
href={data.collector_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('productUsage.openUsagePortal')}
|
||||
<ExternalLink className="ml-2 h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
{/* 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 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || Boolean(data.pending_action)}
|
||||
onClick={openPortal}
|
||||
>
|
||||
{t('productUsage.openUsagePortal')}
|
||||
<ExternalLink className="ml-2 h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
{portalUrl && (
|
||||
<a
|
||||
href={portalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
||||
>
|
||||
{t('productUsage.portalReady')}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
className="btn btn-outline btn-md"
|
||||
href={data.collector_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('productUsage.openUsagePortal')}
|
||||
<ExternalLink className="ml-2 h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
||||
href={`${data.collector_url}/transparency`}
|
||||
@@ -477,31 +553,7 @@ export default function ProductUsageTab() {
|
||||
>
|
||||
{t('productUsage.export')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={WRAPPING_BUTTON}
|
||||
disabled={busy || Boolean(data.pending_action)}
|
||||
onClick={() =>
|
||||
run(async () => {
|
||||
const result = await service.portalSession();
|
||||
setPortalUrl(result.url);
|
||||
if (!result.delivered) setMessage(t('productUsage.queued'));
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('productUsage.connect')}
|
||||
</Button>
|
||||
</div>
|
||||
{portalUrl && (
|
||||
<a
|
||||
href={portalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('productUsage.openPortal')}
|
||||
</a>
|
||||
)}
|
||||
{preview !== null && (
|
||||
<pre
|
||||
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
"disable": "Disable & delete my data",
|
||||
"retry": "Retry / send if due",
|
||||
"openUsagePortal": "Open usage portal",
|
||||
"portalReady": "Open the portal in a new tab",
|
||||
"transparency": "Read the public schema & privacy details",
|
||||
"linkCollector": "Where reports are sent",
|
||||
"hash": "Your private lookup hash",
|
||||
@@ -471,8 +472,6 @@
|
||||
"preview": "Preview next report",
|
||||
"lastPacket": "Last accepted signed usage report",
|
||||
"export": "Download all accepted usage reports",
|
||||
"connect": "Connect to requests & voting",
|
||||
"openPortal": "Open the portal (15-minute voting session)",
|
||||
"queued": "The operation is saved for retry. It has not been confirmed as delivered.",
|
||||
"feedbackTitle": "Feedback & feature requests",
|
||||
"kind": "Type",
|
||||
|
||||
Reference in New Issue
Block a user