fix(web): hand the new heartbeat token over in sessionStorage, not the URL

This commit is contained in:
2026-09-17 09:23:03 +00:00
parent 13e7160405
commit 7c2c5d95e3
3 changed files with 22 additions and 16 deletions
+13 -12
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Incident, Rollup } from "@/lib/api";
import { Button, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
@@ -322,7 +322,6 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
export default function MonitorDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -330,19 +329,21 @@ export default function MonitorDetailPage() {
const [range, setRange] = useState<Range>(RANGES[0]);
const toast = useToast();
/* The token is only ever handed back once, on the create response, riding
in the query string for this one navigation. Keep it in state and strip
the query immediately so a reload, a bookmark or browser history never
holds it. */
/* The token is only ever handed back once, on the create response. The
create page parks it in sessionStorage for this one navigation, because a
query string would reach proxy access logs. Read it once and remove it
straight away so a reload or another tab never shows it again. */
const [heartbeatToken, setHeartbeatToken] = useState<string | null>(null);
useEffect(() => {
const t = searchParams.get("token");
if (t) {
setHeartbeatToken(t);
router.replace(`/monitors/${monitorId}`);
const key = `vantage:hbtoken:${monitorId}`;
try {
const t = sessionStorage.getItem(key);
sessionStorage.removeItem(key);
if (t) setHeartbeatToken(t);
} catch {
// Storage blocked: nothing was handed over.
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [monitorId]);
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
+8 -3
View File
@@ -24,11 +24,16 @@ export default function NewMonitorPage() {
// survive the route change, which is the one thing an inline
// banner on the form cannot do. The error stays on the form.
toast.success(`Created ${m.name}. First check runs within its interval.`);
// The token is shown once. Hand it over in memory, never in the
// URL: the RSC fetch for a query string lands in proxy access logs.
if (m.heartbeat_token) {
router.push(`/monitors/${m.monitor_id}?token=${encodeURIComponent(m.heartbeat_token)}`);
} else {
router.push(`/monitors/${m.monitor_id}`);
try {
sessionStorage.setItem(`vantage:hbtoken:${m.monitor_id}`, m.heartbeat_token);
} catch {
// Storage blocked: the user can rotate to get a token.
}
}
router.push(`/monitors/${m.monitor_id}`);
},
});
+1 -1
View File
@@ -36,7 +36,7 @@ test("heartbeat monitor: URL ping, /fail, header ping", async ({ page, request }
const incidents = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}/incidents`)).json();
expect(incidents.length).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 1100));
await new Promise((r) => setTimeout(r, 1100)); // same kind (ping) as the first request; limit is 1/s per token and kind
const headerPing = await request.post(`${BASE_URL}/public/hb`, { headers: { "X-Vantage-Token": monitor.heartbeat_token } });
expect(headerPing.status()).toBe(200);
got = await (await page.request.get(`${BASE_URL}/api/monitors/${monitor.monitor_id}`)).json();