-
-
-
-
-
{active.title}
-
- {active.place}
- ·
- {tone.label}
-
+
+ {active && tone && (
+
+
+
+
+
+
{active.title}
+
+ {active.place}
+ ·
+ {tone.label}
-
- )
- })()}
+
+
+
+ )}
+
)
diff --git a/apps/admin/src/components/providers.test.tsx b/apps/admin/src/components/providers.test.tsx
index 20a4010..e6b5d92 100644
--- a/apps/admin/src/components/providers.test.tsx
+++ b/apps/admin/src/components/providers.test.tsx
@@ -4,8 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Providers } from "@/components/providers"
vi.mock("@/components/auth/auth-hydrator", () => ({ AuthHydrator: () => null }))
+const session = vi.hoisted(() => ({
+ current: { isOperator: true, operator: null, status: "authenticated" } as {
+ isOperator: boolean
+ operator: null
+ status: string
+ },
+}))
+
vi.mock("@/hooks/use-admin-auth", () => ({
- useOperatorSession: () => ({ isOperator: true, operator: null, status: "authenticated" }),
+ useOperatorSession: () => session.current,
}))
function Crash(): never {
@@ -18,6 +26,7 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks()
+ session.current = { isOperator: true, operator: null, status: "authenticated" }
})
describe("Providers", () => {
@@ -45,4 +54,17 @@ describe("Providers", () => {
expect(alert).not.toHaveTextContent("shell exploded")
expect(within(alert).getByRole("button", { name: "Reload" })).toBeInTheDocument()
})
+
+ it("shows a signing-out screen rather than the sign-in gate while signing out", () => {
+ session.current = { isOperator: false, operator: null, status: "signing-out" }
+ render(
+
+ Dashboard content
+ ,
+ )
+
+ expect(screen.getByRole("status")).toHaveTextContent("Signing out...")
+ expect(screen.queryByText("Dashboard content")).toBeNull()
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull()
+ })
})
diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx
index ade6b43..ef1b2a5 100644
--- a/apps/admin/src/components/providers.tsx
+++ b/apps/admin/src/components/providers.tsx
@@ -35,6 +35,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
/**
* The operator gate. Only an authenticated operator session renders the dashboard:
* - idle / loading -> a minimal loading screen (Access exchange in flight, or pre-hydration).
+ * - signing-out -> the same screen, saying so, until the Access logout navigation lands.
* - not an operator -> the full-page Cloudflare Access gate (anonymous: authenticating + manual
* continue; forbidden: not-authorized message).
* - operator -> the dashboard shell (children).
@@ -43,7 +44,11 @@ function AuthGate({ children }: { children: React.ReactNode }) {
const { isOperator, status } = useOperatorSession()
if (status === "idle" || status === "loading") {
- return
+ return
+ }
+
+ if (status === "signing-out") {
+ return
}
if (!isOperator) {
@@ -53,8 +58,8 @@ function AuthGate({ children }: { children: React.ReactNode }) {
return <>{children}>
}
-/** Minimal centered loading screen shown while the session check is in flight. */
-function BootScreen() {
+/** Minimal centered screen shown while the session is being established or ended. */
+function BootScreen({ label }: { label: string }) {
return (
@@ -63,7 +68,7 @@ function BootScreen() {
-
Loading operations...
+
{label}
)
}
diff --git a/apps/admin/src/components/shared/data-states.test.tsx b/apps/admin/src/components/shared/data-states.test.tsx
new file mode 100644
index 0000000..f7b6a55
--- /dev/null
+++ b/apps/admin/src/components/shared/data-states.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react"
+import { AppError, ErrorCode } from "@civfix/shared"
+import { describe, expect, it } from "vitest"
+
+import { ErrorState } from "@/components/shared/data-states"
+
+describe("ErrorState", () => {
+ it("shows the API's own message", () => {
+ render(
)
+ expect(screen.getByRole("alert")).toHaveTextContent("Operators only")
+ })
+
+ it("explains a failed request in plain words instead of the browser's fetch text", () => {
+ render(
)
+ const alert = screen.getByRole("alert")
+ expect(alert).toHaveTextContent("Could not reach the server. Check your connection and try again.")
+ expect(alert).not.toHaveTextContent("Failed to fetch")
+ })
+
+ it("shows the generic copy for a thrown non-error value", () => {
+ render(
)
+ expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong. Please try again.")
+ })
+
+ it("lets an explicit message replace the error's", () => {
+ render(
)
+ expect(screen.getByRole("alert")).toHaveTextContent("Custom copy")
+ })
+})
diff --git a/apps/admin/src/components/shared/data-states.tsx b/apps/admin/src/components/shared/data-states.tsx
index 1780627..2434909 100644
--- a/apps/admin/src/components/shared/data-states.tsx
+++ b/apps/admin/src/components/shared/data-states.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
-import { toAppError } from "@/lib/api"
+import { errorMessage } from "@/lib/error-messages"
/**
* Standard loading / error / empty state components for data-bound views. The prototype had NONE of
@@ -30,8 +30,7 @@ export function LoadingState({ label = "Loading..." }: { label?: string }) {
/**
* Error panel with an optional retry and an optional primary action beside it (the error boundary's
- * Reload). Surfaces the normalized AppError message unless `message` replaces it; when the backend is
- * down this renders the friendly INTERNAL message rather than crashing.
+ * Reload). Shows the error's operator copy from errorMessage unless `message` replaces it.
*/
export function ErrorState({
error,
@@ -46,7 +45,7 @@ export function ErrorState({
message?: string
action?: { label: string; onClick: () => void }
}) {
- const errorText = React.useMemo(() => toAppError(error).message, [error])
+ const errorText = React.useMemo(() => errorMessage(error), [error])
const retry = onRetry && (