fix: stop granting admin access when API is unreachable

This commit is contained in:
ashim-hq
2026-04-20 22:07:39 +08:00
parent ec991c4a37
commit 62f028eb3e
2 changed files with 79 additions and 43 deletions
+20 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatHeaders } from "@/lib/api"; import { formatHeaders } from "@/lib/api";
import { useConnectionStore } from "@/stores/connection-store";
interface AuthState { interface AuthState {
loading: boolean; loading: boolean;
@@ -36,13 +37,15 @@ export function useAuth() {
}); });
useEffect(() => { useEffect(() => {
let cancelled = false;
async function checkAuth() { async function checkAuth() {
try { try {
// Check if auth is enabled
const configRes = await fetch("/api/v1/config/auth"); const configRes = await fetch("/api/v1/config/auth");
const config = await configRes.json(); const config = await configRes.json();
if (!config.authEnabled) { if (!config.authEnabled) {
if (!cancelled)
setState({ setState({
loading: false, loading: false,
authEnabled: false, authEnabled: false,
@@ -54,9 +57,9 @@ export function useAuth() {
return; return;
} }
// Auth is enabled — check if we have a valid session
const token = localStorage.getItem("ashim-token"); const token = localStorage.getItem("ashim-token");
if (!token) { if (!token) {
if (!cancelled)
setState({ setState({
loading: false, loading: false,
authEnabled: true, authEnabled: true,
@@ -75,6 +78,7 @@ export function useAuth() {
if (sessionRes.ok) { if (sessionRes.ok) {
const session = await sessionRes.json(); const session = await sessionRes.json();
const mustChange = session.user?.mustChangePassword === true; const mustChange = session.user?.mustChangePassword === true;
if (!cancelled)
setState({ setState({
loading: false, loading: false,
authEnabled: true, authEnabled: true,
@@ -85,6 +89,7 @@ export function useAuth() {
}); });
} else { } else {
localStorage.removeItem("ashim-token"); localStorage.removeItem("ashim-token");
if (!cancelled)
setState({ setState({
loading: false, loading: false,
authEnabled: true, authEnabled: true,
@@ -95,19 +100,23 @@ export function useAuth() {
}); });
} }
} catch { } catch {
// Can't reach API — assume no auth needed (dev mode) // API unreachable — stay in loading state.
setState({ // ConnectionBanner explains the outage. AuthGuard shows spinner.
loading: false,
authEnabled: false,
isAuthenticated: true,
mustChangePassword: false,
role: "admin",
permissions: ALL_PERMISSIONS,
});
} }
} }
checkAuth(); 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); const hasPermission = (permission: string) => state.permissions.includes(permission);
+27
View File
@@ -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();
});
});