From 07084208a7b6dde310a3938e3edee77d512a05f8 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 16 Feb 2026 12:12:46 -0600 Subject: [PATCH] feat(#417): add session expiry detection to AuthProvider Adds sessionExpiring and sessionMinutesRemaining to auth context. Checks session expiry every 60s, warns when within 5 minutes. Refs #417 --- apps/web/src/lib/auth/auth-context.test.tsx | 219 +++++++++++++++++++- apps/web/src/lib/auth/auth-context.tsx | 71 ++++++- 2 files changed, 284 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/auth/auth-context.test.tsx b/apps/web/src/lib/auth/auth-context.test.tsx index 98f20b9..5ff97e5 100644 --- a/apps/web/src/lib/auth/auth-context.test.tsx +++ b/apps/web/src/lib/auth/auth-context.test.tsx @@ -1,4 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import React, { act } from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "./auth-context"; import type { AuthUser } from "@mosaic/shared"; @@ -11,9 +12,23 @@ vi.mock("../api/client", () => ({ const { apiGet, apiPost } = await import("../api/client"); +/** Helper: returns a date far in the future (1 hour from now) for session mocks */ +function futureExpiry(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + // Test component that uses the auth context function TestComponent(): React.JSX.Element { - const { user, isLoading, isAuthenticated, authError, signOut } = useAuth(); + const { + user, + isLoading, + isAuthenticated, + authError, + sessionExpiring, + sessionMinutesRemaining, + signOut, + refreshSession, + } = useAuth(); if (isLoading) { return
Loading...
; @@ -23,6 +38,8 @@ function TestComponent(): React.JSX.Element {
{isAuthenticated ? "Authenticated" : "Not Authenticated"}
{authError ?? "none"}
+
{sessionExpiring ? "true" : "false"}
+
{sessionMinutesRemaining}
{user && (
{user.email}
@@ -30,6 +47,7 @@ function TestComponent(): React.JSX.Element {
)} +
); } @@ -65,7 +83,7 @@ describe("AuthContext", (): void => { vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, - session: { id: "session-1", token: "token123", expiresAt: new Date() }, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); render( @@ -107,7 +125,7 @@ describe("AuthContext", (): void => { vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, - session: { id: "session-1", token: "token123", expiresAt: new Date() }, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); vi.mocked(apiPost).mockResolvedValueOnce({ success: true }); @@ -305,7 +323,7 @@ describe("AuthContext", (): void => { }; vi.mocked(apiGet).mockResolvedValueOnce({ user: mockUser, - session: { id: "session-1", token: "token123", expiresAt: new Date() }, + session: { id: "session-1", token: "token123", expiresAt: futureExpiry() }, }); // Trigger a rerender (simulating refreshSession being called) @@ -320,4 +338,195 @@ describe("AuthContext", (): void => { consoleErrorSpy.mockRestore(); }); }); + + describe("session expiry detection", (): void => { + const mockUser: AuthUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + }; + + beforeEach((): void => { + // Reset all mocks to clear any unconsumed mockResolvedValueOnce queues + // from previous tests (vi.clearAllMocks only clears calls/results, not implementations) + vi.resetAllMocks(); + }); + + afterEach((): void => { + // Ensure no stale intervals leak between tests + vi.clearAllTimers(); + }); + + it("should set sessionExpiring to false when session has plenty of time remaining", async (): Promise => { + const farFuture = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 60 minutes + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: farFuture }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); + }); + + it("should set sessionExpiring to true when session is within 5 minutes of expiry", async (): Promise => { + const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + await waitFor(() => { + expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); + }); + }); + + it("should calculate sessionMinutesRemaining correctly", async (): Promise => { + const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3 minutes + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + }); + + await waitFor(() => { + expect(screen.getByTestId("session-minutes-remaining")).toHaveTextContent("3"); + }); + }); + + it("should transition from not-expiring to expiring after interval fires", async (): Promise => { + vi.useFakeTimers(); + + // Session expires 6 minutes from now - just outside the warning window + const expiresAt = new Date(Date.now() + 6 * 60 * 1000).toISOString(); + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt }, + }); + + await act(async () => { + render( + + + + ); + // Flush the resolved mock promise so checkSession completes + await Promise.resolve(); + await Promise.resolve(); + }); + + // Initially not expiring (6 minutes remaining) + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); + + // Advance 2 minutes - should now be within the 5-minute window (4 min remaining) + await act(async () => { + vi.advanceTimersByTime(2 * 60 * 1000); + }); + + expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); + + vi.useRealTimers(); + }); + + it("should log out user when session expires via interval", async (): Promise => { + vi.useFakeTimers(); + + // Session expires 30 seconds from now + const almostExpired = new Date(Date.now() + 30 * 1000).toISOString(); + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: almostExpired }, + }); + + await act(async () => { + render( + + + + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated"); + + // Advance past the expiry time (triggers the 60s interval) + await act(async () => { + vi.advanceTimersByTime(60 * 1000); + }); + + expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated"); + expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); + + vi.useRealTimers(); + }); + + it("should reset sessionExpiring after successful refreshSession", async (): Promise => { + // Session near expiry (3 minutes remaining) + const nearExpiry = new Date(Date.now() + 3 * 60 * 1000).toISOString(); + + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-1", token: "token123", expiresAt: nearExpiry }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("session-expiring")).toHaveTextContent("true"); + }); + + // Set up a refreshed session response with a far-future expiry + const refreshedExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + vi.mocked(apiGet).mockResolvedValueOnce({ + user: mockUser, + session: { id: "session-2", token: "token456", expiresAt: refreshedExpiry }, + }); + + // Click refresh button + const refreshButton = screen.getByRole("button", { name: "Refresh" }); + refreshButton.click(); + + await waitFor(() => { + expect(screen.getByTestId("session-expiring")).toHaveTextContent("false"); + }); + }); + }); }); diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index 99c3dda..57875ea 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"; +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from "react"; import type { AuthUser, AuthSession } from "@mosaic/shared"; import { apiGet, apiPost } from "../api/client"; @@ -9,11 +17,19 @@ import { apiGet, apiPost } from "../api/client"; */ export type AuthErrorType = "network" | "backend" | null; +/** Threshold in minutes before session expiry to start warning */ +const SESSION_EXPIRY_WARNING_MINUTES = 5; + +/** Interval in milliseconds to check session expiry */ +const SESSION_CHECK_INTERVAL_MS = 60_000; + interface AuthContextValue { user: AuthUser | null; isLoading: boolean; isAuthenticated: boolean; authError: AuthErrorType; + sessionExpiring: boolean; + sessionMinutesRemaining: number; signOut: () => Promise; refreshSession: () => Promise; } @@ -72,12 +88,23 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const [authError, setAuthError] = useState(null); + const [sessionExpiring, setSessionExpiring] = useState(false); + const [sessionMinutesRemaining, setSessionMinutesRemaining] = useState(0); + const expiresAtRef = useRef(null); const checkSession = useCallback(async () => { try { const session = await apiGet("/auth/session"); setUser(session.user); setAuthError(null); + + // Track session expiry timestamp + if (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); @@ -91,6 +118,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E } setUser(null); + expiresAtRef.current = null; + setSessionExpiring(false); } finally { setIsLoading(false); } @@ -103,6 +132,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E console.error("Sign out error:", error); } finally { setUser(null); + expiresAtRef.current = null; + setSessionExpiring(false); } }, []); @@ -114,11 +145,49 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E void checkSession(); }, [checkSession]); + // Periodically check whether the session is approaching expiry + useEffect((): (() => void) => { + if (!user || !expiresAtRef.current) { + return (): void => { + /* no-op cleanup */ + }; + } + + const checkExpiry = (): void => { + if (!expiresAtRef.current) return; + + const remainingMs = expiresAtRef.current.getTime() - Date.now(); + const minutes = Math.ceil(remainingMs / 60_000); + + if (minutes <= 0) { + // Session has expired + setUser(null); + setSessionExpiring(false); + setSessionMinutesRemaining(0); + expiresAtRef.current = null; + } else if (minutes <= SESSION_EXPIRY_WARNING_MINUTES) { + setSessionExpiring(true); + setSessionMinutesRemaining(minutes); + } else { + setSessionExpiring(false); + setSessionMinutesRemaining(minutes); + } + }; + + checkExpiry(); + const interval = setInterval(checkExpiry, SESSION_CHECK_INTERVAL_MS); + return (): void => { + clearInterval(interval); + }; + }, [user]); + const value: AuthContextValue = { user, isLoading, isAuthenticated: user !== null, authError, + sessionExpiring, + sessionMinutesRemaining, signOut, refreshSession, };