Merge pull request #1329 from PicPeak/feat/usage-portal-signin-and-copy
feat(usage): open the portal signed in, and rewrite the German copy
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"
|
||||
|
||||
Reference in New Issue
Block a user