refactoring-backend #3
@@ -1,23 +1,3 @@
|
||||
### TASK-STATE-03 — `DashboardFilterBar` Has Dual State Source
|
||||
|
||||
**Where found**
|
||||
`frontend/src/components/DashboardFilterBar.tsx`. The component reads from both `DashboardFilterProvider` context and from props using `??` as a fallback. `HistoryPage` passes filter values via props without mounting a `DashboardFilterProvider`, so the context values are `undefined` and the `??` fallback silently provides context defaults. `DashboardPage` uses context, so props are `undefined` and context values apply. Both pages render the same component but through different code paths.
|
||||
|
||||
**Goal**
|
||||
Choose one data source and use it consistently. The recommended approach is to use props everywhere: `DashboardPage` should read from context via `useDashboardFilter()` and pass the values explicitly to `DashboardFilterBar` as props, exactly like `HistoryPage` does. This makes the component's behaviour predictable — it always reads from props, never from context.
|
||||
|
||||
**Possible traps and issues**
|
||||
- `DashboardFilterProvider` may be used by other components on the dashboard. Audit all consumers of `useDashboardFilter()` before removing the context read from `DashboardFilterBar`.
|
||||
- The `??` fallback chain must be fully removed; otherwise the dual-source behaviour can creep back.
|
||||
|
||||
**Docs changes needed**
|
||||
None required.
|
||||
|
||||
**Why this is needed**
|
||||
Silent dual-source components are a debugging hazard. A developer adding a new consumer of `DashboardFilterBar` has no obvious signal about which data source is active, leading to subtle bugs when one source overrides the other.
|
||||
|
||||
---
|
||||
|
||||
### TASK-STATE-04 — Token in `sessionStorage` Is Never Sent (Misleading Auth Model)
|
||||
|
||||
**Where found**
|
||||
|
||||
@@ -485,7 +485,42 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Handling
|
||||
## 10. Authentication
|
||||
|
||||
### Session Model
|
||||
|
||||
The authentication model is **cookie-based**:
|
||||
|
||||
1. **Login:** The frontend sends the master password (SHA256-hashed) to `POST /api/auth/login`. The backend validates it, creates a session, and returns an HTTP response with a `Set-Cookie` header containing `bangui_session`.
|
||||
|
||||
2. **Requests:** All API requests automatically include the session cookie via `credentials: "include"` in the fetch options. The frontend does **not** send an Authorization header or token in the request body.
|
||||
|
||||
3. **Session validity:** The backend is the **sole authority** on whether a session is valid. The frontend is authenticated when the backend accepts the request (returns 2xx) and is not authenticated when the backend rejects it (returns 401 or 403).
|
||||
|
||||
4. **Logout:** The frontend sends `POST /api/auth/logout`, and the backend invalidates the session and clears the cookie.
|
||||
|
||||
### Frontend Auth State
|
||||
|
||||
- The `AuthProvider` context (`providers/AuthProvider.tsx`) manages a simple boolean `isAuthenticated` state.
|
||||
- On successful login, `isAuthenticated` is set to `true` and persisted to `sessionStorage` for page-reload continuity.
|
||||
- On logout or when `SESSION_EXPIRED_EVENT` fires (triggered by a 401/403 API response), `isAuthenticated` is set to `false` and cleared from `sessionStorage`.
|
||||
- The `sessionStorage` entry (`bangui_authenticated`) survives page refreshes within the same tab but is automatically cleared when the tab closes.
|
||||
- The session cookie persists according to the backend's cookie settings (typically for the duration of the browser session or as configured server-side).
|
||||
|
||||
### Why Not Token-Based?
|
||||
|
||||
The frontend previously stored JWT tokens in `sessionStorage` but never actually used them. The authentication model is entirely cookie-based (handled by the browser automatically), making stored tokens confusing and misleading. If token-based auth is needed in the future, the storage approach would need to change significantly (e.g., to include Authorization headers in all requests). For now, the only persistent state the frontend needs is the boolean `isAuthenticated` flag.
|
||||
|
||||
### Error Handling
|
||||
|
||||
When an API request returns 401 or 403:
|
||||
1. The `client.ts` module dispatches a `SESSION_EXPIRED_EVENT`.
|
||||
2. The `AuthProvider` listener handles it by clearing `isAuthenticated` and redirecting to `/login`.
|
||||
3. Hooks must use `handleFetchError` (from `utils/fetchError.ts`) to avoid displaying auth errors as user-facing error messages.
|
||||
|
||||
---
|
||||
|
||||
## 12. Error Handling
|
||||
|
||||
- Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions.
|
||||
- **All hook catch blocks must use `handleFetchError` rather than directly calling `setError`.** This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. Use the pattern: `handleFetchError(err, setError, "User-friendly fallback message")`.
|
||||
@@ -496,7 +531,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
|
||||
|
||||
---
|
||||
|
||||
## 12. Performance
|
||||
## 13. Performance
|
||||
|
||||
- Use `React.memo` only when profiling reveals unnecessary re-renders — do not wrap every component by default.
|
||||
- Use `useMemo` and `useCallback` for expensive computations and stable callback references passed to child components — not for trivial values.
|
||||
@@ -506,7 +541,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
|
||||
|
||||
---
|
||||
|
||||
## 13. Accessibility
|
||||
## 14. Accessibility
|
||||
|
||||
- Use semantic HTML elements (`<button>`, `<nav>`, `<table>`, `<main>`, `<header>`) — not `<div>` with click handlers.
|
||||
- Every interactive element must be **keyboard accessible** (focusable, operable with Enter/Space/Escape as appropriate).
|
||||
@@ -517,7 +552,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
|
||||
|
||||
---
|
||||
|
||||
## 14. Testing
|
||||
## 15. Testing
|
||||
|
||||
- Write tests for every new component, hook, and utility function.
|
||||
- Use **Vitest** (or Jest) as the test runner and **React Testing Library** for component tests.
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
* Authentication context and provider.
|
||||
*
|
||||
* Manages the user's authenticated state and exposes `login`, `logout`, and
|
||||
* `isAuthenticated` through `useAuth()`. The session token is persisted in
|
||||
* `sessionStorage` so it survives page refreshes within the browser tab but
|
||||
* is automatically cleared when the tab is closed.
|
||||
* `isAuthenticated` through `useAuth()`.
|
||||
*
|
||||
* The authentication model is **cookie-based**: the backend sets a session
|
||||
* cookie (`bangui_session`) on successful login, and the frontend automatically
|
||||
* includes it in all requests via `credentials: "include"`. The backend is the
|
||||
* authority on session validity; the frontend is authenticated when the backend
|
||||
* accepts the request (returns 2xx) and not authenticated when the backend
|
||||
* rejects it (returns 401/403).
|
||||
*
|
||||
* The `isAuthenticated` state persists in `sessionStorage` to survive page
|
||||
* refreshes within the browser tab but is cleared on tab close. The session
|
||||
* cookie itself persists according to the backend's cookie settings.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -19,16 +28,11 @@ import * as authApi from "../api/auth";
|
||||
import { SESSION_EXPIRED_EVENT } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
/** `true` when a valid session token is held in state. */
|
||||
/** `true` when the backend considers the session valid. */
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* Authenticate with the master password.
|
||||
@@ -39,14 +43,9 @@ export interface AuthContextValue {
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const SESSION_KEY = "bangui_token";
|
||||
const SESSION_EXPIRES_KEY = "bangui_expires_at";
|
||||
const IS_AUTHENTICATED_KEY = "bangui_authenticated";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
@@ -63,16 +62,15 @@ export function AuthProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const [auth, setAuth] = useState<AuthState>(() => ({
|
||||
token: sessionStorage.getItem(SESSION_KEY),
|
||||
expiresAt: sessionStorage.getItem(SESSION_EXPIRES_KEY),
|
||||
}));
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
|
||||
const stored = sessionStorage.getItem(IS_AUTHENTICATED_KEY);
|
||||
return stored === "true";
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSessionExpired = useCallback((): void => {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
|
||||
setAuth({ token: null, expiresAt: null });
|
||||
sessionStorage.removeItem(IS_AUTHENTICATED_KEY);
|
||||
setIsAuthenticated(false);
|
||||
navigate("/login", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
@@ -83,17 +81,10 @@ export function AuthProvider({
|
||||
};
|
||||
}, [handleSessionExpired]);
|
||||
|
||||
const isAuthenticated = useMemo<boolean>(() => {
|
||||
if (!auth.token || !auth.expiresAt) return false;
|
||||
// Treat the session as expired if the expiry time has passed.
|
||||
return new Date(auth.expiresAt) > new Date();
|
||||
}, [auth]);
|
||||
|
||||
const login = useCallback(async (password: string): Promise<void> => {
|
||||
const response = await authApi.login(password);
|
||||
sessionStorage.setItem(SESSION_KEY, response.token);
|
||||
sessionStorage.setItem(SESSION_EXPIRES_KEY, response.expires_at);
|
||||
setAuth({ token: response.token, expiresAt: response.expires_at });
|
||||
await authApi.login(password);
|
||||
sessionStorage.setItem(IS_AUTHENTICATED_KEY, "true");
|
||||
setIsAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async (): Promise<void> => {
|
||||
@@ -101,9 +92,8 @@ export function AuthProvider({
|
||||
await authApi.logout();
|
||||
} finally {
|
||||
// Always clear local state even if the API call fails (e.g. expired session).
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
sessionStorage.removeItem(SESSION_EXPIRES_KEY);
|
||||
setAuth({ token: null, expiresAt: null });
|
||||
sessionStorage.removeItem(IS_AUTHENTICATED_KEY);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ describe("AuthProvider", () => {
|
||||
});
|
||||
|
||||
it("clears auth state and redirects to /login when session-expired fires", async () => {
|
||||
sessionStorage.setItem("bangui_token", "token");
|
||||
sessionStorage.setItem("bangui_expires_at", new Date(Date.now() + 10000).toISOString());
|
||||
sessionStorage.setItem("bangui_authenticated", "true");
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
@@ -38,7 +37,6 @@ describe("AuthProvider", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("location")).toHaveTextContent("/login");
|
||||
});
|
||||
expect(sessionStorage.getItem("bangui_token")).toBeNull();
|
||||
expect(sessionStorage.getItem("bangui_expires_at")).toBeNull();
|
||||
expect(sessionStorage.getItem("bangui_authenticated")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user