From d0991e0d40b9abc52920cae6de0558c8d3c1e27a Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 19 Apr 2026 20:20:31 +0200 Subject: [PATCH] Fix SetupGuard error handling and add retry UI --- Docs/Tasks.md | 4 +- frontend/src/components/SetupGuard.tsx | 47 +++++++++++++++++-- .../components/__tests__/SetupGuard.test.tsx | 27 +++++++++-- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index c617c66..9fc9901 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -89,7 +89,9 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr --- -### TASK-005 — `SetupGuard` redirects to `/setup` when the backend is temporarily unreachable +### TASK-005 — `SetupGuard` redirects to `/setup` when the backend is temporarily unreachable (done) + +**Where fixed:** `frontend/src/components/SetupGuard.tsx` **Where found:** `frontend/src/components/SetupGuard.tsx`. When `useSetup` returns `{ loading: false, status: null }` due to a network error, the guard treats this the same as "setup not completed" and redirects to `/setup`. diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx index 6a589dd..cf6329a 100644 --- a/frontend/src/components/SetupGuard.tsx +++ b/frontend/src/components/SetupGuard.tsx @@ -7,7 +7,14 @@ */ import { Navigate } from "react-router-dom"; -import { Spinner, makeStyles } from "@fluentui/react-components"; +import { + Button, + MessageBar, + MessageBarBody, + Spinner, + makeStyles, + tokens, +} from "@fluentui/react-components"; import { useSetup } from "../hooks/useSetup"; /** @@ -22,7 +29,7 @@ interface SetupGuardProps { /** * Render `children` only when setup has been completed. * - * Redirects to `/setup` if setup is still pending. + * Redirects to `/setup` only when setup status is known and incomplete. */ const useStyles = makeStyles({ loadingWrapper: { @@ -31,11 +38,30 @@ const useStyles = makeStyles({ alignItems: "center", minHeight: "100vh", }, + errorContainer: { + display: "flex", + justifyContent: "center", + alignItems: "center", + minHeight: "100vh", + padding: tokens.spacingHorizontalM, + backgroundColor: tokens.colorNeutralBackground2, + }, + errorCard: { + width: "100%", + maxWidth: "520px", + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusXLarge, + boxShadow: tokens.shadow8, + }, }); export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { const styles = useStyles(); - const { status, loading } = useSetup(); + const { status, loading, error, refresh } = useSetup(); if (loading) { return ( @@ -45,6 +71,21 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { ); } + if (error && status === null) { + return ( +
+
+ + {error} + + +
+
+ ); + } + if (!status?.completed) { return ; } diff --git a/frontend/src/components/__tests__/SetupGuard.test.tsx b/frontend/src/components/__tests__/SetupGuard.test.tsx index c7e4a76..cafd143 100644 --- a/frontend/src/components/__tests__/SetupGuard.test.tsx +++ b/frontend/src/components/__tests__/SetupGuard.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Routes, Route } from "react-router-dom"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { SetupGuard } from "../SetupGuard"; @@ -65,13 +65,32 @@ describe("SetupGuard", () => { expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument(); }); - it("redirects to /setup when the API call fails", async () => { - // Task 0.3: a failed check must redirect to /setup, not allow through. + it("renders an error card when the setup status check fails", async () => { mockedGetSetupStatus.mockRejectedValue(new Error("Network error")); renderGuard(); + await waitFor(() => { - expect(screen.getByTestId("setup-page")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); + expect(screen.getByText(/network error/i)).toBeInTheDocument(); expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("setup-page")).not.toBeInTheDocument(); + }); + + it("retries setup status fetch when Retry is clicked", async () => { + mockedGetSetupStatus.mockRejectedValueOnce(new Error("Network error")); + mockedGetSetupStatus.mockResolvedValueOnce({ completed: true }); + + renderGuard(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByTestId("protected-content")).toBeInTheDocument(); + }); }); });