1 Commits

Author SHA1 Message Date
coryHawkvelt 9e5427a262 session timeout 2026-04-27 02:40:00 +09:30
10 changed files with 694 additions and 3 deletions
+37
View File
@@ -64,6 +64,43 @@ This project is built with:
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
## Session Timeout Behavior
This application implements automatic session timeout to align with security best practices (OWASP Session Management Cheat Sheet, NIST 800-63B Section 7.1).
### Backend Configuration
The backend uses a sliding window session model with two independent timeouts:
| Timeout | Default | Description |
|---------|---------|-------------|
| Idle | 15 minutes | If no authenticated request is made within this window, the session expires |
| Absolute | 8 hours | Hard cap from session creation. Activity cannot extend past this point |
Both are configurable via environment variables: `SESSION_IDLE_TIMEOUT` and `SESSION_ABSOLUTE_TIMEOUT` (values in seconds).
### How It Works
- **Sliding Window**: Every authenticated request automatically resets the idle clock
- **Active User**: Session keeps extending up to the 8-hour absolute maximum
- **Idle User**: After 15 minutes of inactivity, the session expires and the next request returns 401
- **Heartbeat**: The frontend sends a periodic `GET /api/v1/auth/me` every 5 minutes to keep sessions alive during passive activities like reading long pages
### Frontend UX
- **Warning Dialog**: When the user is within 3 minutes of session expiry, a warning dialog appears with a countdown timer
- **Extend Session**: Users can click "Keep Me Signed In" to refresh the session via `POST /api/v1/auth/sessions/refresh`
- **Graceful Expiry**: When a session expires, the user is redirected to the login page with a gentle message: "Your session has expired due to inactivity"
- **No Hard Logouts**: The frontend never forcefully logs out an active user; expiry only occurs after API confirmation (401 response)
### API Endpoints
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/auth/sessions/refresh` | Explicitly refresh session (extends idle timeout) |
| `GET /api/v1/auth/sessions` | List all active sessions for the user |
| `DELETE /api/v1/auth/sessions/:id` | Revoke a specific session |
## Can I connect a custom domain to my Lovable project?
Yes, you can!
+24
View File
@@ -104,6 +104,8 @@ function ScrollToTop() {
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { OrgProvider } from "@/contexts/OrgContext";
import { Navigate } from "react-router-dom";
import { useSessionTimeout } from "@/hooks/useSessionTimeout";
import { SessionWarning } from "@/components/auth/SessionWarning";
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
function GuestRoute({ children }: { children: React.ReactNode }) {
@@ -148,9 +150,31 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
/**
* Handles session timeout detection, warning dialog, and heartbeat.
* Rendered inside AuthProvider so it can access auth state.
*/
function SessionTimeoutManager() {
const { isAuthenticated, logout } = useAuth();
const { isWarningOpen, countdownSeconds, extendSession } = useSessionTimeout(
isAuthenticated,
logout
);
return (
<SessionWarning
open={isWarningOpen}
countdownSeconds={countdownSeconds}
onExtend={extendSession}
onLogout={() => logout()}
/>
);
}
function AppRoutes() {
return (
<AuthProvider>
<SessionTimeoutManager />
<OrgProvider>
<Routes>
{/* Marketing pages */}
+66
View File
@@ -0,0 +1,66 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
interface SessionWarningProps {
open: boolean;
countdownSeconds: number;
onExtend: () => void;
onLogout: () => void;
}
/**
* Warning dialog shown before automatic session timeout.
* Gives the user an opportunity to extend their session.
*/
export function SessionWarning({
open,
countdownSeconds,
onExtend,
onLogout,
}: SessionWarningProps) {
const minutes = Math.floor(countdownSeconds / 60);
const seconds = countdownSeconds % 60;
const timeDisplay = `${minutes}:${seconds.toString().padStart(2, '0')}`;
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Session Expiring Soon
</DialogTitle>
<DialogDescription>
Your session will expire in{' '}
<span className="font-mono font-semibold text-foreground">
{timeDisplay}
</span>{' '}
due to inactivity.
</DialogDescription>
</DialogHeader>
<div className="py-2 text-sm text-muted-foreground">
If you do not extend your session, you will be logged out automatically
and any unsaved work may be lost.
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onLogout}>
Log Out Now
</Button>
<Button onClick={onExtend} autoFocus>
Keep Me Signed In
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+15 -2
View File
@@ -21,14 +21,18 @@ interface AuthContextType {
canViewSystemLogs: boolean;
mfaCompliance: MfaComplianceSummary | null;
requiresMfaEnrollment: boolean;
/** True when the previous session ended due to timeout/expiry rather than manual logout. */
sessionExpired: boolean;
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
verifyWebAuthn: () => Promise<void>;
logout: () => Promise<void>;
logout: (expired?: boolean) => Promise<void>;
refreshUser: () => Promise<void>;
refreshCompliance: () => Promise<void>;
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
checkOrgAdmin: () => Promise<void>;
/** Clear the sessionExpired flag (e.g., after showing the message to the user). */
clearSessionExpired: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
@@ -87,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [sessionExpired, setSessionExpired] = useState(false);
const navigate = useNavigate();
// Helper to check if user is admin/owner in any org
@@ -215,6 +220,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (response.user) {
setUser(response.user);
setSessionExpired(false);
if (response.mfa_compliance) {
setMfaCompliance(response.mfa_compliance);
persistMfaCompliance(response.mfa_compliance);
@@ -278,7 +284,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [navigate, checkOrgAdmin]);
const logout = useCallback(async () => {
const logout = useCallback(async (expired = false) => {
try {
await api.auth.logout();
} finally {
@@ -288,10 +294,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setMfaCompliance(null);
persistMfaCompliance(null);
setRequiresMfaEnrollment(false);
setSessionExpired(expired);
navigate('/login');
}
}, [navigate]);
const clearSessionExpired = useCallback(() => {
setSessionExpired(false);
}, []);
return (
<AuthContext.Provider
value={{
@@ -303,6 +314,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
canViewSystemLogs: user?.can_view_system_logs ?? false,
mfaCompliance,
requiresMfaEnrollment,
sessionExpired,
login,
verifyTotp,
verifyWebAuthn,
@@ -310,6 +322,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshUser,
refreshCompliance,
checkOrgAdmin,
clearSessionExpired,
}}
>
{children}
+179
View File
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, tokenManager } from '@/lib/api';
// Configuration constants (in milliseconds)
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const WARNING_BEFORE_MS = 3 * 60 * 1000; // Show warning 3 minutes before timeout
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // Heartbeat every 5 minutes
interface UseSessionTimeoutReturn {
isWarningOpen: boolean;
countdownSeconds: number;
extendSession: () => Promise<void>;
}
/**
* Manages session timeout behavior:
* - Detects user activity to track idle time
* - Shows a warning dialog before the session expires
* - Sends periodic heartbeats to keep the session alive during passive activity
* - Automatically logs out when the session expires
*
* The backend sliding window is authoritative; this hook provides UX around it.
*/
export function useSessionTimeout(
isAuthenticated: boolean,
onLogout: () => Promise<void>
): UseSessionTimeoutReturn {
const [isWarningOpen, setIsWarningOpen] = useState(false);
const [countdownSeconds, setCountdownSeconds] = useState(0);
// Use refs to avoid stale closures in event listeners and intervals
const lastActivityRef = useRef<number>(Date.now());
const warningShownRef = useRef<boolean>(false);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const activityCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isAuthenticatedRef = useRef(isAuthenticated);
const onLogoutRef = useRef(onLogout);
// Keep refs in sync with props
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
useEffect(() => {
onLogoutRef.current = onLogout;
}, [onLogout]);
// Update last activity timestamp
const updateActivity = useCallback(() => {
lastActivityRef.current = Date.now();
}, []);
// Perform logout when session expires
const performLogout = useCallback(async (reason: string) => {
if (import.meta.env.DEV) {
console.log(`[SessionTimeout] Logging out: ${reason}`);
}
// Clear warning state
setIsWarningOpen(false);
warningShownRef.current = false;
// Perform logout
await onLogoutRef.current();
}, []);
// Extend the session by calling the refresh endpoint
const extendSession = useCallback(async () => {
try {
await api.auth.refreshSession();
// Reset activity and warning state
lastActivityRef.current = Date.now();
setIsWarningOpen(false);
warningShownRef.current = false;
if (import.meta.env.DEV) {
console.log('[SessionTimeout] Session extended successfully');
}
} catch (error) {
console.error('[SessionTimeout] Failed to extend session:', error);
// If refresh fails, perform logout
await performLogout('refresh failed');
}
}, [performLogout]);
// Setup activity listeners and timers
useEffect(() => {
if (!isAuthenticated) {
// Clean up any existing state when logged out
setIsWarningOpen(false);
warningShownRef.current = false;
return;
}
// Reset activity timestamp on mount/auth change
lastActivityRef.current = Date.now();
warningShownRef.current = false;
// Activity event listeners
const activityEvents = ['mousedown', 'keydown', 'touchstart', 'scroll'];
const handleActivity = () => {
lastActivityRef.current = Date.now();
// If warning is open and user becomes active, extend session automatically
if (warningShownRef.current) {
extendSession();
}
};
activityEvents.forEach((event) => {
window.addEventListener(event, handleActivity, { passive: true });
});
// Heartbeat: keep session alive during passive reading
heartbeatIntervalRef.current = setInterval(() => {
if (!isAuthenticatedRef.current) return;
api.users.me().catch(async (error) => {
if (error?.code === 401) {
await performLogout('heartbeat received 401');
}
});
}, HEARTBEAT_INTERVAL_MS);
// Activity check: determine when to show warning or logout
activityCheckIntervalRef.current = setInterval(() => {
if (!isAuthenticatedRef.current) return;
const idleTime = Date.now() - lastActivityRef.current;
const timeUntilTimeout = IDLE_TIMEOUT_MS - idleTime;
// If idle time exceeds timeout, logout
if (idleTime >= IDLE_TIMEOUT_MS) {
performLogout('idle timeout exceeded');
return;
}
// Show warning if we're within the warning threshold and not already shown
if (timeUntilTimeout <= WARNING_BEFORE_MS && !warningShownRef.current) {
warningShownRef.current = true;
setIsWarningOpen(true);
setCountdownSeconds(Math.ceil(timeUntilTimeout / 1000));
// Start countdown
countdownIntervalRef.current = setInterval(() => {
const remaining = IDLE_TIMEOUT_MS - (Date.now() - lastActivityRef.current);
if (remaining <= 0) {
// Time's up - logout
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
performLogout('countdown reached zero');
} else {
setCountdownSeconds(Math.ceil(remaining / 1000));
}
}, 1000);
}
}, 5000); // Check every 5 seconds
return () => {
activityEvents.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
if (activityCheckIntervalRef.current) {
clearInterval(activityCheckIntervalRef.current);
}
};
}, [isAuthenticated, extendSession, performLogout]);
return {
isWarningOpen,
countdownSeconds,
extendSession,
};
}
+42
View File
@@ -100,6 +100,24 @@ export interface LoginResponse {
is_first_user?: boolean;
}
export interface Session {
id: string;
created_at: string;
expires_at: string;
last_used_at: string | null;
ip_address: string | null;
user_agent: string | null;
is_current: boolean;
}
export interface SessionsResponse {
sessions: Session[];
}
export interface RefreshSessionResponse {
expires_at: string;
}
export interface TotpEnrollResponse {
secret: string;
provisioning_uri: string;
@@ -333,6 +351,14 @@ export const tokenManager = {
hasValidToken: (): boolean => {
return tokenManager.getToken() !== null;
},
updateExpiry: (expiresAt: string): void => {
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
},
getExpiry: (): string | null => {
return localStorage.getItem(TOKEN_EXPIRY_KEY);
},
};
// Error types that indicate the session/token is truly invalid
@@ -511,6 +537,22 @@ export const api = {
tokenManager.clearToken();
}
},
refreshSession: async () => {
const response = await request<RefreshSessionResponse>('/auth/sessions/refresh', {
method: 'POST',
});
tokenManager.updateExpiry(response.expires_at);
return response;
},
listSessions: () =>
request<SessionsResponse>('/auth/sessions', {}),
revokeSession: (sessionId: string) =>
request<{ message: string }>(`/auth/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
}),
},
users: {
+14 -1
View File
@@ -46,7 +46,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
}
export default function LoginPage() {
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin } = useAuth();
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin, sessionExpired, clearSessionExpired } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const [searchParams] = useSearchParams();
@@ -88,6 +88,13 @@ export default function LoginPage() {
.catch(() => {/* ignore — user will just land on normal login */});
}, [cliToken]);
// Clear session expired flag once the user sees the login page
useEffect(() => {
if (sessionExpired) {
clearSessionExpired();
}
}, [sessionExpired, clearSessionExpired]);
const finishCliFlow = useCallback((token: string) => {
if (!cliRedirectUrl) return false;
// cliRedirectUrl already ends with "token=" — just append the value
@@ -955,6 +962,12 @@ export default function LoginPage() {
{oidcError && (
<p className="text-sm text-destructive mt-2">{oidcError}</p>
)}
{sessionExpired && (
<div className="mt-4 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 text-sm flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>Your session has expired due to inactivity. Please sign in again to continue.</span>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api, tokenManager } from '../src/lib/api';
// Mock localStorage for Node.js test environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
});
describe('Session API', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
global.fetch = fetchMock;
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('refreshSession', () => {
it('calls the correct endpoint and updates token expiry', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Session refreshed',
data: { expires_at: '2026-04-26T18:30:00Z' },
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
// Set a token first
tokenManager.setToken('test-token', '2026-04-26T17:00:00Z');
const result = await api.auth.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/refresh'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
);
expect(result.expires_at).toBe('2026-04-26T18:30:00Z');
expect(tokenManager.getExpiry()).toBe('2026-04-26T18:30:00Z');
});
});
describe('listSessions', () => {
it('calls the correct endpoint', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Sessions listed',
data: {
sessions: [
{
id: 'sess-1',
created_at: '2026-04-26T10:00:00Z',
expires_at: '2026-04-26T18:00:00Z',
last_used_at: '2026-04-26T17:00:00Z',
ip_address: '127.0.0.1',
user_agent: 'Mozilla/5.0',
is_current: true,
},
],
},
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
tokenManager.setToken('test-token');
const result = await api.auth.listSessions();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions'),
expect.objectContaining({
credentials: 'include',
})
);
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].id).toBe('sess-1');
});
});
describe('revokeSession', () => {
it('encodes session ID and calls the correct endpoint', async () => {
const mockResponse = {
success: true,
code: 200,
message: 'Session revoked',
data: {},
};
fetchMock.mockResolvedValueOnce({
json: () => Promise.resolve(mockResponse),
});
tokenManager.setToken('test-token');
await api.auth.revokeSession('sess-abc/123');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/sess-abc%2F123'),
expect.objectContaining({
method: 'DELETE',
})
);
});
});
describe('tokenManager', () => {
it('updateExpiry sets the expiry in localStorage', () => {
tokenManager.setToken('test-token');
tokenManager.updateExpiry('2026-04-26T20:00:00Z');
expect(tokenManager.getExpiry()).toBe('2026-04-26T20:00:00Z');
});
it('getExpiry returns null when no expiry is set', () => {
localStorage.clear();
expect(tokenManager.getExpiry()).toBeNull();
});
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSessionTimeout } from '../src/hooks/useSessionTimeout';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
});
// Mock fetch
const fetchMock = vi.fn();
global.fetch = fetchMock;
describe('useSessionTimeout', () => {
const mockLogout = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
fetchMock.mockReset();
mockLogout.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it('should not show warning when user is active', () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
expect(result.current.isWarningOpen).toBe(false);
});
it('should show warning after idle period approaches timeout', () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout)
act(() => {
vi.advanceTimersByTime(12 * 60 * 1000);
});
// Fast-forward past the activity check interval (5 seconds)
act(() => {
vi.advanceTimersByTime(10 * 1000);
});
expect(result.current.isWarningOpen).toBe(true);
expect(result.current.countdownSeconds).toBeGreaterThan(0);
});
it('should call logout when session expires', async () => {
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Fast-forward past the 15-minute timeout
act(() => {
vi.advanceTimersByTime(16 * 60 * 1000);
});
// Wait for async logout
await act(async () => {
vi.advanceTimersByTime(10 * 1000);
});
expect(mockLogout).toHaveBeenCalled();
});
it('should extend session when extendSession is called', async () => {
fetchMock.mockResolvedValueOnce({
json: () =>
Promise.resolve({
success: true,
code: 200,
message: 'Session refreshed',
data: { expires_at: '2026-04-26T20:00:00Z' },
}),
});
const { result } = renderHook(() =>
useSessionTimeout(true, mockLogout)
);
// Trigger warning
act(() => {
vi.advanceTimersByTime(12 * 60 * 1000);
});
act(() => {
vi.advanceTimersByTime(10 * 1000);
});
expect(result.current.isWarningOpen).toBe(true);
// Extend session
await act(async () => {
await result.current.extendSession();
});
expect(result.current.isWarningOpen).toBe(false);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/auth/sessions/refresh'),
expect.objectContaining({ method: 'POST' })
);
});
it('should send heartbeat periodically', () => {
fetchMock.mockResolvedValue({
json: () =>
Promise.resolve({
success: true,
code: 200,
message: 'OK',
data: { user: { id: '1', email: 'test@example.com' } },
}),
});
renderHook(() => useSessionTimeout(true, mockLogout));
// Fast-forward past heartbeat interval (5 minutes)
act(() => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
});
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/users/me'),
expect.any(Object)
);
});
it('should logout on 401 heartbeat response', async () => {
fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' });
renderHook(() => useSessionTimeout(true, mockLogout));
// Fast-forward past heartbeat interval
await act(async () => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
});
// Wait for the async catch block
await act(async () => {
await Promise.resolve();
});
expect(mockLogout).toHaveBeenCalled();
});
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});