From 869580c9df192af7df7328a54b61964de809de00 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 24 Sep 2026 17:14:39 +0200 Subject: [PATCH] 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) --- .claude/commands | 2 +- internal | 2 +- .../hooks/__tests__/serialDeepLink.test.ts | 36 ++++++++++++++++++ .../app/progress/hooks/serialDeepLink.ts | 28 ++++++++++++++ .../app/progress/hooks/useProgressSearch.ts | 38 ++++++++++++++++--- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/serialDeepLink.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/progress/hooks/serialDeepLink.ts diff --git a/.claude/commands b/.claude/commands index b5ec936c..24ac0140 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit b5ec936c2286266be0a4fb6f554f1b217e28af0b +Subproject commit 24ac01406877755aad00e36a6270c78e3d4a2d3c diff --git a/internal b/internal index deb4fd0a..b5a820a1 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit deb4fd0ab2fbc0717b810c87460fa6f1dc687bce +Subproject commit b5a820a1595e1b555affaec1c84a402a6874eaef diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/serialDeepLink.test.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/serialDeepLink.test.ts new file mode 100644 index 00000000..d5dc2a4f --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/__tests__/serialDeepLink.test.ts @@ -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"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/serialDeepLink.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/serialDeepLink.ts new file mode 100644 index 00000000..248a1ef4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/serialDeepLink.ts @@ -0,0 +1,28 @@ +/** + * Deep link into the progress page: `/progress?serial=`. + * + * 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()}`; +} diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts index 7493e3b1..7df2a88f 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts @@ -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=` 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(api.progress.lookup(tenantId, serialInput.trim()), getAccessToken); + data = await fetchJson(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,