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
37 changes: 33 additions & 4 deletions frontend/src/api/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,41 @@ 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;
};
};

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 <status>")` 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<never> {
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}`);
}
}
14 changes: 8 additions & 6 deletions frontend/src/api/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { apiFetch } from "@/signatures/voteSession";
import type { BallotMetaData } from "@/signatures/signatures";
import { handleErrorResponse } from "./error";

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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();
}

Expand All @@ -54,12 +55,12 @@ export async function removeVoter(voterUuuid: string): Promise<void> {
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<void> {
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 ──────────────────────────────────────────────────────────────
Expand All @@ -71,21 +72,22 @@ export interface TallyFileEntry {

export async function getAllTallyFiles(): Promise<TallyFileEntry[]> {
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();
}

// ─── Meeting lifecycle ────────────────────────────────────────────────────────

export async function closeMeeting(): Promise<void> {
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<VoteProgress> {
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();
}

29 changes: 29 additions & 0 deletions frontend/src/components/Alert/ErrorAlert.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert size="sm" color="accent">
<div>{error.message}</div>
<div
className="text-xs mt-1 font-mono"
style={{ opacity: 0.7 }}
>
{code} · HTTP {httpStatus} · {method} {path} · {time}
</div>
</Alert>
);
}

return (
<Alert size="sm" color="accent">
{String(error)}
</Alert>
);
}
11 changes: 4 additions & 7 deletions frontend/src/components/VotePanel/VotePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +35,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) {
const [status, setStatus] = useState<VoterStatus>("idle");
const [voteData, setVoteData] = useState<VoteData | null>(null);
const [selected, setSelected] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<unknown>(null);

// Track the vote name at derivation time to avoid redundant checks.
const derivedForVoteName = useRef<string | null | undefined>(undefined);
Expand Down Expand Up @@ -70,7 +71,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) {
setSelected([]);
setStatus("selecting");
} catch (err) {
setError(String(err));
setError(err);
setStatus("idle");
}
}
Expand Down Expand Up @@ -304,11 +305,7 @@ export function VotePanel({ voteState, voteName, metadata }: VotePanelProps) {
</>
)}

{error && (
<Alert size="sm" color="accent">
{error}
</Alert>
)}
<ErrorAlert error={error} />
</div>
</Panel>
);
Expand Down
67 changes: 24 additions & 43 deletions frontend/src/routes/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,7 +79,7 @@ function AddVoterPanel({
const [name, setName] = useState("");
const [isHost, setIsHost] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<unknown>(null);

async function handleAdd() {
const trimmed = name.trim();
Expand All @@ -91,7 +92,7 @@ function AddVoterPanel({
setName("");
setIsHost(false);
} catch (err) {
setError(String(err));
setError(err);
} finally {
setLoading(false);
}
Expand Down Expand Up @@ -136,11 +137,7 @@ function AddVoterPanel({
Grant host privileges
</label>

{error && (
<Alert size="sm" color="accent">
{error}
</Alert>
)}
<ErrorAlert error={error} />
</div>
</Panel>
);
Expand Down Expand Up @@ -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<string | null>(null);
const [error, setError] = useState<unknown>(null);

async function handleStart() {
const validOpts = options.filter((o) => o.trim());
Expand All @@ -618,7 +615,7 @@ function HostVoteRoundPanel({
shuffle,
);
} catch (err) {
setError(String(err));
setError(err);
} finally {
setStarting(false);
}
Expand All @@ -630,7 +627,7 @@ function HostVoteRoundPanel({
try {
await onTally();
} catch (err) {
setError(String(err));
setError(err);
} finally {
setTallying(false);
}
Expand All @@ -642,7 +639,7 @@ function HostVoteRoundPanel({
try {
await onEndRound();
} catch (err) {
setError(String(err));
setError(err);
} finally {
setEnding(false);
}
Expand Down Expand Up @@ -873,11 +870,7 @@ function HostVoteRoundPanel({
</div>
)}

{error && (
<Alert size="sm" color="accent">
{error}
</Alert>
)}
<ErrorAlert error={error} />
</div>
</Panel>
);
Expand All @@ -898,15 +891,14 @@ function Admin() {
const [joinedVoterName, setJoinedVoterName] = useState<string | null>(null);
const [confirmClose, setConfirmClose] = useState(false);
const [closing, setClosing] = useState(false);
const [closeError, setCloseError] = useState<string | null>(null);
const [closeError, setCloseError] = useState<unknown>(null);
const [tallyPassword, setTallyPassword] = useState("");
const [downloadingTallies, setDownloadingTallies] = useState(false);
const [tallyDownloadError, setTallyDownloadError] = useState<string | null>(
null,
);
const [tallyDownloadError, setTallyDownloadError] = useState<unknown>(null);
// Ref so the SSE closure always reads the current qrInfo without re-subscribing.
const qrInfoRef = useRef(qrInfo);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<unknown>(null);
const [backgroundError, setBackgroundError] = useState<unknown>(null);

useEffect(() => {
qrInfoRef.current = qrInfo;
Expand All @@ -917,7 +909,7 @@ function Admin() {
const list = await fetchVoterList();
setVoters(list);
} catch (err) {
console.error("Failed to reload voters:", err);
setBackgroundError(err);
}
}, []);

Expand All @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -1053,7 +1045,7 @@ function Admin() {
await closeMeeting();
navigate({ to: "/create-meeting" });
} catch (err) {
setCloseError(String(err));
setCloseError(err);
setClosing(false);
}
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -1101,9 +1090,7 @@ function Admin() {
if (loadError) {
return (
<div className="max-w-xl mx-auto p-8">
<Alert size="m" color="accent">
Failed to load admin panel: {loadError}
</Alert>
<ErrorAlert error={loadError} />
</div>
);
}
Expand Down Expand Up @@ -1140,6 +1127,8 @@ function Admin() {
</Button>
</div>

<ErrorAlert error={backgroundError} />

{confirmClose && (
<Panel title="Close meeting">
<div className="flex flex-col gap-4">
Expand All @@ -1151,11 +1140,7 @@ function Admin() {
below before closing.
</p>

{closeError && (
<Alert size="sm" color="accent">
{closeError}
</Alert>
)}
<ErrorAlert error={closeError} />
<div className="flex gap-3">
<Button
size="m"
Expand Down Expand Up @@ -1227,11 +1212,7 @@ function Admin() {
)}
</Button>
</div>
{tallyDownloadError && (
<Alert size="sm" color="accent">
{tallyDownloadError}
</Alert>
)}
<ErrorAlert error={tallyDownloadError} />
</div>
</div>
</Panel>
Expand Down
Loading