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
+52 -43
View File
@@ -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);
+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();
});
});