Add JWT authentication and role-based authorization
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
|
||||
import { Navbar, Nav, Container } from 'react-bootstrap';
|
||||
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||
import { Navbar, Nav, Container, Button } from 'react-bootstrap';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import EventsPage from './pages/admin/EventsPage';
|
||||
import EventDetailPage from './pages/admin/EventDetailPage';
|
||||
import GroupDetailPage from './pages/admin/GroupDetailPage';
|
||||
@@ -9,33 +12,96 @@ import GuestQrPage from './pages/guest/GuestQrPage';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function NavbarContent() {
|
||||
const { isAuthenticated, user, logout, hasRole } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Navbar bg="primary" variant="dark" expand="lg" className="mb-4">
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/">Hospitality System</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Nav className="me-auto">
|
||||
{hasRole('Admin') && <Nav.Link as={Link} to="/">Admin</Nav.Link>}
|
||||
{hasRole('Staff') && <Nav.Link as={Link} to="/staff">Staff Scanner</Nav.Link>}
|
||||
{hasRole('Guest') && <Nav.Link as={Link} to="/guest">Guest View</Nav.Link>}
|
||||
</Nav>
|
||||
<Nav>
|
||||
<Navbar.Text className="me-3">
|
||||
{user?.email} ({user?.roles.join(', ')})
|
||||
</Navbar.Text>
|
||||
<Button variant="outline-light" size="sm" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Nav>
|
||||
</>
|
||||
)}
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Navbar bg="primary" variant="dark" expand="lg" className="mb-4">
|
||||
<AuthProvider>
|
||||
<NavbarContent />
|
||||
<Container>
|
||||
<Navbar.Brand as={Link} to="/">Hospitality System</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav">
|
||||
<Nav className="ms-auto">
|
||||
<Nav.Link as={Link} to="/">Admin</Nav.Link>
|
||||
<Nav.Link as={Link} to="/staff">Staff Scanner</Nav.Link>
|
||||
<Nav.Link as={Link} to="/guest">Guest View</Nav.Link>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute roles={['Admin']}>
|
||||
<EventsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/events/:id"
|
||||
element={
|
||||
<ProtectedRoute roles={['Admin']}>
|
||||
<EventDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/groups/:id"
|
||||
element={
|
||||
<ProtectedRoute roles={['Admin']}>
|
||||
<GroupDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff"
|
||||
element={
|
||||
<ProtectedRoute roles={['Staff']}>
|
||||
<ScannerPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/guest"
|
||||
element={
|
||||
<ProtectedRoute roles={['Guest']}>
|
||||
<GuestQrPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Container>
|
||||
</Navbar>
|
||||
|
||||
<Container>
|
||||
<Routes>
|
||||
<Route path="/" element={<EventsPage />} />
|
||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||
<Route path="/groups/:id" element={<GroupDetailPage />} />
|
||||
<Route path="/staff" element={<ScannerPage />} />
|
||||
<Route path="/guest" element={<GuestQrPage />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, roles }) => {
|
||||
const { isAuthenticated, hasRole } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (roles && roles.length > 0) {
|
||||
const hasRequiredRole = roles.some((role) => hasRole(role));
|
||||
if (!hasRequiredRole) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { authApi, LoginRequest, UserInfo } from '../lib/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
login: (data: LoginRequest) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
hasRole: (role: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load from localStorage on mount
|
||||
const storedToken = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const response = await authApi.login(data);
|
||||
const userInfo: UserInfo = {
|
||||
email: response.email,
|
||||
roles: response.roles,
|
||||
};
|
||||
|
||||
setToken(response.token);
|
||||
setUser(userInfo);
|
||||
localStorage.setItem('token', response.token);
|
||||
localStorage.setItem('user', JSON.stringify(userInfo));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user?.roles.includes(role) ?? false;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!token,
|
||||
hasRole,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5163/api';
|
||||
const API_BASE_URL = 'http://localhost:5163/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -9,50 +9,56 @@ export const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Event API
|
||||
export const eventsApi = {
|
||||
getAll: () => api.get('/events'),
|
||||
getById: (id: string) => api.get(`/events/${id}`),
|
||||
create: (data: any) => api.post('/events', data),
|
||||
update: (id: string, data: any) => api.put(`/events/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/events/${id}`),
|
||||
};
|
||||
// Request interceptor to add JWT token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Group API
|
||||
export const groupsApi = {
|
||||
getByEventId: (eventId: string) => api.get(`/events/${eventId}/groups`),
|
||||
getById: (id: string) => api.get(`/groups/${id}`),
|
||||
create: (eventId: string, data: any) => api.post(`/events/${eventId}/groups`, data),
|
||||
update: (id: string, data: any) => api.put(`/groups/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/groups/${id}`),
|
||||
};
|
||||
// Response interceptor for 401 errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// People API
|
||||
export const peopleApi = {
|
||||
getById: (id: string) => api.get(`/people/${id}`),
|
||||
create: (groupId: string, data: any) => api.post(`/groups/${groupId}/people`, data),
|
||||
update: (id: string, data: any) => api.put(`/people/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/people/${id}`),
|
||||
assignQuota: (personId: string, data: any) => api.post(`/people/${personId}/quotas`, data),
|
||||
};
|
||||
// Auth API
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Product API
|
||||
export const productsApi = {
|
||||
getByEventId: (eventId: string) => api.get(`/events/${eventId}/products`),
|
||||
create: (eventId: string, data: any) => api.post(`/events/${eventId}/products`, data),
|
||||
update: (id: string, data: any) => api.put(`/products/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/products/${id}`),
|
||||
};
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// QR Code API
|
||||
export const qrCodeApi = {
|
||||
getByQrCode: (qrCode: string) => api.get(`/qr/${qrCode}`),
|
||||
getQuotas: (qrCode: string) => api.get(`/qr/${qrCode}/quotas`),
|
||||
};
|
||||
export interface UserInfo {
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// Transaction API
|
||||
export const transactionsApi = {
|
||||
create: (data: any) => api.post('/transactions', data),
|
||||
getByPersonId: (personId: string) => api.get(`/transactions/person/${personId}`),
|
||||
getByEventId: (eventId: string) => api.get(`/transactions/event/${eventId}`),
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
me: async (): Promise<UserInfo> => {
|
||||
const response = await api.get('/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Card, Form, Button, Alert } from 'react-bootstrap';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login({ email, password });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh' }}>
|
||||
<Card style={{ maxWidth: '400px', width: '100%' }} className="shadow">
|
||||
<Card.Body className="p-4">
|
||||
<h2 className="text-center mb-4">Login</h2>
|
||||
|
||||
{error && <Alert variant="danger">{error}</Alert>}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Form.Control
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
className="w-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<small className="text-muted">
|
||||
<strong>Test Accounts:</strong><br />
|
||||
Admin: admin@example.com / Admin123!<br />
|
||||
Staff: staff@example.com / Staff123!<br />
|
||||
Guest: guest@example.com / Guest123!
|
||||
</small>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user