feat(web): progress portal deep link /progress?serial=

A ?serial= parameter (serial number or exact device name) runs one lookup
as soon as sign-in has resolved the tenant, so organisations can send users
a link to follow their own device. Every hit writes the found session's
serial back into the URL for reload, bookmark and copying. Authorization is
unchanged: sign-in stays mandatory and roleless callers still need the exact
serial server-side. The query survives the login redirect because
ProtectedRoute keeps pathname + search. progress_serial_submitted carries
source form | link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-24 17:14:39 +02:00
parent 1a96cd20da
commit 869580c9df
5 changed files with 98 additions and 8 deletions
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { MAX_SERIAL_PARAM_LENGTH, readSerialParam, withSerialParam } from "../serialDeepLink";
describe("readSerialParam", () => {
it("returns the trimmed serial", () => {
expect(readSerialParam("?serial=ABC123")).toBe("ABC123");
expect(readSerialParam("?serial=%20ABC123%20")).toBe("ABC123");
});
it("decodes device names with spaces and special characters", () => {
expect(readSerialParam("?serial=CPC-user%20A%2B1")).toBe("CPC-user A+1");
});
it("returns null when the parameter is absent, empty or blank", () => {
expect(readSerialParam("")).toBeNull();
expect(readSerialParam("?other=1")).toBeNull();
expect(readSerialParam("?serial=")).toBeNull();
expect(readSerialParam("?serial=%20%20")).toBeNull();
});
it("rejects terms above the length cap", () => {
expect(readSerialParam(`?serial=${"A".repeat(MAX_SERIAL_PARAM_LENGTH)}`)).toHaveLength(MAX_SERIAL_PARAM_LENGTH);
expect(readSerialParam(`?serial=${"A".repeat(MAX_SERIAL_PARAM_LENGTH + 1)}`)).toBeNull();
});
});
describe("withSerialParam", () => {
it("sets the serial and keeps other parameters", () => {
expect(withSerialParam("", "ABC123")).toBe("?serial=ABC123");
expect(withSerialParam("?authapp=legacy&serial=OLD", "NEW 1")).toBe("?authapp=legacy&serial=NEW+1");
});
it("round-trips through readSerialParam", () => {
expect(readSerialParam(withSerialParam("", "CPC-user A+1"))).toBe("CPC-user A+1");
});
});
@@ -0,0 +1,28 @@
/**
* Deep link into the progress page: `/progress?serial=<serial or device name>`.
*
* Organisations send this link to end users (onboarding mail, ticket, workflow) so they
* can follow their device without typing the serial. It grants nothing the form does not:
* sign-in stays mandatory, the tenant comes from the token, and roleless callers still need
* the exact serial or device name server-side. A query parameter, not a fragment, because
* ProtectedRoute keeps only `pathname + search` across the login redirect.
*/
export const SERIAL_PARAM = "serial";
/** Upper bound for a linked term; real serials and device names stay far below it. */
export const MAX_SERIAL_PARAM_LENGTH = 128;
/** The linked search term from a `location.search` string, or null when absent or unusable. */
export function readSerialParam(search: string): string | null {
const value = new URLSearchParams(search).get(SERIAL_PARAM)?.trim();
if (!value || value.length > MAX_SERIAL_PARAM_LENGTH) return null;
return value;
}
/** `location.search` with the serial parameter set to `serial`, other parameters kept. */
export function withSerialParam(search: string, serial: string): string {
const params = new URLSearchParams(search);
params.set(SERIAL_PARAM, serial);
return `?${params.toString()}`;
}
@@ -1,12 +1,13 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { trackEvent } from "@/lib/appInsights";
import { Session } from "@/types";
import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext";
import type { ProgressLookupSessionResponse } from "@/utils/wire-types.generated";
import { ApiError, fetchJson } from "@/lib/apiClient";
import { readSerialParam, withSerialParam } from "./serialDeepLink";
type AddNotification = (
type: NotificationType,
@@ -43,6 +44,8 @@ export interface UseProgressSearchReturn {
* server-side; the tenant-wide list never reaches the browser)
* - exposes `setSession` so real-time refetch can replace the selected session
* - auto-collapses header on match, raises notFound on miss or error
* - runs one lookup for a `?serial=` deep link once the tenant is known, and mirrors every
* hit back into the URL as `?serial=<serial>` so reload, bookmark and a copied link work
*/
export function useProgressSearch({
tenantId,
@@ -57,10 +60,12 @@ export function useProgressSearch({
const [notFound, setNotFound] = useState(false);
const [headerCollapsed, setHeaderCollapsed] = useState(false);
const searchBySerial = useCallback(async () => {
if (!serialInput.trim()) return;
const runSearch = useCallback(async (rawTerm: string, source: "form" | "link") => {
const term = rawTerm.trim();
if (!term) return;
trackEvent("progress_serial_submitted");
trackEvent("progress_serial_submitted", { source });
setSerialInput(term);
setSearching(true);
setSearched(true);
setNotFound(false);
@@ -70,7 +75,7 @@ export function useProgressSearch({
try {
let data: ProgressLookupSessionResponse;
try {
data = await fetchJson<ProgressLookupSessionResponse>(api.progress.lookup(tenantId, serialInput.trim()), getAccessToken);
data = await fetchJson<ProgressLookupSessionResponse>(api.progress.lookup(tenantId, term), getAccessToken);
} catch (err) {
if (!(err instanceof ApiError)) throw err;
notifyApiError(addNotification, "Backend Error", err, "progress-search-error", "Search failed.");
@@ -82,6 +87,12 @@ export function useProgressSearch({
if (found) {
setSession(found);
setHeaderCollapsed(true);
// The serial, not the typed term: a member's partial term or a device name would
// not resolve for a roleless user the link is forwarded to.
const search = withSerialParam(window.location.search, found.serialNumber?.trim() || term);
if (search !== window.location.search) {
window.history.replaceState(null, "", window.location.pathname + search + window.location.hash);
}
} else {
setNotFound(true);
}
@@ -97,7 +108,22 @@ export function useProgressSearch({
} finally {
setSearching(false);
}
}, [serialInput, tenantId, getAccessToken, addNotification, onBeforeSearch]);
}, [tenantId, getAccessToken, addNotification, onBeforeSearch]);
const searchBySerial = useCallback(() => runSearch(serialInput, "form"), [runSearch, serialInput]);
// Deep link: one lookup per page load, once sign-in has resolved the tenant.
const deepLinkHandled = useRef(false);
useEffect(() => {
if (!tenantId || deepLinkHandled.current) return;
deepLinkHandled.current = true;
const linked = readSerialParam(window.location.search);
if (!linked) return;
const run = async () => {
await runSearch(linked, "link");
};
void run();
}, [tenantId, runSearch]);
return {
serialInput,