From 690c4d6ad786c17d6582bc7bc0cb14245cd4a236 Mon Sep 17 00:00:00 2001 From: raven Date: Sun, 28 Dec 2025 18:41:58 -0300 Subject: [PATCH] Intento de Creador --- backend/package.json | 6 +- backend/prisma/schema.prisma | 12 +- backend/src/index.js | 84 +++++- backend/src/middleware/auth.js | 26 ++ backend/src/routes/auth.js | 77 +++++ frontend/package.json | 4 +- frontend/src/App.jsx | 407 +++++--------------------- frontend/src/pages/AdminDashboard.jsx | 202 +++++++++++++ frontend/src/pages/Login.jsx | 70 +++++ frontend/src/pages/UserDashboard.jsx | 160 ++++++++++ 10 files changed, 700 insertions(+), 348 deletions(-) create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/auth.js create mode 100644 frontend/src/pages/AdminDashboard.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/UserDashboard.jsx 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 ; } - }; - - const fetchHistory = async (date) => { - setLoadingHistory(true); - try { - const res = await axios.get(`${API_URL}/api/history?date=${date}`); - setHistoryLogs(res.data); - } catch (err) { - console.error("Error fetching history:", err); - // alert("Failed to fetch history"); - } finally { - setLoadingHistory(false); + if (allowedRoles && !allowedRoles.includes(userRole)) { + return ; } - }; - - // Fetch history when date changes or when switching to history view - useEffect(() => { - if (viewMode === 'history') { - fetchHistory(selectedDate); - } - }, [viewMode, selectedDate]); - - const StatusBadge = ({ status }) => { - const colors = { - GRANTED: 'bg-green-500/20 text-green-400 border-green-500/50', - DENIED: 'bg-red-500/20 text-red-400 border-red-500/50', - UNKNOWN: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50', - ALLOWED: 'bg-green-500/20 text-green-400 border-green-500/50', - }; - - return ( - - {status} - - ); + return children; }; return ( -
- {/* Modal */} - {showModal && ( -
-
-

Register New Plate

-
-
- - setNewPlate({ ...newPlate, number: e.target.value.toUpperCase() })} - className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none font-mono uppercase" - placeholder="ABCD12" - /> + + + setToken(t)} + setUserRole={(r) => setUserRole(r)} + setUsername={(u) => setUsername(u)} // Adding this prop to Login might be needed + /> + ) : ( + + ) + } + /> + + +
+ +
-
- - setNewPlate({ ...newPlate, owner: e.target.value })} - className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none" - placeholder="John Doe" - /> + + } + /> + + {/* Admin can view user db too usually, but separate for now */} +
+ +
-
- - -
- -
-
- )} -
+ + } + /> - {/* Header */} -
-
-
- -
-
-

- Control Patente AI -

-

Real-time ALPR Monitoring System

-
-
-
-
- System Online -
-
- -
- - {/* Main Feed Section (Live / History) */} -
-

-
- {viewMode === 'live' ? : } - {viewMode === 'live' ? 'Live Detections' : 'History Log'} -
- - {/* Toggle Switch */} -
- - -
-

- -
- - {viewMode === 'live' ? ( - <> - {/* Video Feed */} -
- Live Camera Feed { - e.target.style.display = 'none'; - e.target.nextSibling.style.display = 'flex'; - }} - /> -
-

Camera Offline or Connecting...

-
-
-
- LIVE -
-
-
- - {/* Detections List */} -

Recent Scans

- - {detections.length === 0 ? ( -
-

No detections yet...

-
- ) : ( -
- {detections.map((d, i) => ( -
-
-
- {d.plate} -
-
- {new Date(d.timestamp).toLocaleTimeString()} -
-
- -
- ))} -
- )} - - ) : ( - /* History View */ -
-
- - setSelectedDate(e.target.value)} - className="bg-slate-900 border border-slate-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-purple-500 outline-none" - /> -
- -
- {loadingHistory ? ( -

Loading history...

- ) : historyLogs.length === 0 ? ( -
- No records found for {selectedDate} -
- ) : ( - historyLogs.map((log) => ( -
-
-
- {log.plateNumber} -
-
- {new Date(log.timestamp).toLocaleTimeString()} -
-
- -
- )) - )} -
-
- )} -
-
- - {/* Database / Stats */} -
-

- - Registered Plates -

- -
- {loading ? ( -

Loading database...

- ) : ( -
- {plates.map((p) => ( -
-
-
{p.number}
-
{p.owner || 'Unknown Owner'}
-
- - {p.status} - -
- ))} - {plates.length === 0 && ( -

No plates registered.

- )} -
- )} - - -
-
-
- -
-
- ) + } /> + + + ); } -export default App +export default App; diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx new file mode 100644 index 0000000..da5527c --- /dev/null +++ b/frontend/src/pages/AdminDashboard.jsx @@ -0,0 +1,202 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Users, CheckCircle, XCircle, Shield, Trash2 } from 'lucide-react'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + +function AdminDashboard({ token }) { + const [users, setUsers] = useState([]); + const [plates, setPlates] = useState([]); + const [newUser, setNewUser] = useState({ username: '', password: '', role: 'USER' }); + const [activeTab, setActiveTab] = useState('plates'); // 'plates' | 'users' + + useEffect(() => { + fetchData(); + }, [token]); + + const fetchData = async () => { + try { + const authHeader = { headers: { Authorization: `Bearer ${token}` } }; + const [usersRes, platesRes] = await Promise.all([ + axios.get(`${API_URL}/api/auth`, authHeader).catch(err => ({ data: [] })), + axios.get(`${API_URL}/api/plates`, authHeader) + ]); + setUsers(usersRes.data); + setPlates(platesRes.data); + } catch (err) { + console.error(err); + } + }; + + const handleCreateUser = async (e) => { + e.preventDefault(); + try { + await axios.post(`${API_URL}/api/auth/register`, newUser, { + headers: { Authorization: `Bearer ${token}` } + }); + setNewUser({ username: '', password: '', role: 'USER' }); + fetchData(); + alert('User created'); + } catch (err) { + alert('Error: ' + err.message); + } + }; + + const handleDeleteUser = async (id) => { + if (!confirm('Area you sure?')) return; + try { + await axios.delete(`${API_URL}/api/auth/${id}`, { + headers: { Authorization: `Bearer ${token}` } + }); + fetchData(); + } catch (err) { + console.error(err); + } + }; + + const handleApprovePlate = async (id, status) => { + try { + await axios.put(`${API_URL}/api/plates/${id}/approve`, { status }, { + headers: { Authorization: `Bearer ${token}` } + }); + fetchData(); + } catch (err) { + console.error(err); + } + }; + + return ( +
+
+
+

+ + Admin Portal +

+
+ + +
+
+ + {activeTab === 'plates' && ( +
+

Pending Approvals

+
+ {plates.filter(p => p.status === 'PENDING').length === 0 && ( +

No pending plates.

+ )} + {plates.filter(p => p.status === 'PENDING').map(plate => ( +
+
+
{plate.number}
+
Owner: {plate.owner} | Added by: {plate.addedBy?.username || 'Unknown'}
+
+
+ + +
+
+ ))} +
+ +

All Plates

+
+ {plates.filter(p => p.status !== 'PENDING').map(plate => ( +
+
+
+
{plate.number}
+
{plate.owner}
+
+ + {plate.status} + +
+
+ ))} +
+
+ )} + + {activeTab === 'users' && ( +
+
+

Create User

+
+ setNewUser({ ...newUser, username: e.target.value })} + /> + setNewUser({ ...newUser, password: e.target.value })} + /> + + +
+
+ +
+

Existing Users

+
+ {users.map(u => ( +
+
+
+ {u.username[0].toUpperCase()} +
+
+
{u.username}
+
{u.role}
+
+
+ {u.username !== 'admin' && ( + + )} +
+ ))} +
+
+
+ )} +
+
+ ); +} + +export default AdminDashboard; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..c983e6e --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + +function Login({ setToken, setUserRole }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const navigate = useNavigate(); + + const handleLogin = async (e) => { + e.preventDefault(); + try { + const res = await axios.post(`${API_URL}/api/auth/login`, { username, password }); + const { token, role, username: user } = res.data; + + localStorage.setItem('token', token); + localStorage.setItem('role', role); + localStorage.setItem('username', user); + + setToken(token); + setUserRole(role); + + if (role === 'ADMIN') { + navigate('/admin'); + } else { + navigate('/user'); + } + } catch (err) { + alert('Login failed: ' + (err.response?.data?.error || err.message)); + } + }; + + return ( +
+
+

Control Patente AI

+
+
+ + setUsername(e.target.value)} + className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
+
+
+ ); +} + +export default Login; diff --git a/frontend/src/pages/UserDashboard.jsx b/frontend/src/pages/UserDashboard.jsx new file mode 100644 index 0000000..c3868a6 --- /dev/null +++ b/frontend/src/pages/UserDashboard.jsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Car, Clock, CheckCircle, AlertCircle } from 'lucide-react'; +import io from 'socket.io-client'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; +const socket = io(API_URL); + +function UserDashboard({ token, username }) { + const [plates, setPlates] = useState([]); + const [newPlate, setNewPlate] = useState({ number: '', owner: '' }); + const [detections, setDetections] = useState([]); + + useEffect(() => { + fetchPlates(); + + // Listen for live detections (optional, maybe user wants to see their plates detected?) + // For now, let's show all global detections but emphasize this is a "Portal" + socket.on('new_detection', (data) => { + setDetections(prev => [data, ...prev].slice(0, 5)); + }); + + return () => socket.off('new_detection'); + }, [token]); + + const fetchPlates = async () => { + try { + const res = await axios.get(`${API_URL}/api/plates`, { + headers: { Authorization: `Bearer ${token}` } + }); + // Filter plates added by this user (if backend doesn't filter, we filter here) + // Note: Backend currently returns ALL plates. We can filter on client for now. + const myPlates = res.data.filter(p => p.addedBy?.username === username); + setPlates(myPlates); + } catch (err) { + console.error(err); + } + }; + + const handleRegister = async (e) => { + e.preventDefault(); + try { + await axios.post(`${API_URL}/api/plates`, { + number: newPlate.number.toUpperCase(), + owner: newPlate.owner + }, { + headers: { Authorization: `Bearer ${token}` } + }); + setNewPlate({ number: '', owner: '' }); + fetchPlates(); + alert('Plate registered! Waiting for admin approval.'); + } catch (err) { + alert('Error: ' + err.message); + } + }; + + return ( +
+
+ + {/* Left: Register & My Plates */} +
+
+

+ Welcome, {username} +

+

Manage your vehicles and access.

+
+ +
+

+ My Registered Plates +

+ +
+ {plates.length === 0 &&

No plates registered.

} + {plates.map(plate => ( +
+
+
{plate.number}
+
{plate.owner}
+
+
+ {plate.status === 'ALLOWED' && ( + + ACTIVE + + )} + {plate.status === 'PENDING' && ( + + PENDING + + )} + {plate.status === 'DENIED' && ( + + DENIED + + )} +
+
+ ))} +
+
+ +
+

Request New Access

+
+ setNewPlate({ ...newPlate, number: e.target.value })} + required + /> + setNewPlate({ ...newPlate, owner: e.target.value })} + required + /> + +
+
+
+ + {/* Right: Live Feed Preview */} +
+
+

Live Gate Feed

+
+ e.target.style.display = 'none'} + /> +
+
+ +
+

Recent Activity

+
+ {detections.map((d, i) => ( +
+ {d.plate} + {d.status} +
+ ))} +
+
+
+ +
+
+ ); +} + +export default UserDashboard;