barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh

This commit is contained in:
2026-01-17 13:03:21 +02:00
commit 7f04566242
81 changed files with 22551 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
.data-display {
margin-top: 20px;
}
.data-display.empty {
background: white;
padding: 40px;
border-radius: 8px;
text-align: center;
color: #999;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.reading-card.latest {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-left: 4px solid #667eea;
}
.reading-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 15px;
}
.info-item {
padding: 10px;
background: white;
border-radius: 4px;
}
.info-item label {
display: block;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
font-size: 14px;
}
.info-item span {
display: block;
font-size: 16px;
color: #333;
word-break: break-all;
}
.data-value {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
font-weight: bold;
}
.readings-history {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.readings-history h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.table-container {
overflow-x: auto;
margin-top: 15px;
}
table {
width: 100%;
border-collapse: collapse;
}
table thead {
background-color: #f5f5f5;
}
table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #667eea;
border-bottom: 2px solid #ddd;
}
table td {
padding: 12px;
border-bottom: 1px solid #eee;
}
table tbody tr:hover {
background-color: #f9f9f9;
}
.data-cell {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
word-break: break-all;
}
@media (max-width: 768px) {
.reading-info {
grid-template-columns: 1fr;
}
table {
font-size: 14px;
}
table th, table td {
padding: 8px;
}
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import './DataDisplay.css';
function DataDisplay({ readings, selectedPort }) {
if (!readings || readings.length === 0) {
return (
<div className="data-display empty">
<p>No data available. Waiting for COM port readings...</p>
</div>
);
}
const latestReading = readings[0];
return (
<div className="data-display">
<div className="reading-card latest">
<h2>Latest Reading</h2>
<div className="reading-info">
<div className="info-item">
<label>Port:</label>
<span>{latestReading.port}</span>
</div>
<div className="info-item">
<label>Data:</label>
<span className="data-value">{latestReading.data}</span>
</div>
<div className="info-item">
<label>Time:</label>
<span>{new Date(latestReading.timestamp).toLocaleString()}</span>
</div>
</div>
</div>
{/* <div className="readings-history">
<h2>Recent Readings ({readings.length})</h2>
<div className="table-container">
<table>
<thead>
<tr>
<th>Port</th>
<th>Data</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{readings.map((reading, idx) => (
<tr key={reading.id || idx}>
<td>{reading.port}</td>
<td className="data-cell">{reading.data}</td>
<td>{new Date(reading.timestamp).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> */}
</div>
);
}
export default DataDisplay;
+112
View File
@@ -0,0 +1,112 @@
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 10px;
}
.user-name {
font-weight: 600;
font-size: 14px;
}
.user-role {
font-size: 12px;
opacity: 0.9;
}
.avatar-container {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: white;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.logout-button {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.header-left h1 {
font-size: 18px;
text-align: center;
}
.header-right {
justify-content: space-between;
}
.user-info {
align-items: flex-start;
margin-right: 0;
}
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
const handleAvatarClick = () => {
setShowPasswordOverlay(true);
};
return (
<>
<header className="app-header">
<div className="header-content">
<div className="header-left">
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
</>
);
}
export default Header;
+35
View File
@@ -0,0 +1,35 @@
import Header from './Header';
import DataDisplay from './DataDisplay';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
export default function Main() {
const { readings, isConnected, error } = useSerialData();
const { vehicles } = useNomenclatures();
console.log('Vehicles:', vehicles);
return (
<div className="main">
<div>
<div className="vehicles">
{ vehicles.map(vehicle => {
return (
<div key={vehicle.id} className="vehicle-card">
<h3>{vehicle.vehicle_number}</h3>
</div>
);
})}
</div>
</div>
<div className="vehicles-data">
<div className="data-header">
<h2>Vehicle Data</h2>
</div>
<div className="data-list">
<Header />
<DataDisplay readings={readings}/>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;
@@ -0,0 +1,220 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.overlay-header {
padding: 24px 24px 16px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.overlay-header h2 {
margin: 0;
color: #667eea;
font-size: 24px;
}
.close-button {
background: none;
border: none;
font-size: 32px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.close-button:hover {
background: #f5f5f5;
color: #333;
}
.password-form {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.cancel-button,
.submit-button {
flex: 1;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.cancel-button {
background: white;
border: 2px solid #ddd;
color: #666;
}
.cancel-button:hover:not(:disabled) {
border-color: #999;
color: #333;
}
.submit-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.success-message {
padding: 40px 24px;
text-align: center;
}
.success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 16px;
animation: scaleIn 0.4s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.success-message p {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
}
@media (max-width: 540px) {
.overlay-content {
max-width: 100%;
}
.password-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
}
@@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import './ChangePasswordOverlay.css';
function ChangePasswordOverlay({ onClose }) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { changePassword } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validation
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (oldPassword === newPassword) {
setError('New password must be different from old password');
return;
}
setIsLoading(true);
const result = await changePassword(oldPassword, newPassword);
if (result.success) {
setSuccess(true);
setTimeout(() => {
onClose();
}, 2000);
} else {
// Extract error message
let errorMsg = 'Failed to change password';
if (result.error.old_password) {
errorMsg = result.error.old_password[0];
} else if (result.error.new_password) {
errorMsg = result.error.new_password[0];
} else if (result.error.detail) {
errorMsg = result.error.detail;
}
setError(errorMsg);
setIsLoading(false);
}
};
const handleOverlayClick = (e) => {
if (e.target.className === 'overlay') {
onClose();
}
};
return (
<div className="overlay" onClick={handleOverlayClick}>
<div className="overlay-content">
<div className="overlay-header">
<h2>Change Password</h2>
<button className="close-button" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
{success ? (
<div className="success-message">
<div className="success-icon"></div>
<p>Password changed successfully!</p>
</div>
) : (
<form onSubmit={handleSubmit} className="password-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="old-password">Current Password</label>
<input
id="old-password"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Enter current password"
required
autoFocus
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="confirm-password">Confirm New Password</label>
<input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter new password"
required
disabled={isLoading}
/>
</div>
<div className="form-actions">
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default ChangePasswordOverlay;
+141
View File
@@ -0,0 +1,141 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
margin: 0 0 10px 0;
color: #667eea;
font-size: 32px;
}
.login-header p {
margin: 0;
color: #666;
font-size: 16px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 10px;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.login-header h1 {
font-size: 28px;
}
}
+90
View File
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import './Login.css';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/');
} else {
setError(result.error);
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>ScalesApp</h1>
<p>Sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoFocus
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
export default Login;