From cd159239ed2e32815a7f2fc5b6725ac256d28d02 Mon Sep 17 00:00:00 2001 From: Alexander Borg Date: Sat, 13 Sep 2025 15:32:50 +0200 Subject: [PATCH] Fix jwt-token --- client/src/pages/Settings.jsx | 239 +++++++++++++++++- ...0250913000002-add-device-tenant-support.js | 79 ++++++ server/models/Device.js | 9 + server/models/index.js | 3 + server/routes/detectors.js | 71 +----- server/routes/device.js | 96 ++++++- server/routes/tenant.js | 109 ++++++++ 7 files changed, 539 insertions(+), 67 deletions(-) create mode 100644 server/migrations/20250913000002-add-device-tenant-support.js diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx index 74c7c75..fa29fca 100644 --- a/client/src/pages/Settings.jsx +++ b/client/src/pages/Settings.jsx @@ -1028,6 +1028,8 @@ const UsersSettings = ({ tenantConfig, onRefresh }) => { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showCreateUser, setShowCreateUser] = useState(false); + const [showEditUser, setShowEditUser] = useState(false); + const [editingUser, setEditingUser] = useState(null); const authProvider = tenantConfig?.auth_provider; 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 && ( + { + setShowEditUser(false); + setEditingUser(null); + }} + user={editingUser} + onUserUpdated={() => { + fetchUsers(); + setShowEditUser(false); + setEditingUser(null); + }} + /> + )} ); // Helper functions for local user management const handleEditUser = (user) => { - // TODO: Implement edit user modal - toast.info('Edit user functionality coming soon'); + setEditingUser(user); + setShowEditUser(true); }; 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 ( +
+
+
+ +
+
+
+
+
+

+ Edit User: {user?.username} +

+ +
+
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + +
+ +
+

Change Password (Optional)

+ +
+ +
+ 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" + /> + +
+
+ +
+ +
+ 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" + /> + +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+ ); +}; + export default Settings; diff --git a/server/migrations/20250913000002-add-device-tenant-support.js b/server/migrations/20250913000002-add-device-tenant-support.js new file mode 100644 index 0000000..4af6e35 --- /dev/null +++ b/server/migrations/20250913000002-add-device-tenant-support.js @@ -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'); + } +}; diff --git a/server/models/Device.js b/server/models/Device.js index 24b8a7a..c4c633a 100644 --- a/server/models/Device.js +++ b/server/models/Device.js @@ -39,6 +39,15 @@ module.exports = (sequelize) => { defaultValue: false, 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: { type: DataTypes.DATE, allowNull: true, diff --git a/server/models/index.js b/server/models/index.js index 2ac4296..a219859 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -49,6 +49,9 @@ AlertLog.belongsTo(DroneDetection, { foreignKey: 'detection_id', as: 'detection' Tenant.hasMany(User, { foreignKey: 'tenant_id', as: 'users' }); 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 = { sequelize, Device, diff --git a/server/routes/detectors.js b/server/routes/detectors.js index 3a72f41..5df9627 100644 --- a/server/routes/detectors.js +++ b/server/routes/detectors.js @@ -125,45 +125,15 @@ async function handleHeartbeat(req, res) { let device = await Device.findOne({ where: { id: deviceId } }); if (!device) { - // Create new device as unapproved with coordinates if provided - const deviceData = { - id: deviceId, - name: `Device ${deviceId}`, - last_heartbeat: new Date(), - is_approved: false - }; + // Device not found - reject heartbeat and require manual registration + console.log(`� Heartbeat rejected from unknown device ${deviceId} - device must be manually registered first`); - // Add coordinates if provided in heartbeat - 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({ + return res.status(404).json({ success: false, - error: 'Device not approved', - message: 'Device has been registered but requires approval before it can send data', + error: 'Device not registered', + message: 'Device not found. Please register the device manually through the UI before sending data.', 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 } }); if (!device) { - // Create new device as unapproved - device = await Device.create({ - 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 - }); + // Device not found - reject detection and require manual registration + console.log(`🚫 Detection rejected from unknown device ${detectionData.device_id} - device must be manually registered first`); - // Emit notification for new device requiring approval - 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({ + return res.status(404).json({ success: false, - error: 'Device not approved', - message: 'Device has been registered but requires approval before it can send data', + error: 'Device not registered', + message: 'Device not found. Please register the device manually through the UI before sending data.', device_id: detectionData.device_id, - approval_required: true + registration_required: true }); } diff --git a/server/routes/device.js b/server/routes/device.js index 6ece4e9..4ab67d2 100644 --- a/server/routes/device.js +++ b/server/routes/device.js @@ -1,14 +1,18 @@ const express = require('express'); const router = express.Router(); const Joi = require('joi'); -const { Device, DroneDetection, Heartbeat } = require('../models'); +const { Device, DroneDetection, Heartbeat, Tenant } = require('../models'); const { validateRequest } = require('../middleware/validation'); const { authenticateToken } = require('../middleware/auth'); +const MultiTenantAuth = require('../middleware/multi-tenant-auth'); const { Op } = require('sequelize'); +// Initialize multi-tenant auth +const multiAuth = new MultiTenantAuth(); + // Validation schema for device 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(), geo_lat: Joi.number().min(-90).max(90).optional(), geo_lon: Joi.number().min(-180).max(180).optional(), @@ -34,6 +38,23 @@ const updateDeviceSchema = Joi.object({ // GET /api/devices - Get all devices router.get('/', authenticateToken, 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' + }); + } + const { include_stats = false, active_only = false, @@ -41,7 +62,7 @@ router.get('/', authenticateToken, async (req, res) => { offset = 0 } = req.query; - const whereClause = {}; + const whereClause = { tenant_id: tenant.id }; if (active_only === 'true') { whereClause.is_active = true; } @@ -201,7 +222,28 @@ router.get('/map', authenticateToken, async (req, res) => { // GET /api/devices/:id - Get specific device router.get('/:id', authenticateToken, async (req, res) => { 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: [ { model: Heartbeat, @@ -221,7 +263,7 @@ router.get('/:id', authenticateToken, async (req, res) => { if (!device) { return res.status(404).json({ 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) router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => { 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({ success: true, diff --git a/server/routes/tenant.js b/server/routes/tenant.js index 7850ee0..af5c696 100644 --- a/server/routes/tenant.js +++ b/server/routes/tenant.js @@ -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 authentication configuration (auth admins or higher)