From 03dc119a15f597823825cf929d6b27714a8a24e5 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 22 Mar 2025 22:11:03 -0400 Subject: [PATCH] Initial permissions framework and setup --- inventory-server/auth/permissions.js | 113 +++++ inventory-server/auth/routes.js | 403 ++++++++++++++++++ inventory-server/auth/schema.sql | 128 +++++- inventory-server/auth/server.js | 76 +++- inventory-server/scripts/reset-db.js | 2 +- inventory/package-lock.json | 30 +- inventory/package.json | 7 +- inventory/src/App.tsx | 10 +- .../src/components/common/PermissionGuard.tsx | 82 ++++ .../settings/PermissionSelector.tsx | 130 ++++++ .../src/components/settings/UserForm.tsx | 248 +++++++++++ .../src/components/settings/UserList.tsx | 92 ++++ .../components/settings/UserManagement.tsx | 253 +++++++++++ inventory/src/components/ui/form.tsx | 176 ++++++++ inventory/src/contexts/AuthContext.tsx | 140 ++++++ inventory/src/hooks/usePermissions.ts | 80 ++++ inventory/src/pages/Login.tsx | 2 +- inventory/src/pages/Orders.tsx | 322 -------------- inventory/src/pages/Settings.tsx | 8 + 19 files changed, 1961 insertions(+), 341 deletions(-) create mode 100644 inventory-server/auth/permissions.js create mode 100644 inventory-server/auth/routes.js create mode 100644 inventory/src/components/common/PermissionGuard.tsx create mode 100644 inventory/src/components/settings/PermissionSelector.tsx create mode 100644 inventory/src/components/settings/UserForm.tsx create mode 100644 inventory/src/components/settings/UserList.tsx create mode 100644 inventory/src/components/settings/UserManagement.tsx create mode 100644 inventory/src/components/ui/form.tsx create mode 100644 inventory/src/contexts/AuthContext.tsx create mode 100644 inventory/src/hooks/usePermissions.ts delete mode 100644 inventory/src/pages/Orders.tsx diff --git a/inventory-server/auth/permissions.js b/inventory-server/auth/permissions.js new file mode 100644 index 0000000..44c67c1 --- /dev/null +++ b/inventory-server/auth/permissions.js @@ -0,0 +1,113 @@ +const pool = global.pool; + +/** + * Check if a user has a specific permission + * @param {number} userId - The user ID to check + * @param {string} permissionCode - The permission code to check + * @returns {Promise} - Whether the user has the permission + */ +async function checkPermission(userId, permissionCode) { + try { + // First check if the user is an admin + const adminResult = await pool.query( + 'SELECT is_admin FROM users WHERE id = $1', + [userId] + ); + + // If user is admin, automatically grant permission + if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) { + return true; + } + + // Otherwise check for specific permission + const result = await pool.query( + `SELECT COUNT(*) AS has_permission + FROM user_permissions up + JOIN permissions p ON up.permission_id = p.id + WHERE up.user_id = $1 AND p.code = $2`, + [userId, permissionCode] + ); + + return result.rows[0].has_permission > 0; + } catch (error) { + console.error('Error checking permission:', error); + return false; + } +} + +/** + * Middleware to require a specific permission + * @param {string} permissionCode - The permission code required + * @returns {Function} - Express middleware function + */ +function requirePermission(permissionCode) { + return async (req, res, next) => { + try { + // Check if user is authenticated + if (!req.user || !req.user.id) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const hasPermission = await checkPermission(req.user.id, permissionCode); + + if (!hasPermission) { + return res.status(403).json({ + error: 'Insufficient permissions', + requiredPermission: permissionCode + }); + } + + next(); + } catch (error) { + console.error('Permission middleware error:', error); + res.status(500).json({ error: 'Server error checking permissions' }); + } + }; +} + +/** + * Get all permissions for a user + * @param {number} userId - The user ID + * @returns {Promise} - Array of permission codes + */ +async function getUserPermissions(userId) { + try { + // Check if user is admin + const adminResult = await pool.query( + 'SELECT is_admin FROM users WHERE id = $1', + [userId] + ); + + if (adminResult.rows.length === 0) { + return []; + } + + const isAdmin = adminResult.rows[0].is_admin; + + if (isAdmin) { + // Admin gets all permissions + const allPermissions = await pool.query('SELECT code FROM permissions'); + return allPermissions.rows.map(p => p.code); + } else { + // Get assigned permissions + const permissions = await pool.query( + `SELECT p.code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1`, + [userId] + ); + + return permissions.rows.map(p => p.code); + } + } catch (error) { + console.error('Error getting user permissions:', error); + return []; + } +} + +module.exports = { + checkPermission, + requirePermission, + getUserPermissions +}; \ No newline at end of file diff --git a/inventory-server/auth/routes.js b/inventory-server/auth/routes.js new file mode 100644 index 0000000..71aa28b --- /dev/null +++ b/inventory-server/auth/routes.js @@ -0,0 +1,403 @@ +const express = require('express'); +const router = express.Router(); +const pool = global.pool; +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { requirePermission, getUserPermissions } = require('./permissions'); + +// Authentication middleware +const authenticate = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const token = authHeader.split(' ')[1]; + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Get user from database + const result = await pool.query( + 'SELECT id, username, is_admin FROM users WHERE id = $1', + [decoded.userId] + ); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'User not found' }); + } + + // Attach user to request + req.user = result.rows[0]; + next(); + } catch (error) { + console.error('Authentication error:', error); + res.status(401).json({ error: 'Invalid token' }); + } +}; + +// Login route +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + // Get user from database + const result = await pool.query( + 'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1', + [username] + ); + + if (result.rows.length === 0) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + const user = result.rows[0]; + + // Check if user is active + if (!user.is_active) { + return res.status(403).json({ error: 'Account is inactive' }); + } + + // Verify password + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + // Update last login + await pool.query( + 'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', + [user.id] + ); + + // Generate JWT + const token = jwt.sign( + { userId: user.id, username: user.username }, + process.env.JWT_SECRET, + { expiresIn: '8h' } + ); + + // Get user permissions + const permissions = await getUserPermissions(user.id); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + is_admin: user.is_admin, + permissions + } + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get current user +router.get('/me', authenticate, async (req, res) => { + try { + // Get user permissions + const permissions = await getUserPermissions(req.user.id); + + res.json({ + id: req.user.id, + username: req.user.username, + is_admin: req.user.is_admin, + permissions + }); + } catch (error) { + console.error('Error getting current user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all users +router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT id, username, email, is_admin, is_active, created_at, last_login + FROM users + ORDER BY username + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting users:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get user with permissions +router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const userId = req.params.id; + + // Get user details + const userResult = await pool.query(` + SELECT id, username, email, is_admin, is_active, created_at, last_login + FROM users + WHERE id = $1 + `, [userId]); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Get user permissions + const permissionsResult = await pool.query(` + SELECT p.id, p.name, p.code, p.category, p.description + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + ORDER BY p.category, p.name + `, [userId]); + + // Combine user and permissions + const user = { + ...userResult.rows[0], + permissions: permissionsResult.rows + }; + + res.json(user); + } catch (error) { + console.error('Error getting user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Create new user +router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => { + const client = await pool.connect(); + + try { + const { username, email, password, is_admin, is_active, permissions } = req.body; + + // Validate required fields + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + // Check if username is taken + const existingUser = await client.query( + 'SELECT id FROM users WHERE username = $1', + [username] + ); + + if (existingUser.rows.length > 0) { + return res.status(400).json({ error: 'Username already exists' }); + } + + // Start transaction + await client.query('BEGIN'); + + // Hash password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Insert new user + const userResult = await client.query(` + INSERT INTO users (username, email, password, is_admin, is_active, created_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + RETURNING id + `, [username, email || null, hashedPassword, !!is_admin, is_active !== false]); + + const userId = userResult.rows[0].id; + + // Assign permissions if provided and not admin + if (!is_admin && Array.isArray(permissions) && permissions.length > 0) { + const permissionValues = permissions + .map(permId => `(${userId}, ${parseInt(permId, 10)})`) + .join(','); + + await client.query(` + INSERT INTO user_permissions (user_id, permission_id) + VALUES ${permissionValues} + ON CONFLICT DO NOTHING + `); + } + + await client.query('COMMIT'); + + res.status(201).json({ + id: userId, + message: 'User created successfully' + }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error creating user:', error); + res.status(500).json({ error: 'Server error' }); + } finally { + client.release(); + } +}); + +// Update user +router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => { + const client = await pool.connect(); + + try { + const userId = req.params.id; + const { username, email, password, is_admin, is_active, permissions } = req.body; + + // Check if user exists + const userExists = await client.query( + 'SELECT id FROM users WHERE id = $1', + [userId] + ); + + if (userExists.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + // Start transaction + await client.query('BEGIN'); + + // Build update fields + const updateFields = []; + const updateValues = [userId]; // First parameter is the user ID + let paramIndex = 2; + + if (username !== undefined) { + updateFields.push(`username = $${paramIndex++}`); + updateValues.push(username); + } + + if (email !== undefined) { + updateFields.push(`email = $${paramIndex++}`); + updateValues.push(email || null); + } + + if (is_admin !== undefined) { + updateFields.push(`is_admin = $${paramIndex++}`); + updateValues.push(!!is_admin); + } + + if (is_active !== undefined) { + updateFields.push(`is_active = $${paramIndex++}`); + updateValues.push(!!is_active); + } + + // Update password if provided + if (password) { + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + updateFields.push(`password = $${paramIndex++}`); + updateValues.push(hashedPassword); + } + + // Update user if there are fields to update + if (updateFields.length > 0) { + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + await client.query(` + UPDATE users + SET ${updateFields.join(', ')} + WHERE id = $1 + `, updateValues); + } + + // Update permissions if provided + if (Array.isArray(permissions)) { + // First remove existing permissions + await client.query( + 'DELETE FROM user_permissions WHERE user_id = $1', + [userId] + ); + + // Add new permissions if any and not admin + const newIsAdmin = is_admin !== undefined ? is_admin : (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin; + + if (!newIsAdmin && permissions.length > 0) { + const permissionValues = permissions + .map(permId => `(${userId}, ${parseInt(permId, 10)})`) + .join(','); + + await client.query(` + INSERT INTO user_permissions (user_id, permission_id) + VALUES ${permissionValues} + ON CONFLICT DO NOTHING + `); + } + } + + await client.query('COMMIT'); + + res.json({ message: 'User updated successfully' }); + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error updating user:', error); + res.status(500).json({ error: 'Server error' }); + } finally { + client.release(); + } +}); + +// Delete user +router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => { + try { + const userId = req.params.id; + + // Check that user is not deleting themselves + if (req.user.id === parseInt(userId, 10)) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + // Delete user (this will cascade to user_permissions due to FK constraints) + const result = await pool.query( + 'DELETE FROM users WHERE id = $1 RETURNING id', + [userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ message: 'User deleted successfully' }); + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all permissions grouped by category +router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT category, json_agg( + json_build_object( + 'id', id, + 'name', name, + 'code', code, + 'description', description + ) ORDER BY name + ) as permissions + FROM permissions + GROUP BY category + ORDER BY category + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting permissions:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Get all permissions +router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => { + try { + const result = await pool.query(` + SELECT * + FROM permissions + ORDER BY category, name + `); + + res.json(result.rows); + } catch (error) { + console.error('Error getting permissions:', error); + res.status(500).json({ error: 'Server error' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/auth/schema.sql b/inventory-server/auth/schema.sql index 32a9562..836b17b 100644 --- a/inventory-server/auth/schema.sql +++ b/inventory-server/auth/schema.sql @@ -3,4 +3,130 @@ CREATE TABLE users ( username VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); \ No newline at end of file +); + + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Sequence and defined type for users table if not exists +CREATE SEQUENCE IF NOT EXISTS users_id_seq; + +-- Update users table with new fields +ALTER TABLE "public"."users" + ADD COLUMN IF NOT EXISTS "email" varchar UNIQUE, + ADD COLUMN IF NOT EXISTS "is_admin" boolean DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS "is_active" boolean DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS "last_login" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP; + +-- Create permissions table +CREATE TABLE IF NOT EXISTS "public"."permissions" ( + "id" SERIAL PRIMARY KEY, + "name" varchar NOT NULL UNIQUE, + "code" varchar NOT NULL UNIQUE, + "description" text, + "category" varchar NOT NULL, + "created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + "updated_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + +-- Create user_permissions junction table +CREATE TABLE IF NOT EXISTS "public"."user_permissions" ( + "user_id" int4 NOT NULL REFERENCES "public"."users"("id") ON DELETE CASCADE, + "permission_id" int4 NOT NULL REFERENCES "public"."permissions"("id") ON DELETE CASCADE, + "created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("user_id", "permission_id") +); + +-- Add triggers for updated_at on users and permissions +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_permissions_updated_at ON permissions; +CREATE TRIGGER update_permissions_updated_at + BEFORE UPDATE ON permissions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Insert default permissions by page +-- Core page access permissions +INSERT INTO permissions (name, code, description, category) VALUES + ('Dashboard Access', 'access:dashboard', 'Can access the Dashboard page', 'Pages'), + ('Products Access', 'access:products', 'Can access the Products page', 'Pages'), + ('Categories Access', 'access:categories', 'Can access the Categories page', 'Pages'), + ('Vendors Access', 'access:vendors', 'Can access the Vendors page', 'Pages'), + ('Analytics Access', 'access:analytics', 'Can access the Analytics page', 'Pages'), + ('Forecasting Access', 'access:forecasting', 'Can access the Forecasting page', 'Pages'), + ('Purchase Orders Access', 'access:purchase_orders', 'Can access the Purchase Orders page', 'Pages'), + ('Import Access', 'access:import', 'Can access the Import page', 'Pages'), + ('Settings Access', 'access:settings', 'Can access the Settings page', 'Pages'), + ('AI Validation Debug Access', 'access:ai_validation_debug', 'Can access the AI Validation Debug page', 'Pages') +ON CONFLICT (code) DO NOTHING; + +-- Granular permissions for Products +INSERT INTO permissions (name, code, description, category) VALUES + ('View Products', 'view:products', 'Can view product listings', 'Products'), + ('Create Products', 'create:products', 'Can create new products', 'Products'), + ('Edit Products', 'edit:products', 'Can edit product details', 'Products'), + ('Delete Products', 'delete:products', 'Can delete products', 'Products') +ON CONFLICT (code) DO NOTHING; + +-- Granular permissions for Categories +INSERT INTO permissions (name, code, description, category) VALUES + ('View Categories', 'view:categories', 'Can view categories', 'Categories'), + ('Create Categories', 'create:categories', 'Can create new categories', 'Categories'), + ('Edit Categories', 'edit:categories', 'Can edit categories', 'Categories'), + ('Delete Categories', 'delete:categories', 'Can delete categories', 'Categories') +ON CONFLICT (code) DO NOTHING; + +-- Granular permissions for Vendors +INSERT INTO permissions (name, code, description, category) VALUES + ('View Vendors', 'view:vendors', 'Can view vendors', 'Vendors'), + ('Create Vendors', 'create:vendors', 'Can create new vendors', 'Vendors'), + ('Edit Vendors', 'edit:vendors', 'Can edit vendors', 'Vendors'), + ('Delete Vendors', 'delete:vendors', 'Can delete vendors', 'Vendors') +ON CONFLICT (code) DO NOTHING; + +-- Granular permissions for Purchase Orders +INSERT INTO permissions (name, code, description, category) VALUES + ('View Purchase Orders', 'view:purchase_orders', 'Can view purchase orders', 'Purchase Orders'), + ('Create Purchase Orders', 'create:purchase_orders', 'Can create new purchase orders', 'Purchase Orders'), + ('Edit Purchase Orders', 'edit:purchase_orders', 'Can edit purchase orders', 'Purchase Orders'), + ('Delete Purchase Orders', 'delete:purchase_orders', 'Can delete purchase orders', 'Purchase Orders') +ON CONFLICT (code) DO NOTHING; + +-- User management permissions +INSERT INTO permissions (name, code, description, category) VALUES + ('View Users', 'view:users', 'Can view user accounts', 'Users'), + ('Create Users', 'create:users', 'Can create user accounts', 'Users'), + ('Edit Users', 'edit:users', 'Can modify user accounts', 'Users'), + ('Delete Users', 'delete:users', 'Can delete user accounts', 'Users'), + ('Manage Permissions', 'manage:permissions', 'Can assign permissions to users', 'Users') +ON CONFLICT (code) DO NOTHING; + +-- System permissions +INSERT INTO permissions (name, code, description, category) VALUES + ('Run Calculations', 'run:calculations', 'Can trigger system calculations', 'System'), + ('Import Data', 'import:data', 'Can import data into the system', 'System'), + ('System Settings', 'edit:system_settings', 'Can modify system settings', 'System') +ON CONFLICT (code) DO NOTHING; + +-- Set any existing users as admin +UPDATE users SET is_admin = TRUE WHERE is_admin IS NULL; + +-- Grant all permissions to admin users +INSERT INTO user_permissions (user_id, permission_id) +SELECT u.id, p.id +FROM users u, permissions p +WHERE u.is_admin = TRUE +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/inventory-server/auth/server.js b/inventory-server/auth/server.js index e2b2d81..f8f4838 100644 --- a/inventory-server/auth/server.js +++ b/inventory-server/auth/server.js @@ -5,6 +5,7 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { Pool } = require('pg'); const morgan = require('morgan'); +const authRoutes = require('./routes'); // Log startup configuration console.log('Starting auth server with config:', { @@ -27,11 +28,14 @@ const pool = new Pool({ port: process.env.DB_PORT, }); +// Make pool available globally +global.pool = pool; + // Middleware app.use(express.json()); app.use(morgan('combined')); app.use(cors({ - origin: ['http://localhost:5173', 'https://inventory.kent.pw'], + origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'], credentials: true })); @@ -42,7 +46,7 @@ app.post('/login', async (req, res) => { try { // Get user from database const result = await pool.query( - 'SELECT id, username, password FROM users WHERE username = $1', + 'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1', [username] ); @@ -52,6 +56,11 @@ app.post('/login', async (req, res) => { if (!user || !(await bcrypt.compare(password, user.password))) { return res.status(401).json({ error: 'Invalid username or password' }); } + + // Check if user is active + if (!user.is_active) { + return res.status(403).json({ error: 'Account is inactive' }); + } // Generate JWT token const token = jwt.sign( @@ -60,31 +69,84 @@ app.post('/login', async (req, res) => { { expiresIn: '24h' } ); - res.json({ token }); + // Get user permissions for the response + const permissionsResult = await pool.query(` + SELECT code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + `, [user.id]); + + const permissions = permissionsResult.rows.map(row => row.code); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + is_admin: user.is_admin, + permissions: user.is_admin ? [] : permissions + } + }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Internal server error' }); } }); -// Protected route to verify token -app.get('/protected', async (req, res) => { +// User info endpoint +app.get('/me', async (req, res) => { const authHeader = req.headers.authorization; - if (!authHeader) { + if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'No token provided' }); } try { const token = authHeader.split(' ')[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET); - res.json({ userId: decoded.userId, username: decoded.username }); + + // Get user details from database + const userResult = await pool.query( + 'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1', + [decoded.userId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + const user = userResult.rows[0]; + + // Get user permissions + let permissions = []; + if (!user.is_admin) { + const permissionsResult = await pool.query(` + SELECT code + FROM permissions p + JOIN user_permissions up ON p.id = up.permission_id + WHERE up.user_id = $1 + `, [user.id]); + + permissions = permissionsResult.rows.map(row => row.code); + } + + res.json({ + id: user.id, + username: user.username, + email: user.email, + is_admin: user.is_admin, + permissions: permissions + }); } catch (error) { console.error('Token verification error:', error); res.status(401).json({ error: 'Invalid token' }); } }); +// Mount all routes from routes.js +app.use('/', authRoutes); + // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy' }); diff --git a/inventory-server/scripts/reset-db.js b/inventory-server/scripts/reset-db.js index 44673e9..d081078 100644 --- a/inventory-server/scripts/reset-db.js +++ b/inventory-server/scripts/reset-db.js @@ -184,7 +184,7 @@ async function resetDatabase() { SELECT string_agg(tablename, ', ') as tables FROM pg_tables WHERE schemaname = 'public' - AND tablename NOT IN ('users', 'calculate_history', 'import_history'); + AND tablename NOT IN ('users', 'permissions', 'user_permissions', 'calculate_history', 'import_history'); `); if (!tablesResult.rows[0].tables) { diff --git a/inventory/package-lock.json b/inventory/package-lock.json index ba69ecc..0669f39 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -62,6 +63,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", + "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "recharts": "^2.15.0", @@ -71,7 +73,8 @@ "tanstack": "^1.0.0", "uuid": "^11.0.5", "vaul": "^1.1.2", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1227,6 +1230,15 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6937,6 +6949,22 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", diff --git a/inventory/package.json b/inventory/package.json index 4ddf1e5..d68a3a5 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -10,11 +10,12 @@ "preview": "vite preview" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", + "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.2", @@ -64,6 +65,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", + "react-hook-form": "^7.54.2", "react-icons": "^5.4.0", "react-router-dom": "^7.1.1", "recharts": "^2.15.0", @@ -73,7 +75,8 @@ "tanstack": "^1.0.0", "uuid": "^11.0.5", "vaul": "^1.1.2", - "xlsx": "^0.18.5" + "xlsx": "^0.18.5", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index e38ef11..44a5407 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -3,7 +3,6 @@ import { MainLayout } from './components/layout/MainLayout'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Products } from './pages/Products'; import { Dashboard } from './pages/Dashboard'; -import { Orders } from './pages/Orders'; import { Settings } from './pages/Settings'; import { Analytics } from './pages/Analytics'; import { Toaster } from '@/components/ui/sonner'; @@ -25,22 +24,22 @@ function App() { useEffect(() => { const checkAuth = async () => { - const token = sessionStorage.getItem('token'); + const token = localStorage.getItem('token'); if (token) { try { - const response = await fetch(`${config.authUrl}/protected`, { + const response = await fetch(`${config.authUrl}/me`, { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { - sessionStorage.removeItem('token'); + localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); navigate('/login'); } } catch (error) { console.error('Token verification failed:', error); - sessionStorage.removeItem('token'); + localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); navigate('/login'); } @@ -65,7 +64,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/inventory/src/components/common/PermissionGuard.tsx b/inventory/src/components/common/PermissionGuard.tsx new file mode 100644 index 0000000..83d506b --- /dev/null +++ b/inventory/src/components/common/PermissionGuard.tsx @@ -0,0 +1,82 @@ +import { ReactNode } from "react"; +import { usePermissions } from "@/hooks/usePermissions"; + +interface PermissionGuardProps { + /** + * Permission code required to render children + */ + permission?: string; + + /** + * Array of permission codes - if ANY are matched, children will render + */ + anyPermissions?: string[]; + + /** + * Array of permission codes - ALL must match to render children + */ + allPermissions?: string[]; + + /** + * If true, renders children if the user is an admin + */ + adminOnly?: boolean; + + /** + * If true, renders children if the user can access the specified page + */ + page?: string; + + /** + * Fallback component to render if permissions check fails + */ + fallback?: ReactNode; + + /** + * Children to render if permissions check passes + */ + children: ReactNode; +} + +/** + * Component that conditionally renders its children based on user permissions + */ +export function PermissionGuard({ + permission, + anyPermissions, + allPermissions, + adminOnly, + page, + fallback = null, + children +}: PermissionGuardProps) { + const { hasPermission, hasAnyPermission, hasAllPermissions, hasPageAccess, isAdmin } = usePermissions(); + + // Admin check + if (adminOnly && !isAdmin) { + return <>{fallback}; + } + + // Page access check + if (page && !hasPageAccess(page)) { + return <>{fallback}; + } + + // Single permission check + if (permission && !hasPermission(permission)) { + return <>{fallback}; + } + + // Any permissions check + if (anyPermissions && !hasAnyPermission(anyPermissions)) { + return <>{fallback}; + } + + // All permissions check + if (allPermissions && !hasAllPermissions(allPermissions)) { + return <>{fallback}; + } + + // If all checks pass, render children + return <>{children}; +} \ No newline at end of file diff --git a/inventory/src/components/settings/PermissionSelector.tsx b/inventory/src/components/settings/PermissionSelector.tsx new file mode 100644 index 0000000..29cf711 --- /dev/null +++ b/inventory/src/components/settings/PermissionSelector.tsx @@ -0,0 +1,130 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Info } from "lucide-react"; + +interface Permission { + id: number; + name: string; + code: string; + description?: string; +} + +interface PermissionCategory { + category: string; + permissions: Permission[]; +} + +interface PermissionSelectorProps { + permissionsByCategory: PermissionCategory[]; + selectedPermissions: number[]; + onChange: (selectedPermissions: number[]) => void; + disabled?: boolean; +} + +export function PermissionSelector({ + permissionsByCategory, + selectedPermissions, + onChange, + disabled = false +}: PermissionSelectorProps) { + // Handle permission checkbox change + const handlePermissionChange = (permissionId: number) => { + const newSelectedPermissions = selectedPermissions.includes(permissionId) + ? selectedPermissions.filter(id => id !== permissionId) + : [...selectedPermissions, permissionId]; + + onChange(newSelectedPermissions); + }; + + // Handle selecting/deselecting all permissions in a category + const handleSelectCategory = (category: string) => { + const categoryData = permissionsByCategory.find(c => c.category === category); + if (!categoryData) return; + + const categoryPermIds = categoryData.permissions.map(p => p.id); + + // Check if all permissions in category are already selected + const allSelected = categoryPermIds.every(id => selectedPermissions.includes(id)); + + // If all selected, deselect all; otherwise select all + const newSelectedPermissions = allSelected + ? selectedPermissions.filter(id => !categoryPermIds.includes(id)) + : [...new Set([...selectedPermissions, ...categoryPermIds])]; + + onChange(newSelectedPermissions); + }; + + // Check if there are no permission categories + if (!permissionsByCategory || permissionsByCategory.length === 0) { + return ( +
+

No permissions available

+
+ ); + } + + return ( +
+

Permissions

+

+ Select the permissions you want to grant to this user +

+ + {permissionsByCategory.map(category => ( + + + {category.category} + + + + +
+ {category.permissions.map(permission => ( +
+ handlePermissionChange(permission.id)} + disabled={disabled} + /> + +
+ ))} +
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/UserForm.tsx b/inventory/src/components/settings/UserForm.tsx new file mode 100644 index 0000000..e567b1c --- /dev/null +++ b/inventory/src/components/settings/UserForm.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { PermissionSelector } from "./PermissionSelector"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { ControllerRenderProps } from "react-hook-form"; + +interface Permission { + id: number; + name: string; + code: string; + description?: string; + category?: string; +} + +interface User { + id: number; + username: string; + email?: string; + is_admin: boolean; + is_active: boolean; + permissions?: Permission[]; +} + +interface PermissionCategory { + category: string; + permissions: Permission[]; +} + +interface UserFormProps { + user: User | null; + permissions: PermissionCategory[]; + onSave: (userData: any) => void; + onCancel: () => void; +} + +// Form validation schema +const userFormSchema = z.object({ + username: z.string().min(3, { message: "Username must be at least 3 characters" }), + email: z.string().email({ message: "Please enter a valid email" }).optional().or(z.literal("")), + password: z.string().min(6, { message: "Password must be at least 6 characters" }).optional().or(z.literal("")), + is_admin: z.boolean().default(false), + is_active: z.boolean().default(true), +}); + +type FormValues = z.infer; + +export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) { + const [selectedPermissions, setSelectedPermissions] = useState([]); + const [formError, setFormError] = useState(null); + + // Initialize the form with React Hook Form + const form = useForm({ + resolver: zodResolver(userFormSchema), + defaultValues: { + username: user?.username || "", + email: user?.email || "", + password: "", // Don't pre-fill password + is_admin: user?.is_admin || false, + is_active: user?.is_active !== false, + }, + }); + + // Initialize selected permissions + useEffect(() => { + if (user?.permissions && user.permissions.length > 0) { + setSelectedPermissions(user.permissions.map(p => p.id)); + } else { + setSelectedPermissions([]); + } + }, [user]); + + // Handle form submission + const onSubmit = (data: FormValues) => { + try { + setFormError(null); + + // Validate + if (!user && !data.password) { + setFormError("Password is required for new users"); + return; + } + + // Prepare the data + const userData = { + ...data, + id: user?.id, // Include ID if editing existing user + permissions: data.is_admin ? [] : selectedPermissions, + }; + + // If editing and password is empty, remove it + if (user && !userData.password) { + delete userData.password; + } + + onSave(userData); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "An error occurred"; + setFormError(errorMessage); + } + }; + + return ( +
+
+

{user ? "Edit User" : "Add New User"}

+

+ {user ? "Update the user's information and permissions" : "Create a new user account"} +

+
+ + {formError && ( + + {formError} + + )} + +
+ + }) => ( + + Username + + + + + + )} + /> + + }) => ( + + Email + + + + + + )} + /> + + }) => ( + + {user ? "New Password" : "Password"} + + + + {user && ( + + Leave blank to keep the current password + + )} + + + )} + /> + +
+ }) => ( + +
+ Administrator + + Administrators have access to all permissions + +
+ + + +
+ )} + /> + + }) => ( + +
+ Active + + Inactive users cannot log in + +
+ + + +
+ )} + /> +
+ + {!form.watch("is_admin") && ( + + )} + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/UserList.tsx b/inventory/src/components/settings/UserList.tsx new file mode 100644 index 0000000..17bbf97 --- /dev/null +++ b/inventory/src/components/settings/UserList.tsx @@ -0,0 +1,92 @@ +import { formatDistanceToNow } from "date-fns"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Pencil, Trash2 } from "lucide-react"; + +interface User { + id: number; + username: string; + email?: string; + is_admin: boolean; + is_active: boolean; + last_login?: string; +} + +interface UserListProps { + users: User[]; + onEdit: (userId: number) => void; + onDelete: (userId: number) => void; +} + +export function UserList({ users, onEdit, onDelete }: UserListProps) { + if (users.length === 0) { + return ( +
+

No users found

+
+ ); + } + + return ( + + + + Username + Email + Admin + Status + Last Login + Actions + + + + {users.map((user) => ( + + {user.username} + {user.email || '-'} + + {user.is_admin ? ( + Admin + ) : ( + No + )} + + + {user.is_active ? ( + Active + ) : ( + Inactive + )} + + + {user.last_login + ? formatDistanceToNow(new Date(user.last_login), { addSuffix: true }) + : 'Never'} + + + + + + + ))} + +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/settings/UserManagement.tsx b/inventory/src/components/settings/UserManagement.tsx new file mode 100644 index 0000000..455dbab --- /dev/null +++ b/inventory/src/components/settings/UserManagement.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { UserList } from "./UserList"; +import { UserForm } from "./UserForm"; + +interface User { + id: number; + username: string; + email?: string; + is_admin: boolean; + is_active: boolean; + last_login?: string; + permissions?: Permission[]; +} + +interface Permission { + id: number; + name: string; + code: string; + description?: string; + category?: string; +} + +interface PermissionCategory { + category: string; + permissions: Permission[]; +} + +export function UserManagement() { + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [isAddingUser, setIsAddingUser] = useState(false); + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch users and permissions + useEffect(() => { + const fetchData = async () => { + try { + // Fetch users + const usersResponse = await fetch('/auth-inv/users', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!usersResponse.ok) { + throw new Error('Failed to fetch users'); + } + + const usersData = await usersResponse.json(); + setUsers(usersData); + + // Fetch permissions + const permissionsResponse = await fetch('/auth-inv/permissions/categories', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!permissionsResponse.ok) { + throw new Error('Failed to fetch permissions'); + } + + const permissionsData = await permissionsResponse.json(); + setPermissions(permissionsData); + + setLoading(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An error occurred'; + setError(errorMessage); + setLoading(false); + } + }; + + fetchData(); + }, []); + + const handleEditUser = async (userId: number) => { + try { + const response = await fetch(`/auth-inv/users/${userId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch user details'); + } + + const userData = await response.json(); + setSelectedUser(userData); + setIsAddingUser(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load user details'; + setError(errorMessage); + } + }; + + const handleAddUser = () => { + setSelectedUser(null); + setIsAddingUser(true); + }; + + const handleSaveUser = async (userData: any) => { + try { + setLoading(true); + + const endpoint = userData.id + ? `/auth-inv/users/${userData.id}` + : '/auth-inv/users'; + + const method = userData.id ? 'PUT' : 'POST'; + + const response = await fetch(endpoint, { + method, + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(userData) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to save user'); + } + + // Refresh user list + const usersResponse = await fetch('/auth-inv/users', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + const updatedUsers = await usersResponse.json(); + setUsers(updatedUsers); + + // Reset form state + setSelectedUser(null); + setIsAddingUser(false); + setLoading(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save user'; + setError(errorMessage); + setLoading(false); + } + }; + + const handleDeleteUser = async (userId: number) => { + if (!window.confirm('Are you sure you want to delete this user?')) { + return; + } + + try { + setLoading(true); + + const response = await fetch(`/auth-inv/users/${userId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to delete user'); + } + + // Refresh user list + const usersResponse = await fetch('/auth-inv/users', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + const updatedUsers = await usersResponse.json(); + setUsers(updatedUsers); + setLoading(false); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete user'; + setError(errorMessage); + setLoading(false); + } + }; + + const handleCancel = () => { + setSelectedUser(null); + setIsAddingUser(false); + }; + + if (loading && users.length === 0) { + return ( + + +
+

Loading user data...

+
+
+
+ ); + } + + if (error) { + return ( + + + + {error} + + + + ); + } + + return ( + + {(selectedUser || isAddingUser) ? ( + + + + ) : ( + <> + +
+ User Management + + Manage users and their permissions + +
+ +
+ + + + + )} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/ui/form.tsx b/inventory/src/components/ui/form.tsx new file mode 100644 index 0000000..c571717 --- /dev/null +++ b/inventory/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +