From 62f028eb3e316d42ce8eb394b356fe35119104b6 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 22:07:39 +0800 Subject: [PATCH] fix: stop granting admin access when API is unreachable --- apps/web/src/hooks/use-auth.ts | 95 +++++++++++++++++++--------------- tests/unit/web/stores.test.ts | 27 ++++++++++ 2 files changed, 79 insertions(+), 43 deletions(-) diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts index de6f5ab9..173c2695 100644 --- a/apps/web/src/hooks/use-auth.ts +++ b/apps/web/src/hooks/use-auth.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { formatHeaders } from "@/lib/api"; +import { useConnectionStore } from "@/stores/connection-store"; interface AuthState { loading: boolean; @@ -36,35 +37,37 @@ export function useAuth() { }); useEffect(() => { + let cancelled = false; + async function checkAuth() { try { - // Check if auth is enabled const configRes = await fetch("/api/v1/config/auth"); const config = await configRes.json(); if (!config.authEnabled) { - setState({ - loading: false, - authEnabled: false, - isAuthenticated: true, - mustChangePassword: false, - role: "admin", - permissions: ALL_PERMISSIONS, - }); + if (!cancelled) + setState({ + loading: false, + authEnabled: false, + isAuthenticated: true, + mustChangePassword: false, + role: "admin", + permissions: ALL_PERMISSIONS, + }); return; } - // Auth is enabled — check if we have a valid session const token = localStorage.getItem("ashim-token"); if (!token) { - setState({ - loading: false, - authEnabled: true, - isAuthenticated: false, - mustChangePassword: false, - role: null, - permissions: [], - }); + if (!cancelled) + setState({ + loading: false, + authEnabled: true, + isAuthenticated: false, + mustChangePassword: false, + role: null, + permissions: [], + }); return; } @@ -75,39 +78,45 @@ export function useAuth() { if (sessionRes.ok) { const session = await sessionRes.json(); const mustChange = session.user?.mustChangePassword === true; - setState({ - loading: false, - authEnabled: true, - isAuthenticated: true, - mustChangePassword: mustChange, - role: session.user?.role ?? null, - permissions: session.user?.permissions ?? [], - }); + if (!cancelled) + setState({ + loading: false, + authEnabled: true, + isAuthenticated: true, + mustChangePassword: mustChange, + role: session.user?.role ?? null, + permissions: session.user?.permissions ?? [], + }); } else { localStorage.removeItem("ashim-token"); - setState({ - loading: false, - authEnabled: true, - isAuthenticated: false, - mustChangePassword: false, - role: null, - permissions: [], - }); + if (!cancelled) + setState({ + loading: false, + authEnabled: true, + isAuthenticated: false, + mustChangePassword: false, + role: null, + permissions: [], + }); } } catch { - // Can't reach API — assume no auth needed (dev mode) - setState({ - loading: false, - authEnabled: false, - isAuthenticated: true, - mustChangePassword: false, - role: "admin", - permissions: ALL_PERMISSIONS, - }); + // API unreachable — stay in loading state. + // ConnectionBanner explains the outage. AuthGuard shows spinner. } } checkAuth(); + + const unsubscribe = useConnectionStore.subscribe((curr, prev) => { + if (prev.status !== "reconnected" && curr.status === "reconnected") { + checkAuth(); + } + }); + + return () => { + cancelled = true; + unsubscribe(); + }; }, []); const hasPermission = (permission: string) => state.permissions.includes(permission); diff --git a/tests/unit/web/stores.test.ts b/tests/unit/web/stores.test.ts index 5090b2c9..7d51e6d1 100644 --- a/tests/unit/web/stores.test.ts +++ b/tests/unit/web/stores.test.ts @@ -799,3 +799,30 @@ describe("API lib", () => { }); }); }); + +// ========================================================================== +// useAuth security +// ========================================================================== + +describe("useAuth security — network error", () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it("does NOT grant admin when API is unreachable", async () => { + fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); + + const { renderHook, act } = await import("@testing-library/react"); + const { useAuth } = await import("@/hooks/use-auth"); + + const { result } = renderHook(() => useAuth()); + + // Flush the async checkAuth() call + await act(async () => {}); + + // loading stays true — setState was never called + expect(result.current.loading).toBe(true); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.role).toBeNull(); + }); +});