Fix jwt-token

This commit is contained in:
2025-09-13 15:32:50 +02:00
parent 3a6e98d792
commit cd159239ed
7 changed files with 539 additions and 67 deletions

View File

@@ -1028,6 +1028,8 @@ const UsersSettings = ({ tenantConfig, onRefresh }) => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreateUser, setShowCreateUser] = useState(false); const [showCreateUser, setShowCreateUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const authProvider = tenantConfig?.auth_provider; const authProvider = tenantConfig?.auth_provider;
const canManageUsers = authProvider === 'local'; // Only local auth allows user management const canManageUsers = authProvider === 'local'; // Only local auth allows user management
@@ -1246,13 +1248,30 @@ const UsersSettings = ({ tenantConfig, onRefresh }) => {
}} }}
/> />
)} )}
{/* Edit User Modal for Local Auth */}
{showEditUser && canManageUsers && editingUser && (
<EditUserModal
isOpen={showEditUser}
onClose={() => {
setShowEditUser(false);
setEditingUser(null);
}}
user={editingUser}
onUserUpdated={() => {
fetchUsers();
setShowEditUser(false);
setEditingUser(null);
}}
/>
)}
</div> </div>
); );
// Helper functions for local user management // Helper functions for local user management
const handleEditUser = (user) => { const handleEditUser = (user) => {
// TODO: Implement edit user modal setEditingUser(user);
toast.info('Edit user functionality coming soon'); setShowEditUser(true);
}; };
const handleToggleUserStatus = async (user) => { const handleToggleUserStatus = async (user) => {
@@ -1418,4 +1437,220 @@ const CreateUserModal = ({ isOpen, onClose, onUserCreated }) => {
); );
}; };
// Edit User Modal Component (for local auth only)
const EditUserModal = ({ isOpen, onClose, user, onUserUpdated }) => {
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'viewer',
password: '',
confirmPassword: ''
});
const [saving, setSaving] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
if (user) {
setFormData({
email: user.email || '',
first_name: user.first_name || '',
last_name: user.last_name || '',
phone: user.phone || '',
role: user.role || 'viewer',
password: '',
confirmPassword: ''
});
}
}, [user]);
const handleSubmit = async (e) => {
e.preventDefault();
// Validate passwords if provided
if (formData.password && formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
return;
}
setSaving(true);
try {
// Prepare update data (exclude password if empty)
const updateData = {
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone,
role: formData.role
};
// Only include password if it's provided
if (formData.password.trim()) {
updateData.password = formData.password;
}
await api.put(`/tenant/users/${user.id}`, updateData);
toast.success('User updated successfully');
onUserUpdated();
} catch (error) {
const message = error.response?.data?.message || 'Failed to update user';
toast.error(message);
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<form onSubmit={handleSubmit}>
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Edit User: {user?.username}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData(prev => ({ ...prev, first_name: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData(prev => ({ ...prev, last_name: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
value={formData.role}
onChange={(e) => setFormData(prev => ({ ...prev, role: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="border-t pt-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Change Password (Optional)</h4>
<div>
<label className="block text-sm font-medium text-gray-700">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pr-10"
placeholder="Leave empty to keep current password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700">Confirm New Password</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pr-10"
placeholder="Confirm new password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
disabled={saving}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{saving ? 'Updating...' : 'Update User'}
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default Settings; export default Settings;

View File

@@ -0,0 +1,79 @@
/**
* Migration: Add tenant support to devices table
* This migration adds tenant_id field to devices for multi-tenant isolation
*/
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Check if tenant_id column already exists
const tableDescription = await queryInterface.describeTable('devices');
if (!tableDescription.tenant_id) {
// Add tenant_id column to devices table
await queryInterface.addColumn('devices', 'tenant_id', {
type: Sequelize.INTEGER,
allowNull: true, // Nullable for backward compatibility
references: {
model: 'tenants',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
comment: 'Foreign key to tenants table for multi-tenant isolation'
});
// Add index for tenant_id for better query performance
try {
await queryInterface.addIndex('devices', ['tenant_id'], {
name: 'devices_tenant_id_idx'
});
console.log('✅ Added index on devices.tenant_id');
} catch (error) {
if (error.parent?.code === '42P07') { // Index already exists
console.log('⚠️ Index devices_tenant_id already exists, skipping...');
} else {
throw error;
}
}
// Associate existing devices with default tenant (backward compatibility)
const defaultTenant = await queryInterface.sequelize.query(
'SELECT id FROM tenants WHERE slug = :slug',
{
replacements: { slug: 'default' },
type: Sequelize.QueryTypes.SELECT
}
);
if (defaultTenant.length > 0) {
await queryInterface.sequelize.query(
'UPDATE devices SET tenant_id = :tenantId WHERE tenant_id IS NULL',
{
replacements: { tenantId: defaultTenant[0].id },
type: Sequelize.QueryTypes.UPDATE
}
);
console.log('✅ Associated existing devices with default tenant');
}
console.log('✅ Added tenant_id field to devices table');
} else {
console.log('⚠️ Column tenant_id already exists in devices table, skipping...');
}
},
async down(queryInterface, Sequelize) {
// Remove index
try {
await queryInterface.removeIndex('devices', 'devices_tenant_id_idx');
} catch (error) {
console.log('⚠️ Index devices_tenant_id_idx does not exist, skipping...');
}
// Remove column
await queryInterface.removeColumn('devices', 'tenant_id');
console.log('✅ Removed tenant_id field from devices table');
}
};

View File

@@ -39,6 +39,15 @@ module.exports = (sequelize) => {
defaultValue: false, defaultValue: false,
comment: 'Whether the device is approved to send data' comment: 'Whether the device is approved to send data'
}, },
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
},
comment: 'Foreign key to tenants table for multi-tenant isolation'
},
last_heartbeat: { last_heartbeat: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true, allowNull: true,

View File

@@ -49,6 +49,9 @@ AlertLog.belongsTo(DroneDetection, { foreignKey: 'detection_id', as: 'detection'
Tenant.hasMany(User, { foreignKey: 'tenant_id', as: 'users' }); Tenant.hasMany(User, { foreignKey: 'tenant_id', as: 'users' });
User.belongsTo(Tenant, { foreignKey: 'tenant_id', as: 'tenant' }); User.belongsTo(Tenant, { foreignKey: 'tenant_id', as: 'tenant' });
Tenant.hasMany(Device, { foreignKey: 'tenant_id', as: 'devices' });
Device.belongsTo(Tenant, { foreignKey: 'tenant_id', as: 'tenant' });
module.exports = { module.exports = {
sequelize, sequelize,
Device, Device,

View File

@@ -125,45 +125,15 @@ async function handleHeartbeat(req, res) {
let device = await Device.findOne({ where: { id: deviceId } }); let device = await Device.findOne({ where: { id: deviceId } });
if (!device) { if (!device) {
// Create new device as unapproved with coordinates if provided // Device not found - reject heartbeat and require manual registration
const deviceData = { console.log(`<EFBFBD> Heartbeat rejected from unknown device ${deviceId} - device must be manually registered first`);
id: deviceId,
name: `Device ${deviceId}`,
last_heartbeat: new Date(),
is_approved: false
};
// Add coordinates if provided in heartbeat return res.status(404).json({
if (geo_lat && geo_lon) {
deviceData.geo_lat = geo_lat;
deviceData.geo_lon = geo_lon;
console.log(`📍 Setting device coordinates: ${geo_lat}, ${geo_lon}`);
}
// Add location description if provided
if (location_description) {
deviceData.location_description = location_description;
console.log(`📍 Setting device location: ${location_description}`);
}
device = await Device.create(deviceData);
// Emit notification for new device requiring approval
req.io.emit('new_device_pending', {
device_id: deviceId,
device_key: key,
timestamp: new Date().toISOString(),
message: `New device ${deviceId} (${key}) requires approval`
});
console.log(`⚠️ New unapproved device ${deviceId} created, awaiting approval`);
return res.status(202).json({
success: false, success: false,
error: 'Device not approved', error: 'Device not registered',
message: 'Device has been registered but requires approval before it can send data', message: 'Device not found. Please register the device manually through the UI before sending data.',
device_id: deviceId, device_id: deviceId,
approval_required: true registration_required: true
}); });
} }
@@ -243,32 +213,15 @@ async function handleDetection(req, res) {
let device = await Device.findOne({ where: { id: detectionData.device_id } }); let device = await Device.findOne({ where: { id: detectionData.device_id } });
if (!device) { if (!device) {
// Create new device as unapproved // Device not found - reject detection and require manual registration
device = await Device.create({ console.log(`🚫 Detection rejected from unknown device ${detectionData.device_id} - device must be manually registered first`);
id: detectionData.device_id,
name: `Device ${detectionData.device_id}`,
geo_lat: detectionData.geo_lat || 0,
geo_lon: detectionData.geo_lon || 0,
last_heartbeat: new Date(),
is_approved: false,
is_active: false
});
// Emit notification for new device requiring approval return res.status(404).json({
req.io.emit('new_device_pending', {
device_id: detectionData.device_id,
timestamp: new Date().toISOString(),
message: `New device ${detectionData.device_id} requires approval`
});
console.log(`⚠️ New unapproved device ${detectionData.device_id} created, awaiting approval`);
return res.status(202).json({
success: false, success: false,
error: 'Device not approved', error: 'Device not registered',
message: 'Device has been registered but requires approval before it can send data', message: 'Device not found. Please register the device manually through the UI before sending data.',
device_id: detectionData.device_id, device_id: detectionData.device_id,
approval_required: true registration_required: true
}); });
} }

View File

@@ -1,14 +1,18 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const Joi = require('joi'); const Joi = require('joi');
const { Device, DroneDetection, Heartbeat } = require('../models'); const { Device, DroneDetection, Heartbeat, Tenant } = require('../models');
const { validateRequest } = require('../middleware/validation'); const { validateRequest } = require('../middleware/validation');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const MultiTenantAuth = require('../middleware/multi-tenant-auth');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
// Initialize multi-tenant auth
const multiAuth = new MultiTenantAuth();
// Validation schema for device // Validation schema for device
const deviceSchema = Joi.object({ const deviceSchema = Joi.object({
id: Joi.number().integer().required(), id: Joi.number().integer().required().min(1).max(999999999), // Device ID is required for manual registration
name: Joi.string().max(255).allow('').optional(), name: Joi.string().max(255).allow('').optional(),
geo_lat: Joi.number().min(-90).max(90).optional(), geo_lat: Joi.number().min(-90).max(90).optional(),
geo_lon: Joi.number().min(-180).max(180).optional(), geo_lon: Joi.number().min(-180).max(180).optional(),
@@ -34,6 +38,23 @@ const updateDeviceSchema = Joi.object({
// GET /api/devices - Get all devices // GET /api/devices - Get all devices
router.get('/', authenticateToken, async (req, res) => { router.get('/', authenticateToken, async (req, res) => {
try { try {
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
const { const {
include_stats = false, include_stats = false,
active_only = false, active_only = false,
@@ -41,7 +62,7 @@ router.get('/', authenticateToken, async (req, res) => {
offset = 0 offset = 0
} = req.query; } = req.query;
const whereClause = {}; const whereClause = { tenant_id: tenant.id };
if (active_only === 'true') { if (active_only === 'true') {
whereClause.is_active = true; whereClause.is_active = true;
} }
@@ -201,7 +222,28 @@ router.get('/map', authenticateToken, async (req, res) => {
// GET /api/devices/:id - Get specific device // GET /api/devices/:id - Get specific device
router.get('/:id', authenticateToken, async (req, res) => { router.get('/:id', authenticateToken, async (req, res) => {
try { try {
const device = await Device.findByPk(req.params.id, { // Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
const device = await Device.findOne({
where: {
id: req.params.id,
tenant_id: tenant.id
},
include: [ include: [
{ {
model: Heartbeat, model: Heartbeat,
@@ -221,7 +263,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
if (!device) { if (!device) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Device not found' message: 'Device not found in your tenant'
}); });
} }
@@ -243,7 +285,49 @@ router.get('/:id', authenticateToken, async (req, res) => {
// POST /api/devices - Create new device (admin only) // POST /api/devices - Create new device (admin only)
router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => { router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => {
try { try {
const device = await Device.create(req.body); // Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
// Check if device ID already exists in this tenant
const existingDevice = await Device.findOne({
where: {
id: req.body.id,
tenant_id: tenant.id
}
});
if (existingDevice) {
return res.status(409).json({
success: false,
message: 'Device with this ID already exists in your tenant'
});
}
// Create device with tenant association
const deviceData = {
...req.body,
tenant_id: tenant.id,
is_approved: true, // Manually created devices are automatically approved
is_active: true
};
const device = await Device.create(deviceData);
console.log(`✅ Device ${device.id} created in tenant "${tenantId}" by user "${req.user.username}"`);
res.status(201).json({ res.status(201).json({
success: true, success: true,

View File

@@ -564,6 +564,115 @@ router.put('/users/:userId/status', authenticateToken, requirePermissions(['user
} }
}); });
/**
* PUT /tenant/users/:userId
* Update user details (user admin or higher, local auth only)
*/
router.put('/users/:userId', authenticateToken, requirePermissions(['users.edit']), async (req, res) => {
try {
// Determine tenant from request
const tenantId = await multiAuth.determineTenant(req);
if (!tenantId) {
return res.status(400).json({
success: false,
message: 'Unable to determine tenant'
});
}
const tenant = await Tenant.findOne({ where: { slug: tenantId } });
if (!tenant) {
return res.status(404).json({
success: false,
message: 'Tenant not found'
});
}
// Check if tenant uses local authentication
if (tenant.auth_provider !== 'local') {
return res.status(400).json({
success: false,
message: `User management is only available for local authentication. This tenant uses ${tenant.auth_provider}.`
});
}
const { userId } = req.params;
const { email, first_name, last_name, phone, role, password } = req.body;
// Find user in this tenant
const user = await User.findOne({
where: {
id: userId,
tenant_id: tenant.id
}
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found in this tenant'
});
}
// Prepare update data
const updateData = {};
if (email !== undefined) updateData.email = email;
if (first_name !== undefined) updateData.first_name = first_name;
if (last_name !== undefined) updateData.last_name = last_name;
if (phone !== undefined) updateData.phone = phone;
// Role update with permission check
if (role !== undefined) {
// Check if current user has permission to assign this role
if (!hasPermission(req.user.role, 'users.edit')) {
return res.status(403).json({
success: false,
message: 'Insufficient permissions to change user roles'
});
}
updateData.role = role;
}
// Password update
if (password && password.trim()) {
const bcrypt = require('bcryptjs');
updateData.password_hash = await bcrypt.hash(password.trim(), 10);
}
// Update user
await user.update(updateData);
console.log(`✅ User "${user.username}" updated in tenant "${tenantId}" by admin "${req.user.username}"`);
// Return updated user data (without password)
const userData = {
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
phone: user.phone,
role: user.role,
is_active: user.is_active,
created_at: user.created_at,
updated_at: user.updated_at
};
res.json({
success: true,
message: 'User updated successfully',
data: userData
});
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({
success: false,
message: 'Failed to update user'
});
}
});
/** /**
* GET /tenant/auth * GET /tenant/auth
* Get authentication configuration (auth admins or higher) * Get authentication configuration (auth admins or higher)