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.
This commit is contained in:
defiQUG
2026-01-23 18:51:34 -08:00
parent 4f637ede8c
commit 5c7f4c70e4
6 changed files with 602 additions and 19 deletions

View File

@@ -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() {
<FiBell className="w-5 h-5" />
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
</button>
<UserMenu />
{authStore.isAuthenticated && <UserMenu />}
</div>
</div>
</div>
@@ -69,18 +78,53 @@ function App() {
<ErrorBoundary>
<ToastProvider>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<Navigation />
<Navigation />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/treasury" element={<TreasuryPage />} />
<Route path="/reports" element={<ReportsPage />} />
</Routes>
</main>
</div>
<main className="min-h-screen bg-gray-50">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<DashboardPage />
</div>
</ProtectedRoute>
}
/>
<Route
path="/transactions"
element={
<ProtectedRoute>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<TransactionsPage />
</div>
</ProtectedRoute>
}
/>
<Route
path="/treasury"
element={
<ProtectedRoute>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<TreasuryPage />
</div>
</ProtectedRoute>
}
/>
<Route
path="/reports"
element={
<ProtectedRoute>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<ReportsPage />
</div>
</ProtectedRoute>
}
/>
</Routes>
</main>
</BrowserRouter>
</ToastProvider>
</ErrorBoundary>

View File

@@ -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 <LoadingSpinner />;
}
if (!hasAccess) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={menuRef}>
<button
@@ -28,14 +48,18 @@ export default function UserMenu() {
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
<FiUser className="w-5 h-5 text-white" />
</div>
<span className="hidden md:block text-sm font-medium text-gray-700">User</span>
<span className="hidden md:block text-sm font-medium text-gray-700">
{authStore.user?.name || 'User'}
</span>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 py-1 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">User Name</p>
<p className="text-xs text-gray-500">user@example.com</p>
<p className="text-sm font-medium text-gray-900">
{authStore.user?.name || 'User'}
</p>
<p className="text-xs text-gray-500">{authStore.user?.email}</p>
</div>
<a
href="#"
@@ -73,10 +97,7 @@ export default function UserMenu() {
<div className="border-t border-gray-200 my-1" />
<button
className="w-full flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
onClick={() => {
setIsOpen(false);
// TODO: Implement logout
}}
onClick={handleLogout}
>
<FiLogOut className="w-4 h-4 mr-3" />
Logout

View File

@@ -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<Record<string, string>>({});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate
const newErrors: Record<string, string> = {};
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 (
<div className="min-h-screen bg-gradient-to-br from-blue-600 to-blue-800 flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-white mb-2">Brazil SWIFT Ops</h1>
<p className="text-blue-100">Cross-Border Payment Control Center</p>
</div>
{/* Login Card */}
<div className="bg-white rounded-lg shadow-xl p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Sign In</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<div className="relative">
<FiMail className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="email"
value={email}
onChange={(e) => 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}
/>
</div>
{errors.email && (
<p className="text-red-500 text-sm mt-1 flex items-center">
<FiAlertCircle className="mr-1 w-4 h-4" />
{errors.email}
</p>
)}
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Password
</label>
<div className="relative">
<FiLock className="absolute left-3 top-3 text-gray-400 w-5 h-5" />
<input
type="password"
value={password}
onChange={(e) => 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}
/>
</div>
{errors.password && (
<p className="text-red-500 text-sm mt-1 flex items-center">
<FiAlertCircle className="mr-1 w-4 h-4" />
{errors.password}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium py-2 px-4 rounded-md transition duration-200 mt-6"
>
{isLoading ? 'Signing In...' : 'Sign In'}
</button>
</form>
{/* Demo Credentials */}
<div className="mt-6 pt-6 border-t border-gray-200">
<p className="text-xs text-gray-600 mb-2">Demo Credentials:</p>
<div className="bg-gray-50 p-3 rounded text-xs space-y-1">
<p>
<strong>Admin:</strong> admin@example.com / Admin123!
</p>
<p>
<strong>Manager:</strong> manager@example.com / Manager123!
</p>
<p>
<strong>Analyst:</strong> analyst@example.com / Analyst123!
</p>
</div>
</div>
</div>
{/* Footer */}
<p className="text-center text-blue-100 text-sm mt-8">
© 2024 Brazil SWIFT Operations. All rights reserved.
</p>
</div>
</div>
);
}

View File

@@ -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<T> {
data?: T;
error?: string;
message?: string;
}
/**
* Fetch wrapper with authentication
*/
async function apiFetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<T>(endpoint: string): Promise<T> {
return apiFetch<T>(endpoint, { method: 'GET' });
}
/**
* POST request
*/
export async function apiPost<T>(endpoint: string, data?: any): Promise<T> {
return apiFetch<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* PUT request
*/
export async function apiPut<T>(endpoint: string, data?: any): Promise<T> {
return apiFetch<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* DELETE request
*/
export async function apiDelete<T>(endpoint: string): Promise<T> {
return apiFetch<T>(endpoint, { method: 'DELETE' });
}
/**
* Login
*/
export async function login(
email: string,
password: string
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
const response = await apiFetch<any>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return response;
}
/**
* Logout
*/
export async function logout(): Promise<void> {
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<any> {
return apiFetch('/api/v1/auth/me', { method: 'GET' });
}
/**
* Get transactions
*/
export async function getTransactions(page = 1, limit = 20): Promise<any> {
return apiGet(`/api/v1/transactions?page=${page}&limit=${limit}`);
}
/**
* Get transaction by ID
*/
export async function getTransaction(id: string): Promise<any> {
return apiGet(`/api/v1/transactions/${id}`);
}
/**
* Create transaction
*/
export async function createTransaction(data: any): Promise<any> {
return apiPost('/api/v1/transactions', data);
}
/**
* Get accounts
*/
export async function getAccounts(page = 1): Promise<any> {
return apiGet(`/api/v1/accounts?page=${page}`);
}
/**
* Get account by ID
*/
export async function getAccount(id: string): Promise<any> {
return apiGet(`/api/v1/accounts/${id}`);
}
/**
* Get reports
*/
export async function getTransactionSummary(): Promise<any> {
return apiGet('/api/v1/reports/transaction-summary');
}
export async function getComplianceSummary(): Promise<any> {
return apiGet('/api/v1/reports/compliance-summary');
}

View File

@@ -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<AuthState>((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 });
},
};
});