Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/api/auth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getSessionWithRefresh } from '@/lib/session';

export const dynamic = 'force-dynamic';

export async function POST() {
const session = await getSessionWithRefresh();

if (!session?.address) {
return Response.json(
{ error: 'Unauthorized', message: 'Session expired' },
{ status: 401 }
);
}

return Response.json({ success: true, address: session.address });
}
7 changes: 5 additions & 2 deletions app/dashboard/transaction-history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Button from "./components/transaction-history-button";
import { Download, FilterIcon, Loader2 } from "lucide-react";
import { TransactionItem } from '@/lib/remittance/horizon';
import { useClientTranslator } from '@/lib/i18n/client';
import { apiClient } from '@/lib/client/apiClient';

const TransactionHistoryPage = () => {
const { t } = useClientTranslator();
Expand All @@ -33,7 +34,9 @@ const TransactionHistoryPage = () => {
params.append('status', statusFilter);
}

const response = await fetch(`/api/v1/remittance/history?${params}`);
const response = await apiClient.get(`/api/v1/remittance/history?${params}`);
if (!response) return; // Handled by session expiry flow

if (!response.ok) {
throw new Error(t('transactionHistory.alerts.fetchFailed'));
}
Expand All @@ -53,7 +56,7 @@ const TransactionHistoryPage = () => {
} finally {
setLoading(false);
}
}, [statusFilter]);
}, [statusFilter, t]);

useEffect(() => {
fetchTransactions(undefined, true);
Expand Down
6 changes: 4 additions & 2 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
Smartphone,
} from "lucide-react";
import { useDensity } from "@/lib/context/DensityContext";

import { apiClient } from "@/lib/client/apiClient";

const SECTIONS = [
{ id: "profile", label: "Profile", icon: User },
Expand Down Expand Up @@ -224,7 +224,9 @@ function InsuranceReminderPreview() {

async function loadReminders() {
try {
const response = await fetch("/api/insurance/reminders");
const response = await apiClient.get("/api/insurance/reminders");
if (!response) return; // Handled by session expiry flow

if (!active) return;

if (response.status === 401 || response.status === 403) {
Expand Down
14 changes: 12 additions & 2 deletions lib/client/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
import { sessionHandler } from './sessionHandler';

export interface ApiClientOptions extends RequestInit {
// Additional options can be added here
/** Internal flag to prevent infinite retry loops */
_isRetry?: boolean;
}

/**
Expand All @@ -36,7 +37,16 @@ async function request(url: string, options?: ApiClientOptions): Promise<Respons

// Check if session expired
if (await sessionHandler.isSessionExpired(response)) {
// Get current path for post-auth redirect
if (!options?._isRetry) {
// Attempt to refresh session
const refreshed = await sessionHandler.refreshSession();
if (refreshed) {
// Retry original request once
return request(url, { ...options, _isRetry: true });
}
}

// If refresh failed or already retried, trigger session expiry flow
const currentPath = typeof window !== 'undefined' ? window.location.pathname : undefined;
sessionHandler.handleSessionExpiry(currentPath);
return null;
Expand Down
39 changes: 39 additions & 0 deletions lib/client/sessionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,22 @@ export interface SessionHandler {
*/
dispatchSessionExpiring(countdown?: number, message?: string): void;

/**
* Attempt to refresh the session
* @returns true if session was refreshed, false otherwise
*/
refreshSession(): Promise<boolean>;

/**
* Clear local authentication state
* Removes stored wallet address and connection status
*/
clearAuthState(): void;
}

// Store the active refresh promise to deduplicate concurrent requests
let refreshPromise: Promise<boolean> | null = null;

/**
* Check if a response indicates session expiry
* @param response - The fetch Response object to check
Expand All @@ -70,6 +79,35 @@ async function isSessionExpired(response: Response): Promise<boolean> {
}
}

/**
* Attempt to refresh the current session by calling the refresh endpoint
* Deduplicates concurrent calls to ensure only one refresh request is made at a time.
* @returns true if session was refreshed, false otherwise
*/
async function refreshSession(): Promise<boolean> {
if (refreshPromise) {
return refreshPromise;
}

refreshPromise = (async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();

return refreshPromise;
}

/**
* Dispatch session-expiring warning event
* Call this when the backend indicates the session is about to expire
Expand Down Expand Up @@ -138,6 +176,7 @@ function handleSessionExpiry(intendedPath?: string): void {
*/
export const sessionHandler: SessionHandler = {
isSessionExpired,
refreshSession,
handleSessionExpiry,
dispatchSessionExpiring,
clearAuthState,
Expand Down
5 changes: 4 additions & 1 deletion lib/hooks/useFormAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { useState, useTransition, useCallback } from "react";
import { ActionState } from "@/lib/auth/middleware";
import { apiClient } from "@/lib/client/apiClient";


// Merge the base with whatever extra fields your specific route returns
Expand All @@ -17,7 +18,9 @@ export function useFormAction<T extends ActionState = ActionState>(
(formData: FormData) => {
startTransition(async () => {
try {
const res = await fetch(url, { method, body: formData });
const res = await apiClient.request(url, { method, body: formData });
if (!res) return; // Handled by session expiry flow

const data: T = await res.json();
setState(data);
} catch {
Expand Down
2 changes: 2 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 128 additions & 0 deletions tests/session/apiClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { apiClient } from '../../lib/client/apiClient';
import { sessionHandler } from '../../lib/client/sessionHandler';

// Mock the global fetch
const originalFetch = global.fetch;

// Mock window object
const originalWindow = global.window;

describe('apiClient', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();

// Create a mock fetch that we can track
global.fetch = vi.fn();

// Mock window
global.window = {
location: { pathname: '/current-path' },
dispatchEvent: vi.fn(),
} as any;

// Mock the sessionHandler methods
vi.spyOn(sessionHandler, 'isSessionExpired').mockResolvedValue(false);
vi.spyOn(sessionHandler, 'refreshSession').mockResolvedValue(true);
vi.spyOn(sessionHandler, 'handleSessionExpiry').mockImplementation(() => {});
});

afterEach(() => {
global.fetch = originalFetch;
global.window = originalWindow;
});

it('should return response normally on 200 OK', async () => {
const mockResponse = new Response('ok', { status: 200 });
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);

const response = await apiClient.get('/api/test');

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith('/api/test', { method: 'GET' });
expect(response).toBe(mockResponse);
expect(sessionHandler.isSessionExpired).toHaveBeenCalledWith(mockResponse);
expect(sessionHandler.refreshSession).not.toHaveBeenCalled();
});

it('should refresh and retry once on 401 session expiry', async () => {
const mock401Response = new Response('{"message":"Session expired"}', { status: 401 });
const mock200Response = new Response('ok', { status: 200 });

// First fetch returns 401, second returns 200
vi.mocked(global.fetch)
.mockResolvedValueOnce(mock401Response)
.mockResolvedValueOnce(mock200Response);

// Mock isSessionExpired to return true for the 401 response and false for the 200 response
vi.mocked(sessionHandler.isSessionExpired).mockImplementation(async (res) => res.status === 401);
vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(true);

const response = await apiClient.get('/api/test');

// Should have called fetch twice (initial + retry)
expect(global.fetch).toHaveBeenCalledTimes(2);
// Should have attempted refresh once
expect(sessionHandler.refreshSession).toHaveBeenCalledTimes(1);
// Should have returned the successful response
expect(response).toBe(mock200Response);
// Should not have triggered expiry flow
expect(sessionHandler.handleSessionExpiry).not.toHaveBeenCalled();
});

it('should fallback to expiry flow if refresh fails', async () => {
const mock401Response = new Response('{"message":"Session expired"}', { status: 401 });

vi.mocked(global.fetch).mockResolvedValueOnce(mock401Response);
vi.mocked(sessionHandler.isSessionExpired).mockResolvedValueOnce(true);
// Refresh fails
vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(false);

const response = await apiClient.get('/api/test');

// Should have called fetch once (no retry since refresh failed)
expect(global.fetch).toHaveBeenCalledTimes(1);
// Should have attempted refresh
expect(sessionHandler.refreshSession).toHaveBeenCalledTimes(1);
// Should have triggered expiry flow
expect(sessionHandler.handleSessionExpiry).toHaveBeenCalledTimes(1);
// Returns null on expiry
expect(response).toBeNull();
});

it('should not retry on 403 Forbidden', async () => {
const mock403Response = new Response('Forbidden', { status: 403 });
vi.mocked(global.fetch).mockResolvedValueOnce(mock403Response);

// 403 is not a session expiry
vi.mocked(sessionHandler.isSessionExpired).mockResolvedValueOnce(false);

const response = await apiClient.get('/api/test');

expect(global.fetch).toHaveBeenCalledTimes(1);
expect(sessionHandler.refreshSession).not.toHaveBeenCalled();
expect(response).toBe(mock403Response);
});

it('should fallback to expiry flow if retry also returns 401', async () => {
const mock401Response = new Response('{"message":"Session expired"}', { status: 401 });

// Both initial and retry return 401
vi.mocked(global.fetch)
.mockResolvedValueOnce(mock401Response)
.mockResolvedValueOnce(mock401Response);

vi.mocked(sessionHandler.isSessionExpired).mockResolvedValue(true);
// Refresh succeeds
vi.mocked(sessionHandler.refreshSession).mockResolvedValueOnce(true);

const response = await apiClient.get('/api/test');

// Called fetch twice
expect(global.fetch).toHaveBeenCalledTimes(2);
// Expiry flow triggered because retry failed
expect(sessionHandler.handleSessionExpiry).toHaveBeenCalledTimes(1);
expect(response).toBeNull();
});
});