diff --git a/platforms/evoting/api/src/controllers/SigningController.ts b/platforms/evoting/api/src/controllers/SigningController.ts index 02934eeb2..35c6402c8 100644 --- a/platforms/evoting/api/src/controllers/SigningController.ts +++ b/platforms/evoting/api/src/controllers/SigningController.ts @@ -90,15 +90,36 @@ export class SigningController { res.write("data: " + JSON.stringify({ type: "connected", sessionId }) + "\n\n"); // Subscribe to session updates - const unsubscribe = this.ensureService().subscribeToSession(sessionId, (data) => { + const service = this.ensureService(); + const unsubscribe = service.subscribeToSession(sessionId, (data) => { res.write("data: " + JSON.stringify(data) + "\n\n"); }); - // Handle client disconnect + // Clean up the subscription when the client disconnects. Registered before the + // getSession() await below so a disconnect during that await can't slip past + // listener registration and leak the subscriber. req.on("close", () => { unsubscribe(); res.end(); }); + + // Replay the current terminal state on (re)connect. The completion event is + // pushed only once, at callback time. On mobile the browser suspends this SSE + // stream while the eID Wallet is foregrounded, so a client that reconnects + // after signing would otherwise never learn the vote succeeded (and would show + // a misleading error or hang until expiry). Re-emitting is idempotent on the client. + try { + const session = await service.getSession(sessionId); + if (session?.status === "completed") { + res.write("data: " + JSON.stringify({ type: "signed", status: "completed", sessionId }) + "\n\n"); + } else if (session?.status === "security_violation") { + res.write("data: " + JSON.stringify({ type: "security_violation", status: "security_violation", error: "eName verification failed", sessionId }) + "\n\n"); + } else if (session?.status === "expired") { + res.write("data: " + JSON.stringify({ type: "expired", status: "expired", sessionId }) + "\n\n"); + } + } catch (error) { + console.error("Error replaying signing session status on connect:", error); + } } // Handle signed payload callback from eID Wallet diff --git a/platforms/evoting/client/src/components/signing-interface.tsx b/platforms/evoting/client/src/components/signing-interface.tsx index 35d0189ae..ae195b95d 100644 --- a/platforms/evoting/client/src/components/signing-interface.tsx +++ b/platforms/evoting/client/src/components/signing-interface.tsx @@ -35,6 +35,43 @@ export function SigningInterface({ const { toast } = useToast(); const { user } = useAuth(); const hasCreatedSession = useRef(false); + // Guards the completion path so it runs exactly once, whether the "signed" signal + // arrives via the live SSE push or via reconciliation after a reconnect. + const hasCompleted = useRef(false); + + const finishAsSigned = (voteId?: string) => { + if (hasCompleted.current) return; + hasCompleted.current = true; + setStatus("signed"); + toast({ + title: "Vote Signed!", + description: "Your vote has been successfully signed and submitted", + }); + onSigningComplete(voteId ?? ""); + }; + + // Re-fetch the authoritative session status. Called when the SSE stream errors and + // when the tab regains focus: on mobile, opening the eID Wallet backgrounds this page + // and the browser suspends the SSE stream, so the one-shot completion push can be + // missed. This recovers the real outcome instead of showing a false error. + const reconcileSession = async (sid: string) => { + if (!sid || hasCompleted.current) return; + try { + const session = await pollApi.getSigningSession(sid); + if (!session) return; + if (session.status === "completed") { + finishAsSigned(session.voteId); + } else if (session.status === "security_violation") { + setStatus("security_violation"); + } else if (session.status === "expired") { + setStatus("expired"); + } + } catch (error) { + // Non-fatal: the SSE stream auto-reconnects (and re-emits terminal state), and + // the next visibilitychange will retry. + console.error("Failed to reconcile signing session:", error); + } + }; const createSession = async () => { if (!user?.id || hasCreatedSession.current || status === "error") { @@ -77,6 +114,25 @@ export function SigningInterface({ }; }, [eventSource]); + // Recover a completion that landed while we were backgrounded. Mobile browsers + // suspend the SSE stream while the eID Wallet is foregrounded, so the one-shot + // "signed" push can arrive (and be lost) before we return. Re-check the authoritative + // status whenever the tab regains focus/visibility. + useEffect(() => { + if (!sessionId) return; + const recover = () => { + if (document.visibilityState === "visible") { + reconcileSession(sessionId); + } + }; + document.addEventListener("visibilitychange", recover); + window.addEventListener("focus", recover); + return () => { + document.removeEventListener("visibilitychange", recover); + window.removeEventListener("focus", recover); + }; + }, [sessionId]); + const startSSEConnection = (sessionId: string) => { // Prevent multiple SSE connections if (eventSource) { @@ -98,13 +154,7 @@ export function SigningInterface({ const data = JSON.parse(e.data); if (data.type === "signed" && data.status === "completed") { - setStatus("signed"); - - toast({ - title: "Vote Signed!", - description: "Your vote has been successfully signed and submitted", - }); - onSigningComplete(data.voteId); + finishAsSigned(data.voteId); } else if (data.type === "expired") { setStatus("expired"); toast({ @@ -128,7 +178,12 @@ export function SigningInterface({ newEventSource.onerror = (error) => { console.error("SSE connection error:", error); - setStatus("error"); + // A dropped SSE stream is NOT a session-creation failure: the session already + // exists and the vote may already be signed (opening the eID Wallet backgrounds + // this page on mobile, suspending the stream). Reconcile the real status instead + // of showing the misleading "Failed to create signing session" error; the browser + // also auto-reconnects the stream on its own. + reconcileSession(sessionId); }; setEventSource(newEventSource); diff --git a/platforms/evoting/client/src/lib/pollApi.ts b/platforms/evoting/client/src/lib/pollApi.ts index a175aebdb..07b365c31 100644 --- a/platforms/evoting/client/src/lib/pollApi.ts +++ b/platforms/evoting/client/src/lib/pollApi.ts @@ -1,4 +1,5 @@ import { apiClient } from "./apiClient"; +import { isAxiosError } from "axios"; export interface Poll { id: string; @@ -302,6 +303,26 @@ export const pollApi = { return response.data; }, + // Get a signing session's current status. Used to reconcile after the SSE stream + // drops (e.g. the mobile browser suspended it while the eID Wallet was foregrounded), + // so a completion that happened while disconnected is still picked up. + getSigningSession: async ( + sessionId: string + ): Promise<{ status: string; voteId?: string } | null> => { + try { + const response = await apiClient.get(`/api/signing/sessions/${sessionId}`); + return response.data; + } catch (error) { + // A 404 means the session no longer exists (expired and cleaned up, or the + // API lost its in-memory sessions on restart) — nothing to reconcile, so + // return null. Any other error propagates to the caller. + if (isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } + }, + // Delegation methods // Check if a poll can have delegation