diff --git a/backend/package.json b/backend/package.json
index 6520448..0193b9f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -13,10 +13,12 @@
"express": "^4.18.2",
"pg": "^8.11.0",
"socket.io": "^4.6.1",
- "@prisma/client": "^5.0.0"
+ "@prisma/client": "^5.0.0",
+ "bcryptjs": "^2.4.3",
+ "jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"nodemon": "^2.0.22",
"prisma": "^5.0.0"
}
-}
+}
\ No newline at end of file
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 6987457..2ee573a 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -8,12 +8,22 @@ datasource db {
url = env("DATABASE_URL")
}
+model User {
+ id Int @id @default(autoincrement())
+ username String @unique
+ password String
+ role String @default("USER") // ADMIN, USER
+ plates Plate[]
+}
+
model Plate {
id Int @id @default(autoincrement())
number String @unique
owner String?
- status String @default("ALLOWED") // ALLOWED, DENIED
+ status String @default("PENDING") // PENDING, ALLOWED, DENIED
createdAt DateTime @default(now())
+ addedBy User? @relation(fields: [addedById], references: [id])
+ addedById Int?
}
model AccessLog {
diff --git a/backend/src/index.js b/backend/src/index.js
index d40555c..991c85f 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -23,21 +23,41 @@ app.get('/', (req, res) => {
res.send('ALPR Backend Running');
});
+const authRoutes = require('./routes/auth');
+const { authenticateToken, isAdmin } = require('./middleware/auth');
+
+app.use('/api/auth', authRoutes);
+
// Plates CRUD
-app.get('/api/plates', async (req, res) => {
+app.get('/api/plates', authenticateToken, async (req, res) => {
try {
- const plates = await prisma.plate.findMany();
+ // Users see their own plates? Or all?
+ // Requirement: "usuarios al agregar nuevas patentes, deberan ser permitidas por el administrador"
+ // Let's users see all but maybe status distinguishes them.
+ // For now, let's return all.
+ const plates = await prisma.plate.findMany({
+ include: { addedBy: { select: { username: true } } }
+ });
res.json(plates);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
-app.post('/api/plates', async (req, res) => {
- const { number, owner, status } = req.body;
+app.post('/api/plates', authenticateToken, async (req, res) => {
+ const { number, owner } = req.body;
+ const isAdm = req.user.role === 'ADMIN';
+ // Admin -> ALLOWED, User -> PENDING
+ const status = isAdm ? 'ALLOWED' : 'PENDING';
+
try {
const plate = await prisma.plate.create({
- data: { number, owner, status: status || 'ALLOWED' }
+ data: {
+ number,
+ owner,
+ status,
+ addedById: req.user.id
+ }
});
res.json(plate);
} catch (err) {
@@ -45,6 +65,37 @@ app.post('/api/plates', async (req, res) => {
}
});
+// Admin: Approve/Reject Plate
+app.put('/api/plates/:id/approve', authenticateToken, isAdmin, async (req, res) => {
+ const { id } = req.params;
+ const { status } = req.body; // ALLOWED or DENIED
+
+ if (!['ALLOWED', 'DENIED'].includes(status)) {
+ return res.status(400).json({ error: 'Invalid status' });
+ }
+
+ try {
+ const plate = await prisma.plate.update({
+ where: { id: parseInt(id) },
+ data: { status }
+ });
+ res.json(plate);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Admin: Delete Plate (Optional but good to have)
+app.delete('/api/plates/:id', authenticateToken, isAdmin, async (req, res) => {
+ const { id } = req.params;
+ try {
+ await prisma.plate.delete({ where: { id: parseInt(id) } });
+ res.json({ message: 'Plate deleted' });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
// History Endpoint
app.get('/api/history', async (req, res) => {
const { date } = req.query; // Format: YYYY-MM-DD
@@ -159,7 +210,28 @@ app.post('/api/detect', async (req, res) => {
}
});
+const bcrypt = require('bcryptjs');
+
const PORT = process.env.PORT || 3000;
-server.listen(PORT, () => {
+server.listen(PORT, async () => {
console.log(`Server running on port ${PORT}`);
+
+ // Seed Admin User if none exists
+ try {
+ const userCount = await prisma.user.count();
+ if (userCount === 0) {
+ console.log('No users found. Creating default admin user...');
+ const hashedPassword = await bcrypt.hash('admin123', 10);
+ await prisma.user.create({
+ data: {
+ username: 'admin',
+ password: hashedPassword,
+ role: 'ADMIN'
+ }
+ });
+ console.log('Default admin created: admin / admin123');
+ }
+ } catch (err) {
+ console.error('Error seeding admin user:', err);
+ }
});
diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js
new file mode 100644
index 0000000..546f437
--- /dev/null
+++ b/backend/src/middleware/auth.js
@@ -0,0 +1,26 @@
+const jwt = require('jsonwebtoken');
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
+
+const authenticateToken = (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ const token = authHeader && authHeader.split(' ')[1];
+
+ if (!token) return res.sendStatus(401);
+
+ jwt.verify(token, JWT_SECRET, (err, user) => {
+ if (err) return res.sendStatus(403);
+ req.user = user;
+ next();
+ });
+};
+
+const isAdmin = (req, res, next) => {
+ if (req.user && req.user.role === 'ADMIN') {
+ next();
+ } else {
+ res.status(403).json({ error: 'Admin access required' });
+ }
+};
+
+module.exports = { authenticateToken, isAdmin, JWT_SECRET };
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
new file mode 100644
index 0000000..be81266
--- /dev/null
+++ b/backend/src/routes/auth.js
@@ -0,0 +1,77 @@
+const express = require('express');
+const router = express.Router();
+const jwt = require('jsonwebtoken');
+const bcrypt = require('bcryptjs');
+const { PrismaClient } = require('@prisma/client');
+const { JWT_SECRET, authenticateToken, isAdmin } = require('../middleware/auth');
+
+const prisma = new PrismaClient();
+
+// Register (Protected - Admin only or Open? Plan said Admin creates users)
+// Let's allow open registration but default to USER role, or only Admin can create.
+// Requirement: "administrador sea capaz de crear y borrar usuarios".
+// So we will make register protected by isAdmin or just login.
+// For initial setup we might need a seed or allow open registration for the first user.
+// Let's implement a public login and a protected register for now.
+
+router.post('/login', async (req, res) => {
+ const { username, password } = req.body;
+
+ try {
+ const user = await prisma.user.findUnique({ where: { username } });
+ if (!user) return res.status(400).json({ error: 'User not found' });
+
+ const validPassword = await bcrypt.compare(password, user.password);
+ if (!validPassword) return res.status(400).json({ error: 'Invalid password' });
+
+ const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '1h' });
+ res.json({ token, role: user.role, username: user.username });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Admin: Create User
+router.post('/register', authenticateToken, isAdmin, async (req, res) => {
+ const { username, password, role } = req.body;
+
+ try {
+ const hashedPassword = await bcrypt.hash(password, 10);
+ const user = await prisma.user.create({
+ data: {
+ username,
+ password: hashedPassword,
+ role: role || 'USER'
+ }
+ });
+ res.json({ message: 'User created', userId: user.id });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Admin: Delete User
+router.delete('/:id', authenticateToken, isAdmin, async (req, res) => {
+ const { id } = req.params;
+ try {
+ await prisma.user.delete({ where: { id: parseInt(id) } });
+ res.json({ message: 'User deleted' });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// Admin: List Users
+router.get('/', authenticateToken, isAdmin, async (req, res) => {
+ try {
+ const users = await prisma.user.findMany({
+ select: { id: true, username: true, role: true } // Don't return passwords
+ });
+ res.json(users);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+
+module.exports = router;
diff --git a/frontend/package.json b/frontend/package.json
index 7060345..c82b717 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,7 +14,9 @@
"lucide-react": "^0.260.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "socket.io-client": "^4.7.1"
+ "socket.io-client": "^4.7.1",
+ "react-router-dom": "^6.14.2",
+ "jwt-decode": "^3.1.2"
},
"devDependencies": {
"@types/react": "^18.2.15",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 128a5df..914b0dc 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,355 +1,86 @@
-import { useState, useEffect } from 'react'
-import io from 'socket.io-client'
-import axios from 'axios'
-import { Car, AlertCircle, CheckCircle, XCircle, Clock, Calendar } from 'lucide-react'
-
-// Env var logic for Vite
-const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
-const socket = io(API_URL);
+import { useState, useEffect } from 'react';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import Login from './pages/Login';
+import AdminDashboard from './pages/AdminDashboard';
+import UserDashboard from './pages/UserDashboard';
function App() {
- const [plates, setPlates] = useState([]);
- const [detections, setDetections] = useState([]);
- const [loading, setLoading] = useState(true);
- const [showModal, setShowModal] = useState(false);
- const [newPlate, setNewPlate] = useState({ number: '', owner: '' });
+ const [token, setToken] = useState(localStorage.getItem('token'));
+ const [userRole, setUserRole] = useState(localStorage.getItem('role'));
+ const [username, setUsername] = useState(localStorage.getItem('username'));
- // History State
- const [viewMode, setViewMode] = useState('live'); // 'live' | 'history'
- const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
- const [historyLogs, setHistoryLogs] = useState([]);
- const [loadingHistory, setLoadingHistory] = useState(false);
-
- const handleRegister = async (e) => {
- e.preventDefault();
- try {
- if (!newPlate.number) return;
- await axios.post(`${API_URL}/api/plates`, {
- number: newPlate.number.toUpperCase(),
- owner: newPlate.owner
- });
- setNewPlate({ number: '', owner: '' });
- setShowModal(false);
- fetchPlates();
- } catch (err) {
- alert('Error adding plate: ' + err.message);
- }
+ const setAuth = (newToken, newRole, newUser) => {
+ setToken(newToken);
+ setUserRole(newRole);
+ setUsername(newUser);
};
- // Load initial data
- useEffect(() => {
- fetchPlates();
- fetchRecentDetections();
-
- // Socket listeners
- socket.on('new_detection', (data) => {
- console.log('New detection:', data);
- setDetections(prev => [data, ...prev].slice(0, 10)); // Keep last 10
- });
-
- return () => {
- socket.off('new_detection');
- };
- }, []);
-
- const fetchRecentDetections = async () => {
- try {
- const res = await axios.get(`${API_URL}/api/recent`);
- // Map backend AccessLog format to frontend detection format if needed
- // AccessLog: { id, plateNumber, accessStatus, timestamp }
- // Detection: { plate, status, timestamp }
- const formatted = res.data.map(log => ({
- plate: log.plateNumber,
- status: log.accessStatus,
- timestamp: log.timestamp
- }));
- setDetections(formatted);
- } catch (err) {
- console.error("Error fetching recent detections:", err);
- }
+ const handleLogout = () => {
+ localStorage.removeItem('token');
+ localStorage.removeItem('role');
+ localStorage.removeItem('username');
+ setToken(null);
+ setUserRole(null);
+ setUsername(null);
};
- const fetchPlates = async () => {
- try {
- const res = await axios.get(`${API_URL}/api/plates`);
- setPlates(res.data);
- setLoading(false);
- } catch (err) {
- console.error(err);
- setLoading(false);
+ // Protected Route Component
+ const ProtectedRoute = ({ children, allowedRoles }) => {
+ if (!token) {
+ return
No pending plates.
+ )} + {plates.filter(p => p.status === 'PENDING').map(plate => ( +Manage your vehicles and access.
+No plates registered.
} + {plates.map(plate => ( +