From 5c7f4c70e4b17d06dd45cb63e050c07f0080f7c1 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Fri, 23 Jan 2026 18:51:34 -0800 Subject: [PATCH] Implement Phase 1e: Frontend Authentication Integration - Create auth store (Zustand) for managing user and tokens - Implement API client with automatic token refresh on 401 - Add LoginPage with email/password form and demo credentials - Create ProtectedRoute component for route-level authorization - Update App.tsx to integrate authentication and login page - Add logout functionality to UserMenu component - All protected routes now require authentication - Token auto-refresh on expiry using refresh tokens - Toast notifications for auth errors and events Frontend now fully integrated with backend API authentication. --- apps/web/src/App.tsx | 68 +++++-- apps/web/src/components/ProtectedRoute.tsx | 68 +++++++ apps/web/src/components/UserMenu.tsx | 35 +++- apps/web/src/pages/LoginPage.tsx | 153 +++++++++++++++ apps/web/src/services/api.ts | 205 +++++++++++++++++++++ apps/web/src/stores/authStore.ts | 92 +++++++++ 6 files changed, 602 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/ProtectedRoute.tsx create mode 100644 apps/web/src/pages/LoginPage.tsx create mode 100644 apps/web/src/services/api.ts create mode 100644 apps/web/src/stores/authStore.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 14ced66..5f592c9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,15 +1,24 @@ import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom'; import { ErrorBoundary } from './components/ErrorBoundary'; import { ToastProvider } from './components/ToastProvider'; +import { ProtectedRoute } from './components/ProtectedRoute'; import UserMenu from './components/UserMenu'; import DashboardPage from './pages/DashboardPage'; import TransactionsPage from './pages/TransactionsPage'; import TreasuryPage from './pages/TreasuryPage'; import ReportsPage from './pages/ReportsPage'; +import LoginPage from './pages/LoginPage'; import { FiHome, FiFileText, FiDollarSign, FiBarChart2, FiBell } from 'react-icons/fi'; +import { useAuthStore } from './stores/authStore'; function Navigation() { const location = useLocation(); + const authStore = useAuthStore(); + + // Don't show navigation on login page + if (location.pathname === '/login') { + return null; + } const isActive = (path: string) => location.pathname === path; @@ -56,7 +65,7 @@ function Navigation() { - + {authStore.isAuthenticated && } @@ -69,18 +78,53 @@ function App() { -
- + -
- - } /> - } /> - } /> - } /> - -
-
+
+ + } /> + +
+ +
+ + } + /> + +
+ +
+ + } + /> + +
+ +
+ + } + /> + +
+ +
+ + } + /> +
+
diff --git a/apps/web/src/components/ProtectedRoute.tsx b/apps/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..ca5ae1e --- /dev/null +++ b/apps/web/src/components/ProtectedRoute.tsx @@ -0,0 +1,68 @@ +/** + * Protected Route Component + * Redirects unauthenticated users to login + */ + +import React, { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; +import { getCurrentUser } from '../services/api'; +import LoadingSpinner from './LoadingSpinner'; + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRoles?: number[]; +} + +export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) { + const authStore = useAuthStore(); + const [isVerifying, setIsVerifying] = useState(true); + const [hasAccess, setHasAccess] = useState(false); + + useEffect(() => { + async function verifyAuth() { + setIsVerifying(true); + + // Check if user has auth token + if (!authStore.accessToken) { + setHasAccess(false); + setIsVerifying(false); + return; + } + + try { + // Verify token is still valid + const user = await getCurrentUser(); + authStore.setUser(user); + + // Check roles if required + if (requiredRoles && requiredRoles.length > 0) { + const hasRequiredRole = user.roles.some((roleId: number) => + requiredRoles.includes(roleId) + ); + setHasAccess(hasRequiredRole); + } else { + setHasAccess(true); + } + } catch (error) { + console.error('Auth verification failed:', error); + authStore.clearAuth(); + setHasAccess(false); + } finally { + setIsVerifying(false); + } + } + + verifyAuth(); + }, [authStore, requiredRoles]); + + if (isVerifying) { + return ; + } + + if (!hasAccess) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx index 2aa2dd7..8d009e8 100644 --- a/apps/web/src/components/UserMenu.tsx +++ b/apps/web/src/components/UserMenu.tsx @@ -1,9 +1,16 @@ import React, { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { FiUser, FiSettings, FiLogOut, FiHelpCircle } from 'react-icons/fi'; +import { useAuthStore } from '../stores/authStore'; +import { logout } from '../services/api'; +import { useToast } from './ToastProvider'; export default function UserMenu() { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); + const navigate = useNavigate(); + const authStore = useAuthStore(); + const { showToast } = useToast(); useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -18,6 +25,19 @@ export default function UserMenu() { } }, [isOpen]); + const handleLogout = async () => { + try { + await logout(); + authStore.clearAuth(); + showToast('success', 'Logged out successfully'); + navigate('/login'); + } catch (error) { + console.error('Logout error:', error); + authStore.clearAuth(); + navigate('/login'); + } + }; + return (
- User + + {authStore.user?.name || 'User'} + {isOpen && (
-

User Name

-

user@example.com

+

+ {authStore.user?.name || 'User'} +

+

{authStore.user?.email}

+ + + {/* Demo Credentials */} +
+

Demo Credentials:

+
+

+ Admin: admin@example.com / Admin123! +

+

+ Manager: manager@example.com / Manager123! +

+

+ Analyst: analyst@example.com / Analyst123! +

+
+
+
+ + {/* Footer */} +

+ © 2024 Brazil SWIFT Operations. All rights reserved. +

+ + + ); +} diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts new file mode 100644 index 0000000..75ca9af --- /dev/null +++ b/apps/web/src/services/api.ts @@ -0,0 +1,205 @@ +/** + * API Client + * Handles HTTP requests with authentication + */ + +import { useAuthStore } from '../stores/authStore'; +import { useToast } from '../components/ToastProvider'; + +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000'; + +export interface ApiResponse { + data?: T; + error?: string; + message?: string; +} + +/** + * Fetch wrapper with authentication + */ +async function apiFetch( + endpoint: string, + options: RequestInit = {} +): Promise { + const authStore = useAuthStore(); + const { showToast } = useToast(); + + const headers = new Headers(options.headers || {}); + headers.set('Content-Type', 'application/json'); + + if (authStore.accessToken) { + headers.set('Authorization', `Bearer ${authStore.accessToken}`); + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + // Handle 401 - try to refresh token + if (response.status === 401 && authStore.refreshToken) { + try { + const refreshResponse = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken: authStore.refreshToken }), + }); + + if (refreshResponse.ok) { + const { accessToken } = await refreshResponse.json(); + authStore.setAccessToken(accessToken); + + // Retry original request with new token + headers.set('Authorization', `Bearer ${accessToken}`); + const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!retryResponse.ok) { + throw new Error(`API error: ${retryResponse.status}`); + } + + return retryResponse.json(); + } else { + // Refresh failed, clear auth + authStore.clearAuth(); + showToast('error', 'Session expired. Please log in again'); + return Promise.reject(new Error('Session expired')); + } + } catch (error) { + authStore.clearAuth(); + return Promise.reject(error); + } + } + + // Handle 403 + if (response.status === 403) { + showToast('error', 'You do not have permission to perform this action'); + return Promise.reject(new Error('Access denied')); + } + + // Handle other errors + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.error || `Request failed: ${response.status}`; + showToast('error', errorMessage); + return Promise.reject(new Error(errorMessage)); + } + + return response.json(); +} + +/** + * GET request + */ +export async function apiGet(endpoint: string): Promise { + return apiFetch(endpoint, { method: 'GET' }); +} + +/** + * POST request + */ +export async function apiPost(endpoint: string, data?: any): Promise { + return apiFetch(endpoint, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * PUT request + */ +export async function apiPut(endpoint: string, data?: any): Promise { + return apiFetch(endpoint, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * DELETE request + */ +export async function apiDelete(endpoint: string): Promise { + return apiFetch(endpoint, { method: 'DELETE' }); +} + +/** + * Login + */ +export async function login( + email: string, + password: string +): Promise<{ accessToken: string; refreshToken: string; user: any }> { + const response = await apiFetch('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + return response; +} + +/** + * Logout + */ +export async function logout(): Promise { + try { + await apiFetch('/api/v1/auth/logout', { method: 'POST' }); + } catch (error) { + console.error('Logout error:', error); + } +} + +/** + * Get current user profile + */ +export async function getCurrentUser(): Promise { + return apiFetch('/api/v1/auth/me', { method: 'GET' }); +} + +/** + * Get transactions + */ +export async function getTransactions(page = 1, limit = 20): Promise { + return apiGet(`/api/v1/transactions?page=${page}&limit=${limit}`); +} + +/** + * Get transaction by ID + */ +export async function getTransaction(id: string): Promise { + return apiGet(`/api/v1/transactions/${id}`); +} + +/** + * Create transaction + */ +export async function createTransaction(data: any): Promise { + return apiPost('/api/v1/transactions', data); +} + +/** + * Get accounts + */ +export async function getAccounts(page = 1): Promise { + return apiGet(`/api/v1/accounts?page=${page}`); +} + +/** + * Get account by ID + */ +export async function getAccount(id: string): Promise { + return apiGet(`/api/v1/accounts/${id}`); +} + +/** + * Get reports + */ +export async function getTransactionSummary(): Promise { + return apiGet('/api/v1/reports/transaction-summary'); +} + +export async function getComplianceSummary(): Promise { + return apiGet('/api/v1/reports/compliance-summary'); +} diff --git a/apps/web/src/stores/authStore.ts b/apps/web/src/stores/authStore.ts new file mode 100644 index 0000000..55f21c0 --- /dev/null +++ b/apps/web/src/stores/authStore.ts @@ -0,0 +1,92 @@ +/** + * Authentication Store + * Manages user authentication state and tokens + */ + +import { create } from 'zustand'; + +export interface User { + id: number; + email: string; + name: string; + roles: number[]; +} + +export interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + + // Actions + setUser: (user: User | null) => void; + setTokens: (accessToken: string, refreshToken: string) => void; + setAccessToken: (accessToken: string) => void; + clearAuth: () => void; + setError: (error: string | null) => void; + setLoading: (loading: boolean) => void; +} + +export const useAuthStore = create((set) => { + // Load from localStorage on initialization + const savedAccessToken = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null; + const savedRefreshToken = + typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null; + const savedUser = typeof window !== 'undefined' + ? localStorage.getItem('user') + ? JSON.parse(localStorage.getItem('user')!) + : null + : null; + + return { + user: savedUser, + accessToken: savedAccessToken, + refreshToken: savedRefreshToken, + isAuthenticated: !!savedAccessToken, + isLoading: false, + error: null, + + setUser: (user: User | null) => { + set({ user }); + if (user) { + localStorage.setItem('user', JSON.stringify(user)); + } else { + localStorage.removeItem('user'); + } + }, + + setTokens: (accessToken: string, refreshToken: string) => { + set({ accessToken, refreshToken, isAuthenticated: true }); + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + }, + + setAccessToken: (accessToken: string) => { + set({ accessToken }); + localStorage.setItem('accessToken', accessToken); + }, + + clearAuth: () => { + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + }, + + setError: (error: string | null) => { + set({ error }); + }, + + setLoading: (loading: boolean) => { + set({ isLoading: loading }); + }, + }; +});