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
This commit is contained in:
Jason Woltje
2026-02-16 12:12:46 -06:00
parent f500300b1f
commit 07084208a7
2 changed files with 284 additions and 6 deletions

View File

@@ -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<void>;
refreshSession: () => Promise<void>;
}
@@ -72,12 +88,23 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [authError, setAuthError] = useState<AuthErrorType>(null);
const [sessionExpiring, setSessionExpiring] = useState(false);
const [sessionMinutesRemaining, setSessionMinutesRemaining] = useState(0);
const expiresAtRef = useRef<Date | null>(null);
const checkSession = useCallback(async () => {
try {
const session = await apiGet<AuthSession>("/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,
};