From 573f3b422bbe4279dafab9da10cfbbda190fc2f5 Mon Sep 17 00:00:00 2001 From: maffe Date: Sun, 1 Mar 2026 16:11:28 +0100 Subject: [PATCH 1/3] Make APIError type (frontend) match APIErrorFinal (backend) --- frontend/src/api/error.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/error.ts b/frontend/src/api/error.ts index d452151..9deb73b 100644 --- a/frontend/src/api/error.ts +++ b/frontend/src/api/error.ts @@ -31,10 +31,12 @@ export const APIErrorCodes = { export type APIErrorCode = (typeof APIErrorCodes)[keyof typeof APIErrorCodes]; export type APIError = { - code: APIErrorCode; - message: string; - httpStatus: number; - timestamp: string; + error: { + code: APIErrorCode; + message: string; + httpStatus: number; + timestamp: string; + }; endpoint: { method: string; path: string; From d26fd4674bf4288213e619c27c3057e970670210 Mon Sep 17 00:00:00 2001 From: maffe Date: Sun, 1 Mar 2026 16:11:53 +0100 Subject: [PATCH 2/3] Throw more descriptive error messages --- frontend/src/api/host.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/host.ts b/frontend/src/api/host.ts index 1601608..f556bdb 100644 --- a/frontend/src/api/host.ts +++ b/frontend/src/api/host.ts @@ -4,6 +4,7 @@ import { apiFetch } from "@/signatures/voteSession"; import type { BallotMetaData } from "@/signatures/signatures"; +import type { APIError } from "./error"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -45,7 +46,7 @@ export async function addVoter( method: "POST", body: JSON.stringify({ voterName: name, isHost: isHost }), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); return res.json(); } @@ -54,12 +55,12 @@ export async function removeVoter(voterUuuid: string): Promise { method: "DELETE", body: JSON.stringify({ voter_uuuid: voterUuuid }), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); } export async function removeAllVoters(): Promise { const res = await apiFetch("/api/host/remove-all", { method: "DELETE" }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); } // ─── Tally files ────────────────────────────────────────────────────────────── @@ -71,7 +72,7 @@ export interface TallyFileEntry { export async function getAllTallyFiles(): Promise { const res = await apiFetch("/api/host/get-all-tally"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); return res.json(); } @@ -79,13 +80,26 @@ export async function getAllTallyFiles(): Promise { export async function closeMeeting(): Promise { const res = await apiFetch("/api/host/close-meeting", { method: "DELETE" }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); } // ─── Vote progress ──────────────────────────────────────────────────────────── export async function fetchVoteProgress(): Promise { const res = await apiFetch("/api/common/vote-progress"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) await handleErrorResponse(res); return res.json(); } + +// ─── Handle errors ──────────────────────────────────────────────────────────── + +async function handleErrorResponse(res: Response) { + let errorMessage = `HTTP ${res.status}`; + try { + const apiError: APIError = await res.json(); + console.error(apiError); + if (apiError?.error?.message) errorMessage = apiError.error.message; + } finally { + throw new Error(errorMessage); + } +} From 74b1aa21e93f9f7fed0fef482c2a2326edfa4866 Mon Sep 17 00:00:00 2001 From: Felix Hellborg Date: Tue, 3 Mar 2026 16:11:33 +0100 Subject: [PATCH 3/3] Lay some more groundwork for more descriptive error messages --- frontend/src/api/error.ts | 27 ++++++++ frontend/src/api/host.ts | 14 +--- frontend/src/components/Alert/ErrorAlert.tsx | 29 ++++++++ .../src/components/VotePanel/VotePanel.tsx | 11 ++- frontend/src/routes/admin.tsx | 67 +++++++------------ frontend/src/routes/create-meeting.tsx | 12 ++-- frontend/src/routes/login.tsx | 19 +++--- frontend/src/signatures/voteSession.ts | 34 +++++----- 8 files changed, 113 insertions(+), 100 deletions(-) create mode 100644 frontend/src/components/Alert/ErrorAlert.tsx diff --git a/frontend/src/api/error.ts b/frontend/src/api/error.ts index 9deb73b..ef15e35 100644 --- a/frontend/src/api/error.ts +++ b/frontend/src/api/error.ts @@ -42,3 +42,30 @@ export type APIError = { path: string; }; }; + +export class APIRequestError extends Error { + readonly apiError: APIError; + + constructor(apiError: APIError) { + super(apiError.error.message); + this.name = "APIRequestError"; + this.apiError = apiError; + } +} + +/** + * Parses the error body from a non-ok Response and throws an `APIRequestError`. + * Falls back to a generic `Error("HTTP ")` if the body is not a valid + * `APIError`. + * + * Must be called before consuming the response body (i.e. before `res.json()`). + */ +export async function handleErrorResponse(res: Response): Promise { + try { + const apiError: APIError = await res.json(); + throw new APIRequestError(apiError); + } catch (e) { + if (e instanceof APIRequestError) throw e; + throw new Error(`HTTP ${res.status}`); + } +} diff --git a/frontend/src/api/host.ts b/frontend/src/api/host.ts index f556bdb..62b2829 100644 --- a/frontend/src/api/host.ts +++ b/frontend/src/api/host.ts @@ -4,7 +4,7 @@ import { apiFetch } from "@/signatures/voteSession"; import type { BallotMetaData } from "@/signatures/signatures"; -import type { APIError } from "./error"; +import { handleErrorResponse } from "./error"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -91,15 +91,3 @@ export async function fetchVoteProgress(): Promise { return res.json(); } -// ─── Handle errors ──────────────────────────────────────────────────────────── - -async function handleErrorResponse(res: Response) { - let errorMessage = `HTTP ${res.status}`; - try { - const apiError: APIError = await res.json(); - console.error(apiError); - if (apiError?.error?.message) errorMessage = apiError.error.message; - } finally { - throw new Error(errorMessage); - } -} diff --git a/frontend/src/components/Alert/ErrorAlert.tsx b/frontend/src/components/Alert/ErrorAlert.tsx new file mode 100644 index 0000000..cf856b8 --- /dev/null +++ b/frontend/src/components/Alert/ErrorAlert.tsx @@ -0,0 +1,29 @@ +import { APIRequestError } from "@/api/error"; +import { Alert } from "./Alert"; + +export function ErrorAlert({ error }: { error: unknown }) { + if (!error) return null; + + if (error instanceof APIRequestError) { + const { code, httpStatus, timestamp } = error.apiError.error; + const { method, path } = error.apiError.endpoint; + const time = new Date(timestamp).toLocaleTimeString(); + return ( + +
{error.message}
+
+ {code} · HTTP {httpStatus} · {method} {path} · {time} +
+
+ ); + } + + return ( + + {String(error)} + + ); +} diff --git a/frontend/src/components/VotePanel/VotePanel.tsx b/frontend/src/components/VotePanel/VotePanel.tsx index d9fb988..db4a492 100644 --- a/frontend/src/components/VotePanel/VotePanel.tsx +++ b/frontend/src/components/VotePanel/VotePanel.tsx @@ -3,6 +3,7 @@ import { Panel } from "@/components/Panel/Panel"; import { Button } from "@/components/Button/Button"; import { Spinner } from "@/components/Spinner/Spinner"; import { Alert } from "@/components/Alert/Alert"; +import { ErrorAlert } from "@/components/Alert/ErrorAlert"; import { registerVoter, submitVote, @@ -34,7 +35,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) { const [status, setStatus] = useState("idle"); const [voteData, setVoteData] = useState(null); const [selected, setSelected] = useState([]); - const [error, setError] = useState(null); + const [error, setError] = useState(null); // Track the vote name at derivation time to avoid redundant checks. const derivedForVoteName = useRef(undefined); @@ -70,7 +71,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) { setSelected([]); setStatus("selecting"); } catch (err) { - setError(String(err)); + setError(err); setStatus("idle"); } } @@ -304,11 +305,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) { )} - {error && ( - - {error} - - )} + ); diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index c457427..3a223ac 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { Button } from "@/components/Button/Button"; import { Input } from "@/components/Input/Input"; import { Alert } from "@/components/Alert/Alert"; +import { ErrorAlert } from "@/components/Alert/ErrorAlert"; import { Spinner } from "@/components/Spinner/Spinner"; import { Badge } from "@/components/Badge/Badge"; import { Panel } from "@/components/Panel/Panel"; @@ -78,7 +79,7 @@ function AddVoterPanel({ const [name, setName] = useState(""); const [isHost, setIsHost] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); async function handleAdd() { const trimmed = name.trim(); @@ -91,7 +92,7 @@ function AddVoterPanel({ setName(""); setIsHost(false); } catch (err) { - setError(String(err)); + setError(err); } finally { setLoading(false); } @@ -136,11 +137,7 @@ function AddVoterPanel({ Grant host privileges - {error && ( - - {error} - - )} + ); @@ -600,7 +597,7 @@ function HostVoteRoundPanel({ const [starting, setStarting] = useState(false); const [tallying, setTallying] = useState(false); const [ending, setEnding] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); async function handleStart() { const validOpts = options.filter((o) => o.trim()); @@ -618,7 +615,7 @@ function HostVoteRoundPanel({ shuffle, ); } catch (err) { - setError(String(err)); + setError(err); } finally { setStarting(false); } @@ -630,7 +627,7 @@ function HostVoteRoundPanel({ try { await onTally(); } catch (err) { - setError(String(err)); + setError(err); } finally { setTallying(false); } @@ -642,7 +639,7 @@ function HostVoteRoundPanel({ try { await onEndRound(); } catch (err) { - setError(String(err)); + setError(err); } finally { setEnding(false); } @@ -873,11 +870,7 @@ function HostVoteRoundPanel({ )} - {error && ( - - {error} - - )} + ); @@ -898,15 +891,14 @@ function Admin() { const [joinedVoterName, setJoinedVoterName] = useState(null); const [confirmClose, setConfirmClose] = useState(false); const [closing, setClosing] = useState(false); - const [closeError, setCloseError] = useState(null); + const [closeError, setCloseError] = useState(null); const [tallyPassword, setTallyPassword] = useState(""); const [downloadingTallies, setDownloadingTallies] = useState(false); - const [tallyDownloadError, setTallyDownloadError] = useState( - null, - ); + const [tallyDownloadError, setTallyDownloadError] = useState(null); // Ref so the SSE closure always reads the current qrInfo without re-subscribing. const qrInfoRef = useRef(qrInfo); - const [loadError, setLoadError] = useState(null); + const [loadError, setLoadError] = useState(null); + const [backgroundError, setBackgroundError] = useState(null); useEffect(() => { qrInfoRef.current = qrInfo; @@ -917,7 +909,7 @@ function Admin() { const list = await fetchVoterList(); setVoters(list); } catch (err) { - console.error("Failed to reload voters:", err); + setBackgroundError(err); } }, []); @@ -934,10 +926,10 @@ function Admin() { const state = deriveVoteState(progress); setVoteState(state); if (state === "Tally") { - getTally().then(setTallyResult).catch(console.error); + getTally().then(setTallyResult).catch(setLoadError); } } catch (err) { - setLoadError(String(err)); + setLoadError(err); } finally { setVotersLoading(false); } @@ -955,7 +947,7 @@ function Admin() { if (raw === "Creation" || raw === "Voting" || raw === "Tally") { setVoteState(raw); if (raw === "Tally") { - getTally().then(setTallyResult).catch(console.error); + getTally().then(setTallyResult).catch(setBackgroundError); } if (raw === "Creation") { setTallyResult(null); @@ -1053,7 +1045,7 @@ function Admin() { await closeMeeting(); navigate({ to: "/create-meeting" }); } catch (err) { - setCloseError(String(err)); + setCloseError(err); setClosing(false); } } @@ -1081,10 +1073,7 @@ function Admin() { a.click(); URL.revokeObjectURL(url); } catch (err) { - setTallyDownloadError( - "Failed to decrypt — check that you entered the correct password.", - ); - console.error(err); + setTallyDownloadError(err); } finally { setDownloadingTallies(false); } @@ -1101,9 +1090,7 @@ function Admin() { if (loadError) { return (
- - Failed to load admin panel: {loadError} - +
); } @@ -1140,6 +1127,8 @@ function Admin() { + + {confirmClose && (
@@ -1151,11 +1140,7 @@ function Admin() { below before closing.

- {closeError && ( - - {closeError} - - )} +
- {tallyDownloadError && ( - - {tallyDownloadError} - - )} +
diff --git a/frontend/src/routes/create-meeting.tsx b/frontend/src/routes/create-meeting.tsx index 23110b0..292761a 100644 --- a/frontend/src/routes/create-meeting.tsx +++ b/frontend/src/routes/create-meeting.tsx @@ -4,7 +4,7 @@ import { Navbar } from "@/components/Navbar/Navbar"; import { Panel } from "@/components/Panel/Panel"; import { Input } from "@/components/Input/Input"; import { Button } from "@/components/Button/Button"; -import { Alert } from "@/components/Alert/Alert"; +import { ErrorAlert } from "@/components/Alert/ErrorAlert"; import { Spinner } from "@/components/Spinner/Spinner"; import { createMeeting } from "@/signatures/voteSession"; import { @@ -108,7 +108,7 @@ function CreateMeetingPage() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -135,7 +135,7 @@ function CreateMeetingPage() { await createMeeting(trimTitle, trimHost, x25519PublicKeyToPem(publicKey)); navigate({ to: "/admin" }); } catch (err) { - setError(String(err)); + setError(err); setLoading(false); } } @@ -223,11 +223,7 @@ function CreateMeetingPage() { /> - {error && ( - - {error} - - )} +