Add JWT authentication and role-based authorization

This commit is contained in:
steinhelge
2025-11-23 22:26:17 +01:00
parent 587847e7ed
commit 20af7d5b52
21 changed files with 1726 additions and 77 deletions
+89 -23
View File
@@ -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;
};
+48 -42
View File
@@ -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>
);
};