Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions platforms/evoting/api/src/controllers/SigningController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 63 additions & 8 deletions platforms/evoting/client/src/components/signing-interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand All @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions platforms/evoting/client/src/lib/pollApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { apiClient } from "./apiClient";
import { isAxiosError } from "axios";

export interface Poll {
id: string;
Expand Down Expand Up @@ -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
Expand Down
Loading