From e3576b4b3cb0428a6cc738289a66b7951a133320 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Mon, 27 Apr 2026 07:10:41 +0900
Subject: fix(auth): redirect to login page on session expiry

Previously when the session expired, the API client cleared tokens but
the UI displayed "Invalid or expired token" instead of redirecting to
the login page. The root cause was that isAuthenticatedAtom was derived
from userAtom only as a re-evaluation trigger, while the actual value
came from apiClient.isAuthenticated(). On page reload userAtom is null,
so setting it to null on session expiry did not trigger a re-render and
ProtectedRoute never redirected.

Make userAtom (persisted via atomWithStorage) the single source of truth
for auth state, derive isAuthenticatedAtom from it, drop the redundant
apiClient.isAuthenticated(), and explicitly navigate to /login on
session expiry. Also trigger session expiry when a 401 comes back with
no refresh token available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 src/client/App.test.tsx | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

(limited to 'src/client/App.test.tsx')

diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index 189a8e1..5b1607c 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -8,13 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { Router } from "wouter";
 import { memoryLocation } from "wouter/memory-location";
 import { App } from "./App";
-import { authLoadingAtom } from "./atoms";
+import { authLoadingAtom, userAtom } from "./atoms";
 
 vi.mock("./api/client", () => ({
 	apiClient: {
 		login: vi.fn(),
 		logout: vi.fn(),
-		isAuthenticated: vi.fn(),
 		getTokens: vi.fn(),
 		getAuthHeader: vi.fn(),
 		onSessionExpired: vi.fn(() => vi.fn()),
@@ -52,10 +51,17 @@ function mockResponse(data: {
 	>;
 }
 
-function renderWithRouter(path: string) {
+function renderWithRouter(
+	path: string,
+	{ isAuthenticated = false }: { isAuthenticated?: boolean } = {},
+) {
 	const { hook } = memoryLocation({ path, static: true });
 	const store = createStore();
 	store.set(authLoadingAtom, false);
+	store.set(
+		userAtom,
+		isAuthenticated ? { id: "u1", username: "tester" } : null,
+	);
 	return render(
 		<Provider store={store}>
 			<Router hook={hook}>
@@ -68,7 +74,6 @@ function renderWithRouter(path: string) {
 beforeEach(() => {
 	vi.clearAllMocks();
 	vi.mocked(apiClient.getTokens).mockReturnValue(null);
-	vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
 });
 
 afterEach(() => {
@@ -83,7 +88,6 @@ describe("App routing", () => {
 				accessToken: "access-token",
 				refreshToken: "refresh-token",
 			});
-			vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
 			vi.mocked(apiClient.getAuthHeader).mockReturnValue({
 				Authorization: "Bearer access-token",
 			});
@@ -96,7 +100,7 @@ describe("App routing", () => {
 		});
 
 		it("renders home page at /", () => {
-			renderWithRouter("/");
+			renderWithRouter("/", { isAuthenticated: true });
 			expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
 			expect(screen.getByRole("heading", { name: "Your Decks" })).toBeDefined();
 		});
@@ -105,7 +109,6 @@ describe("App routing", () => {
 	describe("when not authenticated", () => {
 		beforeEach(() => {
 			vi.mocked(apiClient.getTokens).mockReturnValue(null);
-			vi.mocked(apiClient.isAuthenticated).mockReturnValue(false);
 		});
 
 		it("redirects to login when accessing / without authentication", () => {
-- 
cgit v1.3-3-g829e

