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('target', '_blank');
|
||||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||||
expect(service.portalSession).not.toHaveBeenCalled();
|
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 () => {
|
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();
|
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 [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [preview, setPreview] = useState<unknown>(null);
|
const [preview, setPreview] = useState<unknown>(null);
|
||||||
const [portalUrl, setPortalUrl] = useState<string | null>(null);
|
|
||||||
const [named, setNamed] = useState(false);
|
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>({
|
const [form, setForm] = useState<ProductFeedback>({
|
||||||
kind: 'feedback',
|
kind: 'feedback',
|
||||||
title: '',
|
title: '',
|
||||||
@@ -231,6 +233,58 @@ export default function ProductUsageTab() {
|
|||||||
if (isPending) return <p>{t('productUsage.loading')}</p>;
|
if (isPending) return <p>{t('productUsage.loading')}</p>;
|
||||||
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
|
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
|
||||||
const active = data.status === 'active';
|
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 (
|
return (
|
||||||
<div className="space-y-6 text-theme">
|
<div className="space-y-6 text-theme">
|
||||||
<p>{t('productUsage.purpose')}</p>
|
<p>{t('productUsage.purpose')}</p>
|
||||||
@@ -326,7 +380,6 @@ export default function ProductUsageTab() {
|
|||||||
await run(async () => {
|
await run(async () => {
|
||||||
await service.abandon();
|
await service.abandon();
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setPortalUrl(null);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -368,7 +421,6 @@ export default function ProductUsageTab() {
|
|||||||
await run(async () => {
|
await run(async () => {
|
||||||
await service.disable();
|
await service.disable();
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setPortalUrl(null);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -379,19 +431,43 @@ export default function ProductUsageTab() {
|
|||||||
)}
|
)}
|
||||||
{data.collector_url && (
|
{data.collector_url && (
|
||||||
<>
|
<>
|
||||||
{/* The public portal itself, not the session-bound link below:
|
{/* One button, two behaviours. Before participation it is a plain
|
||||||
it needs neither participation nor a voting session, so an
|
link: the portal is public and an operator deciding whether
|
||||||
operator can look at the portal before deciding to join.
|
to join should be able to look at it first. While
|
||||||
Styled as a button so it reads as an action, not a footnote. */}
|
participating it opens the portal signed in — see openPortal
|
||||||
<a
|
above — so nobody has to copy the lookup hash around. */}
|
||||||
className="btn btn-outline btn-md"
|
{active ? (
|
||||||
href={data.collector_url}
|
<>
|
||||||
target="_blank"
|
<Button
|
||||||
rel="noopener noreferrer"
|
variant="outline"
|
||||||
>
|
disabled={busy || Boolean(data.pending_action)}
|
||||||
{t('productUsage.openUsagePortal')}
|
onClick={openPortal}
|
||||||
<ExternalLink className="ml-2 h-4 w-4" aria-hidden="true" />
|
>
|
||||||
</a>
|
{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
|
<a
|
||||||
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
||||||
href={`${data.collector_url}/transparency`}
|
href={`${data.collector_url}/transparency`}
|
||||||
@@ -477,31 +553,7 @@ export default function ProductUsageTab() {
|
|||||||
>
|
>
|
||||||
{t('productUsage.export')}
|
{t('productUsage.export')}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{portalUrl && (
|
|
||||||
<a
|
|
||||||
href={portalUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
{t('productUsage.openPortal')}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{preview !== null && (
|
{preview !== null && (
|
||||||
<pre
|
<pre
|
||||||
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
|
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
|
||||||
|
|||||||
@@ -459,6 +459,7 @@
|
|||||||
"disable": "Disable & delete my data",
|
"disable": "Disable & delete my data",
|
||||||
"retry": "Retry / send if due",
|
"retry": "Retry / send if due",
|
||||||
"openUsagePortal": "Open usage portal",
|
"openUsagePortal": "Open usage portal",
|
||||||
|
"portalReady": "Open the portal in a new tab",
|
||||||
"transparency": "Read the public schema & privacy details",
|
"transparency": "Read the public schema & privacy details",
|
||||||
"linkCollector": "Where reports are sent",
|
"linkCollector": "Where reports are sent",
|
||||||
"hash": "Your private lookup hash",
|
"hash": "Your private lookup hash",
|
||||||
@@ -471,8 +472,6 @@
|
|||||||
"preview": "Preview next report",
|
"preview": "Preview next report",
|
||||||
"lastPacket": "Last accepted signed usage report",
|
"lastPacket": "Last accepted signed usage report",
|
||||||
"export": "Download all accepted usage reports",
|
"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.",
|
"queued": "The operation is saved for retry. It has not been confirmed as delivered.",
|
||||||
"feedbackTitle": "Feedback & feature requests",
|
"feedbackTitle": "Feedback & feature requests",
|
||||||
"kind": "Type",
|
"kind": "Type",
|
||||||
|
|||||||
Reference in New Issue
Block a user