fix(#411): QA-005 — production logging, error classification, session-expired state

logAuthError now always logs (not dev-only). Replaced isBackendError with
parseAuthError-based classification. signOut uses proper error type.
Session expiry sets explicit session_expired state. Login page logs in prod.
Fixed pre-existing lint violations in auth package (campsite rule).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 13:37:49 -06:00
parent 8a572e8525
commit 752e839054
10 changed files with 201 additions and 139 deletions

View File

@@ -11,11 +11,12 @@ import {
} from "react";
import type { AuthUser, AuthSession } from "@mosaic/shared";
import { apiGet, apiPost } from "../api/client";
import { parseAuthError } from "./auth-errors";
/**
* Error types for auth session checks
*/
export type AuthErrorType = "network" | "backend" | null;
export type AuthErrorType = "network" | "backend" | "session_expired" | null;
/** Threshold in minutes before session expiry to start warning */
const SESSION_EXPIRY_WARNING_MINUTES = 5;
@@ -37,51 +38,29 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
/**
* Check if an error indicates a network/backend issue vs normal "not authenticated"
* Classify an error into an {@link AuthErrorType} using the centralised
* {@link parseAuthError} utility.
*
* Defaults unrecognised `Error` instances to `"backend"` rather than `null`
* so that unexpected failures surface a "having trouble connecting" banner
* instead of silently logging the user out.
*/
function isBackendError(error: unknown): { isBackendDown: boolean; errorType: AuthErrorType } {
// Network errors (fetch failed, DNS, connection refused, etc.)
if (error instanceof TypeError && error.message.includes("fetch")) {
return { isBackendDown: true, errorType: "network" };
}
// Check for specific error messages that indicate backend issues
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Network-level errors
if (
message.includes("network") ||
message.includes("failed to fetch") ||
message.includes("connection refused") ||
message.includes("econnrefused") ||
message.includes("timeout")
) {
return { isBackendDown: true, errorType: "network" };
}
// Backend errors (5xx status codes typically result in these messages)
if (
message.includes("internal server error") ||
message.includes("service unavailable") ||
message.includes("bad gateway") ||
message.includes("gateway timeout")
) {
return { isBackendDown: true, errorType: "backend" };
}
}
// Normal auth errors (401, 403, etc.) - user is just not logged in
return { isBackendDown: false, errorType: null };
function classifyAuthError(error: unknown): AuthErrorType {
const parsed = parseAuthError(error);
if (parsed.code === "network_error") return "network";
if (parsed.code === "server_error") return "backend";
// For unrecognised errors, default to "backend" rather than null
// (safer to show "having trouble connecting" than silently log out)
if (error instanceof Error) return "backend";
return null;
}
/**
* Log auth errors in development mode
* Log auth errors — always logs, including production.
* Auth failures are operational issues, not debug noise.
*/
function logAuthError(message: string, error: unknown): void {
if (process.env.NODE_ENV === "development") {
console.error(`[Auth] ${message}:`, error);
}
console.error(`[Auth] ${message}:`, error);
}
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
@@ -99,23 +78,17 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
setAuthError(null);
// Track session expiry timestamp
if (session.session?.expiresAt) {
expiresAtRef.current = new Date(session.session.expiresAt);
}
expiresAtRef.current = new Date(session.session.expiresAt);
// Reset expiring state on successful session check
setSessionExpiring(false);
} catch (error) {
const { isBackendDown, errorType } = isBackendError(error);
const errorType = classifyAuthError(error);
if (isBackendDown) {
// Backend/network issue - log and expose error to UI
if (errorType) {
logAuthError("Session check failed due to backend/network issue", error);
setAuthError(errorType);
} else {
// Normal "not authenticated" state - no logging needed
setAuthError(null);
}
setAuthError(errorType);
setUser(null);
expiresAtRef.current = null;
@@ -130,7 +103,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
await apiPost("/auth/sign-out");
} catch (error) {
logAuthError("Sign out request did not complete", error);
setAuthError("network");
setAuthError(classifyAuthError(error) ?? "backend");
} finally {
setUser(null);
expiresAtRef.current = null;
@@ -161,11 +134,12 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
const minutes = Math.ceil(remainingMs / 60_000);
if (minutes <= 0) {
// Session has expired
// Session has expired — set explicit state so the UI can react
setUser(null);
setSessionExpiring(false);
setSessionMinutesRemaining(0);
expiresAtRef.current = null;
setAuthError("session_expired");
} else if (minutes <= SESSION_EXPIRY_WARNING_MINUTES) {
setSessionExpiring(true);
setSessionMinutesRemaining(minutes);