From 433b5a688eab54e6a9507414932fd59d86ac510b Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:30:30 +0100 Subject: [PATCH 1/2] fix(auth): clear an expired/invalid JWT when /users/me returns 401 (#42) refresh() swallowed every /users/me failure with setUser(null), leaving the dead token in localStorage so every request kept sending it. On a 401 (new isUnauthorized helper in api.ts) clear the token first; network/5xx errors still keep it since the session may be valid. --- src/lib/api.ts | 5 +++++ src/lib/auth-context.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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..e8bacc8 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, isUnauthorized, setToken } from './api'; import type { Me } from './types'; interface AuthResponse { @@ -28,7 +28,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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); } }, []); From a1afaae191ec9e0dc046bbd31e05aac457153ea6 Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:30:41 +0100 Subject: [PATCH 2/2] fix(auth): skip /users/me when no token is stored; test isUnauthorized (#42) With no token there is nothing to validate, so refresh() now sets the user to null without the network call that would fail on every page load. Adds unit tests: only a 401 ApiError clears the token; 403/5xx/network errors do not. --- src/lib/api-unauthorized.test.ts | 15 +++++++++++++++ src/lib/auth-context.tsx | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/lib/api-unauthorized.test.ts 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/auth-context.tsx b/src/lib/auth-context.tsx index e8bacc8..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, isUnauthorized, setToken } from './api'; +import { apiFetch, clearToken, getToken, isUnauthorized, setToken } from './api'; import type { Me } from './types'; interface AuthResponse { @@ -25,6 +25,12 @@ 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);