Initial permissions framework and setup

This commit is contained in:
2025-03-22 22:11:03 -04:00
parent 1963bee00c
commit 03dc119a15
19 changed files with 1961 additions and 341 deletions

View File

@@ -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<boolean>} - 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<string[]>} - 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
};

View File

@@ -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;

View File

@@ -4,3 +4,129 @@ CREATE TABLE users (
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- 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;

View File

@@ -5,6 +5,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { Pool } = require('pg'); const { Pool } = require('pg');
const morgan = require('morgan'); const morgan = require('morgan');
const authRoutes = require('./routes');
// Log startup configuration // Log startup configuration
console.log('Starting auth server with config:', { console.log('Starting auth server with config:', {
@@ -27,11 +28,14 @@ const pool = new Pool({
port: process.env.DB_PORT, port: process.env.DB_PORT,
}); });
// Make pool available globally
global.pool = pool;
// Middleware // Middleware
app.use(express.json()); app.use(express.json());
app.use(morgan('combined')); app.use(morgan('combined'));
app.use(cors({ app.use(cors({
origin: ['http://localhost:5173', 'https://inventory.kent.pw'], origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'],
credentials: true credentials: true
})); }));
@@ -42,7 +46,7 @@ app.post('/login', async (req, res) => {
try { try {
// Get user from database // Get user from database
const result = await pool.query( 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] [username]
); );
@@ -53,6 +57,11 @@ app.post('/login', async (req, res) => {
return res.status(401).json({ error: 'Invalid username or 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 // Generate JWT token
const token = jwt.sign( const token = jwt.sign(
{ userId: user.id, username: user.username }, { userId: user.id, username: user.username },
@@ -60,31 +69,84 @@ app.post('/login', async (req, res) => {
{ expiresIn: '24h' } { 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) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
// Protected route to verify token // User info endpoint
app.get('/protected', async (req, res) => { app.get('/me', async (req, res) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
try { try {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET); 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) { } catch (error) {
console.error('Token verification error:', error); console.error('Token verification error:', error);
res.status(401).json({ error: 'Invalid token' }); res.status(401).json({ error: 'Invalid token' });
} }
}); });
// Mount all routes from routes.js
app.use('/', authRoutes);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'healthy' }); res.json({ status: 'healthy' });

View File

@@ -184,7 +184,7 @@ async function resetDatabase() {
SELECT string_agg(tablename, ', ') as tables SELECT string_agg(tablename, ', ') as tables
FROM pg_tables FROM pg_tables
WHERE schemaname = 'public' 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) { if (!tablesResult.rows[0].tables) {

View File

@@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@@ -62,6 +63,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"recharts": "^2.15.0", "recharts": "^2.15.0",
@@ -71,7 +73,8 @@
"tanstack": "^1.0.0", "tanstack": "^1.0.0",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5" "xlsx": "^0.18.5",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
@@ -1227,6 +1230,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -6937,6 +6949,22 @@
"react": ">= 16.8 || 18.0.0" "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": { "node_modules/react-icons": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz",

View File

@@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
@@ -64,6 +65,7 @@
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"recharts": "^2.15.0", "recharts": "^2.15.0",
@@ -73,7 +75,8 @@
"tanstack": "^1.0.0", "tanstack": "^1.0.0",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5" "xlsx": "^0.18.5",
"zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",

View File

@@ -3,7 +3,6 @@ import { MainLayout } from './components/layout/MainLayout';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Products } from './pages/Products'; import { Products } from './pages/Products';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { Orders } from './pages/Orders';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { Analytics } from './pages/Analytics'; import { Analytics } from './pages/Analytics';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
@@ -25,22 +24,22 @@ function App() {
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const token = sessionStorage.getItem('token'); const token = localStorage.getItem('token');
if (token) { if (token) {
try { try {
const response = await fetch(`${config.authUrl}/protected`, { const response = await fetch(`${config.authUrl}/me`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
if (!response.ok) { if (!response.ok) {
sessionStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); sessionStorage.removeItem('isLoggedIn');
navigate('/login'); navigate('/login');
} }
} catch (error) { } catch (error) {
console.error('Token verification failed:', error); console.error('Token verification failed:', error);
sessionStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); sessionStorage.removeItem('isLoggedIn');
navigate('/login'); navigate('/login');
} }
@@ -65,7 +64,6 @@ function App() {
<Route path="/import" element={<Import />} /> <Route path="/import" element={<Import />} />
<Route path="/categories" element={<Categories />} /> <Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} /> <Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
<Route path="/purchase-orders" element={<PurchaseOrders />} /> <Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} /> <Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />

View File

@@ -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}</>;
}

View File

@@ -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 (
<div className="text-center py-4">
<p className="text-muted-foreground">No permissions available</p>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Permissions</h3>
<p className="text-sm text-muted-foreground mb-4">
Select the permissions you want to grant to this user
</p>
{permissionsByCategory.map(category => (
<Card key={category.category} className="mb-4">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-md">{category.category}</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSelectCategory(category.category)}
disabled={disabled}
>
{category.permissions.every(p => selectedPermissions.includes(p.id))
? 'Deselect All'
: 'Select All'}
</Button>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{category.permissions.map(permission => (
<div key={permission.id} className="flex items-center space-x-2 py-1">
<Checkbox
id={`permission-${permission.id}`}
checked={selectedPermissions.includes(permission.id)}
onCheckedChange={() => handlePermissionChange(permission.id)}
disabled={disabled}
/>
<label
htmlFor={`permission-${permission.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center"
>
{permission.name}
{permission.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 ml-1 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>{permission.description}</p>
<p className="text-xs text-muted-foreground mt-1">Code: {permission.code}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</label>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -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<typeof userFormSchema>;
export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps) {
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
const [formError, setFormError] = useState<string | null>(null);
// Initialize the form with React Hook Form
const form = useForm<FormValues>({
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 (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">{user ? "Edit User" : "Add New User"}</h2>
<p className="text-muted-foreground">
{user ? "Update the user's information and permissions" : "Create a new user account"}
</p>
</div>
{formError && (
<Alert variant="destructive">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }: { field: ControllerRenderProps<FormValues, "username"> }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }: { field: ControllerRenderProps<FormValues, "email"> }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }: { field: ControllerRenderProps<FormValues, "password"> }) => (
<FormItem>
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
placeholder={user ? "Leave blank to keep current password" : ""}
/>
</FormControl>
{user && (
<FormDescription>
Leave blank to keep the current password
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="is_admin"
render={({ field }: { field: ControllerRenderProps<FormValues, "is_admin"> }) => (
<FormItem className="flex flex-row items-center justify-between p-4 border rounded-md">
<div>
<FormLabel>Administrator</FormLabel>
<FormDescription>
Administrators have access to all permissions
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_active"
render={({ field }: { field: ControllerRenderProps<FormValues, "is_active"> }) => (
<FormItem className="flex flex-row items-center justify-between p-4 border rounded-md">
<div>
<FormLabel>Active</FormLabel>
<FormDescription>
Inactive users cannot log in
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{!form.watch("is_admin") && (
<PermissionSelector
permissionsByCategory={permissions}
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
)}
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">
{user ? "Update User" : "Create User"}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -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 (
<div className="text-center py-8">
<p className="text-muted-foreground">No users found</p>
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Admin</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>{user.email || '-'}</TableCell>
<TableCell>
{user.is_admin ? (
<Badge variant="default">Admin</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</TableCell>
<TableCell>
{user.is_active ? (
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
) : (
<Badge variant="secondary" className="bg-slate-100">Inactive</Badge>
)}
</TableCell>
<TableCell>
{user.last_login
? formatDistanceToNow(new Date(user.last_login), { addSuffix: true })
: 'Never'}
</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(user.id)}
className="h-8 w-8 p-0"
>
<span className="sr-only">Edit</span>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(user.id)}
className="h-8 w-8 p-0"
>
<span className="sr-only">Delete</span>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -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<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isAddingUser, setIsAddingUser] = useState(false);
const [permissions, setPermissions] = useState<PermissionCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center h-40">
<p>Loading user data...</p>
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent className="pt-6">
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
return (
<Card>
{(selectedUser || isAddingUser) ? (
<CardContent className="p-6">
<UserForm
user={selectedUser}
permissions={permissions}
onSave={handleSaveUser}
onCancel={handleCancel}
/>
</CardContent>
) : (
<>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Management</CardTitle>
<CardDescription>
Manage users and their permissions
</CardDescription>
</div>
<Button onClick={handleAddUser}>
Add User
</Button>
</CardHeader>
<CardContent>
<UserList
users={users}
onEdit={handleEditUser}
onDelete={handleDeleteUser}
/>
</CardContent>
</>
)}
</Card>
);
}

View File

@@ -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<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,140 @@
import { createContext, useState, useEffect, ReactNode } from 'react';
export interface Permission {
id: number;
name: string;
code: string;
description?: string;
category: string;
}
export interface User {
id: number;
username: string;
email?: string;
is_admin: boolean;
permissions: string[];
}
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
fetchCurrentUser: () => Promise<void>;
}
const defaultContext: AuthContextType = {
user: null,
token: null,
isLoading: false,
error: null,
login: async () => {},
logout: () => {},
fetchCurrentUser: async () => {},
};
export const AuthContext = createContext<AuthContextType>(defaultContext);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Load token from localStorage on init
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
fetchCurrentUser();
}
}, []);
const fetchCurrentUser = async () => {
if (!token) return;
try {
setIsLoading(true);
setError(null);
const response = await fetch('/auth-inv/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch user data');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
setError(errorMessage);
// Clear token if authentication failed
if (err instanceof Error && err.message.includes('authentication')) {
logout();
}
} finally {
setIsLoading(false);
}
};
const login = async (username: string, password: string) => {
try {
setIsLoading(true);
setError(null);
const response = await fetch('/auth-inv/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Login failed');
}
const data = await response.json();
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed';
setError(errorMessage);
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
error,
login,
logout,
fetchCurrentUser,
}}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,80 @@
import { useContext, useCallback } from "react";
import { AuthContext } from "@/contexts/AuthContext";
/**
* Hook for checking user permissions
* @returns Functions for checking permissions
*/
export function usePermissions() {
const { user } = useContext(AuthContext);
/**
* Check if the user has a specific permission
* @param permissionCode The permission code to check
* @returns Whether the user has the permission
*/
const hasPermission = useCallback((permissionCode: string): boolean => {
// If not authenticated or no permissions, return false
if (!user || !user.permissions) {
return false;
}
// Admin users have all permissions
if (user.is_admin) {
return true;
}
// Check specific permission
return user.permissions.includes(permissionCode);
}, [user]);
/**
* Check if the user has access to a specific page
* @param pageName The page name (e.g., 'products', 'settings')
* @returns Whether the user has access to the page
*/
const hasPageAccess = useCallback((pageName: string): boolean => {
return hasPermission(`access:${pageName.toLowerCase()}`);
}, [hasPermission]);
/**
* Check if the user has ANY of the specified permissions
* @param permissionCodes Array of permission codes to check
* @returns Whether the user has any of the permissions
*/
const hasAnyPermission = useCallback((permissionCodes: string[]): boolean => {
// If admin or no permissions to check, return true
if (!permissionCodes.length || (user && user.is_admin)) {
return true;
}
return permissionCodes.some(code => hasPermission(code));
}, [user, hasPermission]);
/**
* Check if the user has ALL of the specified permissions
* @param permissionCodes Array of permission codes to check
* @returns Whether the user has all the permissions
*/
const hasAllPermissions = useCallback((permissionCodes: string[]): boolean => {
// If no permissions to check, return true
if (!permissionCodes.length) {
return true;
}
// If admin, return true
if (user && user.is_admin) {
return true;
}
return permissionCodes.every(code => hasPermission(code));
}, [user, hasPermission]);
return {
hasPermission,
hasPageAccess,
hasAnyPermission,
hasAllPermissions,
isAdmin: user?.is_admin || false
};
}

View File

@@ -54,7 +54,7 @@ export function Login() {
const data = await response.json(); const data = await response.json();
console.log("Login successful:", data); console.log("Login successful:", data);
sessionStorage.setItem("token", data.token); localStorage.setItem("token", data.token);
sessionStorage.setItem("isLoggedIn", "true"); sessionStorage.setItem("isLoggedIn", "true");
toast.success("Successfully logged in"); toast.success("Successfully logged in");

View File

@@ -1,322 +0,0 @@
import { useState, useCallback } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DateRangePicker } from "@/components/ui/date-range-picker";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUpDown, Search } from "lucide-react";
import debounce from 'lodash/debounce';
import config from '../config';
import { DateRange } from 'react-day-picker';
import { motion } from 'motion/react';
interface Order {
order_number: string;
customer: string;
date: string;
status: string;
total_amount: number;
items_count: number;
payment_method: string;
shipping_method: string;
}
interface OrderFilters {
search: string;
status: string;
dateRange: DateRange;
minAmount: string;
maxAmount: string;
}
export function Orders() {
const [page, setPage] = useState(1);
const [sortColumn, setSortColumn] = useState<keyof Order>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [filters, setFilters] = useState<OrderFilters>({
search: '',
status: 'all',
dateRange: { from: undefined, to: undefined },
minAmount: '',
maxAmount: '',
});
const { data, isLoading, isFetching } = useQuery({
queryKey: ['orders', page, sortColumn, sortDirection, filters],
queryFn: async () => {
const searchParams = new URLSearchParams({
page: page.toString(),
limit: '50',
sortColumn: sortColumn.toString(),
sortDirection,
...filters.dateRange.from && { fromDate: filters.dateRange.from.toISOString() },
...filters.dateRange.to && { toDate: filters.dateRange.to.toISOString() },
...filters.minAmount && { minAmount: filters.minAmount },
...filters.maxAmount && { maxAmount: filters.maxAmount },
...filters.status !== 'all' && { status: filters.status },
...filters.search && { search: filters.search },
});
const response = await fetch(`${config.apiUrl}/orders?${searchParams}`);
if (!response.ok) throw new Error('Failed to fetch orders');
return response.json();
},
placeholderData: keepPreviousData,
staleTime: 30000,
});
const debouncedFilterChange = useCallback(
debounce((newFilters: Partial<OrderFilters>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
setPage(1);
}, 300),
[]
);
const handleSort = (column: keyof Order) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const getOrderStatusBadge = (status: string) => {
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
pending: { variant: "outline", label: "Pending" },
processing: { variant: "secondary", label: "Processing" },
completed: { variant: "default", label: "Completed" },
cancelled: { variant: "destructive", label: "Cancelled" },
};
const statusConfig = variants[status.toLowerCase()] || variants.pending;
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
};
const renderSortButton = (column: keyof Order, label: string) => (
<Button
variant="ghost"
onClick={() => handleSort(column)}
className="w-full justify-start font-medium"
>
{label}
<ArrowUpDown className={`ml-2 h-4 w-4 ${sortColumn === column && sortDirection === 'desc' ? 'rotate-180' : ''}`} />
</Button>
);
return (
<motion.div layout className="p-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Orders</h1>
<div className="text-sm text-muted-foreground">
{data?.pagination.total.toLocaleString() ?? '...'} orders
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.stats.totalOrders ?? '...'}</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.orderGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${(data?.stats.totalRevenue ?? 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.revenueGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Average Order Value</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${(data?.stats.averageOrderValue ?? 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.aovGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(data?.stats.conversionRate ?? 0).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
+{data?.stats.conversionGrowth ?? 0}% from last month
</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
<div className="flex items-center gap-2 flex-1">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search orders..."
value={filters.search}
onChange={(e) => debouncedFilterChange({ search: e.target.value })}
className="h-8 w-[300px]"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={filters.status}
onValueChange={(value) => debouncedFilterChange({ status: value })}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Processing</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<DateRangePicker
value={filters.dateRange}
onChange={(range: DateRange | undefined) => debouncedFilterChange({ dateRange: range })}
/>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="Min $"
value={filters.minAmount}
onChange={(e) => debouncedFilterChange({ minAmount: e.target.value })}
className="h-8 w-[100px]"
/>
<span>-</span>
<Input
type="number"
placeholder="Max $"
value={filters.maxAmount}
onChange={(e) => debouncedFilterChange({ maxAmount: e.target.value })}
className="h-8 w-[100px]"
/>
</div>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{renderSortButton('order_number', 'Order')}</TableHead>
<TableHead>{renderSortButton('customer', 'Customer')}</TableHead>
<TableHead>{renderSortButton('date', 'Date')}</TableHead>
<TableHead>{renderSortButton('status', 'Status')}</TableHead>
<TableHead className="text-right">{renderSortButton('total_amount', 'Total')}</TableHead>
<TableHead className="text-center">{renderSortButton('items_count', 'Items')}</TableHead>
<TableHead>{renderSortButton('payment_method', 'Payment')}</TableHead>
<TableHead>{renderSortButton('shipping_method', 'Shipping')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading orders...
</TableCell>
</TableRow>
) : data?.orders.map((order: Order) => (
<TableRow key={order.order_number}>
<TableCell className="font-medium">#{order.order_number}</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell>{format(new Date(order.date), 'MMM d, yyyy')}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell className="text-right">${order.total_amount.toFixed(2)}</TableCell>
<TableCell className="text-center">{order.items_count}</TableCell>
<TableCell>{order.payment_method}</TableCell>
<TableCell>{order.shipping_method}</TableCell>
</TableRow>
))}
{!isLoading && !data?.orders.length && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
No orders found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data?.pagination.pages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
aria-disabled={page === 1 || isFetching}
className={page === 1 || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => setPage(p => Math.max(1, p - 1))}
/>
</PaginationItem>
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
aria-disabled={isFetching}
className={isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => setPage(p)}
>
{p}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
aria-disabled={page === data.pagination.pages || isFetching}
className={page === data.pagination.pages || isFetching ? 'pointer-events-none opacity-50' : ''}
onClick={() => setPage(p => Math.min(data.pagination.pages, p + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</motion.div>
);
}

View File

@@ -4,6 +4,7 @@ import { StockManagement } from "@/components/settings/StockManagement";
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics"; import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
import { CalculationSettings } from "@/components/settings/CalculationSettings"; import { CalculationSettings } from "@/components/settings/CalculationSettings";
import { TemplateManagement } from "@/components/settings/TemplateManagement"; import { TemplateManagement } from "@/components/settings/TemplateManagement";
import { UserManagement } from "@/components/settings/UserManagement";
import { motion } from 'motion/react'; import { motion } from 'motion/react';
export function Settings() { export function Settings() {
@@ -26,6 +27,9 @@ export function Settings() {
<TabsTrigger value="templates"> <TabsTrigger value="templates">
Template Management Template Management
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="user-management">
User Management
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="data-management"> <TabsContent value="data-management">
@@ -47,6 +51,10 @@ export function Settings() {
<TabsContent value="templates"> <TabsContent value="templates">
<TemplateManagement /> <TemplateManagement />
</TabsContent> </TabsContent>
<TabsContent value="user-management">
<UserManagement />
</TabsContent>
</Tabs> </Tabs>
</motion.div> </motion.div>
); );