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} { - setIsOpen(false); - // TODO: Implement logout - }} + onClick={handleLogout} > Logout diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..923f31a --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -0,0 +1,153 @@ +/** + * Login Page + * User authentication page + */ + +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FiMail, FiLock, FiAlertCircle } from 'react-icons/fi'; +import { useAuthStore } from '../stores/authStore'; +import { useToast } from '../components/ToastProvider'; +import { login } from '../services/api'; + +export default function LoginPage() { + const navigate = useNavigate(); + const authStore = useAuthStore(); + const { showToast } = useToast(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>({}); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + + // Validate + const newErrors: Record = {}; + if (!email) newErrors.email = 'Email is required'; + if (!password) newErrors.password = 'Password is required'; + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setIsLoading(true); + + try { + const response = await login(email, password); + authStore.setTokens(response.accessToken, response.refreshToken); + authStore.setUser(response.user); + showToast('success', 'Login successful'); + navigate('/'); + } catch (error) { + console.error('Login error:', error); + showToast('error', 'Invalid email or password'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + {/* Header */} + + Brazil SWIFT Ops + Cross-Border Payment Control Center + + + {/* Login Card */} + + Sign In + + + {/* Email */} + + + Email Address + + + + setEmail(e.target.value)} + className={`w-full pl-10 pr-4 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition ${ + errors.email ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="admin@example.com" + disabled={isLoading} + /> + + {errors.email && ( + + + {errors.email} + + )} + + + {/* Password */} + + + Password + + + + setPassword(e.target.value)} + className={`w-full pl-10 pr-4 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition ${ + errors.password ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="••••••••" + disabled={isLoading} + /> + + {errors.password && ( + + + {errors.password} + + )} + + + {/* Submit Button */} + + {isLoading ? 'Signing In...' : 'Sign In'} + + + + {/* 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 }); + }, + }; +});
User Name
user@example.com
+ {authStore.user?.name || 'User'} +
{authStore.user?.email}
Cross-Border Payment Control Center
+ + {errors.email} +
+ + {errors.password} +
Demo Credentials:
+ Admin: admin@example.com / Admin123! +
+ Manager: manager@example.com / Manager123! +
+ Analyst: analyst@example.com / Analyst123! +
+ © 2024 Brazil SWIFT Operations. All rights reserved. +