diff --git a/src/lib/api-unauthorized.test.ts b/src/lib/api-unauthorized.test.ts new file mode 100644 index 0000000..b674748 --- /dev/null +++ b/src/lib/api-unauthorized.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError, isUnauthorized, NetworkError } from './api'; + +describe('isUnauthorized (#42)', () => { + it('is true only for a 401 ApiError', () => { + expect(isUnauthorized(new ApiError('Unauthorized', 401))).toBe(true); + }); + + it('keeps the token for other failures', () => { + expect(isUnauthorized(new ApiError('Forbidden', 403))).toBe(false); + expect(isUnauthorized(new ApiError('Server error', 500))).toBe(false); + expect(isUnauthorized(new NetworkError('Could not reach the server.'))).toBe(false); + expect(isUnauthorized(new Error('boom'))).toBe(false); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index f6d35c3..0a4607b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -26,6 +26,11 @@ export class NetworkError extends ApiError { /** Default per-request timeout; override with `RequestOptions.timeoutMs`. */ export const DEFAULT_TIMEOUT_MS = 15_000; +/** True when the backend rejected our credentials (expired / invalid JWT). */ +export function isUnauthorized(err: unknown): boolean { + return err instanceof ApiError && err.status === 401; +} + export function getToken(): string | null { if (typeof window === 'undefined') return null; return window.localStorage.getItem(TOKEN_KEY); diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx index f1461bd..2a58e1f 100644 --- a/src/lib/auth-context.tsx +++ b/src/lib/auth-context.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { apiFetch, clearToken, setToken } from './api'; +import { apiFetch, clearToken, getToken, isUnauthorized, setToken } from './api'; import type { Me } from './types'; interface AuthResponse { @@ -25,10 +25,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { + // No stored token: we're signed out, so skip the /users/me round-trip + // that would only fail on every page load (#42). + if (!getToken()) { + setUser(null); + return; + } try { const me = await apiFetch('/users/me'); setUser(me); - } catch { + } catch (err) { + // A 401 means the stored JWT is expired or invalid: drop it so later + // requests stop sending a dead Authorization header (#42). Other + // failures (network, 5xx) keep the token — the session may be fine. + if (isUnauthorized(err)) clearToken(); setUser(null); } }, []);