diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts index 824a2fa8..f5ffb94d 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressEvents.ts @@ -67,7 +67,7 @@ export function useProgressEvents({ const fetchEvents = async () => { try { const response = await authenticatedFetch( - api.progress.sessionEvents(session.sessionId, tenantId), + api.progress.sessionEvents(session.sessionId, tenantId, session.serialNumber), getAccessToken, ); if (response.ok) { @@ -121,21 +121,24 @@ export function useProgressEvents({ const currentSession = sessionRef.current; if (!currentSession) return; try { - const sessionsResponse = await authenticatedFetch( - api.progress.sessions(tenantId), + // Refresh the session summary via the serial lookup (the canonical serial from the + // session row always matches exactly). Guard on sessionId: the lookup returns the + // NEWEST session for the serial, which after a re-enrollment is a different session — + // the page must keep showing the one the user selected. + const lookupResponse = await authenticatedFetch( + api.progress.lookup(tenantId, currentSession.serialNumber), getAccessToken, ); - if (sessionsResponse.ok) { - const sessionsData = await sessionsResponse.json(); - const sessions: Session[] = sessionsData.sessions || []; - const updated = sessions.find( - (s) => s.sessionId === currentSession.sessionId, - ); - if (updated) setSession(updated); + if (lookupResponse.ok) { + const lookupData = await lookupResponse.json(); + const updated: Session | null = lookupData.found ? lookupData.session : null; + if (updated && updated.sessionId === currentSession.sessionId) { + setSession(updated); + } } const eventsResponse = await authenticatedFetch( - api.progress.sessionEvents(currentSession.sessionId, tenantId), + api.progress.sessionEvents(currentSession.sessionId, tenantId, currentSession.serialNumber), getAccessToken, ); if (eventsResponse.ok) { 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 f81ab89e..6ffd73d5 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSearch.ts @@ -37,7 +37,9 @@ export interface UseProgressSearchReturn { /** * Owns the progress page's serial-number search lifecycle: - * - fetches tenant-wide sessions list, finds best match by serial / device name + * - resolves the session via the server-side lookup (exact serial/device-name match for + * roleless end users — the serial IS the authorization proof; members keep fuzzy search + * 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 */ @@ -66,27 +68,13 @@ export function useProgressSearch({ try { const response = await authenticatedFetch( - api.progress.sessions(tenantId), + api.progress.lookup(tenantId, serialInput.trim()), getAccessToken, ); if (response.ok) { const data = await response.json(); - const sessions: Session[] = data.sessions || []; - - const query = serialInput.trim().toLowerCase(); - const found = sessions - .filter( - (s) => - s.serialNumber.toLowerCase() === query || - s.serialNumber.toLowerCase().includes(query) || - s.deviceName?.toLowerCase().includes(query), - ) - .sort( - (a, b) => - new Date(b.startedAt).getTime() - - new Date(a.startedAt).getTime(), - )[0]; + const found: Session | null = data.found ? data.session : null; if (found) { setSession(found); diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts index ff570e01..4f31018c 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts @@ -1,8 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Session } from "@/types"; import type { SignalRMessageName } from "@/lib/signalrMessages"; +import type { JoinGroupOptions } from "@/contexts/SignalRContext"; +import type { NotificationType } from "@/contexts/NotificationContext"; interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -10,24 +12,35 @@ interface SignalRApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void; isConnected: boolean; - joinGroup: (group: string) => Promise; + joinGroup: (group: string, options?: JoinGroupOptions) => Promise; leaveGroup: (group: string) => Promise; } +type AddNotification = ( + type: NotificationType, + title: string, + message: string, + key?: string, + href?: string, +) => void; + interface UseProgressSignalRParams { session: Session | null; sessionRef: React.RefObject; signalR: SignalRApi; scheduleFetchEvents: (delayMs?: number) => void; + addNotification: AddNotification; } /** * Owns the progress page's SignalR integration: - * - joins the session-specific group when a session is selected - * (the tenant-wide broadcast group is member-role gated server-side; roleless - * progress viewers would get a 403, and all signals for the selected session — - * eventStream on ingest, newevents on admin mark-succeeded/failed — arrive on - * the session group anyway) + * - joins the session-specific group when a session is selected, presenting the device's + * serial number as knowledge proof (roleless progress viewers are refused without it; + * the tenant-wide broadcast group stays member-role gated server-side, and all signals + * for the selected session — eventStream on ingest, newevents on admin + * mark-succeeded/failed — arrive on the session group anyway) + * - surfaces a refused join as a notification instead of a silently frozen page + * (a swallowed join 403 is exactly how the c4dabeee regression stayed invisible) * - listens for newevents / newSession / eventStream → debounced refetch * - cleans up groups + handlers on unmount / session change */ @@ -36,6 +49,7 @@ export function useProgressSignalR({ sessionRef, signalR, scheduleFetchEvents, + addNotification, }: UseProgressSignalRParams): void { const { on, off, isConnected, joinGroup, leaveGroup } = signalR; @@ -43,19 +57,38 @@ export function useProgressSignalR({ // session, not object identity — a refetched session object must not rejoin. const sessionId = session?.sessionId; const sessionTenantId = session?.tenantId; + const sessionSerial = session?.serialNumber; + + // Ref-stabilized so an unstable addNotification identity can never churn the join effect + // (which would leave + rejoin the group on every render). + const addNotificationRef = useRef(addNotification); + useEffect(() => { + addNotificationRef.current = addNotification; + }, [addNotification]); useEffect(() => { if (!isConnected || !sessionId || !sessionTenantId) return; const sessionGroup = `session-${sessionTenantId}-${sessionId}`; console.log("[Progress] Joining session group:", sessionGroup); - joinGroup(sessionGroup); + joinGroup(sessionGroup, { + serialNumber: sessionSerial, + onDenied: (status) => { + console.warn(`[Progress] Session group join denied (status ${status})`); + addNotificationRef.current( + "warning", + "Live Updates Unavailable", + "Real-time progress updates could not be enabled. The page will still refresh when you search again.", + "progress-join-denied", + ); + }, + }); return () => { console.log("[Progress] Leaving session group:", sessionGroup); leaveGroup(sessionGroup); }; - }, [isConnected, sessionId, sessionTenantId, joinGroup, leaveGroup]); + }, [isConnected, sessionId, sessionTenantId, sessionSerial, joinGroup, leaveGroup]); useEffect(() => { const scheduleRefetch = (source: string, sessionId: string) => { diff --git a/src/Web/autopilot-monitor-web/app/progress/page.tsx b/src/Web/autopilot-monitor-web/app/progress/page.tsx index 5a7a12e8..b2120d0f 100644 --- a/src/Web/autopilot-monitor-web/app/progress/page.tsx +++ b/src/Web/autopilot-monitor-web/app/progress/page.tsx @@ -68,6 +68,7 @@ export default function ProgressPortalPage() { sessionRef, signalR, scheduleFetchEvents, + addNotification, }); const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress } = diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 2bce8de3..e8c7e681 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -12,6 +12,16 @@ import { useAuth } from './AuthContext'; // consumer handlers keep their narrower parameter types without laundering here. type SignalRHandler = Parameters[1]; +// Session-group joins by roleless Progress-Portal users must present the device's serial number +// (server-side knowledge proof). The serial is remembered per group so the automatic re-join +// after a reconnect carries it too — without that, live updates would silently die on the first +// network blip. onDenied surfaces a join the server refused (403); the context otherwise only +// logs it, and a silent join failure historically looked like a frozen page. +export interface JoinGroupOptions { + serialNumber?: string; + onDenied?: (status: number) => void; +} + interface SignalRContextType { connection: signalR.HubConnection | null; connectionState: signalR.HubConnectionState; @@ -21,7 +31,7 @@ interface SignalRContextType { on: (eventName: SignalRMessageName, callback: SignalRHandler) => void; off: (eventName: SignalRMessageName, callback: SignalRHandler) => void; invoke: (methodName: string, ...args: unknown[]) => Promise; - joinGroup: (groupName: string) => Promise; + joinGroup: (groupName: string, options?: JoinGroupOptions) => Promise; leaveGroup: (groupName: string) => Promise; isConnected: boolean; joinedGroups: string[]; @@ -50,6 +60,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { const [connectionState, setConnectionState] = useState(signalR.HubConnectionState.Disconnected); const connectionRef = useRef(null); const joinedGroupsRef = useRef>(new Set()); + // Serial-number proof per session group (see JoinGroupOptions). Kept across disconnects so the + // auto-rejoin after a reconnect can re-present it; entries are removed on a confirmed leave. + const joinSerialsRef = useRef>(new Map()); const [joinedGroups, setJoinedGroups] = useState([]); const retryCountRef = useRef(0); const retryTimeoutRef = useRef | null>(null); @@ -133,7 +146,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId, groupName }) + // Re-present the stored serial proof for session groups — a roleless + // Progress-Portal user's rejoin is refused without it. + body: JSON.stringify({ + connectionId, + groupName, + serialNumber: joinSerialsRef.current.get(groupName), + }) } ); @@ -246,7 +265,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { throw new Error('SignalR connection not established'); }; - const joinGroup = useCallback(async (groupName: string) => { + const joinGroup = useCallback(async (groupName: string, options?: JoinGroupOptions) => { if (!connection || connectionState !== signalR.HubConnectionState.Connected) { return; } @@ -258,6 +277,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // Add to Set immediately to prevent race conditions with multiple simultaneous calls joinedGroupsRef.current.add(groupName); + if (options?.serialNumber) { + joinSerialsRef.current.set(groupName, options.serialNumber); + } syncJoinedGroups(); try { @@ -274,19 +296,29 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connectionId, groupName }) + body: JSON.stringify({ + connectionId, + groupName, + serialNumber: options?.serialNumber, + }) } ); if (!response.ok) { - // Remove from Set if API call failed (so we can retry) + // Remove from Set if API call failed (so we can retry). A refused join must be + // VISIBLE: a silently swallowed 403 here historically left the page looking frozen + // (no live updates, no error) — see the c4dabeee regression. + console.warn(`[SignalR] Failed to join group ${groupName} (status ${response.status})`); joinedGroupsRef.current.delete(groupName); + joinSerialsRef.current.delete(groupName); syncJoinedGroups(); + options?.onDenied?.(response.status); } } catch (error) { console.error(`[SignalR] Error joining group ${groupName}:`, error); // Remove from Set if API call failed (so we can retry) joinedGroupsRef.current.delete(groupName); + joinSerialsRef.current.delete(groupName); syncJoinedGroups(); } }, [connection, connectionState, getAccessToken, syncJoinedGroups]); @@ -325,6 +357,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // Re-add to Set if API call failed (so we can retry) joinedGroupsRef.current.add(groupName); syncJoinedGroups(); + } else { + // Confirmed leave — drop the stored serial proof for this group. + joinSerialsRef.current.delete(groupName); } } catch (error) { console.error(`[SignalR] Error leaving group ${groupName}:`, error); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 398b6436..7cd91da6 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -389,11 +389,14 @@ export const api = { }, // ── Progress ────────────────────────────────────────────────────────────── + // Serial-knowledge model: there is NO tenant-wide session list at this tier. The lookup + // resolves at most one session server-side (roleless callers need the exact serial/device + // name), and the events route re-presents the serial as proof on every read. progress: { - sessions: (tenantId: string) => - `${API_BASE_URL}/api/progress/sessions${qs({ tenantId })}`, - sessionEvents: (sessionId: string, tenantId: string) => - `${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId })}`, + lookup: (tenantId: string, search: string) => + `${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`, + sessionEvents: (sessionId: string, tenantId: string, serial: string) => + `${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`, }, // ── Bootstrap ─────────────────────────────────────────────────────────────