mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+1
-1
Submodule .claude/commands updated: b5ec936c22...24ac014068
+1
-1
Submodule internal updated: deb4fd0ab2...b5a820a159
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user