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,
};